Skip to main content
Capy is zero-trust: the service stores your encrypted secrets but can never decrypt them on its own. Every decryption requires two independent shares - one held by you, one held by the service. This page walks through the complete client-side cryptography, from the root seed phrase down to the exact bytes your app decrypts at runtime. Service-internal constructions (how the outer wrap is stored, how the service gates co-decrypt requests) are intentionally out of scope here. The guarantee you should rely on is that the service holds only ciphertext and membership records, never key material in recoverable form.

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.
Trust model: your machine holds the seed phrase, master key, project keys, and an inner wrap key. Capy's service holds identities, memberships, the ciphertext, and an outer wrap. Both halves are needed to co-decrypt.
If the service is fully compromised, an attacker can strip the outer layer off every 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.
Key hierarchy: BIP-39 seed phrase derives master key M via PBKDF2. Master key M derives project key PK via HKDF-SHA256. The project key AES-256-GCM encrypts each value into a capy:... snippet.
The seed phrase is the only long-lived secret the system relies on. Every other key is either derived from it on demand or generated fresh for a single purpose and then discarded. 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.
Invite flow: Alice generates token T, computes inner = AES(M, HKDF(T, email)), sends inner to the service. The service produces an outer-wrapped blob and returns it. Alice builds code = b64(T, orgId, outer) and sends it out-of-band to Bob. Bob sends outer with his auth to the service co-decrypt endpoint; the service verifies membership, strips the outer wrap, and returns inner to Bob. Bob derives M by inverting the AES step using HKDF(T, email).
1

Generate the invite token

Your CLI generates T = randomBytes(32) and derives an inner key bound to the recipient’s email:
Binding the email into the HKDF salt means only the intended recipient can later derive the key that decrypts the blob.
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.
The redeem code carries both T and outerBlob, so treat it like a password and deliver it over a channel you trust. It is not enough on its own: stripping the outer wrap takes an authenticated, active member of the org, and the inner key is bound to the invitee’s email address, so an interceptor who is not that person fails at both gates. Codes also expire - 7 days by default.

Sync: pushing and pulling secrets

When you run capy, 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.
Sync flow: CLI posts to the service co-decrypt endpoint to get the master key M. CLI derives the project key PK. CLI fetches the current ciphertext blob from the service, decrypts per-variable, diffs against local, re-encrypts merged values, and sends the new blob back.
What goes up is one blob of 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):
Deploy mint flow: capy deploy generates a deploy ID and a one-time token DT, wraps the project key under HKDF(DT, projectId), and POSTs the inner blob to the service. The service KMS-wraps it and returns an opaque outer blob. The CLI encrypts the variables and packs everything into SECRETS_BLOB for the user's platform.
Build time (inside the CI runner):
Deploy build flow: capy run parses SECRETS_BLOB and posts the outer blob to the service deploy decrypt endpoint. The service checks the deploy has not been revoked, strips the outer wrap, and returns SERVICE_KEY. The runner combines it with PROJECT_KEY to derive the decryption key and decrypts the variables in memory.
The crucial detail is that the service never learns the project key. It only ever holds the wrapped inner blob, and what it returns is 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.
Revocation flow: an owner runs capy kick with the member's email; the service deletes the user's membership and returns ok. Later the kicked user runs any capy command and posts to the co-decrypt endpoint; the service checks membership, fails, returns 403. Their key.enc is cryptographically inert because the outer wrap needs the service and the service now refuses.
Why it’s safe to skip re-encryption: the kicked user’s 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.
The one case that does require full rotation: seed phrase compromise. A user who copied their BIP-39 seed phrase can derive M offline, bypassing the service’s co-decrypt gate entirely. If you suspect seed-phrase exfiltration, generate a new seed, derive a new M, re-encrypt every secret, and re-invite every member.

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.
Last modified on August 11, 2026