Crypto model
One crypto choice everywhere, made once in ADR-0001: age. Every vault impl stores age ciphertext; the same identity unlocks the local file vault and the remote cf vault alike.
The pieces
- Identity — one X25519 keypair, held by an
arca-identityimpl (ADR-0002): the session keyring when one is reachable (secret-service, macOS Keychain, Credential Manager), else the~/.config/arca/identity.txtkeyfile (mode0600). An explicit file —ARCA_IDENTITY_FILEor[vaults.*].identity— pins the keyfile outright; that is the headless/CI path.arca initbootstraps whichever backend applies and prints the publicage1…recipient. - Recipients —
[vaults.*].recipientsencrypts to extra public keys beyond the identity's own — a team recipient list without sharing the private key. - Ciphertext at rest — the local vault is a single age-encrypted file (
vault.age); thecfvault stores per-secret age ciphertext in D1. Neither store — local disk, remote worker — holds plaintext or key material. - Seal/open at the edge — values are plaintext only inside the arca process, and only for as long as the op needs them.
Secretis aZeroizingnewtype: memory is scrubbed on drop, and nothing but.expose()reads it. - HMAC alongside age — the
cfwire uses HMAC-SHA256 twice (P-003, P-004), keyed but unkeyed-payload, complementary to the age envelope: request signing so tokens never travel, andref_keyderivation so storage keys are unlinkable. Neither is a second encryption primitive — it's authentication and deterministic naming.
Why age (and not KMS / libsodium / SSH keys)
- One identity, every backend — a remote vault that stores client-side ciphertext needs an offline asymmetric primitive; age gives recipient-style encryption with no service to trust.
- Dependency posture — the
agecrate (rage's Rust impl) is pulled withdefault-features = false: binary ciphertext, sync API, no async/armor/plugin machinery — the binary stays light per the workspace rules. - Interop for free — the vault file decrypts with stock
rage/agetooling, so a human is never locked into the CLI for disaster recovery.
The cf wire (arca-vault-cf/2)
- Request signing —
Authorization: ArcaHmac <token_id>, <ts>, <sig>withsig = hex HMAC-SHA256(token, method+path+ts+ sha256hex(body)). The token is the HMAC key and never crosses the wire; a ±60s timestamp window bounds replay. Worker-side,ARCA_VAULT_TOKENSmapstoken_id → {env → {read, write}}scopes — per-env credentials instead of one static bearer (which stays accepted during migration). - Private refs — with
[vaults.*] private_refs = true, the storage key isref_key = hex HMAC-SHA256(k, env + "/" + ref)wherek = HMAC-SHA256(identity, "arca-cf/private-refs/v1"). Derivation is deterministic (lookup is a plain keyed read) and identity-bound (rotating the identity re-keys everything, viaarca migrate --private-refs). The real ref travels inside anameblob — age ciphertext — solistmaps keys back while the store sees only hex. - Constant-time compares — every token/signature comparison on the worker is a byte-wise XOR-fold over the longer input with length folded in; no early exit, no prefix oracle.
What is deliberately not here
- No KMS port yet — managed-key backends (AWS KMS/Secrets Manager) were explored in the
arca-vault-awsspike to prove theVaultport generalizes to non-age stores; a KMS-backed crypto port is a later ADR, not v1. - No passphrase-wrapped identity file — when the keyring is unavailable the keyfile is still raw age key material at
0600, like an unencrypted SSH key. A passphrase-wrapped arm of the identity port is a later impl, not a new port. - No signing / integrity metadata beyond what age's AEAD gives — tampered ciphertext fails decryption loudly, which is the correct failure.