Skip to content

Repository files navigation

SLATE: Secure, Log-structured, Authenticated, Tamper-Evident Key–Value Engine

SLATE Logo

DOI crates.io docs.rs codecov CI License: MIT OR Apache-2.0 no_std

SLATE is a key-value storage engine for edge devices, from bare-metal microcontrollers like the ESP32 up to Linux-class boards. It targets a setting most storage engines are not designed for: raw NOR flash with no filesystem beneath it, no battery-backed cache, well under 100 KiB of RAM, and a power supply that can be interrupted in the middle of a page program.

SLATE composes well-understood primitives — log-structured storage, AEAD, cuckoo hashing, Reed–Solomon parity — into a design whose guarantees are proven rather than assumed, and it states where those guarantees stop. See docs/specification.md for the normative on-flash format, the operational semantics, and the conformance data behind every number quoted here.

What SLATE guarantees

Three properties hold simultaneously, each with a proof and an empirical check:

  • Prefix durability. After an arbitrary number of power failures at arbitrary instants, recovery returns exactly the state produced by some prefix of the acknowledged write sequence, containing every write acknowledged before the last crash. Verified across 20,000 power-loss trials with the cut at a uniformly random byte — zero violations.
  • Rollback resistance. An at-rest adversary who replaces the flash with an older authentic image is detected: making recovery accept a stale epoch requires a MAC forgery. Protection is per epoch — a rollback within the current epoch is not distinguished, and the window is bounded by Θ. Verified across 5,000 splice attacks — all rejected.
  • Erasure tolerance. Segment parity is maximum-distance-separable, so any n-k declared block erasures reconstruct exactly. Verified exhaustively: all 794 patterns within RS(12,8)'s distance recover byte-exactly, all 792 beyond it are refused, zero wrong bytes across 1,586 patterns. This is a property of the coder, verified against it directly — the shipped write path does not yet encode parity per sealed segment (see Known limitations).

Alongside these, closed forms govern deployment: constant-time index lookup, recovery linear in the post-checkpoint tail and independent of stored volume, steady-state write amplification 1/(1-u), and an optimal commit batch size B* = sqrt(2·λ·A/c) that landed within 5.35% of the measured optimum.

Design highlights

  • Commit markers as the acknowledgement point. A write is acknowledged when its commit marker is durable, not when the record lands — which is what makes the recovered state a prefix by construction. Batch size b_commit is the cost–durability dial.
  • Heapless no_std core under #![forbid(unsafe_code)], with no dynamic allocation anywhere. Every buffer is an owned array or a caller-supplied slice.
  • Async-native, blocking-projected. Each algorithm is written once as an async fn over an AsyncFlash trait; the blocking API is a one-line projection per method. Zero-cost when the flash driver never suspends — see the limitations below.
  • Partial-key cuckoo index with a fixed arena and a small stash: exactly 2b+s = 16 slot probes per lookup, independent of load factor, at ~4.2 bytes per key.

Known limitations

These are measured, not hypothetical. docs/specification.md records each one with the command and data file behind it:

  • Reclaimed space is reusable as of 0.6.0. Earlier releases could not wrap the log head into freed segments, so a device halted with most of its segments free; see CHANGELOG.md. This costs an on-flash format break — a 0.5.x volume is rejected at mount rather than mis-replayed, and there is no in-place migration.
  • Segment parity is not encoded on the data path. The RS(12,8) erasure tolerance below is verified in simulation, but segment::encode_parity has no production caller, so the shipped write path does not exercise it. The parity blocks are reserved by the 0.6.0 format, so closing this needs no further format break.
  • RAM exceeds the documented budget. The shipped ESP32 configuration needs ~81 KiB resident (~86 KiB at mount peak) against a 64 KiB target, dominated by a checkpoint buffer that must hold the entire serialised index. The largest configuration that fits 64 KiB is n_buckets = 1024.
  • Mount replay does not yield. The recovery path is still on the blocking flash trait, so replaying 8,192 records is one uninterruptible span of ~4.6 s.
  • Sequential keys defeat the fingerprint. Keys like sensor_000123 drive the index collision rate to ~5.7× its theoretical bound; well-mixed keys stay below it.
  • Rollback protection needs a monotonic counter. With a hardware counter the guarantee is as stated; with a flash-backed counter it is best-effort; with none it is absent. The engine reports which mode it is in rather than implying the strongest.

