The Vault
softfig-vault is the crypto core: it mints and guards the keys, encrypts and
decrypts blobs, signs commits, and holds the unlocked session in the daemon’s memory.
This page explains how the pieces fit and why — the two encryption layers and
their threat models, the key hierarchy, recovery, and how two devices come to trust
each other.
The bare primitive choices (which cipher, which KDF, which curve) are the Cryptography reference; this page assumes them and explains the construction. The day-to-day sealing workflow is the Secrets guide.
Two layers, two threat models
Section titled “Two layers, two threat models”soft-fig encrypts at two independent layers, both rooted in one master key M.
Layer A — everything, at rest. Every blob in the store is encrypted under a
per-blob subkey derived from M. This is always on, and it defends against an
attacker with your disk: a stolen laptop, a lifted backup, a compromised replica.
Without M, the object store is bytes indistinguishable from random. But Layer A is
transparent once the vault is unlocked — the whole point of the FUSE mount is to show
you plaintext — so it does nothing against a threat that reads the mounted garden.
Layer B — selected secrets, even when unlocked. Layer B seals specific things:
whole files (matched by globs in sealed-paths.toml) and inline <vault id="…">
regions inside otherwise-plain files. Its subkeys are also derived from M, but its
job is different — a Layer-B secret stays encrypted past Layer A’s decryption, so it
never appears in the plaintext mount at all. That is what defends against the threats a
memory garden actually invites: a prompt-injected Claude session, an app or tool that
reads a file it shouldn’t, an accidental paste. The mount shows [sealed:<path>] or
[encrypted] where the secret would be, and the only way to the plaintext is the
CLI-and-human-only softfig reveal.
The two layers use domain-separated subkeys so ciphertext from one can never be decrypted as the other, and Layer B files carry a marker byte that distinguishes them on the wire. Both derive per-item subkeys, so compromising one item’s subkey never leaks another’s. The reference table lays the two side by side.
The key hierarchy
Section titled “The key hierarchy”The passphrase never encrypts data. It unwraps a random key K, which wraps
everything else.
passphrase ──Argon2id──▶ unwraps ┐ ├──▶ K (random 32 bytes, the key-encryption key)recovery phrase ──Argon2id──▶ unwraps ┘ │ ├── wraps ──▶ master key M (master/<id>.key) ├── wraps ──▶ Ed25519 identity key ├── wraps ──▶ X25519 transport key └── wraps ──▶ shared-subtree keys SM ──HKDF-SHA-256──▶ per-blob keys (Layer A), per-file / per-region keys (Layer B)Kis random and wrapped once per door:k.selfunder the passphrase and, optionally,k.recoveryunder the recovery phrase. Each wrapping records its own Argon2id costs, so a vault made on a strong machine still unlocks on a weaker one (older vaults fall back toparams.toml). Production cost is deliberately high.Kwraps the long-lived keys under distinct associated-data labels, so one wrapped blob can’t be swapped for another.Mis generational: rotating writes a newmaster/<id>.key, and every commit records themaster_key_idthat encrypted it.- Why the indirection: changing the passphrase, adding or dropping a door, or
raising the KDF cost rewrites about 100 bytes of wrapping.
M, every blob and every commit hash are untouched.
Keys are held in Zeroizing wrappers and wiped when the session drops, which is
what “lock” does. See Daemon & FUSE → Lock and unlock.
Convergent encryption and its trade-off
Section titled “Convergent encryption and its trade-off”Layer A blobs use master-keyed convergent encryption, and it is the hinge that lets
the VCS content-address ciphertext. The nonce is
derived deterministically from the content and the master key —
nonce = BLAKE3-keyed(M, plaintext)[..24] — and the per-blob key is
HKDF(salt = nonce, ikm = M). So identical plaintext under the same M yields
identical ciphertext, hence an identical BLAKE3 address, hence automatic dedup — all
without the store ever seeing plaintext.
The trade-off is honest and bounded: deterministic ciphertext reveals plaintext equality to an attacker who can read the object store (they can tell two blobs hold the same content, though not what it is). That attacker already has your disk, which is squarely inside Layer A’s threat model, and a different master key produces entirely different ciphertext — so key rotation breaks the correlation. The dedup and the content-addressing are worth the leak of equality-under-a-key.
Recovery, as a declared policy
Section titled “Recovery, as a declared policy”At vault creation you see a 12-word BIP39 recovery phrase, once. It is a second
door to the same K: k.recovery is K wrapped under the phrase. Recovering with
the phrase plus a new passphrase writes a fresh k.self and leaves data untouched.
The sharp edge is what “recovery” means with one door. A passphrase alone opens only
k.self, a file of about 100 bytes that exists on this device and nowhere else. Lose
it and the garden is gone, however many ciphertext replicas exist. So soft-fig treats
the recovery shape as something you declare and the tool holds you to:
- Declared policy.
passphrase-only,passphrase+phrase,passphrase+peers, or a combination.vault statusshows the declaration, the wrappings on disk, and any divergence, without a passphrase. With no declaration it reports inferred; it never invents one. Thepeersdoor is declarable but not built. - Passphrase-only is legitimate. A phrase stored badly (photographed, in a notes app) is the weakest door, so dropping it can be a net gain.
- Escrow comes first.
vault escrow exportwrites every wrapping plus non-secret parameters to one inert file, with no passphrase needed.vault escrow verifyproves it actually opens by unwrappingK. An untested backup is not a backup. - Drop is guarded. Removal is authorized by your live session, never by the door being removed, and refuses to remove the last door, to proceed without a verified escrow of what remains, or to remove the door you used. Today that means the passphrase door cannot be dropped.
- Rewrap compensates. With one door, Argon2 cost is the whole brute-force
defence.
vault rewrapre-derivesk.selfunder heavier costs by rewriting that one file. It invalidates outstanding relock tokens and any earlier escrow proof.
Commands: Recovery & escrow. Formats and parameters: Cryptography reference.
Device identity and trust
Section titled “Device identity and trust”Each device holds two long-lived keys (both wrapped under K): the Ed25519
identity key, whose public half is the device’s identity and which signs every
commit, and the X25519 transport key, the static key for the encrypted channel
between devices. The transport key is minted alongside the identity at vault init (and
generated transparently on first unlock for vaults that predate it).
Pairing establishes a mutually-authenticated channel between two devices:
- A Noise
XXhandshake over TCP exchanges and authenticates both static transport keys on first contact (no prior knowledge). Reconnects use the fasterIKpattern, since each side already holds the other’s key. - An Ed25519 attestation binds each device’s identity key to its transport static, so a peer proves the transport key really belongs to that identity.
- A six-digit SAS is derived from the handshake hash and shown on both screens. You compare them out of band; a man-in-the-middle on the LAN produces mismatched codes and is caught. No camera, no PKI.
- On a match, each device records the other in a signed trust ring (
peers.toml), whose every row’s attestation is re-verified on load — a tampered ring is rejected, not silently trusted.
The crucial discipline is that pairing grants nothing but recognition. soft-fig keeps trust questions strictly separate, each answered by a different key:
| Question | Answered by | State |
|---|---|---|
| “Can I talk to this device securely?” | the network ring (peers.toml) |
built |
| “May this device back up my chain?” | an owner-minted replica grant | built |
| “May this device read and write a shared subtree?” | membership of that subtree’s key S |
built (two-member topology) |
| “May this device unlock me?” | a peer-unlock ACL | not built |
Backup trust flows one way, blindly. The owner pushes its signed, content-addressed
ciphertext to a backup host that verifies and stores but cannot decrypt it (the
host never has M). It takes two-sided consent — the host opts in via config and the
owner mints a signed grant naming that peer — and it is fast-forward-only, so a host
can never fork or roll back an honest owner’s history. The owner side tracks per-host
push health, so a backup that quietly stops is reported rather than silent.
Shared subtrees
Section titled “Shared subtrees”A shared subtree is a directory several devices read and write, kept as its own
chain. Its blobs are encrypted
under a key S instead of M, so members work offline and non-members hold nothing
readable. The ceremony and labels are in the
Cryptography reference.
Sis born collaboratively (commit-then-reveal), so no member can bias it.- The honest limit: this protects the key’s birth, not its custody. Every member
holds the full
S, so custody hygiene is rotation. Each blob names theSgeneration that sealed it, so old content stays readable. - Placement is yours. Each device mounts a subtree wherever it likes; the sharer’s path is only a recommendation. Enable and disable are local toggles.
- Today’s limit: built and tested for two paired devices.
The ring, the replica grant, subtree membership, and the unlock ACL are four different keys to four different doors; the code never conflates them.
Related: Cryptography reference (the primitive facts), Version Control (what the signing key signs and how ciphertext is addressed), Daemon & FUSE (where the unlocked session lives), and the Secrets guide (sealing and revealing Layer B).