Skip to content

At-Rest Encryption

Tellstone can encrypt data written to disk with ChaCha20-Poly1305: values persisted to the write-ahead log and sealed audit records are encrypted before they leave memory. Encryption is off by default and adds no overhead when disabled.

Encryption needs exactly one key source. The server refuses to start with --enable-encryption unless one of the two sources below is provided — it never silently falls back to plaintext.

Terminal window
./bin/tellstone --enable-encryption --encryption-key "$(openssl rand -base64 32)"
FlagEnv varDefaultMeaning
--enable-encryptionTSD_ENABLE_ENCRYPTIONfalseEnable at-rest encryption
--enable-envelopeTSD_ENABLE_ENVELOPEfalseEnvelope encryption — wrap a per-shard random DEK with the configured key; requires --enable-encryption
--encryption-keyTSD_ENCRYPTION_KEY(none)Base64-encoded 32-byte key; raw 32-character value accepted but deprecated
--encryption-key-fileTSD_ENCRYPTION_KEY_FILE(none)Path to a file holding the raw 32-byte key; mutually exclusive with --encryption-key

The flag carries the key base64-encoded because process arguments and environment variables are NUL-terminated: roughly 1 in 8 random 32-byte keys contains a 0x00 byte that cannot survive them. Base64 is text-safe.

Terminal window
./bin/tellstone \
--enable-encryption \
--encryption-key "$(openssl rand -base64 32)"

Both the padded (...==) and unpadded base64 forms are accepted.

The file carries the same key as raw, unencoded bytes, which suits a mounted Kubernetes Secret or a vault-agent-rendered file. Every byte of the file is significant, so it must be exactly 32 bytes with no trailing newline — a stray newline makes it 33 bytes and the key is rejected.

Terminal window
# umask 077 in a subshell so the file is created 0600; a later chmod would
# leave the key world-readable in between.
(umask 077; head -c 32 /dev/urandom > /etc/tellstone/key)
./bin/tellstone --enable-encryption --encryption-key-file /etc/tellstone/key

The two sources are mutually exclusive: setting both is rejected at startup.

Mount the Secret and point --encryption-key-file at the projected file:

volumeMounts:
- name: encryption-key
mountPath: /etc/tellstone/encryption
Terminal window
./bin/tellstone \
--enable-encryption \
--encryption-key-file /etc/tellstone/encryption/key

Unlike certificate rotation, the encryption key is read once at startup; rotating it requires a restart, which necessarily re-encrypts nothing already on disk under the old key.

By default a single key seals everything, so a stolen key decrypts the whole dataset. --enable-envelope upgrades this to envelope encryption: the configured key becomes a Key Encryption Key (KEK) that wraps a per-shard Data Encryption Key (DEK).

Terminal window
./bin/tellstone \
--enable-encryption \
--enable-envelope \
--encryption-key "$(openssl rand -base64 32)"

--enable-envelope requires --enable-encryption and a key source; the server refuses to start without them.

Each shard generates its own random 32-byte DEK on first boot. That DEK is wrapped (encrypted) with the KEK and stored beside the data the shard protects as shard-<n>.env:

[version:1][KEK fingerprint:16][wrapped DEK: nonce(12) + ct(32) + tag(16)]

The KEK never touches data — values are sealed by the shard’s DEK engine, and the KEK exists only to protect the DEK at rest. On restart each shard loads its envelope and unwraps its own DEK.

Envelope mode buys three things over the single-key mode:

  • Key isolation. One compromised shard DEK does not decrypt the others.
  • Separation of duties. The KEK is read once at startup and used only to wrap DEKs; no shard ever seals data with it.
  • Colocated key and data are safe. A wrapped DEK is worthless without its KEK, so envelope files can live alongside the data they protect.

Envelope files live in the persistence directory when --enable-persistence is set, or the platform default data directory (~/.local/share/tellstone/data on Linux) otherwise. They are created even when persistence is disabled, so a DEK stays durable independently of the WAL. Envelope directories and files are created with 0700 / 0600 permissions.

Like the single-key mode, the KEK is read once at startup. Each envelope carries a fingerprint of the KEK that wrapped it, so a restart with a changed KEK fails closed with a fingerprint-mismatch error instead of silently generating fresh DEKs and bricking the dataset.