Quickstart

1. Rust (std)

[dependencies]
slate-kv = "0.6"
use slate_kv::db::{Db, KeySource, Options, Profile};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // `Options::default()` is a 4 MiB Pi-profile volume with b_commit = 8.
    let opts = Options {
        profile: Profile::Pi,
        ..Options::default()
    };

    // The root key is 32 bytes. Prefer KeySource::File or ::Env in production
    // so the key never appears in the binary.
    let mut db = Db::open(
        std::path::Path::new("./slate_db.bin"),
        KeySource::Bytes([0x42u8; 32]),
        opts,
    )?;

    db.put(b"sensor_1", b"23.5 C")?;

    // `put` buffers into the open batch; the write is acknowledged only once
    // `commit` makes the marker durable. `put_durable` does both in one call.
    db.commit()?;

    if let Some(val) = db.get(b"sensor_1")? {
        println!("sensor_1 = {}", String::from_utf8_lossy(&val));
    }

    // Rollback protection degrades honestly — check what you actually have.
    println!("security mode: {:?}", db.security_mode());
    Ok(())
}

2. C / C++ (slate-kv-ffi)

Build the library and header, then include slate.h and link libslate_kv_ffi:

make ffi-staticlib      # -> target/release/libslate_kv_ffi.a + include/slate.h
make ffi-native-libs    # prints the platform link flags you also need
#include "slate.h"
#include <stdio.h>

int main(void) {
    uint8_t key[32] = {0x42};
    slate_options opts = {
        .capacity_bytes = 4u * 1024 * 1024,
        .max_keys       = 8192,
        .b_commit       = 8,
        .theta          = 0,     /* 0 = use the built-in default */
        .profile        = SLATE_PROFILE_PI,   /* 0; ESP32 is 1 */
    };

    struct slate_db *db = NULL;
    if (slate_open("./slate_db.bin", key, &opts, &db) != SLATE_OK) {
        /* The message is written into a caller-owned buffer. Pass NULL for the
           handle when open itself failed — there is no database to ask. */
        char err[256];
        slate_last_error_message(NULL, err, sizeof err);
        printf("open failed: %s\n", err);
        return 1;
    }

    slate_put(db, (const uint8_t *)"key1", 4, (const uint8_t *)"val1", 4);
    slate_commit(db);

    /* `vlen_inout` is in/out: set it to the buffer capacity on the way in,
       and it comes back as the value's true length. Passing NULL for the
       buffer turns this into a size query. */
    uint8_t buf[64];
    size_t out_len = sizeof buf;
    if (slate_get(db, (const uint8_t *)"key1", 4, buf, &out_len) == SLATE_OK) {
        printf("value: %.*s\n", (int)out_len, buf);
    }

    slate_close(db);
    return 0;
}

Check slate_abi_version() against the header's SLATE_ABI_VERSION_MAJOR before use, and treat SLATE_ERR_TAMPERED / SLATE_ERR_ROLLBACK as distinct security failures — never retry them.

3. Go (bind/go)

make bind-test          # builds the staticlib, runs the conformance suite

The Go binding is a thin cgo layer over the same C ABI; see bind/README.md for the rules every binding must follow. It runs wherever Rust std runs — it is not a microcontroller path.

4. Bare-metal ESP32 firmware

cd targets/esp32
cargo build --release --bin kv_demo \
  --no-default-features --features chip-esp32c3,counter-flash,metrics \
  --target riscv32imc-unknown-none-elf

