Cryptography
The cryptographic facts: which primitives soft-fig uses, how keys are wrapped and derived, what the encryption layers are, and what protects the network channel. This page is reference. The how and why is Internals → Vault, and the day-to-day sealing workflow is the Secrets guide.
All primitives come from established Rust crates; soft-fig implements no ciphers of its own. This is alpha software and has not had an independent security audit.
Primitives
Section titled “Primitives”| Concern | Choice |
|---|---|
| AEAD | XChaCha20-Poly1305 (24-byte nonce, 16-byte tag, 32-byte keys) |
| Content hashing (object, tree, and commit addresses) | BLAKE3 (32-byte) |
| Password KDF | Argon2id v1.3. Baseline m = 64 MiB, t = 3, p = 4 (OWASP), raisable with vault rewrap |
| Subkey derivation | HKDF-SHA-256 |
| Commit signing | Ed25519 (per-device identity key), checked with verify_strict |
| Recovery phrase | BIP39, 12 words (128 bits plus checksum), shown once |
| Canonicalization | RFC 8785 JCS for everything hashed or signed |
| Network channel | Noise (snow): XX for pairing, IK for reconnects; X25519, ChaCha20-Poly1305, BLAKE2s |
Key hierarchy
Section titled “Key hierarchy”Three kinds of key, in a chain. The passphrase is never the key that encrypts data.
passphrase ──Argon2id──▶ unwraps ┐ ├──▶ K (random 32 bytes, the key-encryption key)recovery phrase ──Argon2id──▶ unwraps ┘ │ ├── wraps ──▶ master key M (one per generation) ├── 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, not derived. It is generated atvault initand wrapped once per door:k.self(under the master passphrase) and, if you add one,k.recovery(under the recovery phrase). Each wrapping is its own Argon2id derivation with a fresh random 16-byte salt and 24-byte nonce.- 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, and no replica resyncs. Kwraps every long-lived secret in the vault, each under a distinct AEAD associated-data label so one cannot be substituted for another.M → subkeys. HKDF-SHA-256 expands the master key into per-blob keys (Layer A) and per-file / per-region keys (Layer B).- Master-key generations.
vault rotate-keymakes a newMactive. New blobs use it; old blobs name their generation and keep decrypting. - Wrapping format. The current
k.selfformat embeds its own Argon2 costs (and authenticates them as associated data), so the file is self-describing and a rewrap writes exactly one file. Older wrappings that read their costs fromparams.tomlare still readable. Derivation refuses costs Argon2 rejects or anm_costabove 1 GiB, so a malformed file can’t force a huge allocation.
Recovery, escrow, and rewrap
Section titled “Recovery, escrow, and rewrap”| Fact | Detail |
|---|---|
| Doors | passphrase (k.self) and phrase (k.recovery) exist today. A peers door can be declared but no code backs it yet; vault status reports it as a promised-but-absent door. |
| Declared policy | A non-secret recovery.toml records the intended shape (passphrase-only, passphrase+phrase, …). vault status compares it to the wrappings on disk and reports divergence. It needs no passphrase. |
| Passphrase-only risk | k.self is a small file that exists on one device. Losing it with no other door is unrecoverable. Escrow exists to make that survivable. |
| Escrow bundle | One TOML file (about 1 KiB, mode 0600) holding every wrapping of K, the wrapped key files, and the non-secret parameters, each with a BLAKE3 manifest hash. Export is a file copy: it takes no passphrase and works on a locked vault. Deterministic: an unchanged vault exports identical bytes. |
| Why it’s safe to copy | Every member is already ciphertext or a non-secret parameter. The bundle is inert without the master passphrase. |
| Verify | Two stages: re-hash members against the manifest and check it carries every declared door, then (unless --integrity-only) unwrap K from the bundle’s k.self under the passphrase and open every master generation. A passing proof is recorded and bound to the hash of the k.self it proved. |
| Drop guards | vault recovery drop refuses to remove the last wrapping, one without a current verified escrow of what remains, or the door the session authenticated through. Removal is authorized by the live session, never by the door being removed. |
| Rewrap | Re-derives k.self under heavier costs. Without flags it calibrates m_cost to a target unlock time (default 1000 ms), capped at 1 GiB, and won’t go below the baseline or the vault’s current cost without --allow-downgrade. Side effects: outstanding relock tokens become invalid, and a prior escrow proof becomes historical, so re-export and re-verify afterward. |
The two layers
Section titled “The two layers”Two encryption layers with two threat models, both rooted in M:
| Layer A | Layer B | |
|---|---|---|
| Scope | every blob in the store, at rest | selected files (glob) and inline <vault id="…"> regions |
| Subkey | per blob: HKDF(ikm = M, salt = nonce) |
per file: HKDF(ikm = M, salt = path); per region: salt is path and region id |
| Nonce | BLAKE3-keyed(M, plaintext)[..24] |
BLAKE3-keyed(subkey, plaintext)[..24] |
| AAD | softfig.blob.v1 |
softfig.layer-b.v1 |
| In the mount | plaintext, decrypted at read time inside the daemon | placeholder: [sealed:<path>] or <vault id="x">[encrypted]</vault> |
| Defends against | stolen disk, stolen backup, compromised replica | prompt injection, accidental AI or app exposure |
Layer A is always on; Layer B is opt-in per path. Layer B seals stay encrypted past Layer A’s decryption, so a sealed secret never appears in the mounted tree, which keeps it out of a Claude session. The reveal path is CLI- and human-only; see Secrets.
Blobs carry a leading marker so the layers can’t be confused: Layer A starts with
the master-key id, Layer B with 0xFF, and shared-subtree blobs with 0xFE
(Layer A analog) or 0xFD (Layer B analog).
Convergent encryption
Section titled “Convergent encryption”Blob encryption is convergent: the AEAD nonce is derived from the content and the key, so identical plaintext under the same key yields identical ciphertext and an identical BLAKE3 address. That lets the VCS content-address ciphertext and dedup it. The trade-off is inherent: someone holding the ciphertext can tell whether two blobs, or two versions, are equal, but not what they contain.
Shared subtrees
Section titled “Shared subtrees”A shared subtree’s blobs encrypt under a per-chain key S, not the device master
M, so every member reads and writes offline and non-members hold nothing
readable. The construction mirrors Layers A and B, with S in place of M and
its own labels (softfig.shared.v1, softfig.layer-b-shared.v1).
S is generated collaboratively. Each member commits to a random contribution
(BLAKE3(nonce ‖ device_id ‖ r), signed), and only after every commitment is in do
members reveal. Then S = HKDF-SHA-256(salt = ceremony nonce, ikm = sorted contributions, info = "softfig.shared-subtree.v1" ‖ member count ‖ sorted device ids).
Commit-then-reveal denies the last member any power to bias the key. The limit:
this protects the key’s birth, not its custody. Every member holds the full S,
which is why rotation exists.
Network crypto
Section titled “Network crypto”| Concern | Choice |
|---|---|
| Channel | Noise XX (first contact, pairing) and IK (reconnect to a known peer), Noise_*_25519_ChaChaPoly_BLAKE2s |
| Device identity | Ed25519 key; its public key is the device id and fingerprint |
| Transport key | A separate X25519 key (the Noise static), wrapped under K |
| Key binding | Each device signs its own X25519 key with its Ed25519 key (domain-separated, so it can never be replayed as a commit signature). Verified at pairing and on every load of the trust ring. |
| Pairing check | A 6-digit short authentication string from HKDF-SHA-256 over the Noise handshake hash, compared on both devices. A man-in-the-middle produces different codes on the two legs. Each attempt is online and one-shot, so the per-attempt collision chance is 1 in 10^6. |
| Relay | Blind: devices authenticate to it by ring membership over an outer IK session, and it splices inner end-to-end Noise sessions it cannot read. There is no open relay. |
| Backup hosts | Store signed, content-addressed ciphertext only. They hold no keys and learn nothing about content. |
What gets signed
Section titled “What gets signed”Commits and trees are canonicalized with RFC 8785 JCS before hashing, so the
BLAKE3 address is stable regardless of field order. A commit’s hash is signed with
the device’s Ed25519 identity key and verified with verify_strict on read. The
signed, intent-classified chain is Internals → VCS.
Relock token
Section titled “Relock token”A short-lived convenience for unattended daemon restarts. The live K is wrapped
under a random 256-bit token (full entropy, so a single HKDF step and no Argon2),
written to 0600 tmpfs with an authenticated 20-minute expiry. It is bound to the
vault by a fingerprint (the BLAKE3 hash of k.self) and is minted only if you
set allow_relock in the local bootstrap config. See
Config Files.
Related: Internals → Vault, Secrets, CLI → The vault, and Internals → VCS.