tellstone kek rewrap performs the offline rewrap: it loads every shard-<n>.env and audit.env in the data directory, verifies each envelope against the old KEK, re-wraps the DEK with the new KEK, and writes the updated envelope back to disk. The server must be stopped before running this command.

Terminal window
tellstone kek rewrap \
--data-dir ~/.local/share/tellstone/data \
--key-file /etc/tellstone/old.key \
--new-key-file /etc/tellstone/new.key \
--retain-old-keys
FlagEnv varMeaning
--data-dirTSD_DATA_DIRDirectory holding shard-*.env, audit.env, and data files
--key-fileTSD_KEY_FILEPath to the current (old) KEK file — raw 32-byte key
--new-key-fileTSD_NEW_KEY_FILEPath to the new KEK file — raw 32-byte key
--retain-old-keysKeep a .bak copy of each original envelope before rewriting

Both key files must be exactly 32 raw bytes (no trailing newline). The two key sources are mutually exclusive with --encryption-key / --encryption-key-file — this command always reads raw key files.

  • Fail-closed pre-scan. Before any file is rewritten, every envelope in the directory is verified against the old KEK. If any fingerprint is unrecognized, the command aborts with an error and no files are touched.
  • Idempotent. A crashed rewrap can be recovered by running the same command again. Envelopes already rewrapped with the new KEK (new fingerprint) are skipped silently; only old-fingerprint envelopes are processed.
  • Atomic per-file. Each envelope is written to a temporary file, fsynced, and renamed into place. A power failure mid-rewrap leaves at most one file in its old state — never half-written.
  • DEK preservation. The data encryption keys are never touched. Only the envelope wrapper changes (old KEK → new KEK), so existing data remains readable by the same DEK engine.

--retain-old-keys creates a .bak copy of each envelope before rewriting it. The backup holds the original DEK wrapped with the old KEK, so you can roll back if something goes wrong:

Terminal window
# roll back: rename .bak files back
for f in ~/.local/share/tellstone/data/*.env.bak; do
mv "$f" "${f%.bak}"
done

After a successful rewrap the output confirms how many files were processed:

rewrap complete: 3 rewrapped, 0 skipped, 3 total
  • rewrapped — envelopes where the DEK was re-wrapped with the new KEK.
  • skipped — envelopes already carrying the new KEK fingerprint (idempotent no-op).
  • total — all *.env files found in the directory.
  1. Generate a new KEK file:
    Terminal window
    (umask 077; head -c 32 /dev/urandom > /etc/tellstone/new.key)
  2. Stop the Tellstone server.
  3. Run the rewrap:
    Terminal window
    tellstone kek rewrap \
    --data-dir ~/.local/share/tellstone/data \
    --key-file /etc/tellstone/old.key \
    --new-key-file /etc/tellstone/new.key \
    --retain-old-keys
  4. Start the Tellstone server with the new key:
    Terminal window
    ./bin/tellstone \
    --enable-encryption \
    --enable-envelope \
    --encryption-key-file /etc/tellstone/new.key
  5. Optionally remove old key and .bak files once verified.
  • Mixed keys. If some envelopes carry a fingerprint that matches neither the old nor the new KEK, the command aborts with an error. This protects against partial rotations from interrupted prior runs.
  • Wrong old key. If --key-file does not match the fingerprint on any envelope, verification fails before any file is rewritten.
  • No envelope files. If the directory contains no *.env files, the command exits with a clear error.

In envelope mode the audit log gets a DEK of its own — never the KEK, never a shard’s DEK — wrapped by the KEK and stored as an audit.env envelope beside the audit records, mirroring how each shard seals its data. A stdout audit destination is never persisted, so no envelope is created and records stay plaintext.

Before base64 decoding was added, --encryption-key used its value as raw text, so a literal 32-character key such as 0123456789abcdef0123456789abcdef was accepted. That form still works and logs a warning at startup, but it is insecure and will be removed in the next major release.

The two forms are never ambiguous: a base64 key is 44 characters (43 unpadded), while a 32-character value can only decode to 24 bytes. Re-encode an existing raw key with base64 < <keyfile>, or move it to --encryption-key-file.