At-Rest Encryption
At-rest encryption is the node's AES-256-GCM protection for sensitive columns it writes to its metadata DB. It is the ColumnEncryption type in tinycloud-core/src/encryption.rs: a symmetric cipher keyed by a node-derived 32-byte key, with a one-byte version prefix that lets new encrypted writes coexist with pre-existing plaintext. It is an internal node detail, distinct from the user-facing encryption networks.
Role
At-rest encryption lives in Layer 1 and is invisible to clients — there is no API for it. The node uses it to encrypt bytes it owns: delegation/invocation serializations, webhook secrets and hook tickets, and (critically) the sealed private keys of encryption networks. So ColumnEncryption is both a standalone at-rest mechanism and the AEAD building block the network mechanism reuses — see encryption-networks and the overview.
Shape
The encrypted wire format is a single version-prefixed byte string:
0x01 || nonce(12B) || ciphertext || tag(16B)
0x01—VERSION_ENCRYPTED, the only encrypted version. Any other first byte is treated as legacy plaintext and returned as-is on decrypt.- nonce — 12 bytes, freshly generated per encryption (
Aes256Gcm::generate_nonce(&mut OsRng)). - ciphertext + tag — the AES-256-GCM output (the 16-byte GCM tag is appended by the AEAD; the minimum valid encrypted length is therefore
1 + 12 + 16 = 29bytes — anything shorter isEncryptionError::TooShort).
The key is [u8; 32] (256-bit), passed to ColumnEncryption::new and never rotated within an instance.
Mechanics
ColumnEncryption::decrypt does version dispatch on the first byte:
- Empty input → returned empty (
Ok). - First byte
!= 0x01→ legacy plaintext, returned unchanged. This is the gradual-migration path: old rows written before encryption was enabled still read correctly, because CBOR/DAG-CBOR payloads start with bytes like0xa_, never0x01. - First byte
== 0x01→ split off the 12-byte nonce, AES-256-GCM-decrypt the remainder; a wrong key or tampered ciphertext fails the GCM tag check (EncryptionError::Decrypt).
Encryption is unconditional — every encrypt call produces a 0x01-prefixed value with a fresh nonce. The maybe_encrypt/maybe_decrypt free functions take an Option<&ColumnEncryption>: when encryption is not configured they pass bytes through untouched, so call sites stay uniform whether or not a node has encryption keys.
A key node invariant: at call sites, the content hash is always computed on plaintext, then maybe_encrypt is applied — so enabling/disabling column encryption never changes a record's content-addressed identity.
Relationships
Reused as the AEAD inside the encryption-network ECIES wrap (over the X25519 shared secret) and to seal each network's private key at rest; protects delegation/invocation serializations and webhook secrets in the metadata DB; one mechanism of the encryption overview.
Example
A node writes an encrypted column for plaintext hello world:
0x01 || <12 random bytes> || AES-256-GCM(key, nonce, "hello world") || <16-byte tag>
On read, the first byte 0x01 selects the encrypted path; the node slices bytes [1..13] as the nonce and GCM-decrypts [13..] back to hello world. A row written earlier as raw CBOR (first byte 0xa2) read through the same decrypt is returned verbatim — no key needed.
Status & drift
Shipped and stable. The seal-at-rest key for network private keys is a ColumnEncryption derived from b"tinycloud/encryption/network-seal"; in DStack mode that derivation is rooted in the dstack-derived key setup, lifting network private keys into TEE key management (tinycloud-node-server/src/lib.rs:216-221). Code is canonical.
Sources
tinycloud-node:tinycloud-core/src/encryption.rs(ColumnEncryption,VERSION_ENCRYPTED,maybe_encrypt/maybe_decrypt, tests at:99-166)