stablesince 0.3.0-pre.11

broker Secret-bound rolling store credentials — short-lived cloud access with no long-lived secret in the client

The credential broker: a secret-bound client obtains short-lived, auto-rotating cloud-storage credentials without ever holding a long-lived secret.
Subpath@noy-db/hub/broker
FactorywithBroker(config)
Layerwith-party
ClusterG — Collaboration & Auth
LOC cost~500 (off-bundle when not opted in)

What it does

The credential broker lets a client secret-bound to a vault obtain short-lived, rolling cloud-storage credentials (AWS access keys today; a vendor-neutral token shape is reserved for a later slice) without ever putting a long-lived secret in the client. A 32-byte seed is generated once, encrypted under the vault's own _broker collection DEK (data-encryption key), and never leaves the device. A derived proof key — never the seed itself — is registered with a broker host at enrolment; from then on the client re-derives the same key, signs a challenge the host just issued, and the host mints scoped, minutes-to-an-hour credentials on a verified proof.

Nothing here weakens the zero-knowledge guarantee over document contents — this broker only gates storage-backend access (can this client currently read/write ciphertext at all), not the confidentiality of what's inside it. It is the same trust class as the at-* sealing-key family: a host you run participates, but it never sees a secret, KEK, DEK, or plaintext.

When you need it

  • An app backed by a cloud store (@noy-db/to-aws-dynamo, @noy-db/to-aws-s3, @noy-db/as-aws-s3) that wants per-vault-scoped, auto-rotating credentials instead of one static IAM key baked into every client.
  • Revocation without a client-side ceremony: stop re-issuing at the broker and every client's credential goes dead within its TTL.
  • Per-role access tiers (profile, e.g. 'read' vs 'admin') mapped to distinct STS session policies, enforced by the same proof.

Threat model — what it defeats, and what it honestly does not

Store-access credentials gate availability / tamper / deletion of ciphertext, never its confidentiality — that rests on the enclave, unchanged. The secret and every key/DEK never leave the client; the broker sees only a challenge proof. The stolen-ciphertext story is unchanged by any of this.

PropertyDefeatsDoes not defeat
Rolling (short-lived, rotating)A credential lifted from a memory dump, crash log, or devtools session is dead within its TTL (minutes–1 h). Revocation = the broker stops re-issuing; no client-side rotation ceremony.Live abuse within the TTL.
Non-extractableAn attacker who copies the app bundle, localStorage, or IndexedDB at rest gets nothing replayable: the steady-state proof key is a WebCrypto extractable: false ['sign'] HMAC key.A resident XSS attacker while the vault is unlocked — they can drive the whole proof flow with the live key, and re-run the key derivation from the decrypted seed, whose bytes are re-derivable while unlocked. Non-extractability denies capability-cloning (reuse of a handle offline/later/elsewhere), not live in-page use. It is defense-in-depth against a leaked handle only.
User-bound (access = f(app config, unlocked keyring))App config alone (bundle constants, endpoint URLs) grants zero cloud access — the broker refuses without a proof derivable only from a decrypted _broker seed. Per-user STS session tags + a scoped session policy let the cloud side enforce per-vault scoping, so user A's credentials cannot read user B's rows.A compromised broker host. It is the credential authority; its compromise mints credentials for anyone. This is outside the zero-knowledge boundary by design — the broker still never sees secret, KEK, DEK, or plaintext.
Symmetric-verifiable (the broker holds the same key it verifies)A forged client proof grants nothing the broker cannot already grant itself — it is the credential mint.A read-only leak of the broker's registered-key store. Because verification is symmetric HMAC, a registered proof key is a forgeable credential — unless the host wraps registered keys at rest under a broker-side KMS key (mandated below): with KMS-wrap, a read-only DB leak yields only ciphertext the attacker cannot unwrap.
Transparent (dev wires the cloud store at setup; the end user never sees it)Nothing — this is UX, not security. Listed so nobody mistakes it for a security property.—

An asymmetric enrolment (register only a public verifier) would remove the at-rest forgeability structurally, but WebCrypto cannot portably derive a deterministic keypair from the key-derivation output. KMS-wrap is the accepted mitigation for the symmetric-HMAC at-rest exposure — see the reference host below.

Protocol: enrol → challenge → mint → refresh → rotate

