broker Secret-bound rolling store credentials — short-lived cloud access with no long-lived secret in the client
| Subpath | @noy-db/hub/broker |
| Factory | withBroker(config) |
| Layer | with-party |
| Cluster | G — 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.
| Property | Defeats | Does 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-extractable | An 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.
- Enrol (
vault.broker().enroll(), idempotent). Creates the_brokerseed if absent (a create-if-absent compare-and-set — two concurrent enrols converge on one seed, never two), derives the proof bits, andPOST /enrolls them plus a dev-backend attestation token. Only after a 2xx does the local record flipregistered: true— a seed whose/enrollPOST 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. - Challenge. The client
POST /challenges; the host mints a random, single-use challenge with a short (≤ 60 s) TTL and returns it. - 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 itsexpiresAt, thenPOST /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. - 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 untilmax(expiresAt − skewMs, now + minCacheMs). Nothing is ever written to disk — credentials live only in memory and die with the tab. - 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:
brokerStrategy: withBroker(config)oncreateNoydb(this page's opt-in).- A running broker host implementing the four obligations listed under "The reference broker host" below — the hub ships the protocol, you run the host.
- The
credentialSourcewired into a cloud store'scredentialsoption (e.g.@noy-db/to-aws-s3/to-aws-dynamo) so the short-lived credentials actually reach the SDK. - 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
consumeChallengecallback passed toverifyBrokerProofmust 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
/credentialsper(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/adminonly forenroll/rotate) reuses the same keyring roleswithTeam()grants.session— a broker-fronted app typically also gates app sessions withwithSession(); the two are independent (broker gates cloud-store access, session gates app-level tokens).
Edge cases & limits
_brokeris not a normal collection.vault.collection('_broker')throws a reserved-collection error — the seed is reachable only throughvault.broker(). A granted sub-admin keyring (operator, viewer, client) never receives the_brokerDEK, 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
NetworkErrorfromcredentialSource(); a rejected proof surfacesBrokerProofError. Both are treated as offline-degradable by a syncing store — local writes queue, nothing is lost. profileandendpointOriginare bound into the MAC. A proof captured at oneprofile(e.g.'read') cannot be replayed at another ('admin'); a proof cannot be relayed to a different broker endpoint reusing the samebrokerId.kind: 'token'stores (postgres/turso/supabase/webdav) are not wired yet — theStoreCredentialstype 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
forgetandsync— how offline-degradable writes queue when the broker or store is unreachable.team— the keyring roles that gateenroll/rotate.- Proven by showcase
97-broker-aws.