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 with a value bound to each user’s identity and never stored server-side.
Key hierarchy
One root, everything else derived.
Full derivations:
Each
capy:… snippet also includes a 5-character resourceId - a stable per-project identifier for the variable that lets Capy diff ciphertext without leaking plaintext or revealing that two projects share a value.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 concatenates
T, the org ID, and outerBlob into a single base64 string. You deliver this code to the invitee out-of-band (Signal, paper, QR). The service never sees the redeem code.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 same double-wrap scheme (SHA256(userId:orgId) inner, service-added outer) and saves it to ~/.capy/orgs/{orgId}/users/{userId}/key.enc. The one-time token T is discarded.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.
capy:{resourceId}:{iv||ciphertext||tag} snippets. Identical variable sets produce identical content-addressed blobs, so uploads are deduplicated.
Your
.env on disk never contains plaintext. Capy rewrites it in place with capy:{...} snippets after every sync. The plaintext only exists in memory, briefly, during the diff.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 a deploy token DT that is itself double-wrapped: you keep one half as a CI secret, the service keeps the other half, and at build time your CI runner presents its half to the service to get the second half back.
Mint time (on the developer machine):
DT half.
1
Mint
Run
capy deploy. Your CLI generates DT, wraps PK with HKDF(DT, salt=projectId, info="capy:deploy"), sends the inner blob to the service for outer-wrapping, and receives a deployId.2
Store
Your CLI prints the deploy code. You paste it into your CI secret store (GitHub Actions secrets, Vercel env vars, etc.).
3
Bootstrap at build time
Your build step reads the deploy code, posts the outer portion to
POST /deploy/{deployId}/decrypt, and receives the material it needs to reconstruct PK locally using its own DT-derived key.4
Reconstruct and discard
The runner derives
PK locally, writes .capy/decrypt (hex of PK), and discards DT. From here on the app reads decrypted values the usual way.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. Their bytes are still on disk but cryptographically inert.
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. All key derivations are HKDF-SHA256 except the one-shot seed-to-master conversion, which uses PBKDF2 to slow down brute force against the BIP-39 wordlist.