# Keychain security contract

## What the cryptography enforces

Assuming the actual frontend/SDK, chosen identity provider, trusted Lit TLS endpoint,
Lit runtime and derivation root remain trustworthy, compromising the Keychain API,
database and billing key cannot fabricate owner authorization or extend its signed
scope. Ciphertext, metadata, version and release mode are bound together. An agent
must prove possession of its own signing key; each request binds the exact envelope,
operation, policy hash, deadline and response encryption key. Results also carry an
action signature, preventing a proxy from fabricating an encrypted result.

The hosted frontend is part of the trust boundary. Open source does not prove that
a server served an audited build. A compromise that also changes delivered JavaScript
can steal new plaintext/owner approvals. Run a verified client release for a stronger
separation from the hosting operator. Passkeys remain tied to their registered RP/origin.

## Attested Lit endpoint

The SDK, CLI and MCP server attest `VITE_LIT_API_URL`/`litApiUrl` before the first
Chipotle request when a policy is pinned for that origin (`ATTESTED_ORIGINS`). The
check verifies the TDX v4 quote's ECDSA chain to the pinned Intel SGX Root CA,
replays the dstack event log into RTMR0-3, requires the measured app-id, compose
hash (SHA-256 of the served `app_compose`, all images digest-pinned) and OS image,
and confirms both hashes are whitelisted in the DstackApp and DstackKms contracts on
Base. Node callers also bind the observed TLS certificate to the enclave through the
dstack-ingress evidence quote. Any failure blocks the request.

Limits: Intel TCB status, QE identity and PCK revocation collateral are not
checked; the quote has no caller nonce, so freshness comes from the TLS binding,
which browsers cannot perform; the Base RPC endpoint is trusted for the governance
lookup (a lying RPC can only cause false rejections or accept a hash the Safe never
whitelisted). The policy constants are compiled into the client, so the same
"verified client release" caveat above applies.

## Authenticated live discovery

`POST /api/agents/challenge` issues a 60-second random challenge naming the agent's
public key, exact configured service audience, protocol version and dedicated
`lit-keychain/discovery/v2` domain. The client validates those fields before signing
the canonical SHA-256 digest with its local Ed25519 key. `/api/agents/discover`
strictly verifies possession and atomically deletes the exact unexpired challenge
in PostgreSQL. A proof is valid for one discovery, including across replicas.
Tampered, unknown, replayed and expired challenges fail. There is no agent session
cookie or bearer authorization token, and no private key transport.

Discovery returns only locators for current enabled, unexpired owner-signed grants
covering the release operation and selected envelope version/hash, plus each
approved vault's already-provisioned execution-only billing key. Manifest, envelope
and policy receipts are verified before disclosure. No plaintext, other-agent
labels/grant lists, owner sessions, master/bootstrap or account-management keys are
returned. The SDK verifies bundles against its separately trusted Lit origin, which
discovery cannot replace. Every live list/get/use discovers again. Exact
`vaultId/secretId` IDs avoid cross-vault confusion; ambiguous names fail. The
unchanged Lit action remains the final owner/agent authorization boundary.

These billing keys are deliberately reusable, not new access authority. A former
agent retaining one can consume sponsored execution for that vault until rotation,
but an honest policy lookup still denies revoked secret access. Rotation is picked
up on the next live discovery. This preserves the existing billing abuse limit
below. Discovery never mints a grant and is not exactly-once execution.

Challenges have per-minute global (10,000) and peer (600) issuance/verification
budgets, a 60-second TTL and expiry cleanup on issuance. Global checks precede
peer budget rows; existing budget expiry cleanup applies. JSON bodies retain the
service's 256 KiB cap. Queries are limited to 1,001 candidates and reject above
1,000 rather than hiding additions through truncation. Responses are `no-store`;
agents must not log billing bootstrap responses. Discovery has the same documented
operator-rollback trust as policy lookup. Revocation applies on the next request,
not already received plaintext or already-authorized in-flight operations.

## Authenticator-app two-factor login

