From 18bd5cbbd0c28f8d2e51a5b0e868fb4fccaa8640 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 01:02:13 +0000 Subject: [PATCH] Identity moves: service contract, architecture, bifrost profile docs and changelog line Co-Authored-By: Claude Sonnet 5.5 --- docs/app-profiles/bifrost.md | 15 ++++++++ docs/architecture.md | 18 ++++++++- docs/service-contract.md | 72 +++++++++++++++++++++++++++++++++++- state/evidence.jsonl | 1 + 4 files changed, 103 insertions(+), 3 deletions(-) diff --git a/docs/app-profiles/bifrost.md b/docs/app-profiles/bifrost.md index 41508e5..9a7926c 100644 --- a/docs/app-profiles/bifrost.md +++ b/docs/app-profiles/bifrost.md @@ -96,3 +96,18 @@ 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). + +## Identity moves + +Bifrost's profile carries `identityMoves: { accountProofMethods: +["bifrost-passkey"] }`, the only such grant (its methods are a subset of +`stepUpAssertion.methods`). With its secret, `POST /v1/apps/bifrost/identity-moves` +moves logins from one Heimdall account to another when it presents fresh proofs +of both: Bifrost's merge saga owns survivor choice, preview and undo timing; +Heimdall moves what the proofs cover. A proof through one login moves only that +login; an asserted `bifrost-passkey` proof covers the whole source account; +`bifrost-email-code` proves an inbox and does not. A multi-login source without a +passkey therefore needs one fresh sign-in per login. Until the Google client is +live, a member's strong proof is the asserted passkey only; Discord and Patreon +give weak (300 s) proofs. Contract, refusal order and idempotency are in +`docs/service-contract.md`. diff --git a/docs/architecture.md b/docs/architecture.md index f1d66c3..f5c89ae 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -348,13 +348,29 @@ for the account that holds it. The store role therefore holds DELETE on `linked_identities`, checked at startup. The HTTP side (`/v1/me/identities`) is in `service-contract.md`; an unlink needs a step-up proof inside 600 seconds. +`moveIdentities` is the only statement that sets `linked_identities.account_id` +to another account, reached only through `POST /v1/apps/{app}/identity-moves`. +One transaction locks both accounts in id order (opposite moves queue, they do +not deadlock), answers a repeated `moveId` from its `identity_moves` row, +re-checks the proof sessions, applies `decideMove` (one pure function both stores +call, so memory and Postgres cannot disagree), moves the rows with credentials +stripped, revokes the source's sessions, expires its pending links and unredeemed +completions, and appends the row. `identity_moves` is append-only (SELECT and +INSERT only, checked at startup). An alias is derived by `resolveAccount`, never +stored. + ### Step-up proof has two sources An access token's `step_up_at` is set in exactly two places: the `sign_in` step-up callback (Heimdall asked the provider to re-authenticate; label `provider:`) and `assertStepUp`, the app-asserted route (the app says it checked a factor; label from the profile's `stepUpAssertion.methods`). Both -feed the one reader, the unlink check, and a refresh sets neither. The +feed the one reader, `proofTier` in `claims.ts` (unlink and identity moves both +read it: `strong` is a step-up inside 600 s, `weak` a sign-in inside 300 s via +`auth_at`), and a refresh sets neither. `proofCovers` says what a proof speaks +for: only a strong proof whose method the profile lists as an account proof +covers the whole account; any other proof covers the one login in +`auth_identity_id`. 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; diff --git a/docs/service-contract.md b/docs/service-contract.md index 2266c0d..dd2c174 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -720,8 +720,9 @@ cookie ticket would turn cookie theft into bearer theft and, with 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. +identity a Google re-authentication would have guarded. Identity moves +(below) are the first route to require a method for whole-account coverage; +unlink is unchanged. 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 @@ -732,6 +733,71 @@ live bearer it holds. `identity_unlinked` records the proof's `stepUpMethod` (`provider:`, an app label, or null), and unlink does not yet require any particular method. +### `POST /v1/apps/{appSlug}/identity-moves` + +The one sanctioned way a login changes account (`moveIdentities` is the only +writer of `linked_identities.account_id`). Bifrost owns the merge: survivor +choice, preview, grace and undo timing. Heimdall receives the choice as which +proofs fill which side, renders no preview and picks nothing. Only a profile with +`identityMoves` may call it (today bifrost). + +Credential: the app's secret. Body (closed; unknown properties are ignored, and +no account is ever named by the body): + +- `moveId` (`^[A-Za-z0-9_-]{16,64}$`): the caller's idempotency key. +- `fromProofs`: 1 to 8 live Heimdall access tokens for this app, all of the + source account. +- Forward move: `toProof` (a live token of the destination) and optionally + `identityIds` (1 to 32; default every login the source holds). +- Reversal: `reversesMoveId` instead of `toProof`/`identityIds`. It moves back + exactly the logins the forward move carried, once; `fromProofs` must be of the + forward move's destination. A reversal is not itself reversible. + +Each proof must be fresh: a step-up inside 600 s (`strong`) or a sign-in inside +300 s (`weak`), by `proofTier`, the same freshness rule the unlink route reads. +A token with neither claim (a refresh, a link) is stale. + +Coverage: a proof through login X (`auth_identity_id`) moves X and nothing else +from that account. Only a strong proof asserted with a method in the profile's +`identityMoves.accountProofMethods` (`bifrost-passkey`; not `bifrost-email-code`, +which proves an inbox) speaks for the whole account. Moving several logins takes +one proof per login, or the passkey. + +Order of refusals, each a fixed code: `401 app_auth_required`; `403 +not_allowed_for_app`; a replayed `moveId` is answered next (below); `409 +not_reversible` (reversal names a missing, foreign or reversal move); `403 +proof_invalid` `{proof: "from"|"to"}` (not a live bearer of this app, or +`fromProofs` naming two accounts); `409 same_account`; `403 proof_stale` +`{proof}`; `403 identity_not_proved`; then the store: `403 proof_invalid` (a +proof session lapsed under the lock), `409 already_moved` (either side is an +alias), `409 provider_conflict` `{provider}`, `409 not_reversible`, `409 +move_id_conflict`, `404 identity_not_found`, `403 identity_not_proved`. A refusal +body is `{error, detail?, proof?, provider?}` and never names an account id, an +identity the caller did not send, an email or a name. + +On success, in one transaction: the logins move to the destination with their +stored credentials stripped; every live session of the source is revoked and its +pending links and unredeemed completions expire; one append-only `identity_moves` +row is written. Afterwards, for each moved provider the source no longer holds, +its live grants get revocation receipts under the source, and `identity_moved` +is audited. Answer `200`, `Cache-Control: no-store`: +`{moveId, accountId: , movedIdentityIds, proof: {from, to}, reversesMoveId?}`, +with `proof.from` `strong` if any from-proof was strong and `to` null on a +reversal. + +Idempotency: the same `moveId` with the same app, accounts and `reversesMoveId` +answers the stored receipt byte for byte, even though the first success revoked +the source's sessions: the proofs are then checked for a valid Heimdall +signature, issuer, type and audience, and account, not for liveness. The replay +re-runs the revocation receipts, so a crash between commit and receipts heals on +the caller's retry. Anything else under a used `moveId` is `409 move_id_conflict`. + +What a move leaves: an account holding no login with an unreversed move from it +is an alias of the move's destination (derived by `resolveAccount`, never +stored; one hop). A move into or out of an alias is `already_moved`. Bearers of +the destination keep their old identity list until they refresh. A sign-in +through a moved login lands on the destination because the row moved. + ### `POST /v1/apps/{appSlug}/managed-credentials/resolve` Purpose: @@ -869,6 +935,8 @@ Current claim payload fields: - `access_revision` - `step_up_at` optional: epoch seconds of the proof; set only by a `sign_in` step-up callback or `POST /v1/apps/{appSlug}/step-up`, never by a refresh - `step_up_method` optional: present exactly when `step_up_at` is; `provider:` for a provider step-up sign-in, otherwise the method label the app asserted +- `auth_at` optional: epoch seconds of the sign-in this token was minted by; set only by a `sign_in` callback (never link, connect or refresh), always with `auth_identity_id` +- `auth_identity_id` optional: the `linked_identities.id` that sign-in came through; the only record of which login proved the session. An asserted step-up spreads the bearer's claims, so these pass through unchanged - `display_name` optional - `app.slug` - `app.profile_version` diff --git a/state/evidence.jsonl b/state/evidence.jsonl index caaa8e6..279b21e 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -40,3 +40,4 @@ {"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 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"} +{"ts": "2026-11-14T01:10:00+00:00", "type": "design", "status": "accepted", "note": "Changelog 2026-11-14: POST /v1/apps/{app}/identity-moves (profile grant identityMoves, bifrost only; accountProofMethods bifrost-passkey) and the identity_moves table, the only writer of linked_identities.account_id (moveIdentities, one transaction, accounts locked in id order, shared decideMove on both stores). Access claims gain auth_at and auth_identity_id (sign_in callback only). proofTier is the one freshness rule (unlink reads it; strong 600 s step_up_at, weak 300 s auth_at); proofCovers: only an asserted account-proof method covers a whole account, otherwise the login in auth_identity_id (ruling move-proof-strict-passkey). Moves strip credentials, revoke the source's sessions, expire its pending links and unredeemed completions, write revocation receipts under the source, audit identity_moved; moveId is idempotent (replay answers the stored receipt without proof liveness); a move is reversible once. Not deployed: the owner must apply the identity_moves DDL and GRANT SELECT, INSERT ON identity_moves to the store role before the new build starts (startup refuses SCHEMA_MISSING / SCHEMA_PRIVILEGES otherwise).", "branch": "heimdall/identity-moves"}