From 12599116325bf82c8649f4ff57d3ec2e3e5cc1f2 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Fri, 13 Nov 2026 20:13:13 +0000 Subject: [PATCH] Step-up assertion: audit a flood once per window, budget the not-permitted branch, base64url nonce, honest nonce retention and precondition docs Co-Authored-By: Claude Sonnet 5.5 --- docs/app-profiles/bifrost.md | 7 +++++ docs/architecture.md | 3 +- docs/service-contract.md | 42 ++++++++++++++++++++----- src/app.ts | 39 ++++++++++++++++------- src/fixed-window.ts | 20 ++++++------ state/evidence.jsonl | 2 +- tests/step-up-assertion.test.ts | 55 +++++++++++++++++++++++++++++++-- 7 files changed, 135 insertions(+), 33 deletions(-) diff --git a/docs/app-profiles/bifrost.md b/docs/app-profiles/bifrost.md index a174d50..41508e5 100644 --- a/docs/app-profiles/bifrost.md +++ b/docs/app-profiles/bifrost.md @@ -89,3 +89,10 @@ Heimdall's unlink check. Bifrost must send a fresh nonce per assertion, keep the returned token only for the action it is for, and never send a method it did not verify: Heimdall cannot tell. Contract, codes, limits and what it does not prove are in `docs/service-contract.md`. + +Precondition and limit: Bifrost can call this only while it holds the member's +Heimdall bearer, which it deliberately does not today. Holding it in a cookie +ticket would turn cookie theft into bearer theft and, with a passkey or email +code, into unlink power. And unlink accepts any method: a `bifrost-email-code` +proof can unlink an identity a Google re-authentication would have guarded. +Requiring a method per route is the later hardening (identity moves). diff --git a/docs/architecture.md b/docs/architecture.md index d78687c..f1d66c3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -357,7 +357,8 @@ checked a factor; label from the profile's `stepUpAssertion.methods`). Both feed the one reader, the unlink check, and a refresh sets neither. The asserted route trusts the calling app and is bounded by the app secret, a live bearer for that app, the method allowlist, a nonce claimed once per session -through `claimOAuthStateUse` (phase `step_up_assert`, no new table or grant), +through `claimOAuthStateUse` (phase `step_up_assert`, no new table or grant; +retained to the session expiry as of the claim, which a refresh may slide past), and in-process fixed-window limits (`src/fixed-window.ts`). ### Three nested guards diff --git a/docs/service-contract.md b/docs/service-contract.md index 35cb6fc..32b402a 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -645,32 +645,45 @@ Body (`additionalProperties` are dropped as on the sibling routes): { "method": "bifrost-passkey", "nonce": "16 to 128 characters, fresh per assertion" } ``` +Nonce: 16 to 128 characters of the base64url alphabet (`A-Za-z0-9_-`), so a +random token fits and spaces, controls and lone surrogates are a 400 schema +refusal. `method` is 1 to 64 characters and then must be in the profile's set. + Order of refusals, each a closed `error` code: - `401 app_auth_required`: missing, wrong or another app's secret. Checked first; nothing is written. - `403 step_up_assertion_not_permitted`: the app's profile has no `stepUpAssertion` grant. Only `bifrost` has one; every other app is refused - with its own secret. + with its own secret. This branch has its own small budget (5 per 60 s per + app); past it the answer is 429. - `429 step_up_rate_limited` (`retryAfterSeconds`): the per-app budget (120 per 60 s, spent by every attempt that passed the secret and the grant, including - ones whose bearer then fails) or the per-account budget (10 per 600 s) is - spent. In-process fixed windows: they reset when the process restarts and are - not shared between instances. + ones whose bearer then fails) is spent. In-process fixed windows: they reset + when the process restarts and are not shared between instances. - `401 sign_in_required`: the bearer is missing, malformed, expired, signed by another key, a refresh token, or rests on a session that is not live (no row, wrong account, revoked or expired, revision moved). One answer for all of them; it does not say whether an account exists. - `403 bearer_app_mismatch`: a live bearer issued to another app. An app cannot - assert for a token issued to another app. + assert for a token issued to another app. It tells only a holder of the + secret and a valid bearer that the bearer is live and for another app, which + that holder already knows. +- `429 step_up_rate_limited`: the per-account budget (10 per 600 s) is spent. + It is checked after the bearer and its audience, before method and nonce. - `400 unknown_step_up_method`: `method` is not in the profile's closed set (`methods` in the answer lists it). Bifrost: `bifrost-passkey`, `bifrost-email-code`. Provider labels (`provider:*`) cannot be asserted. - `409 step_up_nonce_replayed`: that nonce was already spent for this session. The claim is the store's `oauth_state_uses` single-use primitive with phase `step_up_assert` and the key a SHA-256 of session id and nonce, so it holds - across restarts and instances and is kept until the session's own expiry. A - refused method or a rate-limited attempt does not spend the nonce. + across restarts and instances. **Retention is the session's expiry as of the + claim, plus the store's prune margin.** A refresh slides the session's expiry + without bound, so a nonce can be pruned while its session is still live and + then be accepted again. Replaying it still needs the secret and a live + bearer, which is the power to assert with a fresh nonce; the nonce guards a + captured request, not a compromised app. A refused method or a rate-limited + attempt does not spend the nonce. - `201`: `{"accessToken", "claimSet", "stepUp": {"at", "method", "validForSeconds"}}`, `Cache-Control: no-store`. @@ -684,12 +697,25 @@ is good for the usual 600 seconds (`STEP_UP_PROOF_TTL_SECONDS`) and only until the token's own `exp`. Receipts: each assertion writes the audit event `step_up_asserted`, and each -refusal after the secret verified writes `step_up_assertion_refused`, with the +refusal after the secret verified writes `step_up_assertion_refused`, except +that a flood is not recorded row by row: a rate-limited refusal is recorded +once per window per budget (its first), and an app without the grant once per +window, with the app, the account and session when the bearer resolved, `method` (null when it was not in the allowlist), `outcome`, `code` and `nonceDigest`. Never the secret, the bearer, the minted token or the nonce. A request without the secret writes nothing. +Precondition: the route is usable only once the app holds the member's Heimdall +bearer. Bifrost deliberately does not hold it today. Putting the bearer in a +cookie ticket would turn cookie theft into bearer theft and, with +`bifrost-passkey` or `bifrost-email-code`, into unlink power; whether and how +Bifrost keeps the bearer is its own decision and is not made here. + +Unlink accepts any method today. A `bifrost-email-code` proof unlinks an +identity a Google re-authentication would have guarded. Requiring a method per +route (identity moves first) is the later hardening and is not in this change. + What this does not prove: Heimdall cannot see the passkey or the code. It trusts the calling app's word, exactly as it trusts `stepUpRequested` (asked, not verified). The word is bounded by the app secret, a live member bearer for that diff --git a/src/app.ts b/src/app.ts index 43ead5f..ac2f97a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -951,18 +951,25 @@ async function resolveAccessAccount( } /** What an app may spend asserting step-up proofs: per member account, and per calling app. */ -export const STEP_UP_ASSERT_LIMITS: { account: FixedWindowLimit; app: FixedWindowLimit } = { +export const STEP_UP_ASSERT_LIMITS: { account: FixedWindowLimit; app: FixedWindowLimit; notPermitted: FixedWindowLimit } = { account: { max: 10, windowMs: 600_000 }, app: { max: 120, windowMs: 60_000 }, + // An app whose profile has no grant, holding its own secret: a handful of refusals per window, no more. + notPermitted: { max: 5, windowMs: 60_000 }, }; export interface StepUpAssertBudgets { account: FixedWindowBudget; app: FixedWindowBudget; + notPermitted: FixedWindowBudget; } export function createStepUpAssertBudgets(now: () => number = () => Date.now()): StepUpAssertBudgets { - return { account: new FixedWindowBudget(STEP_UP_ASSERT_LIMITS.account, now), app: new FixedWindowBudget(STEP_UP_ASSERT_LIMITS.app, now) }; + return { + account: new FixedWindowBudget(STEP_UP_ASSERT_LIMITS.account, now), + app: new FixedWindowBudget(STEP_UP_ASSERT_LIMITS.app, now), + notPermitted: new FixedWindowBudget(STEP_UP_ASSERT_LIMITS.notPermitted, now), + }; } /** @@ -971,8 +978,10 @@ export function createStepUpAssertBudgets(now: () => number = () => Date.now()): * what the route demands: the app's secret AND the member's live bearer for * that same app, the profile's explicit grant and method allowlist, a nonce * spent once per session, and a rate per account and per app. Every - * refusal after the secret verified is recorded; a request without the secret - * writes nothing, since anyone can send one. The audit rows carry a digest of + * refusal after the secret verified is recorded, except that a flood is not: + * a rate-limited refusal is recorded once per window per budget (its first), + * and an app without the grant is recorded once per window; a request without + * the secret writes nothing, since anyone can send one. The audit rows carry a digest of * the nonce, never the nonce, the bearer or the secret. */ export async function assertStepUp( @@ -989,7 +998,8 @@ export async function assertStepUp( const permitted = getAppProfile(appSlug)?.stepUpAssertion; let known: { accountId: string; sessionId: string } | undefined; let nonceDigest: string | undefined; - const refuse = async (statusCode: number, error: string, extra: Record = {}): Promise => { + const refuse = async (statusCode: number, error: string, extra: Record = {}, record = true): Promise => { + if (!record) return { statusCode, body: { error, ...extra } }; await store.createAuditEvent({ ...(known ? { accountId: known.accountId, sessionId: known.sessionId } : {}), appSlug, @@ -999,13 +1009,17 @@ export async function assertStepUp( }); return { statusCode, body: { error, ...extra } }; }; - const limited = (seconds: number) => refuse(429, "step_up_rate_limited", { retryAfterSeconds: seconds }); + // Audit only the first denial of a window: the rows record that a budget tripped, not every request behind it. + const limited = (spent: { n: number; retryAfterSeconds: number }, max: number) => + refuse(429, "step_up_rate_limited", { retryAfterSeconds: spent.retryAfterSeconds }, spent.n === max + 1); if (!permitted) { - return refuse(403, "step_up_assertion_not_permitted", { detail: `${appSlug} may not assert step-up proofs.` }); + const spent = budgets.notPermitted.spend(appSlug); + if (spent.retryAfterSeconds) return limited(spent, STEP_UP_ASSERT_LIMITS.notPermitted.max); + return refuse(403, "step_up_assertion_not_permitted", { detail: `${appSlug} may not assert step-up proofs.` }, spent.n === 1); } - const appWait = budgets.app.spend(appSlug); - if (appWait) return limited(appWait); + const appSpent = budgets.app.spend(appSlug); + if (appSpent.retryAfterSeconds) return limited(appSpent, STEP_UP_ASSERT_LIMITS.app.max); const bearer = await resolveBearer(ctx, headers.authorization as string | undefined); if (!bearer) { @@ -1016,8 +1030,8 @@ export async function assertStepUp( if (bearer.claims.aud !== appSlug) { return refuse(403, "bearer_app_mismatch", { detail: `The access token was not issued to ${appSlug}.` }); } - const accountWait = budgets.account.spend(bearer.accountId); - if (accountWait) return limited(accountWait); + const accountSpent = budgets.account.spend(bearer.accountId); + if (accountSpent.retryAfterSeconds) return limited(accountSpent, STEP_UP_ASSERT_LIMITS.account.max); nonceDigest = createHash("sha256").update(`heimdall-step-up-nonce\0${sessionId}\0${input.nonce}`).digest("hex"); if (!permitted.methods.includes(input.method)) { @@ -2553,7 +2567,8 @@ export async function buildApp(options: BuildAppOptions = {}): Promise= this.limit.windowMs) { this.sweep(at); - this.windows.set(key, { startedAt: at, used: 1 }); - return 0; - } - if (current.used >= this.limit.max) { - return Math.max(1, Math.ceil((current.startedAt + this.limit.windowMs - at) / 1000)); + current = { startedAt: at, used: 0 }; + this.windows.set(key, current); } current.used += 1; - return 0; + if (current.used <= this.limit.max) return { n: current.used, retryAfterSeconds: 0 }; + return { n: current.used, retryAfterSeconds: Math.max(1, Math.ceil((current.startedAt + this.limit.windowMs - at) / 1000)) }; } /** Drop turned-over windows once the table is full; if it is still full, the oldest key goes. */ diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 24a56eb..caaa8e6 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -39,4 +39,4 @@ {"ts": "2026-10-19T16:03:16+00:00", "type": "verification", "status": "accepted", "note": "Live d51fa6b (H1) verified by a real human sign-in: the operator's StreamPixels Twitch sign-in through Heimdall start/callback, 2026-10-19 ('Twitch auth worked fine'); a streampixels session was created 16:02:33Z, after the 15:51Z switch. Joystick consent round trip still open: joystick:follow_up:h1-joystick-consent-round-trip."} {"ts": "2026-11-12T23:10:15+00:00", "type": "design", "status": "accepted", "note": "Changelog 2026-11-12: sign_in start accepts stepUp (app-authenticated, sign_in only, off by default); signed state carries step_up; launch adds Twitch force_verify=true, YouTube prompt='login consent', Google prompt=login; any other provider is 400 step_up_not_supported; completion reports stepUpRequested. Not deployed. For joystick:follow_up:chat-s7055-7056 (StreamPixels link hardening, second factor via fresh sign-in)."} {"ts": "2026-11-13T19:10:00+00:00", "type": "design", "status": "accepted", "note": "Changelog 2026-11-13: GET /v1/me/identities lists an account's identities (no credentials, profile or email); POST /v1/me/identities/:id/unlink deletes one with a step-up proof (access claim step_up_at, 600 s, never carried by refresh), refuses the last identity (409), writes revocation receipts when the provider's last identity goes. upsertLinkedIdentity no longer moves an identity between accounts (IdentityHeldElsewhereError; links/confirm 409 identity_linked_elsewhere, callback link_initiator_mismatch). The store role needs DELETE on linked_identities (startup refuses without it): owner-role GRANT at deploy."} -{"ts": "2026-11-13T20:01:42+00:00", "type": "design", "status": "accepted", "note": "Changelog 2026-11-13: POST /v1/apps/{app}/step-up (profile grant stepUpAssertion, bifrost only; methods bifrost-passkey, bifrost-email-code). App secret plus the member's live bearer for that app; mints a same-session access token with step_up_at now and new claim step_up_method (exp kept, no refresh, no session write); nonce single-use per session via oauth_state_uses phase step_up_assert (no schema change); in-process rate limits 10 per 600 s per account, 120 per 60 s per app; audit step_up_asserted and step_up_assertion_refused with a nonce digest. Provider step-up sign-ins now carry step_up_method provider:; identity_unlinked records stepUpMethod. Heimdall cannot verify the factor: it trusts the calling app. Not deployed; no env, secret or migration. For heimdall-body:follow_up:chat-s7419-7419.", "branch": "heimdall/app-asserted-step-up"} +{"ts": "2026-11-13T20:01:42+00:00", "type": "design", "status": "accepted", "note": "Changelog 2026-11-13: POST /v1/apps/{app}/step-up (profile grant stepUpAssertion, bifrost only; methods bifrost-passkey, bifrost-email-code). App secret plus the member's live bearer for that app; mints a same-session access token with step_up_at now and new claim step_up_method (exp kept, no refresh, no session write); nonce single-use via oauth_state_uses phase step_up_assert (no schema change), retained to the session expiry as of the claim (a refresh may slide the session past it); in-process rate limits 10 per 600 s per account, 120 per 60 s per app; audit step_up_asserted and step_up_assertion_refused with a nonce digest (429s and not-permitted refusals audited once per window; not-permitted has a 5 per 60 s per-app budget); nonce is base64url 16-128. Provider step-up sign-ins now carry step_up_method provider:; identity_unlinked records stepUpMethod. Heimdall cannot verify the factor: it trusts the calling app. Not deployed; no env, secret or migration. For heimdall-body:follow_up:chat-s7419-7419.", "branch": "heimdall/app-asserted-step-up"} diff --git a/tests/step-up-assertion.test.ts b/tests/step-up-assertion.test.ts index e505829..b3115f1 100644 --- a/tests/step-up-assertion.test.ts +++ b/tests/step-up-assertion.test.ts @@ -18,7 +18,7 @@ import { createTestConfig } from "./oauth-flow.js"; const now = "2026-11-13T12:00:00.000Z"; const secret = "bifrost-secret"; const appAuth = { "x-heimdall-app-secret": secret }; -const nonce = (tag: string) => `nonce-${tag}-`.padEnd(32, "x"); +const nonce = (tag: string) => `nonce-${tag.replace(/[^A-Za-z0-9_-]/g, "_")}-`.padEnd(32, "x"); const dir = mkdtempSync(join(tmpdir(), "heimdall-stepup-assert-")); const config = { ...createTestConfig(), signingPrivateKeyPath: join(dir, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true }; @@ -259,7 +259,7 @@ describe("the method and the nonce", () => { it("malformed bodies are 400 before anything is minted", async () => { const r = await rig(); const m = await member(r); - for (const payload of [{}, { method: "bifrost-passkey" }, { nonce: nonce("a") }, { method: "bifrost-passkey", nonce: "short" }, { method: 7, nonce: nonce("a") }]) { + for (const payload of [{}, { method: "bifrost-passkey" }, { nonce: nonce("a") }, { method: "bifrost-passkey", nonce: "short" }, { method: "bifrost-passkey", nonce: "has a space in it, 16+ chars" }, { method: "bifrost-passkey", nonce: `${"a".repeat(20)}\ud800` }, { method: "bifrost-passkey", nonce: "a".repeat(129) }, { method: "bifrost-passkey", nonce: "ok-looking-nonce-é-ünï" }, { method: 7, nonce: nonce("a") }]) { expect((await assertStepUp(r, { token: m.token, payload })).statusCode, JSON.stringify(payload)).toBe(400); } expect(auditOf(r, "step_up_asserted")).toEqual([]); @@ -278,6 +278,25 @@ describe("the method and the nonce", () => { expect((await assertStepUp(r, { token: second.token })).statusCode).toBe(201); }); + it("a nonce is retained to the session's expiry as of the claim: a refresh slides the session past it, and then the nonce can be reused (documented limit)", async () => { + const r = await rig(); + const m = await member(r); + vi.useFakeTimers({ toFake: ["Date"] }); + const t0 = Date.now(); + const R = config.refreshTtlSeconds * 1000; + expect((await assertStepUp(r, { token: m.token, nonce: nonce("N") })).statusCode).toBe(201); + vi.setSystemTime(t0 + R - 60_000); + const refreshed = await r.app.inject({ method: "POST", url: "/v1/apps/bifrost/sessions/refresh", payload: { refreshToken: m.issued.refreshToken } }); + expect(refreshed.statusCode).toBe(201); + const token = refreshed.json().accessToken as string; + // Inside the retained horizon the replay is still refused, refreshed session or not. + expect(refused(await assertStepUp(r, { token, nonce: nonce("N") }))).toEqual([409, "step_up_nonce_replayed"]); + // Past the old expiry plus the store's prune margin, the next claim prunes N while the session lives on. + vi.setSystemTime(t0 + R + 11 * 60_000); + expect((await assertStepUp(r, { token, nonce: nonce("M") })).statusCode).toBe(201); + expect((await assertStepUp(r, { token, nonce: nonce("N") })).statusCode).toBe(201); + }); + it("a nonce stays spent for the session's life, past the access token's own expiry, and across a restart of the process", async () => { const r = await rig(); const m = await member(r); @@ -323,6 +342,38 @@ describe("rate limits", () => { }); }); +describe("flood handling", () => { + it("a flood is not an audit flood: an app without the grant is audited once per window, a tripped budget once per window", async () => { + const r = await rig(); + const sp = await member(r, "streampixels"); + vi.useFakeTimers({ toFake: ["Date"] }); + const codes: Record = {}; + for (let i = 0; i < 500; i += 1) { + const res = await assertStepUp(r, { app: "streampixels", headers: { "x-heimdall-app-secret": "streampixels-secret" }, token: sp.token, nonce: nonce(`f${i}`) }); + codes[res.statusCode] = (codes[res.statusCode] ?? 0) + 1; + } + expect(codes).toEqual({ 403: STEP_UP_ASSERT_LIMITS.notPermitted.max, 429: 500 - STEP_UP_ASSERT_LIMITS.notPermitted.max }); + const rows = (code: string) => auditOf(r, "step_up_assertion_refused").filter((e) => e.eventPayloadJson.code === code); + expect(rows("step_up_assertion_not_permitted")).toHaveLength(1); + expect(rows("step_up_rate_limited")).toHaveLength(1); + vi.setSystemTime(Date.now() + STEP_UP_ASSERT_LIMITS.notPermitted.windowMs + 1000); + await assertStepUp(r, { app: "streampixels", headers: { "x-heimdall-app-secret": "streampixels-secret" }, token: sp.token }); + expect(rows("step_up_assertion_not_permitted")).toHaveLength(2); + + // The same for the granted app's per-app and per-account budgets. + const m = await member(r); + const before = r.audit.length; + for (let i = 0; i < 300; i += 1) await assertStepUp(r, { token: "garbage", nonce: nonce(`g${i}`) }); + expect(r.audit.length - before, "garbage-bearer refusals up to the budget, then one row for the trip").toBe(STEP_UP_ASSERT_LIMITS.app.max + 1); + vi.setSystemTime(Date.now() + STEP_UP_ASSERT_LIMITS.app.windowMs + 1000); + const mark = r.audit.length; + for (let i = 0; i < 30; i += 1) await assertStepUp(r, { token: m.token, nonce: nonce(`a${i}`) }); + const accountRows = r.audit.slice(mark); + expect(accountRows.filter((e) => e.eventType === "step_up_asserted")).toHaveLength(STEP_UP_ASSERT_LIMITS.account.max); + expect(accountRows.filter((e) => e.eventPayloadJson.code === "step_up_rate_limited")).toHaveLength(1); + }); +}); + describe("receipts", () => { it("records every assertion and every post-secret refusal with account, app, session, method, outcome and time, and nothing secret", async () => { const r = await rig();