Optional RFC 6238 TOTP (SHA-1, six digits, 30-second steps, one step of clock
skew) gates **hosted Keychain login** for every approved owner method. A first
factor produces only a hashed, single-use, five-minute pending token; no session
or execution key is returned until the second factor succeeds. Authenticator
steps cannot be reused. Verification is serialized under the vault lock, and
failed attempts are limited across sessions and pending tokens (10 per vault per
five-minute budget window, plus an IP limit).

Enrollment requires a fresh Lit owner receipt for a server nonce bound to the
current session and setup operation. The pending enrollment expires after ten
minutes and is activated only after a valid code. Disabling 2FA or replacing
recovery codes requires another operation-bound owner receipt and a current
TOTP or unused recovery code. Enabling, disabling, and regenerating codes revoke
other browser sessions and outstanding login/setup challenges. Credential changes
also invalidate pending login/setup challenges. Successful changes and recovery
code consumption are audited in their database transaction.

TOTP seeds are application login credentials, separate from user-stored vault
secrets. The API necessarily returns the seed once during setup and verifies
codes server-side. Seeds are encrypted at rest with AES-256-GCM, vault-bound AAD,
and a key derived by HMAC-SHA-256 from `USAGE_KEY_ENCRYPTION_KEY` with the label
`lit-keychain/totp-encryption/v1`; this key is distinct from the usage-key cipher
key. QR codes are rendered locally. Ten random 128-bit recovery codes are shown
once, stored only as domain- and vault-bound SHA-256 hashes, and atomically
consumed. Logs and audit entries contain neither seeds nor codes. Security
responses use `Cache-Control: no-store`.

This is an additional **server-enforced login control**, not a change to Lit's
immutable owner authorization or existing secret releases. It does not revoke
agent grants, add a second factor to direct Lit execution, protect against a
compromised Keychain operator, or require codes again on each request in an
existing authenticated session. A code alone cannot sign in without an approved
owner method. Recovery owner methods do not bypass 2FA. Loss of both the
authenticator and all recovery codes locks out hosted login; there is no automatic
email or owner-only bypass. Encrypted vault exports intentionally exclude seeds
and recovery codes. A complete server database restore must also retain the 2FA
records and encryption key; a vault-only restore after complete DB loss does not
restore hosted-login 2FA settings.

## Agent-side plaintext handling

`get`, the `get_secret` MCP tool and `keychain run` all deliver plaintext to the
agent host; from there the client is trusted. `run` avoids stdout and passes the
value through the child process's environment. The Keychain CLI itself does not
print it; the child program can still log or disclose it, including into agent
transcripts. It is also within reach of other processes running as
the same user (`ps eww`, `/proc/<pid>/environ`). `--file` writes plaintext to a
mode-0600 file that is created before the child starts, never overwrites an existing
path, and is unlinked when the child exits; a SIGKILL of the CLI leaves it behind and
the bytes may survive on disk after unlink. Neither mode is a sandbox. The CLI zeroes
its copy of the agent key and drops its references to the fetched values once the
child has started; JavaScript strings cannot be scrubbed, so the plaintext may linger
in the CLI process heap briefly before it exits.

## Accepted operator trust

The database holds signed policy records and chooses the current record. The action
queries that selection on every authorization, without a cross-execution policy cache.
The operator can withhold updates, serve an older valid record, restore a disabled
agent/vault or retired version, or restore a previously approved owner credential.
Revision/hash chains give honest concurrency checks, not rollback protection.

Rollback does not create a new agent identity or extend signed expiry. A requester
still needs an allowed private key. An operator colluding with a formerly allowed
agent can restore that agent's prior access while the old permission remains valid.
Owner credential membership may be indefinite. Access policies default to 30 days and
the owner chooses their lifetime, including none; a longer lifetime widens the window
in which a replayed policy stays valid, so the expiry is the owner's rollback bound.
Credential receipts never make Google session keys permanent.
The root owner is the initial state when no credential policy exists; withholding a
later credential policy can restore that initial owner, as part of the accepted model.

