Trust model
Neither side can read plaintext alone.- Share 1 - your machine. Holds the inner wrapping key, any derived project keys, and (for owners) the BIP-39 seed phrase. None of these ever leave your machine.
- 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 file but still cannot recover any master key - the inner layer is AES-encrypted under a key derived from K_local, 32 random bytes minted per machine and stored beside key.enc. K_local is never transmitted and cannot be recomputed from any identifier the service knows.
Key hierarchy
One root, everything else derived.
Full derivations:
Organizations created before the current KDF derived M with PBKDF2-SHA512,
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.Each
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.1
Generate the invite token
Your CLI generates Binding the email into the HKDF salt means only the intended recipient can later derive the key that decrypts the blob.
T = randomBytes(32) and derives an inner key bound to the recipient’s email:2
Outer-wrap via the service
Your CLI sends
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.3
Build the redeem code
Your CLI packs a version byte,
T, an expiry timestamp, the org ID, and outerBlob into a single base64 string. The expiry defaults to 7 days and is bound into the service’s outer wrap, so editing it inside the code makes the unwrap fail. You deliver this code to the invitee out-of-band (Signal, paper, QR). The token T never reaches the service.4
Invitee authenticates
The invitee runs
capy redeem <code> and authenticates, receiving an auth token bound to their email.5
Service strips the outer wrap
The invitee sends
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.6
Invitee strips the inner wrap
The invitee derives the inner key locally using the token
T and the email claim from their auth token. AES-GCM decrypts innerBlob and produces M. If the auth email doesn’t match the salt the inviter used, decryption fails cryptographically - not by policy.7
Persist for reuse
The invitee re-wraps
M with the storage scheme - inner AES-256-GCM under HKDF(K_local), outer added by the service - and saves it 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 unlocks your master key, 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. The service sees variable names, resource IDs, and ciphertext - never a plaintext value. 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.
Capy rewrites
.env in place (mode 0600) so every value becomes a capy:{resourceId}:{...} snippet, and the full plaintext only exists in memory, briefly, during 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.
1
Mint
Run
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.2
Encrypt the variables
Your CLI derives
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.3
Store
A connector pushes
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).4
Fetch the service half at build time
Wrap your build or start command in
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.5
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) - a membership deletion on the service side. No key rotation, no re-encryption of secrets.key.enc on disk is outer-wrapped. To use it, they need the service to strip the outer layer, and the service now refuses them, so the blob is cryptographically inert. On top of that, when the service answers a kicked user with an explicit revoked response, their CLI deletes that org’s key.enc, the project key cache, and the local keep.lock.
If the kicked user had already decrypted some values and kept plaintext copies elsewhere, those copies are outside Capy’s control. Rotate the specific secrets they had access to through your normal secret rotation process - the revocation above only prevents new decryption.
Cryptographic primitives
A single reference for every client-side algorithm used on the data path.
There is no asymmetric cryptography on the data path. All confidentiality and authentication comes from AES-256-GCM. Key derivations are HKDF-SHA256 except the two PBKDF2 steps - seed phrase to master key, and the passphrase that wraps
M at rest in local-only mode - which deliberately use a slow KDF against low-entropy or partially-leaked inputs.