Trust model
Ordinary cloud and BYOC key-file access requires both local configuration and authenticated service cooperation.- Share 1 - your machine. Holds the inner wrapping key, any derived project keys, and (for owners) the BIP-39 seed phrase. Transport and pairing move local key material only inside encrypted envelopes for another device; they do not expose it as service-readable plaintext.
- Share 2 - Capy service. Holds the outer encryption layer and the membership records that gate every decrypt request. The service never sees plaintext and cannot derive any user key on its own.
key.enc files still need the client-held inner key. K_local is 32 random bytes stored beside key.enc; it is not provided to the service as usable plaintext. Transport and pairing may carry it inside encryption envelopes for the receiving browser or machine.
Key hierarchy
One root, everything else derived.key.enc and local.key are also long-lived; the remaining values are derived on demand or generated for one purpose and discarded.
salt="capy-mnemonic", 2048 iterations. That value can never change without re-keying the whole org, so Capy detects the older parameters by trial decryption during recovery rather than migrating them.capy:… snippet also carries a 5-character resourceId: SHA-256(branch + ":" + name) mapped onto a 31-character alphabet. It is a stable handle for the variable in keep.lock, so Capy can track a variable across pushes even though the ciphertext changes on every re-encryption. It is derived from public inputs only, so it is not a secret - two projects that use the same variable name on the same branch get the same resourceId.Inviting a new member
Capy’s invite flow is “double-wrapped”: the master key is encrypted first by a client-side key derived from a one-time token, then again by the service. The token travels out-of-band to the invitee. The service never sees it.Generate the invite token
T = randomBytes(32) and derives an inner key bound to the recipient’s email:Outer-wrap via the service
innerBlob to POST /orgs/{orgId}/wrap. The service adds its outer wrap and returns an opaque outerBlob. The service never sees the inner key or M.Build the redeem code
T, an expiry timestamp, the org ID, and outerBlob into a single base64 string. Invite lifetime defaults to 12 hours and is capped at 12 hours. The expiry is bound into the service’s outer wrap, so editing it inside the code makes the unwrap fail. You deliver this code out-of-band. The token T never reaches the service.Invitee authenticates
capy redeem <code> and authenticates, receiving an auth token bound to their email.Service strips the outer wrap
outerBlob to POST /orgs/{orgId}/co-decrypt with their auth. The service verifies org membership - if the user is not an active member, the request fails here, and they cannot proceed. Otherwise, the service returns innerBlob.Complete recipient-bound access
T and the email claim from their auth token. AES-GCM validates the recipient-bound encrypted material. If the auth email doesn’t match the salt the inviter used, decryption fails cryptographically - not by policy.Persist for reuse
HKDF(K_local), outer added by the service - to ~/.capy/orgs/{orgId}/users/{userId}/key.enc, with K_local beside it in local.key. The one-time token T is discarded. Because the inner layer is keyed to that machine’s K_local, copying key.enc on its own to another machine does not carry access.Sync: pushing and pulling secrets
When you runcapy, the CLI verifies access, pulls the latest encrypted secrets from the service, diffs them against your local .env, and writes any changes back. Every secret is encrypted with AES-256-GCM under the project key.
NAME=capy:{resourceId}:{base64(iv || ciphertext || tag)} lines, plus keep.lock. On this normal sync path, the service sees variable names, resource IDs, and ciphertext rather than plaintext values. Every encryption draws a fresh random IV, so the blob bytes differ on every push; what stays stable is the content-addressed keep.lock hash - SHA-256 over the sorted variable names, resource IDs, and per-value hashes - which is how Capy tells whether local, pinned, and remote have drifted.
.env in place (mode 0600) so every value becomes a capy:{resourceId}:{...} snippet and handles full plaintext in memory for the diff. Each line does keep a short plaintext preview spliced around the ciphertext - at most the first 4 and last 6 characters of the value - so you can recognise a secret at a glance. Keep treating .env as sensitive.Deploying to production
Deploying an app adds a second zero-trust flow. You can’t just ship your master key to CI - that would collapse the two-share property. Instead,capy deploy mints two values for your platform’s environment: SECRETS_BLOB, which carries your encrypted variables, and PROJECT_KEY, the hex project key. Neither decrypts anything alone. The key that opens the blob is derived from PROJECT_KEY combined with a SERVICE_KEY that only Capy can produce, and Capy hands SERVICE_KEY back only for a deploy that has not been revoked.
Mint time (on the developer machine):
SERVICE_KEY - half of the decryption key. The other half lives in your platform as PROJECT_KEY, so neither side can open the blob on its own.
Mint
capy deploy. Your CLI generates a 32-byte deployId and a one-time DT, wraps PK with HKDF(DT, salt=projectId, info="capy:deploy") into innerBlob, and posts innerBlob to POST /orgs/{orgId}/deploy. The service wraps it with its own KMS layer and returns outerBlob.Encrypt the variables
SERVICE_KEY = HKDF(innerBlob, salt=projectId + hex(deployId), info="capy:deploy:service-key") and DECRYPT_KEY = HKDF(PK || SERVICE_KEY, salt=deployId, info="capy:deploy:decrypt"), encrypts your variables as one AES-256-GCM JSON payload under DECRYPT_KEY, and packs deployId, outerBlob, and that ciphertext into SECRETS_BLOB. DT is discarded.Store
SECRETS_BLOB and PROJECT_KEY into your platform for you, or capy deploy shows both values so you can paste them into your secret store (GitHub Actions secrets, Vercel env vars, and so on).Fetch the service half at build time
capy run. It reads SECRETS_BLOB and PROJECT_KEY from the environment and posts the outer blob to POST /deploy/{deployId}/decrypt. The service checks the deploy has not been revoked, strips its KMS layer, and returns SERVICE_KEY.Decrypt in memory
capy run re-derives DECRYPT_KEY from PROJECT_KEY and SERVICE_KEY, decrypts the variables, and passes them to your child process. Nothing decrypted is written to disk - the only file it emits is .capy/next-env.js, which maps variable names to process.env lookups for Next.js build-time inlining.Revocation
Removing a member is O(1) for future service-assisted unwraps of ordinary on-disk key files. It does not itself rekey secrets.key.enc on disk, the kicked user needs the service to strip the outer layer. The service refuses that request after membership is deleted. When the service sends an explicit revoked response, the CLI deletes that org’s key.enc, the project-key cache, and the local keep.lock.
Transport and device pairing
Pairing gives another machine access to keys you already hold. It does not create an organization, generate a new master key, or invite another member. The receiving CLI authenticates as your account and installs the encrypted key files supplied by your unlocked Keep browser. There are two transfers:capy transport moves an existing machine’s key material into Keep; capy pair moves that material from Keep to the receiving machine. In CLI 0.9.7, Transport uses a symmetric envelope, while Pair uses asymmetric key agreement to seal its envelope to one receiving CLI process.
Prepare the browser with Transport
On a machine that already has access,capy transport packages the organization’s local.key and key.enc. Transport v4 generates a fresh random 32-byte secret S and encrypts the package with AES-256-GCM:
S; the CLI does not send S to the service. After authenticated activation, Keep retrieves the package and decrypts it in the browser. Keep protects the imported material with its browser unlock mechanism for later pairing.
K_local and the existing key.enc file to the browser; it does not itself unwrap the organization master key. The normal cloud/BYOC key.enc retains its inner and service-held outer wraps.Pair the receiving machine
Generate a receiving key pair
capy pair on the machine you want to add. The CLI creates a one-time P-256 key pair and sends its public key with POST /auth/device/authorize. The private key stays in that CLI process and is not persisted.The response supplies a device code, a short user code, and the polling interval and expiry. The CLI displays a QR code and a Keep link containing the user code, then polls the device-token endpoint while you approve in the browser.Authenticate and unlock Keep
Derive an envelope key in the browser
Seal the key files
org_id, user_id, k_local (the 32-byte local root encoded as base64url), and key_enc (the existing key file’s JSON text).Confirm the account on the receiving machine
--json.After confirmation, the CLI installs the authenticated session and uses it to request the sealed envelope from POST /device-pairings/pickup.Open the envelope locally
ECDH(cliPrivateKey, browserPublicKey) and repeats the same HKDF derivation. It decrypts with AES-256-GCM and verifies the authentication tag using the same additional data. A wrong private key or a modified ciphertext fails to decrypt.The relay handles the sealed envelope; it is not given either endpoint’s private key or the derived envelope key. Account authorization and the trusted delivery of the public key remain necessary: ECDH alone does not identify the approving user.Install the matching entries
user_id matches the authenticated account. It writes local.key and key.enc under that organization’s user directory. It refuses to overwrite a different existing local key unless you explicitly use --force.This copies the existing key material; it does not generate a new master key or re-encrypt the organization’s secrets. Subsequent secret access uses the ordinary key-unwrapping and membership checks described above.PAIR_NO_KEYS and directs you to run capy transport on a machine that has them. A terminated CLI process loses its one-time private key: start a new pairing request rather than expecting an old envelope to work in another process.
See Transport, Pair, and Switching computers for command examples.
Cryptographic primitives
A single reference for every client-side algorithm used on the data path.M at rest in local-only mode - which deliberately use a slow KDF against low-entropy or partially-leaked inputs.