The operator can deny service or delete/withhold ciphertext and metadata. Database
backups and exported ciphertext help availability; they do not make deletion
cryptographically irreversible. Revocation cannot retract plaintext already received.
With an honest backend, a committed update applies to subsequent policy lookups;
in-flight actions that already authorized may finish. The action rechecks signed
request/policy expiry immediately before decryption/use. An external operation can
complete after its authorization window if already started.

## Identity and session proofs

- Wallet: EOA EIP-712 authorization, fixed domain/version and vault salt, exact object
  digest/operation, nonce and short validity. Wallet picker availability is not proof
  of ERC-1271/6492 support; those wallet types are deliberately unsupported.
- Passkey: P-256 WebAuthn signature over authenticator data and client-data hash;
  exact origin, RP-ID hash, operation challenge, user presence and user verification.
  Cross-origin assertions are rejected. Registration uses discoverable credentials;
  fresh-device sign-in discovers a public descriptor and then obtains a new bound
  assertion. Synced/recovery providers are part of the chosen custody model.
- Google: fixed Google JWKS HTTPS URL, RS256, issuer, exact audience/subject, expiry,
  issued time, optional authorized-party check and nonce binding. A 10-minute client
  session's Ed25519 public key, random nonce, scope, network, registry and lifetime
  are hashed into the Google nonce. A successful fresh Google login also issues a
  domain-separated device approval signed by that exact authority release. It binds
  the owner, vault and session key, with an expiry 30 days after the nonce session
  began. Each approval proves session-key possession and rechecks owner membership.
  A device approval cannot renew itself or extend its expiry. It needs no Google
  JWKS fetch after issuance, so Google token expiry/key rotation do not end it.
  The browser persists the device key, approval and original token in localStorage;
  sign-out clears them. Access to that browser storage confers the remaining device
  authority. Google account issuance/recovery is accepted custody. Email is not the identity.
  Archived authorities retain their original checks and receive the 10-minute
  Google proof (compatible with their 15-minute maximum); after that window the UI
  requests fresh Google sign-in for older secrets. Existing secrets are not migrated.

The immutable authorization action signs a domain-separated receipt for an exact
object while the owner proof is valid. An owner proof covers either one document or a
**batch** (`operation: "batch"`, object hash = digest of `{ kind: "batch", vaultId,
documents[] }`, at most eight manifest/envelope/policy documents, never sign-in or
owner-credential documents). The action verifies the proof once, applies every
per-document invariant, and issues one ordinary receipt per document, so verifiers of
receipts cannot tell (and need not care) how many prompts the owner saw. Reordering,
dropping, adding or altering any document, or replaying a single-document proof as a
batch (or vice versa), changes the signed digest and is refused. To fetch a new
secret's encryption key before its manifest is approved, the API lets the signed-in
owner enrol the derived action unsigned (`/api/actions/prepare`); enrolment only
permits the vault's own billing key to run the vault's own action and is bounded by
the same plan headroom as signed enrolment. An old ID token cannot mint a new device approval after
expiry. An existing device approval can authorize exact-object receipts until its
own signed expiry. Durable ciphertext/permission receipts survive these expirations;
they are not reusable management sessions. Keychain sessions only authorize storage/UI operations
and never replace a receipt. They are HttpOnly, SameSite=Strict, Secure on HTTPS and
expire after 30 days (persistent cookie; a page refresh keeps the vault open). Credential changes invalidate existing metadata sessions.

Deleting a secret is a session operation without a receipt. It removes the registry
entry every agent request is checked against, all signed policies and every
ciphertext version in one transaction, then retires the derived action's execution
grant. No action ever verifies "deleted": absence is the denial, so there is nothing
to forge. A session holder can therefore deny service, which the operator can already
do by withholding data; it cannot grant anything. Nothing remains for the operator to
roll back to. An owner who kept an encrypted backup can restore the secret deliberately.

## Encryption protocol

Canonical JSON sorts ASCII property names and permits only safe integer numbers.
Arrays retain order; strings are exact Unicode text, without normalization. Unknown
schema fields, invalid hex/base64url, unsafe numbers and oversized values are rejected.
The protocol is versioned; changes must retain deployed action artifacts.

