Skip to main content

What BKey’s vault is

A normal secrets manager holds your API keys in someone else’s database. The operator has read access in principle; a breach gives the attacker everything at once; a pushed config change can rotate who sees what without you noticing. BKey’s vault flips that. The server stores ciphertext and metadata only. The decryption key material lives in the Secure Enclave / Keystore on the user’s phone, and plaintext appears on the server’s wire only long enough to relay an envelope the server itself cannot open — one that was sealed to an ephemeral public key the caller generated for that single access. The mental model is less “secret manager” and more “the user’s phone is a hardware vault, and the cloud is a mailbox for sealed envelopes.”

The two flows

Everything the vault does is a composition of two flows: store once, access per-use. Both require the user to confirm with facial biometrics on their phone.

Store

caller                     BKey                 owner's phone
  │                          │                      │
  ├── GET vault pubkey ─────▶│                      │
  │◀── phonePub ─────────────┤                      │
  │                          │                      │
  ├─ ENCRYPT locally:        │                      │
  │  ephemeral X25519 keypair│                      │
  │  aesKey = SHA256(ECDH    │                      │
  │            (ephPriv,     │                      │
  │             phonePub))   │                      │
  │  aes-256-gcm over        │                      │
  │   {key: value}           │                      │
  │                          │                      │
  ├── createStoreRequest ───▶│                      │
  │     (E2EE envelope)      │                      │
  │                          ├── push ─────────────▶│
  │                          │         biometric ───┤
  │                          │     decrypt locally  │
  │                          │     confirm fields   │
  │                          │◀── stored ───────────┤
  │◀── status: stored ───────┤                      │
The server saw: an opaque envelope, the item metadata (name, type, fields names — not values), a push receipt. Nothing it could decrypt if you subpoenaed its disks.

Access

caller                     BKey                 owner's phone
  │                          │                      │
  ├─ ephemeral X25519        │                      │
  │  keypair (NEW per access)│                      │
  │                          │                      │
  ├── createAccessRequest ──▶│                      │
  │     (purpose,            │                      │
  │      ephemeralPub)       │                      │
  │◀── { id } ───────────────┤                      │
  │                          ├── push ─────────────▶│
  │                          │      facial bio ─────┤
  │                          │   decrypt locally,   │
  │                          │   reseal to caller's │
  │                          │   ephemeralPub       │
  │                          │◀── sealed cipher ────┤
  │─ poll ────────────────▶ ─┤                      │
  │◀── sealed ciphertext ────┤                      │
  │                          │                      │
  ├─ DECRYPT locally with    │                      │
  │  ephPriv + phonePub      │                      │
  │  from envelope           │                      │
The caller never stored a long-lived shared key. The ephemeral private key dies with the process that requested the access, so the ciphertext the server just relayed cannot be opened retroactively even if both the envelope and the server are compromised later.

Who this is for

Anyone running code that needs a secret, where “the secret lives on a server we trust” is an answer you’re tired of giving:
  • CLIs and scriptscurl, psql, kubectl with a token pulled from the vault at invocation time instead of $HOME/.env.
  • AI agents — a long-running agent can ask for a secret when it needs one, with the user confirming each access on their phone. No “give the agent read access to all my keys forever.”
  • CI and one-shot jobs — nightly backfill, manual migration, break-glass access. Each run gets a fresh release tied to an approver.
  • Team shared credentials — the team owner’s phone holds the vault, and each team member’s agent client asks for per-use access. Rotation = revoke the agent client; there’s no cached plaintext to chase.
If your use case is “constantly read a hot path secret” then a normal secret manager is still the right tool. BKey’s vault is for the kind of access a human would sign off on if they were standing there.

The reference example

A working CLI lives at examples/typescript/vault-access that stores and retrieves single-scalar secrets using @bkey/sdk.
git clone https://github.com/bkeyID/bkey.git
cd bkey/examples/typescript/vault-access
npm install
npm run build
cp .env.example .env     # fill in CLIENT_ID / CLIENT_SECRET / USER_DID
Before the first store, open the vault in the BKey mobile app once so the phone generates its X25519 keypair and publishes the public half. Store and access:
node dist/index.js store openai sk-proj-abc123...
# → push to phone, confirm, "Stored \"openai\" on your device."

node dist/index.js access openai > key.txt
# → push to phone, facial biometrics, plaintext lands on stdout
Values stored under the default key field interoperate with the main CLI’s placeholder syntax:
bkey proxy GET https://api.openai.com/v1/models \
  --header "Authorization: Bearer {vault:openai}" \
  --purpose "List available models"

The minimal pattern

Store

import { randomBytes, createCipheriv, createHash } from 'node:crypto';
import { x25519 } from '@noble/curves/ed25519';
import { BKey, pollStoreRequest } from '@bkey/sdk';

const bkey = new BKey({
  apiUrl: 'https://api.bkey.id',
  clientId: process.env.BKEY_CLIENT_ID!,
  clientSecret: process.env.BKEY_CLIENT_SECRET!,
  did: process.env.BKEY_USER_DID!,
});

// 1. Fetch the phone's vault public key.
const { publicKey } = await bkey.getVaultPublicKey();
const phonePub = Buffer.from(publicKey, 'base64'); // 32 bytes

