> ## Documentation Index
> Fetch the complete documentation index at: https://capy.sc/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Capy Cryptography: BIP-39 to AES-256-GCM

> End-to-end Capy cryptography: BIP-39 seed → PBKDF2 master key → HKDF project key → AES-256-GCM ciphertext. Invite, deploy, and revocation primitives explained.

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.

<Frame>
  <img src="https://mintcdn.com/capy-c95c79c0/N7qpVCkQCaY3Lo-w/images/diagrams/01-trust.svg?fit=max&auto=format&n=N7qpVCkQCaY3Lo-w&q=85&s=92317b2d9e087280ef27d88c472bdd0f" className="block dark:hidden" alt="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." width="368" height="192" data-path="images/diagrams/01-trust.svg" />

  <img src="https://mintcdn.com/capy-c95c79c0/N7qpVCkQCaY3Lo-w/images/diagrams/01-trust-dark.svg?fit=max&auto=format&n=N7qpVCkQCaY3Lo-w&q=85&s=0fe4c161d6749e8f4b355992fa5c9d11" className="hidden dark:block" alt="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." width="368" height="192" data-path="images/diagrams/01-trust-dark.svg" />
</Frame>

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.

<Frame>
  <img src="https://mintcdn.com/capy-c95c79c0/N7qpVCkQCaY3Lo-w/images/diagrams/02-keys.svg?fit=max&auto=format&n=N7qpVCkQCaY3Lo-w&q=85&s=270e62be5659f2aea4d7ee82b350a626" className="block dark:hidden" alt="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." width="414" height="384" data-path="images/diagrams/02-keys.svg" />

  <img src="https://mintcdn.com/capy-c95c79c0/N7qpVCkQCaY3Lo-w/images/diagrams/02-keys-dark.svg?fit=max&auto=format&n=N7qpVCkQCaY3Lo-w&q=85&s=fc5f1538111c826a6a5439f4aa2e8599" className="hidden dark:block" alt="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." width="414" height="384" data-path="images/diagrams/02-keys-dark.svg" />
</Frame>

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.

| Key                 | Size     | Scope             | Origin         |
| ------------------- | -------- | ----------------- | -------------- |
| BIP-39 seed phrase  | 24 words | Organization root | User-generated |
| Master key **M**    | 32 bytes | Organization      | PBKDF2-SHA512  |
| Project key **PK**  | 32 bytes | Project           | HKDF-SHA256    |
| Inner wrapping key  | 32 bytes | Local storage     | SHA-256        |
| Invite token **T**  | 32 bytes | One-time invite   | Random         |
| Deploy token **DT** | 32 bytes | One deployment    | Random         |

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)
```

<Note>
  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.
</Note>

## 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.

<Frame>
  <img src="https://mintcdn.com/capy-c95c79c0/N7qpVCkQCaY3Lo-w/images/diagrams/03-invite.svg?fit=max&auto=format&n=N7qpVCkQCaY3Lo-w&q=85&s=49d859f559e779c9dd2537314af8a209" className="block dark:hidden" alt="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)." width="521" height="544" data-path="images/diagrams/03-invite.svg" />

  <img src="https://mintcdn.com/capy-c95c79c0/N7qpVCkQCaY3Lo-w/images/diagrams/03-invite-dark.svg?fit=max&auto=format&n=N7qpVCkQCaY3Lo-w&q=85&s=3a65b05f10642b7f651407eb39d967bc" className="hidden dark:block" alt="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)." width="521" height="544" data-path="images/diagrams/03-invite-dark.svg" />
</Frame>

<Steps>
  <Step title="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.
  </Step>

  <Step title="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`.
  </Step>

  <Step title="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.**
  </Step>

  <Step title="Invitee authenticates">
    The invitee runs `capy redeem <code>` and authenticates, receiving an auth token bound to their email.
  </Step>

  <Step title="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`.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

<Warning>
  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.
</Warning>

## 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.

<Frame>
  <img src="https://mintcdn.com/capy-c95c79c0/N7qpVCkQCaY3Lo-w/images/diagrams/04-sync.svg?fit=max&auto=format&n=N7qpVCkQCaY3Lo-w&q=85&s=8aa0500bf76549abbd707ef5cce48609" className="block dark:hidden" alt="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." width="224" height="432" data-path="images/diagrams/04-sync.svg" />

  <img src="https://mintcdn.com/capy-c95c79c0/N7qpVCkQCaY3Lo-w/images/diagrams/04-sync-dark.svg?fit=max&auto=format&n=N7qpVCkQCaY3Lo-w&q=85&s=4055f1b5e17aa39f2a03ea4e214e9b97" className="hidden dark:block" alt="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." width="224" height="432" data-path="images/diagrams/04-sync-dark.svg" />
</Frame>

The service only ever sees opaque `capy:{resourceId}:{iv||ciphertext||tag}` snippets. Identical variable sets produce identical content-addressed blobs, so uploads are deduplicated.

<Info>
  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.
</Info>

## 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):

<Frame>
  <img src="https://mintcdn.com/capy-c95c79c0/N7qpVCkQCaY3Lo-w/images/diagrams/05-deploy-mint.svg?fit=max&auto=format&n=N7qpVCkQCaY3Lo-w&q=85&s=6bc7b0e549ec1cca047c98586b047fb4" className="block dark:hidden" alt="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." width="445" height="288" data-path="images/diagrams/05-deploy-mint.svg" />

  <img src="https://mintcdn.com/capy-c95c79c0/N7qpVCkQCaY3Lo-w/images/diagrams/05-deploy-mint-dark.svg?fit=max&auto=format&n=N7qpVCkQCaY3Lo-w&q=85&s=5731ae1fff4189ca87aeeb8934081604" className="hidden dark:block" alt="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." width="445" height="288" data-path="images/diagrams/05-deploy-mint-dark.svg" />
</Frame>

**Build time** (inside the CI runner):

<Frame>
  <img src="https://mintcdn.com/capy-c95c79c0/N7qpVCkQCaY3Lo-w/images/diagrams/06-deploy-build.svg?fit=max&auto=format&n=N7qpVCkQCaY3Lo-w&q=85&s=7f05aac47285db9a9a4e9750cb089265" className="block dark:hidden" alt="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." width="448" height="320" data-path="images/diagrams/06-deploy-build.svg" />

  <img src="https://mintcdn.com/capy-c95c79c0/N7qpVCkQCaY3Lo-w/images/diagrams/06-deploy-build-dark.svg?fit=max&auto=format&n=N7qpVCkQCaY3Lo-w&q=85&s=22a522acd02cfbd7661fc0e0b5f23aab" className="hidden dark:block" alt="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." width="448" height="320" data-path="images/diagrams/06-deploy-build-dark.svg" />
</Frame>

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.

<Steps>
  <Step title="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`.
  </Step>

  <Step title="Store">
    Your CLI prints the deploy code. You paste it into your CI secret store (GitHub Actions secrets, Vercel env vars, etc.).
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

## Revocation

Removing a member is O(1) - a membership deletion on the service side. No key rotation, no re-encryption of secrets.

<Frame>
  <img src="https://mintcdn.com/capy-c95c79c0/N7qpVCkQCaY3Lo-w/images/diagrams/08-revoke.svg?fit=max&auto=format&n=N7qpVCkQCaY3Lo-w&q=85&s=cc88680531db2a7c45adc225df00b248" className="block dark:hidden" alt="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." width="487" height="384" data-path="images/diagrams/08-revoke.svg" />

  <img src="https://mintcdn.com/capy-c95c79c0/N7qpVCkQCaY3Lo-w/images/diagrams/08-revoke-dark.svg?fit=max&auto=format&n=N7qpVCkQCaY3Lo-w&q=85&s=666b6da112b1a6c72ee3b4fa5d02f521" className="hidden dark:block" alt="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." width="487" height="384" data-path="images/diagrams/08-revoke-dark.svg" />
</Frame>

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.

<Note>
  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.
</Note>

<Warning>
  **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.
</Warning>

## Cryptographic primitives

A single reference for every client-side algorithm used on the data path.

| Purpose                   | Algorithm     | Parameters                                       |
| ------------------------- | ------------- | ------------------------------------------------ |
| Symmetric encryption      | AES-256-GCM   | IV = 12 random bytes, tag = 16 bytes             |
| Key derivation            | HKDF-SHA256   | Per-use `salt` and `info` - see key hierarchy    |
| Seed → master key         | PBKDF2-SHA512 | `salt="capy-mnemonic"`, `iter=2048`, `keylen=32` |
| Inner wrap + resource IDs | SHA-256       | Deterministic 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.
