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 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-13 20:13:13 +00:00
commit 1259911632
7 changed files with 135 additions and 33 deletions

View file

@ -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).

View file

@ -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

View file

@ -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

View file

@ -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<string, unknown> = {}): Promise<HandlerResult> => {
const refuse = async (statusCode: number, error: string, extra: Record<string, unknown> = {}, record = true): Promise<HandlerResult> => {
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<FastifyIn
additionalProperties: false,
properties: {
method: { type: "string", minLength: 1, maxLength: 64 },
nonce: { type: "string", minLength: 16, maxLength: 128 },
// base64url alphabet: what a random token looks like, and no spaces, controls or lone surrogates.
nonce: { type: "string", pattern: "^[A-Za-z0-9_-]{16,128}$" },
},
},
},

View file

@ -17,20 +17,22 @@ export class FixedWindowBudget {
private readonly maxKeys = 4096,
) {}
/** Spend one attempt for `key`. Returns 0 when allowed, else the seconds until the window turns over. */
spend(key: string): number {
/**
* Spend one attempt for `key`. `n` counts attempts in the window, denied ones
* included, so a caller can act on the first denial (`n === max + 1`) and not
* on the flood behind it. `retryAfterSeconds` is 0 when allowed.
*/
spend(key: string): { n: number; retryAfterSeconds: number } {
const at = this.now();
const current = this.windows.get(key);
let current = this.windows.get(key);
if (!current || at - current.startedAt >= 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. */

View file

@ -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:<name>; 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:<name>; 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"}

View file

@ -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<number, number> = {};
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();