// 2. Ephemeral X25519 → SHA-256 → AES-256-GCM.
const ephPriv = x25519.utils.randomPrivateKey();
const ephPub = x25519.getPublicKey(ephPriv);
const aesKey = createHash('sha256')
  .update(x25519.getSharedSecret(ephPriv, phonePub))
  .digest();

const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', aesKey, iv);
const ct = Buffer.concat([
  cipher.update(JSON.stringify({ key: 'sk-proj-...' }), 'utf8'),
  cipher.final(),
]);

// Envelope: version(0x02) || ephPub(32) || iv(12) || tag(16) || ct
const encryptedPayload = Buffer.concat([
  Buffer.from([0x02]),
  Buffer.from(ephPub),
  iv,
  cipher.getAuthTag(),
  ct,
]).toString('base64');

// 3. Send to phone for biometric confirmation.
const { storeRequest } = (await bkey.createStoreRequest({
  itemType: 'api_key',
  name: 'openai',
  encryptedPayload,
  expiresInSecs: 300,
})) as { storeRequest: { id: string } };

await pollStoreRequest(bkey, storeRequest.id); // throws on denial/expiry

Access

import { createDecipheriv, createHash } from 'node:crypto';
import { x25519 } from '@noble/curves/ed25519';
import { BKey, pollAccessRequest } from '@bkey/sdk';

const bkey = new BKey({ /* ... same config ... */ });

// 1. Fresh ephemeral keypair per access. Do NOT reuse.
const ephPriv = x25519.utils.randomPrivateKey();
const ephPub = x25519.getPublicKey(ephPriv);

// 2. Ask the phone to release, sealed to our ephemeral public key.
const { id } = (await bkey.createAccessRequest({
  itemName: 'openai',
  fieldPath: 'key',
  purpose: 'Nightly backfill — cron job on api-01',
  ephemeralPublicKey: Buffer.from(ephPub).toString('base64'),
  expiresInSecs: 300,
})) as { id: string };

// 3. Poll. Resolves on approval, throws on denial/expiry/timeout.
const sealed = await pollAccessRequest(bkey, id);

// 4. Decrypt. Envelope: phonePub(32) || iv(12) || tag(16) || ct (no version byte).
const buf = Buffer.from(sealed.e2eeCiphertext!, 'base64');
const phonePub = buf.subarray(0, 32);
const iv = buf.subarray(32, 44);
const tag = buf.subarray(44, 60);
const ct = buf.subarray(60);

const aesKey = createHash('sha256')
  .update(x25519.getSharedSecret(ephPriv, phonePub))
  .digest();
const dec = createDecipheriv('aes-256-gcm', aesKey, iv);
dec.setAuthTag(tag);
const plaintext = Buffer.concat([dec.update(ct), dec.final()]).toString('utf8');
const { key } = JSON.parse(plaintext) as { key: string };

Design rules

Always set a meaningful purpose. The phone shows it to the user next to the item name at approval time. “Nightly backfill — cron job on api-01” lets the user decide safely. “CLI access” does not — an attacker could trigger that and it would look normal. One access request = one release. The returned ID and its underlying jti are nonces. Don’t cache the sealed ciphertext and reuse it; make a fresh request every time. The phone treats replays as suspicious and the server will reject them. New ephemeral keypair per access. The forward-secrecy guarantee depends on the private key living only for the lifetime of one request. Reusing it across accesses means a single past-key compromise opens every previously sealed envelope. Tight expiresInSecs. 300 seconds is a reasonable default — long enough to pick up a phone, short enough that a queued request can’t wait hours for the device to be set down unattended. Don’t extend this unless you have a specific reason. Don’t log the plaintext. The SDK’s pollAccessRequest returns an object with e2eeCiphertext; the plaintext only exists after your decrypt step. Keep it that way — write it into an env var, a file with tight perms, or a memory buffer, and don’t let it escape to your normal log pipeline. The server-side boolean isn’t trust. status === 'approved' from the poll helper means the CIBA-style state machine finished cleanly; it doesn’t mean the ciphertext is authentic. The authentication comes from AES-GCM’s auth tag inside the envelope. If decipher.final() throws, don’t fall back — refuse.

What happens when things go wrong

Denial or timeout. pollAccessRequest / pollStoreRequest throw with messages like “Access request was denied by the user.” or “Access request timed out after 120s.” Don’t retry blindly — the user declined for a reason, and a tight retry loop looks like an attack to the phone. “No vault encryption key found.” The owner hasn’t opened the vault in the BKey app yet. The phone generates the vault keypair lazily on first use and publishes the public key; until then, there’s no key to encrypt against. Decryption failure after approval. If the auth tag fails, treat it as tampering. Don’t output a partial buffer; raise and halt. Repeated pushes with no response. The user’s device may be offline. The expiresInSecs clock still runs. Plan your retries with back-off, not hammering.

Beyond the example

  • Combine with per-action approval. vault-access tells you what secret to use; gating the action that uses it behind bkey.approve() tells you whether to use it for this specific invocation. For high-stakes flows, do both — fetch the secret, then approve its use — so even a compromised vault client can’t silently make requests on your behalf.
  • Multi-field items. The example uses a single key field. createStoreRequest accepts an arbitrary JSON object; use separate fields for client_id, client_secret, webhook_url, etc., and request specific fieldPaths on access so the phone releases only what’s needed.
  • Auditing. Every access and store produces a server-side record (who asked, when, what item, what purpose, approved/denied). This is your audit trail — it’s the whole point of replacing “password in a .env file” with per-use release.

References