UniNet

Storage

Encrypted backends

State and block history persist as one file per key, each value sealed with AES-256-GCM envelope encryption under a master key the node resolves at boot and holds in process memory.

AvailableStorage endpointsNot implementedHardware security modulesNot implementedHardware-backed confidential computing

What an encrypted backend is

A storage backend in UniNet is a byte-keyed, byte-valued store behind one trait. EncryptedBackend is a backend that wraps another backend and encrypts every value on the way in and decrypts it on the way out, leaving the enclosing code unaware that it happened. The node runs DiskBackend inside EncryptedBackend twice: once for the state trie, once for block history.

The problem this shape addresses is that persistence and confidentiality are usually entangled. A store that encrypts is typically a different store, with a different API, a different failure mode and a different set of callers to migrate. Here the encrypting store is a backend — it satisfies the same trait, is held as the same Box<dyn StorageBackend>, and is driven through the same conformance assertions as the plaintext one.

The trait

StorageBackend is defined in uninet-primitives and inherits SwappableModule, so every implementation is Send + Sync and advertises a module id, an interface id and a version. It has seven methods.

| Method | Contract | | --- | --- | | get | value or None; an absent key is not an error | | put | write, overwriting any previous value | | delete | remove; deleting an absent key succeeds | | batch_write | apply a list of puts and deletes | | prefix_iterator | every pair whose key starts with a byte prefix, ascending | | snapshot | a read-only view isolated from later writes | | contains | presence, without reading the value |

Five types implement it. Three are on the live path or the test path: MemoryBackend (a BTreeMap behind an RwLock), DiskBackend and EncryptedBackend. Two are not: RocksDbBackend sits behind a storage-rocksdb feature that nothing in the workspace enables, so a default build does not compile it, and ContainerStorage — a prefix-scoping wrapper in the same crate — is never constructed outside uninet-storage.

The cross-backend conformance suite builds four configurations — memory, disk, encrypted+memory, encrypted+disk — and drives all of them through the same eighteen assertions; a nineteenth test pins DiskBackend alone. encrypted+disk is in that list because it is what the node actually runs, so the wrapper is held to the same contract as the thing it wraps. The suite also records three deviations it deliberately does not assert: MemoryBackend applies a batch under one lock and DiskBackend cannot, MemoryBackend can store the empty key while DiskBackend cannot, and filesystem-backed stores cap key length where the in-memory one does not.

DiskBackend

DiskBackend is a file-per-key store with no embedded database. A key becomes a filename by lowercase hex encoding, so the file for a key lives at {base_dir}/{hex(key)} and the file's contents are the value bytes verbatim. Hex is what makes arbitrary key bytes — including / and NUL — safe as filenames, and it is why the store has two limits that follow directly from the encoding: the empty key hex-encodes to the empty string, whose path is the base directory itself, so put(b"") fails loudly and contains(b"") answers false; and hex doubles length, so a filesystem capping names at 255 bytes caps keys at 127.

Writes go through std::fs::write. There is no fsync and no cross-file commit point, so batch_write applies sequentially and is not atomic — the implementation says so at the impl rather than claiming otherwise. prefix_iterator reads the directory, keeps entries whose name starts with the hex of the prefix, decodes the name back to the key, and sorts. snapshot copies every pair into memory, which is stated in the code as acceptable for the trie's key count rather than as a general mechanism.

The durability argument the code makes is ordering, not atomicity. The trie's writes are content-addressed — a node's key is its own hash — plus a small number of mutable pointer keys. Callers write the content first and the pointer last, so an interrupted write leaves a stale pointer and unreferenced nodes, never a node whose bytes disagree with its hash.

Envelope encryption

EncryptedBackend holds the inner backend and a 32-byte master key. Every put performs envelope encryption:

  value ──▶ AES-256-GCM ──▶ ciphertext        key: fresh random 32-byte DEK
                                              nonce: fresh random 12 bytes
                                              AAD: "envelope-data"

  DEK   ──▶ AES-256-GCM ──▶ encrypted_dek     key: the 32-byte master key
                                              nonce: fresh random 12 bytes
                                              AAD: "envelope-dek"

  { encrypted_dek, dek_nonce, ciphertext, data_nonce }
        ──▶ serde_json ──▶ inner.put(key, json)

The AEAD is AES-256-GCM from the aes-gcm crate, in both positions. There is a ChaCha20-Poly1305 implementation beside it in uninet-crypto, but the envelope path calls the AES functions. Nonces are 96 bits and every one is drawn fresh from OsRng at the moment of encryption — there is no counter and no derivation from the key or the value. The data-encryption key is 32 random bytes per value, generated inside Zeroizing so it is wiped when the encrypt or decrypt call returns.

What the wrapper does and does not touch is worth stating exactly:

| | Behaviour | | --- | --- | | put | value encrypted; key passes through unchanged | | get | value decrypted; a value that fails to authenticate is an error, not a miss | | delete, contains | forwarded to the inner backend untouched | | prefix_iterator | decrypts every match, and fails the whole call if any one fails | | snapshot | wraps the inner snapshot and copies the master key into it |

Both EncryptedBackend and its snapshot zeroize the master key on drop.

Where the key comes from, and where it lives

The node resolves one 32-byte master key at boot, before it opens anything. Source precedence, first match wins:

  1. UNINET_DATA_KEY — 64 hex characters in the environment.
  2. {data_dir}/metadata/data.key — generated on first boot from the OS CSPRNG, written as hex, locked to the owner, and reused on every restart.

load_or_create_master_key accepts a third source between those two — a path to a file holding the same 64 hex characters — but uninet-node defines no flag for it and its one call site passes None, so that path is not reachable from the binary.