What the broker learns: vaultId, brokerId, a one-way 32-byte derivation output (registered once, inverts to nothing), the requested profile, request timing/IP, and the dev-backend attestation identity at enrol. What it never learns: the secret, any KEK/DEK, collection names, record contents, or the _broker seed itself.

  1. Enrol (vault.broker().enroll(), idempotent). Creates the _broker seed if absent (a create-if-absent compare-and-set — two concurrent enrols converge on one seed, never two), derives the proof bits, and POST /enrolls them plus a dev-backend attestation token. Only after a 2xx does the local record flip registered: true — a seed whose /enroll POST failed stays unregistered, so a later mint fails fast instead of degrading into an opaque proof error. Requires owner/admin role, and — for first-ever seed creation only — the vault's KEK. A keyring holding only a DEK (e.g. a PIN quick-resume) can still use an already-enrolled seed, just not create the first one.
  2. Challenge. The client POST /challenges; the host mints a random, single-use challenge with a short (≤ 60 s) TTL and returns it.
  3. Mint. The client re-derives the proof key from the seed, signs a canonical string binding vaultId, the broker endpoint's origin, brokerId, profile, the challenge, and its expiresAt, then POST /credentialss the proof. The host burns the challenge before doing any HMAC work (a replay of an already-used challenge is rejected before the MAC is even checked), verifies the signature against the unwrapped registered key, and — only then — mints scoped, short-lived cloud credentials.
  4. Refresh / cache. credentialSource(profile) (what a store's credential hook calls) holds a single-flight, per-profile cache: concurrent callers near expiry await one round-trip; cached credentials are valid until max(expiresAt − skewMs, now + minCacheMs). Nothing is ever written to disk — credentials live only in memory and die with the tab.
  5. Rotate (vault.broker().rotate()). Quiesces the cache (awaits any in-flight round-trip), mints a fresh seed, registers it before overwriting the local record, and overwrites last. The host must accept both the old and new registration for a short grace window so an in-flight proof computed under the pre-rotation seed still verifies.

Prerequisites

Four parts must exist together before a brokered credential reaches a store — each is detailed below, all four up front:

  1. brokerStrategy: withBroker(config) on createNoydb (this page's opt-in).
  2. A running broker host implementing the four obligations listed under "The reference broker host" below — the hub ships the protocol, you run the host.
  3. The credentialSource wired into a cloud store's credentials option (e.g. @noy-db/to-aws-s3 / to-aws-dynamo) so the short-lived credentials actually reach the SDK.
  4. Owner/admin role for registration — and, for first-ever seed creation only, the vault's KEK.

Opt in

import { createNoydb } from '@noy-db/hub'
import { withBroker } from '@noy-db/hub/broker'
import { toBrowserIdb } from '@noy-db/to-browser-idb'

const db = await createNoydb({
  store: toBrowserIdb(),
  user: 'me',
  secret: 'correct horse battery staple',
  brokerStrategy: withBroker({
    brokerId: 'broker-1',
    endpoint: 'https://broker.example.com',
    // Your app's proof to the host that this enrollment call is
    // legitimate — e.g. the session token your backend already issued.
    attestation: () => mySessionToken,
  }),
})
const vault = await db.openVault('acme')
await vault.broker().enroll()

The whole flow, end to end

The four parts above, assembled — this is the shape the showcase suite runs in CI (97-broker-aws, Tier 1: an in-process reference host built from the published issueChallenge()/verifyBrokerProof() primitives, real proof crypto, no network):

import { createNoydb } from '@noy-db/hub'
import { withBroker } from '@noy-db/hub/broker'
import { toMemory } from '@noy-db/to-memory'
import { toAwsDynamo } from '@noy-db/to-aws-dynamo'

// Part 1 + 4 — opt in, with your attestation:
const db = await createNoydb({
  store: toMemory(),
  user: 'alice',
  secret: 'broker-secret-2026',
  brokerStrategy: withBroker({
    brokerId: 'broker-1',
    endpoint: 'https://broker.example.com', // Part 2 — your running host
    attestation: () => mySessionToken,
  }),
})
const vault = await db.openVault('acme')

// One-time per device: register this client's proof key with the host.
await vault.broker().enroll()

// Part 3 — the rolling credential source, wired into a cloud store's
// `credentials` refresh hook. The SDK re-invokes it near expiry; each
// invocation runs the challenge → proof → mint round trip.
const source = vault.broker().credentialSource('read')
const cloudStore = toAwsDynamo({ table: 'acme-vault-table', credentials: source })

What the showcase asserts about this exact flow: the first source() call performs one challenge and one mint through the real HKDF/HMAC proof chain; the minted credentials carry kind: 'aws' and an expiry; the store's SDK client receives credentials as a function (rolling, not baked); and a replayed challenge is rejected with 401 before any proof verification runs (burn-then-verify). Vault records written meanwhile are ordinary encrypted envelopes — the broker changes how the store authenticates, never what it can read.

To run it yourself: Run the showcases, file 97-broker-aws.showcase.test.ts — Tier 2 of the same file drives real STS against a deployable CloudFormation stack when you set the gate variables.

Client surface

export interface BrokerConfig {
  readonly brokerId: string        // stable id; part of the derivation info tag AND the proof MAC.
                                    // MUST be globally-unique / endpoint-derived — reusing one
                                    // brokerId across two endpoints enables a cross-endpoint relay.
  readonly endpoint: string        // https broker base URL; its origin is bound into the proof MAC
  readonly attestation?: () => string | Promise<string>  // dev-backend session token for /enroll
  readonly fetch?: typeof fetch    // DI for tests / non-window runtimes
  readonly skewMs?: number         // refresh margin, default 60_000
}

export interface CredentialBrokerHandle {
  enroll(): Promise<void>                                     // generate + persist seed, register
  rotate(): Promise<void>                                     // rotate seed + re-register
  credentialSource(profile?: string): StoreCredentialSource   // single-flight, cached
}

vault.broker() returns the CredentialBrokerHandle. Every StoreCredentialSource produces the vendor-neutral StoreCredentials type:

export type StoreCredentials =
  | { readonly kind: 'aws'; readonly accessKeyId: string; readonly secretAccessKey: string
      readonly sessionToken?: string; readonly expiresAt?: string }
  | { readonly kind: 'token'; readonly token: string; readonly expiresAt?: string } // a later slice

Wiring into a store's credential hook

Wire the resulting credentialSource into a store's rolling-credential hook — e.g. @noy-db/as-aws-s3's credentials option, which feeds the AWS SDK's own memoizeIdentityProvider (the SDK owns refresh on the kind: 'aws' arm, re-invoking the provider whenever the returned identity carries an expiration: Date):

import { asAwsS3 } from '@noy-db/as-aws-s3'

const objects = asAwsS3({
  bucket: 'acme-assets',
  credentials: vault.broker().credentialSource('read'),
})

Behavior when NOT opted in

Every broker method on the default floor throws BrokerNotEnabledError (its message points at withBroker() from @noy-db/hub/broker). vault.broker().enroll(), .rotate(), and .credentialSource() all throw/reject the same way. Zero bytes enter a bundle that never imports @noy-db/hub/broker.

The reference broker host — four obligations

A conforming broker host embeds two host-side helpers re-exported from @noy-db/hub/broker — issueChallenge() (nonce mint, TTL-clamped to [10 s, 60 s]) and verifyBrokerProof(args) (burn-then-verify). Around them, every conforming host must implement four obligations. Skipping (a) or (b) breaks the threat-model candor above; skipping (c) or (d) degrades availability, not confidentiality.

  • (a) KMS-wrap registered proof keys at rest — mandated, not optional. The host must store each registered proof key wrapped under a broker-side KMS key and unwrap only in-memory at verify time. Symmetric HMAC verification means a read-only leak of an unwrapped key store is credential-equivalent — this is the one control that prevents that.
  • (b) Atomic single-use challenge burn, before any MAC work. The consumeChallenge callback passed to verifyBrokerProof must atomically test-and-delete the challenge; a second presentation of the same challenge must fail even with a byte-identical, unexpired, otherwise-valid proof.
  • (c) Rate-limit /credentials per (vaultId, brokerId) (recommended). Bounds the multi-tab credential-amplification surface. App-side hooks + consent audit remain the primary controls; this is a defense-in-depth backstop, not a substitute.
  • (d) Accept old and new registrations for a short grace window on rotate. An in-flight proof computed under the pre-rotation seed (e.g. mid-sync-flush) must still verify; never drop the old registration the instant a new one lands.

The illustrative host below is Lambda/STS-shaped — swap the KMS/DynamoDB/STS clients for whatever your infra uses. It issues a challenge, burns it once before any candidate-key loop, verifies against a KMS-unwrapped key, and mints via STS, hardened with obligations (a) and (c):

import { issueChallenge, verifyBrokerProof } from '@noy-db/hub/broker'
import { KMSClient, EncryptCommand, DecryptCommand } from '@aws-sdk/client-kms'
import { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts'
// registeredKeys / challenges / rateLimiter: any durable KV with an atomic
// delete-if-present (DynamoDB conditional delete, Redis DEL, etc.) works.

const kms = new KMSClient({})
const sts = new STSClient({})
const KMS_KEY_ID = process.env.BROKER_KMS_KEY_ID!            // (a)
const keyOf = (vaultId: string, brokerId: string) => `${vaultId}:${brokerId}`

// ─── POST /enroll ──────────────────────────────────────────────
export async function handleEnroll(req: { vaultId: string; brokerId: string; proofKey: string }, attestation?: string) {
  if (!attestation || !(await verifyDevBackendSession(attestation))) return { statusCode: 401 }
  // (a) Wrap BEFORE it ever touches durable storage. A read-only table leak is
  // otherwise credential-equivalent under symmetric HMAC.
  const { CiphertextBlob } = await kms.send(new EncryptCommand({
    KeyId: KMS_KEY_ID, Plaintext: Buffer.from(req.proofKey, 'base64'),
  }))
  // (d) APPEND, never overwrite: rotate() registers new before local overwrite,
  // so this key must coexist with any prior one for a grace window.
  await registeredKeys.append(keyOf(req.vaultId, req.brokerId), Buffer.from(CiphertextBlob!).toString('base64'))
  return { statusCode: 200 }
}

// ─── POST /challenge ───────────────────────────────────────────
export async function handleChallenge() {
  const { challenge, expiresAt } = issueChallenge()
  await challenges.put(challenge, { expiresAt })            // immutable once written
  return { statusCode: 200, body: { challenge, expiresAt } }
}

// ─── POST /credentials ─────────────────────────────────────────
export async function handleCredentials(req: { vaultId: string; brokerId: string; challenge: string; proof: string; profile?: string }) {
  if (!(await rateLimiter.allow(keyOf(req.vaultId, req.brokerId)))) return { statusCode: 429 } // (c)

  // expiresAt is immutable per challenge once written, so reading it is safe
  // without coordination; only the delete below needs to be atomic.
  const expiresAt = await challenges.peekExpiresAt(req.challenge)
  const burnedFresh = await challenges.deleteIfPresent(req.challenge)  // (b) burn ONCE, before any candidate loop
  if (!burnedFresh || expiresAt === undefined) return { statusCode: 401 }

  const wrappedCandidates = await registeredKeys.allFor(keyOf(req.vaultId, req.brokerId)) // may hold old+new (d)
  let ok = false
  for (const wrapped of wrappedCandidates) {
    const { Plaintext } = await kms.send(new DecryptCommand({ CiphertextBlob: Buffer.from(wrapped, 'base64') }))
    ok = await verifyBrokerProof({
      consumeChallenge: async () => true,                  // already burned above — trivially fresh
      registeredProofKey: new Uint8Array(Plaintext!),
      vaultId: req.vaultId, endpointOrigin: 'https://broker.example.com', brokerId: req.brokerId,
      profile: req.profile, challenge: req.challenge, expiresAt, proof: req.proof,
    })
    if (ok) break
  }
  if (!ok) return { statusCode: 401 }

  const { Credentials } = await sts.send(new AssumeRoleCommand({
    RoleArn: roleForProfile(req.profile),
    RoleSessionName: `broker-${req.vaultId}`.slice(0, 64),
    Tags: [{ Key: 'noydbVault', Value: req.vaultId }],
    Policy: scopedSessionPolicy(req.vaultId),              // dynamodb:LeadingKeys / S3 prefix on the vault id
    DurationSeconds: 3600,
  }))
  return {
    statusCode: 200,
    body: {
      kind: 'aws', accessKeyId: Credentials!.AccessKeyId, secretAccessKey: Credentials!.SecretAccessKey,
      sessionToken: Credentials!.SessionToken, expiresAt: Credentials!.Expiration!.toISOString(),
    },
  }
}

Pairs well with

  • team — role gating (owner/admin only for enroll/rotate) reuses the same keyring roles withTeam() grants.
  • session — a broker-fronted app typically also gates app sessions with withSession(); the two are independent (broker gates cloud-store access, session gates app-level tokens).

Edge cases & limits

  • _broker is not a normal collection. vault.collection('_broker') throws a reserved-collection error — the seed is reachable only through vault.broker(). A granted sub-admin keyring (operator, viewer, client) never receives the _broker DEK, so it cannot decrypt the seed even with API access to everything else; only owner/admin grantees do.
  • Enrol vs. use. First-ever seed creation needs the vault's KEK (throws BrokerEnrolmentError — "re-authenticate" — on a DEK-only keyring, e.g. mid PIN quick-resume). Using an already-enrolled seed to mint credentials needs only the DEK.
  • Offline / broker-down. A network failure or an unreachable host surfaces NetworkError from credentialSource(); a rejected proof surfaces BrokerProofError. Both are treated as offline-degradable by a syncing store — local writes queue, nothing is lost.
  • profile and endpointOrigin are bound into the MAC. A proof captured at one profile (e.g. 'read') cannot be replayed at another ('admin'); a proof cannot be relayed to a different broker endpoint reusing the same brokerId.
  • kind: 'token' stores (postgres/turso/supabase/webdav) are not wired yet — the StoreCredentials type reserves the shape, but no store owns its refresh loop for it today.
  • Sealed-to-instance delivery (encrypting the credential response to a specific device's keypair) is a deferred future slice.

See also

  • forget and sync — how offline-degradable writes queue when the broker or store is unreachable.
  • team — the keyring roles that gate enroll/rotate.
  • Proven by showcase 97-broker-aws.