The action's secp256k1 key comes from Lit's current execution CID. Its X25519 private
key is HKDF-SHA256(root, salt=`lit-keychain/v2`, info=`secret-envelope-x25519`, 32).
The root only signs fixed receipt, key-binding and encrypted-response structures.
The browser verifies an action-signed binding of its challenge, manifest hash and
X25519 public key against the root public key fetched directly from the trusted Lit
TLS origin. Neither the backend nor a generic unattached enclave quote establishes
that binding. The fixed public-discovery Lit Action returns only the already-public secp256k1
identity through the existing Chipotle execution API; it has no private-key operation.

Import creates a random 256-bit DEK and a 96-bit AES-GCM IV. Metadata is authenticated
as AAD; HPKE wraps the DEK with the same metadata in its domain-separated info context.
HPKE uses DHKEM(X25519, HKDF-SHA256), HKDF-SHA256 and AES-256-GCM (RFC9180 IDs 32/1/2).
Owner receipts bind the complete envelope digest. Agent responses use a fresh
recipient X25519 key and HPKE info bound to the signed request hash.

Random keys and byte buffers are cleared where practical; JavaScript strings, garbage
collection and provider/browser internals do not offer guaranteed memory erasure.
No secrets, owner private keys or agent private keys are persisted by the API.
Names, public identities, permissions and traffic metadata are not encrypted.

## Authority releases

The immutable authorization action is released in versions, and every released
version is kept forever in `actions/archive/` (content-addressed, append-only; the
build refuses to run if an archived file is missing or altered). A vault's identity is
the hash of its owner document, not of any code, so a vault keeps working across
releases:

- The API accepts a sign-in receipt from any released authority version bound to the
  vault's owner document. The version that verifies is recorded for the vault
  (`kc_vault_authorities`) and granted to its execution group the first time it is
  used. The vault's current authority only ever moves to a newer release.
- Each secret pins the authority release that approved it. Policy updates, rotations
  and restores for that secret are verified, by the API and by the secret action
  itself, under exactly that release. Clients run the pinned release's bytes,
  fetched by hash from `/api/templates/<sha256>` and checked against the hash list
  compiled into the client, so the registry cannot introduce a release the client was
  not built to trust.
- New secrets pin the newest release the client knows. Sign-in tries the newest
  release first and falls back to the vault's recorded release if the newest denies
  the owner.