If UNINET_KEYSTORE_PASS is set, the bytes resolved above are used as an Argon2id salt and the master key becomes the passphrase derivation instead, so the on-disk material alone does not open the node.

That single key opens everything the node encrypts at rest: the state trie, block history, the node's Ed25519 signing keystore, the relay onion key, and users' file chunks. When it comes from the sealed file, it sits inside the same directory as the data it protects — the node emits a warning saying so at boot, in those terms.

At runtime the key is a Zeroizing<[u8; 32]> resolved in the node's state constructor and copied by value into each EncryptedBackend and into the file store. This is software custody in process memory. uninet-crypto does carry an hsm module and a confidential module, but both hold software implementations only — SoftwareHsm keeps key material in process memory and its own module docs say it is not an HSM — and neither is referenced anywhere outside crates/crypto. Nothing on this key's path touches either. An operator with root on the machine can read the key out of the process; the threat model treats that as a boundary rather than a gap.

How state and blocks compose

The node opens two DiskBackend instances, wraps each in its own EncryptedBackend under the same master key, and hands one to the chain's state tree and the other to its block store.

   {data_dir}/state                    {data_dir}/blocks
        │                                     │
   DiskBackend                          DiskBackend
        │                                     │
   EncryptedBackend ──── master key ──── EncryptedBackend
        │                                     │
   SparseMerkleTrie                     block + receipt store
   trie:node:<hash>                     chain:block:<height>
   trie:root                            chain:receipt:<tx hash>
                                        chain:tip-height

They are separate on purpose, and the reason is in the chain code: the state root has to be a function of state alone, so history cannot live in the trie without two nodes holding identical balances disagreeing about their root.

The state side is write-through. Trie nodes are persisted inline as state mutates, each under trie:node: followed by its own 32-byte hash; the root pointer at trie:root is written separately, and writing it is the commit point. On restart, a trie constructed over a backend that already holds a root adopts that root and faults reachable nodes back in lazily; if no root is present it writes a fresh empty-branch node and root pointer so a later restore is well formed. The chain uses the presence of a non-empty restored root to decide not to re-apply genesis.

The block side writes receipts first, then the block at chain:block:{height}, then the tip height last — a crash mid-write leaves a block replay never reaches rather than a tip pointing at unfinished bytes. On attach, the store replays heights 1 through the recorded tip; a hole or a block that fails to deserialize stops the replay there and is logged, rather than resuming on top of a gap.

The scratch KV is not durable

/api/storage/put, /api/storage/get, /api/storage/delete and /api/storage/snapshot are backed by a MemoryBackend field on the node's application state. It is constructed empty on every start, it is never wrapped in EncryptedBackend, it is never written to disk, and it has nothing to do with the chain. Writes survive until the process exits and no further. All four handlers require an authenticated session. The three that take a key namespace it as kv:{identity}:{key}, so callers cannot read or clobber each other; /snapshot takes no key and reports a timestamp after dropping the snapshot it just made. Namespacing is isolation, not durability.

/api/storage/proof shares the prefix and is a different thing entirely: the caller names a target — one of its own file manifests, or its own storage plan — and the handler derives the trie key from that target and the authenticated identity, proves inclusion or exclusion against the chain's sparse trie, and verifies the proof against the node's current state root. The response carries that root, the node's own verified result, and the node path — withheld when returning it would disclose another identity's value. Nothing in the response binds the root to a signed block, so the root is this node's word for it.

What this design does not address

  • The operator of the machine. The master key is in process memory and, in the default mode, on the same disk as the data. At-rest encryption here defends against a disk that leaves the building. It does not defend against anyone who can read the data directory or attach to the process.
  • Key confidentiality. Storage keys are never encrypted. EncryptedBackend transforms values only, and DiskBackend turns the key into the filename, so a directory listing discloses every trie node hash, every block height and every receipt transaction hash the node holds.
  • Binding a value to its key. The additional authenticated data is the constant string envelope-data or envelope-dek; the storage key is not part of it. A ciphertext therefore authenticates as itself, not as the value of a particular key, and swapping two files' contents produces two reads that both succeed.
  • Deletion and rollback. delete passes straight through and nothing authenticates the set of keys as a whole. Removing a file, or restoring an older one, is not detectable by the read path.
  • Value length. The envelope is stored as JSON in which the byte fields serialize as arrays of decimal numbers, so a stored value is several times its plaintext and its size still tracks the plaintext's. Nothing is padded and nothing is compressed on this path.
  • Concurrent writers and crash atomicity. DiskBackend is a single-node store with no file locking, no fsync and no multi-file commit. What it recovers after a crash comes from the ordering its callers use, and is only as good as that ordering.
  • Rotation. Changing the master key does not re-encrypt anything. Deleting the sealed key file generates a new one, and the previously written state, keystore and file chunks are then unreadable.

How it composes

The state trie beneath the encryption is what the Merkle proof endpoint is served from: the proof path is trie nodes, and those nodes are exactly the values EncryptedBackend seals on the way to disk and opens on the way back.

The same master key opens the node's signing keystore and its relay onion key, which is why losing it costs the node its identity and not merely its data — see running a node for what else that key sits under on the machine.

Everything above lives inside the operator's trust boundary. If the party you are defending against is the party running the hardware, the storage layer is not the control that solves it, and the threat model says which parties UniNet treats as trusted and which attack classes are out of scope today.

What is not finished

Hardware security modules are not implemented, so key custody is software custody in process memory.

Hardware-backed confidential computing is not implemented. If your threat model includes the operator running your workload, UniNet does not solve that today.

See the full build status