Skip to content

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.

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

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 S
M ──HKDF-SHA-256──▶ per-blob keys (Layer A), per-file / per-region keys (Layer B)
  • K is random, not derived. It is generated at vault init and 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.
  • K wraps 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-key makes a new M active. New blobs use it; old blobs name their generation and keep decrypting.
  • Wrapping format. The current k.self format 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 from params.toml are still readable. Derivation refuses costs Argon2 rejects or an m_cost above 1 GiB, so a malformed file can’t force a huge allocation.
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.

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).

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.

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.

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.

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.

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.