Skip to main content

Encryption

This page answers: what encryption protects my data at each step of a BKey integration, and what key lives where? BKey’s guarantee is that sensitive material — approval tokens, vault secrets, payment payloads — is either protected end-to-end to the user’s phone, or signed by a key that never leaves the phone’s secure enclave. The backend is designed to be a courier, not a custodian.

One-slide summary

WhatAlgorithmWhere the key lives
Transport (every HTTP request)TLS 1.2+ with modern cipher suitesTerminating LB / TLS-offload at the edge
Approval tokens (CIBA JWTs)EdDSA (Ed25519)Private key at BKey issuer; public key served via JWKS
Vault at rest — plaintextNever storedOnly in your CLI process memory after decryption
Vault storage (CLI → phone)X25519 ECDH → AES-256-GCM, v2 envelopeVault private key in iOS Secure Enclave / Android Keystore (biometric-gated)
Vault access (phone → CLI)X25519 ECDH → AES-256-GCM, ephemeral-ephemeralCLI generates ephemeral keypair per request
Challenge binding (anti-MITM)SHA-256 + secp256k1 signatureDevice signing key (biometric-gated on-device)
x402 payment payloadsEIP-3009 USDC authorization, secp256k1 signatureOn-device wallet key; server encrypts at-rest with AES-256-GCM before DB write
Ciphertext at rest in DBAES-256-GCM with server DEK (where applicable)Server-held DEK, rotated out-of-band
Key takeaways:
  • Approvals are signed, not encrypted. The signature is the proof.
  • Vault fields are end-to-end encrypted between your CLI and the user’s phone. The backend sees only ciphertext.
  • Every on-device key is biometric-gated — facial authentication is required to use it.

The developer integration flow — where encryption kicks in

Say you’re building an agent that reads a vault secret and uses it to deploy to production. Here’s every boundary where encryption matters:
┌─── your agent (server) ────────┐      ┌─── BKey ────┐      ┌─── user's phone ───┐
│                                 │      │             │      │                    │
│  1. @bkey/sdk → bkey.approve()─── TLS ─▶ issue CIBA ─ push ─▶ biometric prompt   │
│                                 │      │             │      │   (face scan)      │
│                                 │      │             │ ◀─── signed consent ──┤   │
│  3. ◀── EdDSA JWT access token ── TLS ─│             │      │                    │
│                                 │      │             │      │                    │
│  4. verifyToken() ← JWKS fetch ── TLS ─▶ /oauth/jwks │      │                    │
│                                 │      │             │      │                    │
│  5. bkey.createAccessRequest()── TLS ─▶ store req    │      │                    │
│     (sends ephemeral X25519 pub)│      │   → push ──────────▶ decrypt + re-enc   │
│                                 │      │             │      │   to our pub key   │
│                                 │      │             │ ◀── X25519+GCM ciphertext ┤│
│  6. ◀── ciphertext (opaque) ───── TLS ─│             │      │                    │
│  7. CLI decrypts locally        │      │             │      │                    │
│                                 │      │             │      │                    │
│  8. run deploy with secret      │      │             │      │                    │
└─────────────────────────────────┘      └─────────────┘      └────────────────────┘

Step-by-step

1–3. Approval request (CIBA): bkey.approve() (TypeScript) or client.approve() (Python) starts here. Traffic to BKey is TLS 1.2+. BKey pushes a prompt to the phone; the phone signs the consent with the device’s Ed25519 key (gated on facial biometrics) and returns the signature through the same channel. BKey wraps it in a JWT signed by the issuer’s Ed25519 key. 4. Token verification: Never trust the JWT on your side until you call verifyToken() from @bkey/node (or equivalent). That library fetches BKey’s public keys from /oauth/jwks — cached 5 minutes, refetched on kid miss — and verifies the EdDSA signature, iss, exp, aud, and required scopes. No HS256 confusion, no alg: none. See the verifyToken reference. 5. Vault access request: Your SDK generates an ephemeral X25519 key pair (one per request). It sends the public key in the access request. The request is bound to challenge || itemName || purpose || ephemeralPubKey so a compromised backend can’t swap the key mid-flight. 6. Phone re-encrypts to your ephemeral key: After facial biometrics unlock the vault key, the phone runs X25519 ECDH with your ephemeral public key, derives aesKey = SHA-256(sharedSecret), and encrypts the plaintext with AES-256-GCM. The ciphertext format is:
phonePubKey(32) || iv(12) || authTag(16) || ciphertext
The backend stores and forwards this blob verbatim. It can’t decrypt it — only your CLI, which holds the ephemeral X25519 private key, can. 7. CLI decrypts locally: bkey vault access / bkey wrap decrypt in-process. The plaintext is never written to disk. With bkey wrap, it’s passed to the child process as an env var and discarded when the process exits. 8. Use the secret.