counter-efuse selects the hardware monotonic counter (full rollback protection); counter-flash keeps it in a flash sector (best-effort). metrics wires the write-amplification counters. The scripts/ directory holds the QEMU crash suite and the Wokwi scenario used in CI.

scripts/build_matrix.sh builds every supported chip against its correct target triple, and scripts/wokwi_matrix.sh runs the demo scenario on each board Wokwi emulates.

5. Bare-metal Raspberry Pi Pico firmware

cd targets/rp2
cargo build --release --bin kv_demo   # thumbv6m-none-eabi

The Pico's external QSPI NOR is 4 KiB sector / 256 B page — the geometry the on-flash format was designed around — so no format change is needed. Flash writes run from RAM with XIP disabled and interrupts masked, via the RP2040 ROM routines.

Hardware compatibility

Verdicts are measured, not inferred from datasheets: geometry by driving the real engine across synthetic flash parameters (docs/data/geometry_probe.csv), and boot behaviour on emulated hardware (docs/data/wokwi_matrix.csv).

Family Parts Status Notes
ESP32 esp32, c2, c3, c5, c6, c61, h2, s2, s3 Supported — all 9 build c3/c6/h2/s2/s3 verified booting on Wokwi; Xtensa parts need the espup toolchain
RP2 RP2040, RP2350 Supported RP2040 firmware builds; external QSPI NOR needs no format change
Arduino (ARM) Nano 33 BLE (nRF52840) Portable via its external QSPI NOR
STM32 (≥64 KB SRAM) F4, F7, H7 Needs a page shim programs 4–32 B per write, below the 83-byte commit marker
STM32 (20–32 KB SRAM) F103C8, Arduino Uno R4 Needs shim + small-index profile also below the 14.8 KB RAM floor at default sizing
STM32 (<16 KB SRAM) C031, L031 Not feasible 8–12 KB SRAM against a 14.8 KB floor
Arduino (AVR) Uno, Nano, Mega, ATtiny Not feasible 1–8 KB SRAM, and EEPROM rather than NOR

Two numbers govern the table. The engine needs a program page of at least 83 bytes, because the commit marker is written by a single program call — that is what separates external SPI/QSPI NOR (256 B pages: fine) from STM32 internal flash (word writes: needs a RAM page-buffer shim). And the shipping firmware needs 82.9 KB of RAM, of which most is the index and its checkpoint buffer; a minimum-index profile reaches 14.8 KB, which is still above every AVR Arduino.

Repository layout

Path Contents
crates/slate-kv-core Heapless no_std engine: log, index, epochs, GC, recovery
crates/slate-kv std wrapper: Db, file-backed flash, POSIX durability
crates/slate-kv-crypto AEAD sealer and key derivation
crates/slate-kv-erasure Reed–Solomon over GF(2⁸)
crates/slate-kv-hal Flash / counter traits and blocking↔async adapters
crates/slate-kv-ffi Stable C ABI and generated slate.h
crates/slate-kv-sim Simulated flash, crash injection, study harnesses
crates/slate-kv-cli Command-line tool
targets/esp32 Bare-metal esp-hal firmware (9 chips), QEMU and Wokwi harnesses
targets/rp2 Bare-metal Raspberry Pi Pico (RP2040) firmware
bind/go Go / TinyGo binding over the C ABI
docs/specification.md Normative format, semantics and conformance data

Development

make check-all          # fmt, lint, test, bare-metal build, FFI, bindings
cargo test --workspace  # 63 tests

CI additionally lints the out-of-workspace ESP32 firmware, runs the QEMU crash suite across three attack modes, and drives the Wokwi emulator scenario.

Contributing

Contributions are welcome — see CONTRIBUTING.md before opening a PR or issue.

License

Dual-licensed under either of:

at your option.

About

SLATE is a key-value storage engine for edge computing, from bare-metal microcontrollers like the ESP32 up to boards like the Raspberry Pi. It's built around four goals that usually fight each other: a tiny memory footprint, good performance, low energy use, and real at-rest security.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages