Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.capy.sc/llms.txt

Use this file to discover all available pages before exploring further.

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 with a value bound to each user’s identity and never stored server-side.

Key hierarchy

One root, everything else derived.
Key hierarchy: BIP-39 seed phrase derives master key M via PBKDF2-SHA512. 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.
KeySizeScopeOrigin
BIP-39 seed phrase24 wordsOrganization rootUser-generated
Master key M32 bytesOrganizationPBKDF2-SHA512
Project key PK32 bytesProjectHKDF-SHA256
Inner wrapping key32 bytesLocal storageSHA-256
Invite token T32 bytesOne-time inviteRandom
Deploy token DT32 bytesOne deploymentRandom
Full derivations:
M  = PBKDF2-SHA512(
       seed,
       salt = "capy-mnemonic",
       iter = 2048,
       keylen = 32)

PK = HKDF-SHA256(
       M,
       salt = orgId,
       info = "capy:project:{projectId}",
       length = 32)

inner_wrap = SHA-256(userId + ":" + orgId)
T          = randomBytes(32)
DT         = randomBytes(32)
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.
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:
innerKey = HKDF-SHA256(
  T,
  salt = "{orgId}:{email}",
  info = "capy:invite")

innerBlob = AES-256-GCM(M, innerKey)
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 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.
The redeem code contains the token T. Anyone who intercepts it plus the separately-delivered outerBlob could recover M. In practice the service holds outerBlob behind authentication, so T alone is useless - but treat the redeem code like a password and deliver it over a channel you trust.

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.
The service only ever sees opaque 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):
Deploy mint flow: capy deploy CLI generates DT, wraps the project key under HKDF(DT, projectId), and POSTs the inner blob to the service. The service returns deployId plus an opaque outer blob. The CLI builds the deploy code for the user to paste into their CI secret store.
Build time (inside the CI runner):
Deploy build flow: the build step parses the deploy code, posts the outer blob to the service deploy decrypt endpoint. The service checks the token has not been revoked and returns enough material for the runner to reconstruct the project key locally. The runner writes it to .capy/decrypt and discards the deploy token.
The crucial detail is that the service never learns the project key. It only ever holds the outer-wrapped form, which is useless without the customer-held 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.
Revocation flow: Owner runs capy kick bob; 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. The user's key.enc remains on disk but 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. 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.
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.
PurposeAlgorithmParameters
Symmetric encryptionAES-256-GCMIV = 12 random bytes, tag = 16 bytes
Key derivationHKDF-SHA256Per-use salt and info - see key hierarchy
Seed → master keyPBKDF2-SHA512salt="capy-mnemonic", iter=2048, keylen=32
Inner wrap + resource IDsSHA-256Deterministic hashing
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.