Credentials (the vault's owner set) are receipted by whichever release approved
them, and a release can only verify its own receipts. A release that cannot verify
the current credentials receipt treats the vault as having no credentials policy:
the root owner from the vault document is accepted and nothing else is. This grants
no capability beyond the documented operator trust, since the operator can already
serve a null credentials state for any vault, and it never accepts an unverifiable
owner set. Consequence: a vault whose root owner was replaced under release A keeps
operating under A (its recorded release) until the root owner, or an owner approved
under a newer release, re-approves the owner set there.

## "Use inside Lit" actions

Every non-export action comes from the reviewed public catalog
[agent-keychain-library](https://github.com/LIT-Protocol/agent-keychain-library),
pinned to an exact commit and integrity hash in `package.json`/`package-lock.json`. An
action cannot export, rewrap, sign arbitrary data, follow redirects, or execute caller
code. Its manifest declares, and the shared harness enforces inside the enclave: a
credential pattern the plaintext must match before the action runs; an exact HTTPS
hostname allowlist and request budget for the only HTTP client the action can reach;
a shape for the agent's signed input, validated before any key derivation; a shape and
16 KiB cap for the result; and timeouts and response-size limits. The build rejects
action code that imports anything but the contributor library or references `fetch`,
`eval`, dynamic `import`, timers, `crypto` or the Lit runtime. Raw upstream responses,
errors and headers are never returned or logged; agents see only `access_denied`.

For example, the Stripe action only GETs `https://api.stripe.com/v1/balance` and
returns bounded numeric amounts, three-letter currency codes and a boolean. Upstream
providers necessarily receive the credential over TLS and remain trusted for their
operation. Actions whose purpose is to return upstream text (an OpenAI reply, a GitHub
file) can carry whatever that provider returns; review, not the shape, is what keeps a
projection from echoing the credential. Owners must retain the original credential to
reimport into a future action release. Catalog ids are permanent and their template
bytes are pinned in `actions/catalog.lock.json`; deprecated actions stay restorable.

## Budgets, audit and recovery

The master management key and wildcard bootstrap execution key stay server-side.
Per-vault execution-only usage keys are intentionally returned to authenticated owners
and returned through authenticated live discovery. They are encrypted in the DB with AES-256-GCM, a separate
operator-held key, random nonces and vault-bound AAD. The operator can recover those
billing keys, but they do not grant owner/agent authority or decrypt secrets by themselves.
Protect exported configs as billing credentials. Rotation persists pending revocation
before calling Chipotle and does not report success until removal is confirmed.

Each usage key can execute a fixed recovery/discovery group and, while paid, the
vault's secret group. Enrollment requires a valid owner-signed manifest and matching
immutable source/CID. Incremental group grants avoid Chipotle's ten-CID bulk-update cap.
Cancellation changes key permissions rather than deleting action grants or ciphertext.
Billing-owner guards deny child usage keys access to parent funding/card management.

**No hard per-user execution or dollar cap exists.** Users share the operator's parent
balance. This explicitly accepted limit allows abuse of allowed actions to exhaust
that balance; account funding and provider auto-recharge settings bound exposure.
Free (5 secrets) and the $10/month subscription both include fair use execution with
no automatic overage charges.
Atomic app counters only limit server-sponsored login attempts. Anonymous capacity
can be exhausted independently. The Stripe subscription is a storage/sponsorship
entitlement, not a cryptographic access policy: an independently funded payer may
still run the signed actions after subscription expiry.

With an honest service, storage mutations stop at paid expiry while the vault exceeds
the Free limit. Sponsored execution of enrolled secret actions continues on Free;
lapsing a subscription is not a revocation mechanism and never was a spending
guarantee. The worker runs every minute and refreshes Stripe state every five minutes;
webhooks and explicit refresh also update the DB. Login, owner revocation and encrypted
backups remain available. Data is never automatically deleted on cancellation.

No read-count/one-time-use guarantee exists against an operator who can roll back the
database. Identical signed requests may execute repeatedly until expiry; integrations
must account for external side effects. Stripe balance reads have no write side effects.

Management writes and their audit events share a transaction. The activity feed is
not a complete execution ledger: an independent Lit payer can execute an action
without going through this API. Action-signed responses are verifiable evidence of
individual completed results; browser/API cookies alone are not.

Encrypted exports contain current ciphertext versions, their signed policy/manifests,
the root authority descriptor, and signed recovery credential settings. They contain
neither private keys nor historical ciphertext versions. Credential restoration only
initializes a missing vault and cannot overwrite existing credential settings. Restoring does not overwrite a different existing secret. Google-only users
recover by signing in to the same Google account. Alternative recovery credentials
must be approved while an existing owner is available; download a fresh encrypted
export when changing credentials, since it carries the root authority descriptor. Lost owner credentials without an approved recovery path
cannot be replaced by an operator-issued reset token.

## Release validation boundaries

Local tests use real WebCrypto, browser WebAuthn, the actual Deno worker and PostgreSQL,
with synthetic Lit keys and Google issuance fixtures. They do not prove live Google
OAuth configuration, production TEE measurements, or future Lit root-key continuity.
Before launch, deploy the telemetry and billing-owner guards, configure Stripe, Google and wallet
providers, and run live import/read/rotation/revocation/recovery and Stripe balance
tests through the intended Chipotle API. Keychain uses Chipotle for all TEE execution
and action public-key discovery; application validation requires no direct TEE
infrastructure access. Runtime security and derivation-root continuity across provider
restarts/upgrades remain Chipotle trust assumptions, with infrastructure qualification
owned by the platform. Retain an independent recovery copy of the original strict-mode
credential.