Vault storage — the other direction

Storing a secret goes through the same envelope in reverse:
CLI → phone:  version(1) || cliEphemeralPub(32) || iv(12) || authTag(16) || ciphertext
  • The phone registers a long-lived X25519 public key with the backend (RFC 7748 §6.1; all-zeros rejected).
  • The CLI generates an ephemeral X25519 keypair per store operation.
  • CLI does ECDH with the phone’s public key, derives AES-256-GCM key via SHA-256, encrypts the fields as JSON.
  • The version(0x02) byte lets future envelopes coexist with old ones.
  • The backend only sees ciphertext; the phone does the matching decrypt.
The phone’s X25519 private key lives in the OS secure keystore — iOS Keychain and Android Keystore (hardware-backed where available, StrongBox on supported devices). It’s tied to the device; it never leaves, it doesn’t sync to iCloud.

Challenge binding — why we can’t be MITM’d

For vault access specifically, the signed consent is over:
SHA-256(challenge || itemName || purpose || ephemeralPublicKey)
The ephemeral public key is part of the hash. A backend that tried to swap your CLI’s public key with its own (to intercept the ciphertext) would break the signature — the phone signs the hash before it knows the backend has tampered, and your CLI verifies the signature before trusting the ciphertext. This is what lets us claim “end-to-end” and mean it. The backend is a courier, not a custodian.

x402 payment payloads

Agent payments use a different path — the value at stake is an on-chain USDC transfer, not a secret.
  1. Your agent calls bkey.authorizeX402Payment({ amountCents, recipientAddress, chainId }).
  2. If within the per-agent spending limit → auto-approved, BKey returns an EIP-3009 TransferWithAuthorization payload signed with the user’s secp256k1 wallet key.
  3. If above the limit → CIBA biometric prompt. After the user approves, same signed payload, same format.
  4. Signed payloads are stored at rest encrypted with AES-256-GCM using a server-held DEK. This is defence-in-depth — the payload is already short-lived and publicly verifiable on-chain once submitted — but it means a DB leak doesn’t leak spend authorizations.
  5. Validity window: < 5 minutes. Front-run resistance comes from EIP-3009’s ReceiveWithAuthorization variant where supported.
Wallet private keys live in the same on-device secure storage as vault keys.

What the backend doesn’t have

A quick checklist for security reviewers:
  • ❌ No plaintext vault values at rest or in flight
  • ❌ No cleartext CIBA signing key (device keys stay on device)
  • ❌ No iCloud/Google Drive sync of on-device keys
  • ❌ No bearer tokens with long-lived power — CIBA tokens are single-action, scoped, and JTI-trackable for replay protection
  • ✅ Ciphertext, timestamps, DIDs, scopes, audit metadata

Transport details

  • TLS 1.2+ on every endpoint, with modern cipher suite selection terminated at the edge.
  • HSTS enforced on api.bkey.id.
  • No plaintext fallback — HTTP connections are redirected to HTTPS.

Token format reference

CIBA and OAuth tokens from BKey are JWTs with:
header:  { "alg": "EdDSA", "kid": "<key id>", "typ": "JWT" }
payload: {
  "iss": "https://api.bkey.id",
  "sub": "did:bkey:...",
  "aud": "your-client-id",
  "scope": "approve:deploy",
  "jti": "<unique>",
  "iat": ..., "exp": ...
}
signature: Ed25519(header || "." || payload)
Verify with any JWKS-aware JWT library or @bkey/node’s verifyToken().

Further reading