Skip to main content
For cloud and BYOC profiles, Capy stores encrypted secrets and ordinary on-disk key files use two independent shares: one held by the client and one held by the service. This page walks through the client-side cryptography, from the root seed phrase down to the bytes supplied to your app at runtime. Local-only profiles use a passphrase-held local wrap instead. Service-internal constructions (how the outer wrap is stored, how the service gates co-decrypt requests) are intentionally out of scope here. The relevant storage guarantee is narrower: the service’s stored secret values and ordinary key files are ciphertext, while its outer-wrap service key cannot by itself recover a client key.

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.
Trust model: your machine holds the recovery material, protected key file, 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.Trust model: your machine holds the recovery material, protected key file, 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 an attacker compromises service storage alone, ordinary 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 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.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 recovery root for an organization. Machine-held key.enc and local.key are also long-lived; the remaining values are derived on demand or generated for one purpose and 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 completes recipient-bound access configuration.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 completes recipient-bound access configuration.
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. 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.
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

Complete recipient-bound access

The invitee derives the inner key locally using the token 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.
7

Persist for reuse

The invitee saves the protected organization access file using the storage scheme - inner AES-256-GCM under 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.
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 default to a 12-hour lifetime and cannot exceed it.

Sync: pushing and pulling secrets

When you run capy, 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.
Sync flow: CLI posts to the service co-decrypt endpoint for an authenticated response. 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.Sync flow: CLI posts to the service co-decrypt endpoint for an authenticated response. 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. 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.
During sync, Capy rewrites .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):
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.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.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) for future service-assisted unwraps of ordinary on-disk key files. It does not itself rekey 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 posts an ordinary on-disk key file to the co-decrypt endpoint; the service checks membership, fails, and returns 403. The service no longer removes its outer wrap.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 posts an ordinary on-disk key file to the co-decrypt endpoint; the service checks membership, fails, and returns 403. The service no longer removes its outer wrap.
For an ordinary 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.
Offline-key compromise requires rekeying. A copied BIP-39 seed phrase can derive M offline. Rotate or rekey the affected scope; for a compromised seed phrase, generate a new seed and M, re-encrypt secrets, and re-invite members.

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:
The service stores the sealed package. The link’s fragment carries the transport ID and 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.
The Transport envelope and the organization key file are separate encryption layers. Opening the transport package exposes 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

1

Generate a receiving key pair

Run 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.
2

Authenticate and unlock Keep

Open the link in the browser where you activated Transport. Sign in and unlock the stored key material. Keep obtains the receiving CLI’s public key for that device request and prepares the stored entries for the signed-in user.Authentication establishes which account is approving the request. Browser unlock makes its stored key material available for encryption. These are separate from the ECDH operation that protects the transfer.
3

Derive an envelope key in the browser

Keep creates a fresh ephemeral P-256 key pair for the envelope. It combines its private key with the receiving CLI’s public key using ECDH, then derives the encryption key:
The receiving CLI can compute the same shared secret using its private key and the browser’s public key. Neither private key nor the shared secret is sent to the relay.
4

Seal the key files

Keep serializes a versioned payload containing one or more organization/user entries. Each entry carries org_id, user_id, k_local (the 32-byte local root encoded as base64url), and key_enc (the existing key file’s JSON text).
Keep sends the sealed envelope to the device-pairing endpoint. The envelope contains only its version, the browser’s ephemeral public key, the IV, and ciphertext with the 16-byte GCM authentication tag appended. Binary fields use base64url encoding.
5

Confirm the account on the receiving machine

After browser authorization, the CLI receives the device-grant result and displays the returned account. It asks whether to enable this location as that account, with No as the default. Until you explicitly confirm, it installs neither the session nor the keys. This confirmation is required even with --json.After confirmation, the CLI installs the authenticated session and uses it to request the sealed envelope from POST /device-pairings/pickup.
6

Open the envelope locally

The CLI computes 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.
7

Install the matching entries

The CLI selects only entries whose 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.
Approve only a pairing request you initiated, and check the account displayed by the receiving CLI. Pairing grants that machine usable local key material. Encrypted transport protects the relay path; it does not protect keys from malicious code on an unlocked endpoint.
If no matching keys are available, the CLI reports 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. Device pairing uses asymmetric P-256 ECDH key agreement; AES-256-GCM provides authenticated encryption for its payload and for secret values. 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 October 2, 2026