diff --git a/README.md b/README.md index 3cdcc75..ba62504 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ This repository now includes a viable alpha foundation slice of the ASP.NET Core - Razor Pages app under `src/Bifrost.Web` - PostgreSQL-backed EF Core model with an initial migration under `src/Bifrost.Web/Data/Migrations` - Bifrost-native identity registration plus GitHub OAuth and Heimdall-linked provider identities, with invite/approval-based membership gating -- a Linked accounts page where a member sees, links and unlinks their Heimdall provider logins, with unlink gated on a fresh Google step-up proof ([docs/linked-accounts.md](docs/linked-accounts.md)) +- a Linked accounts page where a member sees, links and unlinks their Heimdall provider logins, with unlink gated on a strong proof: a WebAuthn passkey (enrolled behind a fresh sign-in, 48 hour cooling-off), or a fresh Google step-up for an account with none ([docs/linked-accounts.md](docs/linked-accounts.md), [docs/passkeys.md](docs/passkeys.md)) - explicit member roles and admin-managed patron or contributor tier snapshots - patron support event recording with derived patron point summaries and tier refresh for voting weight - shared work board with estimates, actual time logs, review flow, and GitHub issue or PR links diff --git a/docs/jurisdiction-map.md b/docs/jurisdiction-map.md index 8163eee..ede5294 100644 --- a/docs/jurisdiction-map.md +++ b/docs/jurisdiction-map.md @@ -105,7 +105,7 @@ This keeps Reddit useful without making subreddit mechanics the secret constitut Heimdall authenticates; it does not issue GameCult identity. Bifrost owns the identity (`UserAccount.Id`, `NormalizedBifrostIdentity`) and issues it to chat as an OIDC provider (`docs/oidc-issuer.md`). `HeimdallAccountId` is an authenticator handle that links a Heimdall account to a Bifrost identity. -A member's provider logins are Heimdall's (`linked_identities`); Bifrost shows, links and unlinks them on `/Membership/LinkedAccounts` by calling Heimdall's member routes with a bearer from a fresh Heimdall sign-in, and keeps none (`docs/linked-accounts.md`). +A member's provider logins are Heimdall's (`linked_identities`); Bifrost shows, links and unlinks them on `/Membership/LinkedAccounts` by calling Heimdall's member routes with a bearer from a fresh Heimdall sign-in, and keeps none (`docs/linked-accounts.md`). Strong proof for an unlink is a Bifrost-verified passkey (WebAuthn, RP ID the Bifrost host) that Bifrost asserts to Heimdall through its app-asserted step-up route; Bifrost owns the credential store and the ceremonies, Heimdall owns the unlink gate (`docs/passkeys.md`). Bifrost consumes Heimdall claims. It does not become the key vault. The split is bridge versus gate: Heimdall decides who may cross under which grant; Bifrost moves authorized work across public protocols and records where it landed. diff --git a/docs/linked-accounts.md b/docs/linked-accounts.md index 4ecf3e9..0da0b95 100644 --- a/docs/linked-accounts.md +++ b/docs/linked-accounts.md @@ -9,6 +9,8 @@ | Show my logins | A fresh ordinary Heimdall sign-in as the session's account, through the proof provider (below); inside its completion, the list | `GET /v1/me/identities` with that sign-in's bearer | | Link Discord, Patreon or Google | The same fresh sign-in as proof, then the link start for the chosen provider with its bearer. Needs a current list. Discord and Patreon attach in Heimdall's callback and return a link completion; Google writes a pending link, and a second fresh sign-in confirms it | `POST /v1/oauth/{provider}/start` `mode: "link"`, then for Google `POST /v1/apps/bifrost/links/confirm` | | Unlink | A sign-in through Google with `stepUp: true` (app secret); inside its completion, the unlink | `POST /v1/me/identities/{id}/unlink` with that sign-in's bearer | +| Unlink with a passkey (an account with a strong passkey) | A WebAuthn assertion verified at Bifrost, then a fresh ordinary sign-in as the account; inside its completion, Heimdall's app-asserted step-up, then the unlink with the token it returns | `POST /v1/apps/bifrost/step-up` `{method: "bifrost-passkey", nonce}`, then `POST /v1/me/identities/{id}/unlink` | +| Add or revoke a passkey | Enrolment needs a fresh Heimdall sign-in first and cools off 48 hours; revoking needs no proof | none | The linkable providers are the ones Heimdall's `bifrost` profile admits (`identityProviders` in Heimdall `src/app-profiles.ts`): Discord, Patreon, Google. Twitch is offered for sign-in elsewhere in Bifrost but the profile refuses it, so it is not offered here. @@ -30,9 +32,11 @@ An ordinary proof sign-in through Discord or Patreon when the account no longer ## The proof limit +Passkeys (`docs/passkeys.md`) are the strong proof: an account with a passkey past its cooling-off unlinks with it, and the Google step-up below is then closed (`passkey_required`). The rest of this section is the Google proof, which stays for an account with no strong passkey. + Unlinking needs fresh proof: Heimdall refuses `403 step_up_required` unless the access token carries `step_up_at` from a step-up sign-in within 600 seconds (`docs/service-contract.md`, `POST /v1/me/identities/{identityId}/unlink`, in the Heimdall repository). Only Google can give that to a `bifrost` sign-in: Discord and Patreon have no re-authentication parameter (an open provider session completes without credentials), and Twitch and YouTube, which do, are not in the `bifrost` profile. So: -- An account with no Google login linked cannot unlink anything from here. The page says so and shows no Unlink button; a posted unlink starts nothing. +- An account with no Google login linked and no strong passkey cannot unlink anything from here. The page says what it can do (add a passkey; the time a cooling-off one becomes strong) and shows no Unlink button; a posted unlink starts nothing. - Heimdall answers `503 provider_not_configured` for Google until a Google OAuth client is configured on the Heimdall host (follow-up `heimdall-body:follow_up:google-console-client-registration`; not set live as of 2026-11-13). The step-up start is then refused and the page says fresh proof could not be started. Nothing loops: the refusal sets no attempt cookie. - Linking Google needs the same client. Until then the Google link button ends in "That could not be completed. Nothing was changed." - A Google step-up proof is as strong as Google's `prompt=login`; Heimdall says "asked", not "verified" (`stepUpRequested`). @@ -53,8 +57,9 @@ A link refused because the login already belongs to another account (`409 identi - `Pages/Membership/LinkedAccounts.cshtml[.cs]`: the page and its three POST handlers. - `Features/Heimdall/HeimdallAuthService.cs`: the attempt machinery (`StartListAsync`, `StartLinkAsync`, `StartUnlinkAsync`, `ContinueLinkAsync`), shared with sign-in. - `Features/Heimdall/HeimdallMemberClient.cs`: the two member calls and their outcome mapping. +- `Features/Passkeys/`, `wwwroot/js/passkeys.js`: the passkey ceremonies (`docs/passkeys.md`). - `Features/Heimdall/LinkedAccountsSnapshots.cs`: the five-minute list. -- Tests: `tests/Bifrost.Web.Tests/LinkedAccountsTests.cs`, `HeimdallGoogleLinkTests.cs`, with the fake Heimdall in `Support/HeimdallTestKit.cs`. +- Tests: `tests/Bifrost.Web.Tests/LinkedAccountsTests.cs`, `HeimdallGoogleLinkTests.cs`, `PasskeyTests.cs`, with the fake Heimdall in `Support/HeimdallTestKit.cs`. ## Known leftover diff --git a/docs/passkeys.md b/docs/passkeys.md new file mode 100644 index 0000000..6a04d07 --- /dev/null +++ b/docs/passkeys.md @@ -0,0 +1,133 @@ +# Passkeys: strong proof for unlinking a login + +A Heimdall-backed member can enrol WebAuthn passkeys on `/Membership/LinkedAccounts` and use one as the strong proof that unlinking a provider login needs. Bifrost verifies the passkey; Heimdall accepts Bifrost's word through its app-asserted step-up route (`POST /v1/apps/bifrost/step-up`, Heimdall `docs/service-contract.md`, `docs/app-profiles/bifrost.md`). Scoping and the option comparison are in the handoff `strong-proof-scoping-20261113.md` (option 1a with the S1 sink). + +## Scope + +- Accounts that have a `HeimdallAccountId`: the accounts that can unlink at all. A Bifrost-native-only account (a native `BifrostIdentity` or GitHub sign-in) has no logins to unlink and gets no passkeys from this cut; the page shows it nothing. +- Unlink only. A passkey is not a sign-in method here. + +## The library: Fido2NetLib (`Fido2` 4.3.3) + +Decision: use `Fido2` 4.3.3 (NuGet id `Fido2`, repository `passwordless-lib/fido2-net-lib`) for every byte of WebAuthn parsing and verification. Bifrost hand-rolls no CBOR, COSE or signature code. + +What was checked on 2026-11-13, against the package and the repository, not from memory: + +| Question | Finding | +|---|---| +| Licence | MIT, in the nuspec (`MIT`) and in the repository's `LICENSE.txt` (GitHub reports `MIT`). Its closure: `Fido2.Models` MIT; `NSec.Cryptography` 25.4.0 MIT; `libsodium` 1.0.20.1 ISC; `System.Formats.Cbor` 9.0.24, `Microsoft.IdentityModel.JsonWebTokens` 8.2.0, `Microsoft.Extensions.Http` 9.0.0, `Microsoft.Bcl.Memory` 9.0.14 all Microsoft MIT. All open source. | +| Maintained | Not archived; repository pushed 2026-11-11; 4.3.3 released 2026-11-11 and 4.3.2 on 2026-10-29; about 1,460 stars. | +| net8.0 | The 4.3.3 nuspec has a `net8.0` dependency group (and `net10.0`); it targets the framework this project targets. | +| Packed attestation | `Src/Fido2/AttestationFormat/` holds `Packed`, `None`, `FidoU2f`, `Tpm`, `AndroidKey`, `AndroidSafetyNet`, `Apple`, `AppleAppAttest`. Bifrost asks for `none` attestation and does not use a metadata service, so it does not depend on any of them being trusted; a client that sends another format still has it parsed and its statement verified structurally by the library. | +| What verification covers | Read in `AuthenticatorAssertionResponse.cs`: challenge, origin (against `Fido2Configuration.Origins`), `webauthn.get` type, RP ID hash, user-present and user-verified flags (when required), signature over `authData || SHA-256(clientDataJSON)` with the stored COSE key, user handle ownership, and the sign count (see "Sign count" below). Enrolment verifies the attestation, the credential id and the key. | +| Transitive weight | Heavier than the repository's other packages: `Microsoft.Extensions.Http` 9.0.0 pulls `Microsoft.Extensions.*` 9.0.0, so existing 8.x entries move to 9.0.0 (seven in `src/Bifrost.Web/packages.lock.json`; twelve `Microsoft.Extensions.*` entries plus `System.Diagnostics.DiagnosticSource` in the test project's lockfile, which sees them too; app-local on top of the net8 shared framework, a supported combination), and the lockfiles gain `Fido2`, `Fido2.Models`, `Microsoft.Bcl.Memory`, `Microsoft.Extensions.Http`, `Microsoft.Extensions.Diagnostics[.Abstractions]`, `Microsoft.Extensions.Configuration[.Binder]`, `System.Diagnostics.DiagnosticSource`, `Microsoft.Extensions.Options.ConfigurationExtensions`, `NSec.Cryptography`, `System.Formats.Cbor` and the native `libsodium` package (the Linux x64 glibc and musl binaries ship in the package's `runtimes/`). `Microsoft.IdentityModel.JsonWebTokens` is requested at 8.2.0 and already resolved to 8.19.2 (through OpenIddict) before this change; it still does. `packages.lock.json` in both projects is regenerated and committed. | +| Alternatives | Not needed: the candidate passed. | + +Not verified: that the 9.0.0 `Microsoft.Extensions.*` assemblies behave on the live host's ASP.NET Core 8 runtime beyond the test host (the suite runs the real app under it); that `libsodium` loads on Yggdrasil (the EdDSA test loads it here, on Debian x64). + +The library is used through four calls: `RequestNewCredential`, `MakeNewCredentialAsync`, `GetAssertionOptions`, `MakeAssertionAsync`. `PasskeyRelyingParty` builds it from `Host:PublicBaseUrl`. + +## Sign count + +The stored sign count is updated on every successful assertion, and the library refuses a counter that does not increase once either side is non-zero. Authenticators that always report zero (most synced passkeys) are accepted every time and get no clone detection from this: both counters stay zero. An authenticator that reported a non-zero count and then reports zero is refused. Clone detection is therefore real only for hardware keys and other counting authenticators. + +## Relying party + +RP ID is the host of `Host:PublicBaseUrl`, the one origin is its scheme, host and port. They come from configuration and from nothing the browser sends. Passkeys are unavailable, with a warning logged once at start, when `Host:PublicBaseUrl` is not https or its host is not `Host:ExpectedHost`: the page offers none and every ceremony endpoint refuses (`passkeys_unavailable`). `PasskeyRelyingParty` is resolved at startup, so a misconfiguration is logged once at boot (not at the first ceremony). `Host:ExpectedHost` must be a bare host name: one with a port (`host:443`) or an IP address (`[::1]`, `127.0.0.1`) makes passkeys unavailable and is reported, because an IP address is not a valid RP ID and a port is not part of it. No new configuration key exists. + +If passkeys are unavailable while an account holds a strong passkey, the page shows no unlink form and says so: unlinking needs the passkey, the Google route stays closed (`passkey_required`), and the member can revoke their passkeys on the page (revoking does not need the relying party) to fall back to the Google proof, or wait for the operator to fix the server. This is a lockout until the operator or the member acts; it fails closed. + +## State + +- `PasskeyCredentials` (migration `20261113120000_AddPasskeyCredentials`, additive, applied at boot like the others): per `UserAccount` id, `CredentialId` (unique), `PublicKey`, `SignCount`, `Transports`, `AaGuid`, `Label`, `CreatedUtc`, `StrongFromUtc` (`CreatedUtc` + 48 h), `RevokedUtc`, `LastUsedUtc`. No secret exists to store: the private key never leaves the authenticator. Rows cascade with the account. +- `PasskeyCeremonies` (in memory, this process only, like the Linked accounts list): the short-lived single-use records of the flows, each bound to a Heimdall account id and a kind (and to an identity id for unlink). A restart empties it and every flow is begun again; with more than one Bifrost instance a flow must finish on the instance that began it, otherwise it is refused and begun again. Both fail closed (a lost ticket, challenge or fact refuses; it never grants). It is chosen over a sealed cookie because a cookie cannot be spent: a copied cookie would replay. + +| Kind | Lifetime | Spent | +|---|---|---| +| enrolment ticket | 5 min | by the registration post | +| enrolment challenge | 2 min | by the registration post | +| unlink challenge | 2 min | by the assertion post (success or not) | +| unlink assertion fact | 5 min | by the completion that asks Heimdall for the step-up | + +A challenge kind has one live record per account: a newer one replaces the older. The table holds at most 2048 records and refuses past that. + +A caller's own preconditions are checked before anything is spent. A registration needs the live ticket cookie (a session thief has none), a decodable body and room under the cap; an assertion needs a decodable body naming one of the session account's own strong, un-revoked credentials. Only then are the verification permit, the challenge and (for enrolment) the ticket taken. An assertion also needs a live challenge for that account and identity before a permit is spent. So junk posted by someone who holds only the session, naming no credential of the account or arriving with no live challenge, spends nothing. Junk naming one of the account's own credentials (the id is in `allowCredentials`) spends one verification permit per challenge that was asked for, because the first such post takes the challenge; it cannot drain the 20 permits per 10 minutes in one go. It can still burn that challenge once, and a session holder who keeps asking for challenges (an options permit each) and posting junk can exhaust both budgets: a lockout the same session already has through revoking passkeys, not a bypass. A junk post naming no credential of the account costs two cheap database lookups and no permit; that is accepted rather than spending an options-style permit on it. Both budgets are per process: with more than one instance each has its own. Asking for options spends a separate options budget (20 per 10 minutes), which a session holder can exhaust: that blocks new options for a while and nothing else. A session holder can also replace the member's pending challenge by asking for new options, which makes the member's assertion answer `passkey_expired` and the member tries again; that session can already revoke passkeys, so it is not a new capability. The member whose challenge was burned retries with a new one. + +## Enrolment + +1. The member is on the page with a current list (Bifrost will not guess a proof provider) and presses "Sign in again to add a passkey". Bifrost starts a fresh ordinary Heimdall sign-in through a provider the account holds (an attempt with purpose `EnrolProof`, the same machinery as the list and link proofs, `ExpectedAccountId` the session's account). +2. In that sign-in's completion, when the proven account is the session's own, Bifrost issues an enrolment ticket and sets its id in `__Host-bifrost_passkey_ticket` (HttpOnly, Secure, 5 minutes). The ticket exists only because a fresh sign-in completed in this browser: a stolen session cookie alone has no ticket and cannot get registration options. +3. The page's script asks for creation options (antiforgery header), calls `navigator.credentials.create` (user verification required, resident key preferred, `none` attestation, existing credentials excluded) and posts the base64url result. Bifrost spends the ticket and the challenge, verifies, and stores the credential with `StrongFromUtc` = now + 48 hours. + +A label is limited to 40 text elements (cut by rune and grapheme, never inside a surrogate pair) and loses control and format characters (bidi overrides, joiners, zero-width marks). + +Cap: five un-revoked passkeys per account (`passkey_limit`; a sixth starts no sign-in). Every options request, enrolment, assertion and enrolment start spends a permit of one `FixedWindowBudget` per account (20 per 10 minutes, `rate_limited`). + +## Cooling-off + +A passkey is strong from `StrongFromUtc` (at that instant and after), until revoked. Until then it is listed on the page with its time and cannot unlink anything. So someone who controls both a member's session and a provider login long enough to add a key of their own still waits 48 hours with the key visible, and the member can revoke it from the page. Revoking needs no strong proof (it only removes capability) and is shown. + +## Unlink with a passkey + +When the account has a strong passkey, the page's Unlink button runs a WebAuthn assertion instead of the Google step-up, and the Google route (`Unlink` handler) answers `passkey_required` and starts nothing: a member who holds a strong passkey is not offered the weaker proof. With no strong passkey the Google step-up stays as it was, and when neither exists the page says what to do (add a passkey, with the time a cooling-off one becomes strong). + +1. The script asks for request options for the chosen identity (`allowCredentials` is the account's strong passkeys; user verification required). One live challenge per account, bound to the identity. +2. The browser's assertion is posted. Bifrost spends the challenge, requires the credential to be the session account's own, not revoked and past `StrongFromUtc`, and has the library verify origin, RP ID, challenge, user verification, signature and sign count. On success it records the new sign count and last use, and issues the single-use fact "passkey assertion verified" for this account and this identity (5 minutes; no credential, no bearer). +3. Bifrost starts a fresh ordinary Heimdall sign-in for the account (attempt purpose `UnlinkPasskeyProof`; proof provider as on the page: one the current list shows, never a guess). The attempt carries the fact's id. +4. In the completion, which is the only place the bearer exists: the fact is taken (missing, expired, spent, for another account or identity: no Heimdall step-up call and `passkey_expired`); the credential the fact was made with must still be the account's own, un-revoked and strong (a passkey revoked since the assertion: no step-up call, `passkey_failed`); the app secret, the bearer and `{method: "bifrost-passkey", nonce}` go to `POST /v1/apps/bifrost/step-up` (nonce: 32 fresh random bytes, base64url, 43 characters); the returned `accessToken` goes to the existing unlink call; everything is dropped. The bearer and the minted token are never stored, logged, put in a cookie or a redirect, or rendered. + +Heimdall's refusals are shown as closed codes and nothing is retried in the flow: `401 sign_in_required` is `sign_in_required`; `403`/`400`/`401 app_auth_required` is `step_up_unavailable`; `409 step_up_nonce_replayed` is `step_up_replayed`; `429` is `step_up_rate_limited`; anything else, or Heimdall down, is `error`. A member whose attempt ends this way starts the unlink again (a new assertion). + +## Abuse cases and what stops them + +Each is a test in `tests/Bifrost.Web.Tests/PasskeyTests.cs` unless noted. + +| Case | Defence | +|---|---| +| Stolen session cookie tries to enrol | No ticket without a fresh Heimdall sign-in completed in the victim's own browser. A ticket is per account, spent once, expires in 5 minutes, and is held by id in an HttpOnly cookie | +| Stolen session tries to unlink | Needs an assertion by a strong passkey of the account; the Google route is closed while one exists; no step-up is asked of Heimdall without the fact | +| Attacker who controls the provider session enrols their own key | Cooling-off: 48 hours before it counts, shown on the page, revocable by the member | +| Replay of an assertion, double submit | The challenge is single-use; two concurrent posts start one sign-in | +| Assertion for another account or identity | The credential must belong to the session's account; the challenge, the fact and the attempt are bound to the account and the chosen identity | +| Phishing site | The library checks origin and RP ID hash against the configured values; a registration or assertion with another origin or RP ID is refused | +| Cloned authenticator | A sign count that does not increase is refused; the stored count is updated on every use | +| Cooling-off boundary | Strong at exactly `StrongFromUtc`, not one second before | +| Revoked credential | Never strong; a revoke between challenge and assertion also refuses, and so does one between the assertion and the completion | +| Junk from a session thief | Preconditions first, nothing spent before they hold (see State) | +| No user verification | Required at enrolment and at assertion | +| Cross-site POST | Antiforgery on every page POST including the JSON option requests | +| Bearer anywhere | Only in the completion request's stack; tests look in logs (with HttpClient trace logging on), cookies, redirects, the page and the decrypted attempt | +| Reused or replayed step-up | A fresh nonce each call; Heimdall's 409 is shown, not retried | +| Brute force of ceremonies | One `FixedWindowBudget` per account | + +## Account-scoped lookup and the allow list + +An assertion is accepted only for a credential of the session's own account (strong, un-revoked), and the user handle, when the authenticator sends one, must be that account's id. The request options also carry the account's strong credentials as `allowCredentials`, which the library enforces, so a credential of another account is refused twice. The test for the account-scoped lookup (`The_credential_lookup_is_scoped_to_the_session_account_even_when_the_allow_list_is_empty`) takes the allow list out of the other account's challenge through the in-process ceremony store, which production code cannot do, so the lookup is exercised alone without weakening the server. + +## Content Security Policy + +Bifrost sends no `Content-Security-Policy` header (nothing in the application sets one; this predates passkeys). The passkey script is first-party and external so that a policy of `script-src 'self'` would admit it, but nothing enforces that today. + +## The script + +`wwwroot/js/passkeys.js` is first-party, loaded by the page with an external `src` (nothing inline, no third-party asset, no `eval`, no `innerHTML`). It asks for options with the antiforgery header, calls `navigator.credentials`, and submits a same-origin form of base64url strings (`id`, `rawId`, `attestationObject` or `authenticatorData`, `clientDataJson`, `signature`, `userHandle`, `transports`). On any failure it navigates to this page with a result code from a closed set; nothing from a response is shown. `PasskeyScriptTests` runs it under Node against a simulated browser and pins what it posts. + +## What it does not do + +- No passkey sign-in; passkeys prove an already signed-in session only. +- No native-only accounts (no Heimdall account, nothing to unlink). +- No recovery without mail: a member who loses every passkey revokes them and falls back to the Google step-up if a Google login is linked, or waits for the mail gate (`docs/registration-gates.md`) and the email-code factor that follows it. The email code is not built. +- Named limit and next hardening: enrolling a second passkey should require an assertion by an existing strong passkey, and enrolment should use a Google step-up when a Google login is held. Today enrolment needs only the fresh ordinary sign-in and the cooling-off, so an attacker who controls a provider session can add a key that becomes strong after 48 hours unless the member sees and revokes it. Not built; a follow-up is filed. +- A revoke between the completion's check and its unlink does not stop that unlink: the window is the few milliseconds of the two Heimdall calls and is inherent to checking before acting. +- Revoking needs no strong proof, so a stolen session can revoke a member's passkeys and fall back to the Google proof: no weaker than the account was before it had a passkey. +- A passkey synced through a cloud account the attacker also controls is a residual risk, as in the scoping note. +- Heimdall cannot tell that a passkey was checked: `bifrost-passkey` is Bifrost's word, gated by the app secret and a live bearer (Heimdall `docs/app-profiles/bifrost.md`). +- Not verified here: a real authenticator or browser. Tests use a software authenticator built from the WebAuthn specification and the script runs under Node against a simulated browser. + +## Where the code is + +- `Features/Passkeys/PasskeyService.cs`: enrol, assert, revoke, list; `PasskeyCeremonies.cs`: the single-use records and the rate limit; `PasskeyRelyingParty.cs`: RP ID, origin, library construction. +- `Features/Heimdall/HeimdallAuthService.cs`: the `EnrolProof` and `UnlinkPasskeyProof` attempts, `StepUpAsync`. +- `Pages/Membership/LinkedAccounts.cshtml[.cs]`, `wwwroot/js/passkeys.js`. +- `Domain/BifrostModels.cs` (`PasskeyCredential`), `Data/Migrations/20261113120000_AddPasskeyCredentials.cs`. +- Tests: `PasskeyTests.cs`, `PasskeyScriptTests.cs`, `Support/SoftwareAuthenticator.cs`, and the step-up route of the fake Heimdall in `Support/HeimdallTestKit.cs`. diff --git a/src/Bifrost.Web/Bifrost.Web.csproj b/src/Bifrost.Web/Bifrost.Web.csproj index 177c194..8e26e7b 100644 --- a/src/Bifrost.Web/Bifrost.Web.csproj +++ b/src/Bifrost.Web/Bifrost.Web.csproj @@ -13,6 +13,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index d227c42..f710aa8 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -45,6 +45,7 @@ public sealed class BifrostDbContext(DbContextOptions options) public DbSet ConsentReceipts => Set(); public DbSet MindKeys => Set(); + public DbSet PasskeyCredentials => Set(); public DbSet MindNonces => Set(); @@ -333,6 +334,16 @@ public sealed class BifrostDbContext(DbContextOptions options) .OnDelete(DeleteBehavior.Cascade); }); + modelBuilder.Entity(entity => + { + entity.HasIndex(x => x.CredentialId).IsUnique(); + entity.HasIndex(x => x.UserAccountId); + entity.HasOne(x => x.UserAccount) + .WithMany() + .HasForeignKey(x => x.UserAccountId) + .OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity(entity => { entity.HasIndex(x => new { x.PersonaId, x.PublicKey }).IsUnique(); diff --git a/src/Bifrost.Web/Data/Migrations/20261113120000_AddPasskeyCredentials.cs b/src/Bifrost.Web/Data/Migrations/20261113120000_AddPasskeyCredentials.cs new file mode 100644 index 0000000..fd1c2e0 --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20261113120000_AddPasskeyCredentials.cs @@ -0,0 +1,48 @@ +using System; +using Bifrost.Web.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + // Hand-written like AddConsentReceipts: no dotnet-ef on the authoring host; the snapshot was edited to match. Additive only. + [DbContext(typeof(BifrostDbContext))] + [Migration("20261113120000_AddPasskeyCredentials")] + public partial class AddPasskeyCredentials : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PasskeyCredentials", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserAccountId = table.Column(type: "uuid", nullable: false), + CredentialId = table.Column(type: "bytea", nullable: false), + PublicKey = table.Column(type: "bytea", nullable: false), + SignCount = table.Column(type: "bigint", nullable: false), + Transports = table.Column(type: "text", nullable: false), + AaGuid = table.Column(type: "uuid", nullable: true), + Label = table.Column(type: "text", nullable: false), + CreatedUtc = table.Column(type: "timestamp with time zone", nullable: false), + StrongFromUtc = table.Column(type: "timestamp with time zone", nullable: false), + RevokedUtc = table.Column(type: "timestamp with time zone", nullable: true), + LastUsedUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PasskeyCredentials", x => x.Id); + table.ForeignKey("FK_PasskeyCredentials_UserAccounts_UserAccountId", x => x.UserAccountId, "UserAccounts", "Id", onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateIndex(name: "IX_PasskeyCredentials_CredentialId", table: "PasskeyCredentials", column: "CredentialId", unique: true); + migrationBuilder.CreateIndex(name: "IX_PasskeyCredentials_UserAccountId", table: "PasskeyCredentials", column: "UserAccountId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "PasskeyCredentials"); + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index 042e2f0..ab4ef16 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -1470,6 +1470,59 @@ namespace Bifrost.Web.Data.Migrations b.ToTable("ConsentReceipts"); }); + modelBuilder.Entity("Bifrost.Web.Domain.PasskeyCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AaGuid") + .HasColumnType("uuid"); + + b.Property("CreatedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialId") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUsedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("RevokedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SignCount") + .HasColumnType("bigint"); + + b.Property("StrongFromUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Transports") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CredentialId") + .IsUnique(); + + b.HasIndex("UserAccountId"); + + b.ToTable("PasskeyCredentials"); + }); + modelBuilder.Entity("Bifrost.Web.Domain.MindKey", b => { b.Property("Id") @@ -2183,6 +2236,17 @@ namespace Bifrost.Web.Data.Migrations b.Navigation("UserAccount"); }); + modelBuilder.Entity("Bifrost.Web.Domain.PasskeyCredential", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany() + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b => { b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") diff --git a/src/Bifrost.Web/Domain/BifrostModels.cs b/src/Bifrost.Web/Domain/BifrostModels.cs index 00e3f9c..7449a6b 100644 --- a/src/Bifrost.Web/Domain/BifrostModels.cs +++ b/src/Bifrost.Web/Domain/BifrostModels.cs @@ -1177,6 +1177,41 @@ public sealed class ConsentReceipt public UserAccount? UserAccount { get; set; } } +/// +/// A WebAuthn passkey a Heimdall-backed member enrolled (docs/passkeys.md). Holds only what verifying an assertion needs: +/// the credential id, the public key and the sign count. No secret exists to hold. It counts as strong proof only from +/// (the cooling-off after enrolment) and only while not revoked. +/// +public sealed class PasskeyCredential +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid UserAccountId { get; set; } + + public byte[] CredentialId { get; set; } = []; + + public byte[] PublicKey { get; set; } = []; + + public long SignCount { get; set; } + + // Comma-separated WebAuthn transport hints the authenticator reported at enrolment; a hint for the browser, never trusted. + public string Transports { get; set; } = string.Empty; + + public Guid? AaGuid { get; set; } + + public string Label { get; set; } = string.Empty; + + public DateTimeOffset CreatedUtc { get; set; } + + public DateTimeOffset StrongFromUtc { get; set; } + + public DateTimeOffset? RevokedUtc { get; set; } + + public DateTimeOffset? LastUsedUtc { get; set; } + + public UserAccount? UserAccount { get; set; } +} + /// A Mind's registered Ed25519 public key. Bifrost never holds a private key. public sealed class MindKey { diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index 12e028e..4741fe1 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -8,6 +8,7 @@ using Bifrost.Web.Configuration; using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Features.Membership; +using Bifrost.Web.Features.Passkeys; using Bifrost.Web.Features.Shared; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.WebUtilities; @@ -33,7 +34,9 @@ public sealed class HeimdallAuthService( IDataProtectionProvider dataProtectionProvider, TimeProvider timeProvider, HeimdallMemberClient memberClient, - LinkedAccountsSnapshots snapshots) + LinkedAccountsSnapshots snapshots, + PasskeyCeremonies ceremonies, + PasskeyService passkeys) { public const string ErrorRedirect = "/?auth=heimdall-error"; public const string AppSecretHeader = "x-heimdall-app-secret"; @@ -89,7 +92,8 @@ public sealed class HeimdallAuthService( string? pendingLinkId = null, string? replaceSlot = null, bool stepUp = false, - string? target = null) + string? target = null, + string? proof = null) { // The public sign-in routes offer AllowedProviders; the proofs of the Linked accounts page may also use Google // (a step-up proof only Google can give a bifrost sign-in, an ordinary one for an account that holds Google). @@ -144,7 +148,8 @@ public sealed class HeimdallAuthService( purpose, expectedAccountId, pendingLinkId, - target); + target, + proof); return new HeimdallAuthStartResult( new Uri(payload.AuthorizationUrl, UriKind.Absolute), replaceSlot ?? ChooseSlot(requestCookies), Protect(attempt)); } @@ -197,7 +202,8 @@ public sealed class HeimdallAuthService( IEnumerable> requestCookies, bool stepUp, string? target, - CancellationToken cancellationToken) => + CancellationToken cancellationToken, + string? proof = null) => StartAsync( provider, callbackBaseUrl, @@ -208,7 +214,8 @@ public sealed class HeimdallAuthService( purpose, sessionAccountId, stepUp: stepUp, - target: target); + target: target, + proof: proof); /// /// Reads the account's logins: an ordinary sign-in, then GET /v1/me/identities inside its completion. Through a provider on @@ -250,6 +257,29 @@ public sealed class HeimdallAuthService( StartProofAsync( HeimdallAttemptPurpose.UnlinkProof, StepUpProvider, sessionAccountId, callbackBaseUrl, requestCookies, true, identityId, cancellationToken); + /// + /// The fresh sign-in that earns an enrolment ticket (docs/passkeys.md): an ordinary sign-in through a provider the account + /// holds, proving the account to this browser's session. Its completion issues the ticket; no passkey exists before it. + /// + public Task StartEnrolProofAsync( + string sessionAccountId, string callbackBaseUrl, + IEnumerable> requestCookies, CancellationToken cancellationToken) => + StartProofAsync( + HeimdallAttemptPurpose.EnrolProof, ProofProviderFor(sessionAccountId, null), + sessionAccountId, callbackBaseUrl, requestCookies, false, null, cancellationToken); + + /// + /// Unlinks one login on a verified passkey assertion: an ordinary fresh sign-in as the account (never a guessed provider), and + /// inside its completion, with that bearer, Heimdall's app-asserted step-up and then the unlink. is the + /// id of the single-use assertion fact; the attempt carries it and nothing else about the passkey. + /// + public Task StartUnlinkPasskeyAsync( + string sessionAccountId, string identityId, string factId, string callbackBaseUrl, + IEnumerable> requestCookies, CancellationToken cancellationToken) => + StartProofAsync( + HeimdallAttemptPurpose.UnlinkPasskeyProof, ProofProviderFor(sessionAccountId, null), + sessionAccountId, callbackBaseUrl, requestCookies, false, identityId, cancellationToken, factId); + /// /// The providers Heimdall's bifrost profile admits (src/app-profiles.ts, identityProviders): a link or a proof can use them. /// Twitch is offered for sign-in here but the profile refuses it, so it is not linkable. @@ -472,19 +502,44 @@ public sealed class HeimdallAuthService( return new HeimdallLinkStep(null, cookieName, null, HeimdallLinkOutcome.StepUpRequired); } - var unlinked = await memberClient.UnlinkAsync(bearer, attempt.Target ?? string.Empty, cancellationToken); - // Only Heimdall's definitive refusals leave the account as the list showed it. Any other end (not found, a lost - // answer, a 500, a malformed 200) may have applied the unlink, so the list is dropped rather than trusted. - if (unlinked.Call == HeimdallMemberCall.Ok) + return new HeimdallLinkStep(null, cookieName, null, await UnlinkAsync(identity.AccountId, bearer, attempt.Target, cancellationToken)); + } + + case HeimdallAttemptPurpose.EnrolProof: + { + // A fresh ordinary sign-in as the session's own account: the one thing a stolen session cookie cannot produce. + // It earns a short-lived single-use ticket, handed to this browser in a cookie; no passkey exists yet. + var (identity, _) = await RedeemOwnerAsync(callbackBaseUrl, attempt, code, cancellationToken); + var ticket = ceremonies.Issue(CeremonyKind.EnrolTicket, identity.AccountId, null, null, PasskeyCeremonies.TicketLifetime); + return new HeimdallLinkStep( + null, cookieName, null, ticket is null ? HeimdallLinkOutcome.Error : HeimdallLinkOutcome.EnrolReady, ticket); + } + + case HeimdallAttemptPurpose.UnlinkPasskeyProof: + { + // The assertion fact is spent before anything else: missing, expired, spent, or for another account or identity + // means no step-up is asked of Heimdall and nothing is unlinked. + var fact = ceremonies.Take(CeremonyKind.UnlinkProof, attempt.ExpectedAccountId, attempt.Target, attempt.Proof); + var (identity, bearer) = await RedeemOwnerAsync(callbackBaseUrl, attempt, code, cancellationToken); + if (fact is null) { - snapshots.Put(identity.AccountId, unlinked.Identities!); - } - else if (unlinked.Call is not (HeimdallMemberCall.SignInRequired or HeimdallMemberCall.StepUpRequired or HeimdallMemberCall.LastIdentity)) - { - snapshots.Remove(identity.AccountId); + return new HeimdallLinkStep(null, cookieName, null, HeimdallLinkOutcome.PasskeyExpired); } - return new HeimdallLinkStep(null, cookieName, null, unlinked.Call == HeimdallMemberCall.Ok ? HeimdallLinkOutcome.Unlinked : OutcomeOf(unlinked.Call)); + // A passkey revoked since the assertion no longer proves anything: Heimdall is not asked. + if (!await passkeys.StillStrongAsync(identity.AccountId, fact.Data, cancellationToken)) + { + return new HeimdallLinkStep(null, cookieName, null, HeimdallLinkOutcome.PasskeyFailed); + } + + var asserted = await StepUpAsync(bearer, cancellationToken); + if (asserted.Refusal is { } refusal) + { + return new HeimdallLinkStep(null, cookieName, null, refusal); + } + + // The returned token is this request's alone: it goes to the one unlink and is dropped. + return new HeimdallLinkStep(null, cookieName, null, await UnlinkAsync(identity.AccountId, asserted.AccessToken!, attempt.Target, cancellationToken)); } default: @@ -492,6 +547,69 @@ public sealed class HeimdallAuthService( } } + /// + /// Heimdall's unlink with a bearer that carries fresh proof, and what the list becomes. Only Heimdall's definitive refusals leave + /// the account as the list showed it. Any other end (not found, a lost answer, a 500, a malformed 200) may have applied the + /// unlink, so the list is dropped rather than trusted. + /// + private async Task UnlinkAsync(string accountId, string bearer, string? identityId, CancellationToken cancellationToken) + { + var unlinked = await memberClient.UnlinkAsync(bearer, identityId ?? string.Empty, cancellationToken); + if (unlinked.Call == HeimdallMemberCall.Ok) + { + snapshots.Put(accountId, unlinked.Identities!); + } + else if (unlinked.Call is not (HeimdallMemberCall.SignInRequired or HeimdallMemberCall.StepUpRequired or HeimdallMemberCall.LastIdentity)) + { + snapshots.Remove(accountId); + } + + return unlinked.Call == HeimdallMemberCall.Ok ? HeimdallLinkOutcome.Unlinked : OutcomeOf(unlinked.Call); + } + + /// The method label Heimdall's bifrost profile admits for an app-asserted passkey proof. + public const string PasskeyStepUpMethod = "bifrost-passkey"; + + /// + /// POST /v1/apps/{app}/step-up (Heimdall docs/service-contract.md): the app secret, the member's live bearer and a fresh random + /// nonce; Heimdall answers a token for the same session that carries the proof. Called only from the completion that holds the + /// bearer, after a verified passkey assertion fact was spent. Each refusal is a closed outcome; nothing is retried here. + /// + private async Task<(HeimdallLinkOutcome? Refusal, string? AccessToken)> StepUpAsync(string bearer, CancellationToken cancellationToken) + { + var nonce = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32)); + try + { + using var message = new HttpRequestMessage( + HttpMethod.Post, $"/v1/apps/{Uri.EscapeDataString(heimdallOptions.Value.AppSlug)}/step-up") + { + Content = JsonContent.Create(new HeimdallStepUpRequest(PasskeyStepUpMethod, nonce)) + }; + message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer); + using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (response.StatusCode == HttpStatusCode.Created) + { + var minted = await response.Content.ReadFromJsonAsync(cancellationToken); + return string.IsNullOrWhiteSpace(minted?.AccessToken) ? (HeimdallLinkOutcome.Error, null) : (null, minted.AccessToken); + } + + var error = await HeimdallRefusal.CodeAsync(response, cancellationToken); + return ((int)response.StatusCode, error) switch + { + (401, "sign_in_required") => (HeimdallLinkOutcome.SignInRequired, null), + (429, _) => (HeimdallLinkOutcome.StepUpRateLimited, null), + (409, "step_up_nonce_replayed") => (HeimdallLinkOutcome.StepUpReplayed, null), + (401 or 403 or 400, _) => (HeimdallLinkOutcome.StepUpUnavailable, null), + _ => (HeimdallLinkOutcome.Error, null) + }; + } + catch (Exception exception) when (exception is HttpRequestException or JsonException or NotSupportedException or TaskCanceledException + && !cancellationToken.IsCancellationRequested) + { + return (HeimdallLinkOutcome.Error, null); + } + } + private static HeimdallLinkOutcome OutcomeOf(HeimdallMemberCall call) => call switch { HeimdallMemberCall.SignInRequired => HeimdallLinkOutcome.SignInRequired, @@ -623,7 +741,7 @@ public sealed class HeimdallAuthService( private string Protect(HeimdallAttempt attempt) => _protector.Protect(JsonSerializer.Serialize(new AttemptWire( attempt.Nonce, attempt.Provider, attempt.RequiresMemberAccess, attempt.ReturnPath.Value, attempt.ExpiresAt, - attempt.Purpose, attempt.ExpectedAccountId, attempt.PendingLinkId, attempt.Target))); + attempt.Purpose, attempt.ExpectedAccountId, attempt.PendingLinkId, attempt.Target, attempt.Proof))); /// The live attempt a cookie value protects when it belongs to this nonce, else null. private HeimdallAttempt? Unprotect(string? cookieValue, string? nonce) => @@ -647,7 +765,7 @@ public sealed class HeimdallAuthService( return new HeimdallAttempt( wire.Nonce, wire.Provider, wire.RequiresMemberAccess, LocalReturnPath.Parse(wire.ReturnPath), wire.ExpiresAt, - wire.Purpose, wire.ExpectedAccountId, wire.PendingLinkId, wire.Target); + wire.Purpose, wire.ExpectedAccountId, wire.PendingLinkId, wire.Target, wire.Proof); } catch (Exception error) when (error is CryptographicException or JsonException or FormatException) { @@ -664,7 +782,8 @@ public sealed class HeimdallAuthService( HeimdallAttemptPurpose Purpose = HeimdallAttemptPurpose.SignIn, string? ExpectedAccountId = null, string? PendingLinkId = null, - string? Target = null); + string? Target = null, + string? Proof = null); private async Task UpsertUserAccountAsync( HeimdallVerifiedIdentity identity, @@ -820,7 +939,8 @@ public sealed record HeimdallAttempt( HeimdallAttemptPurpose Purpose = HeimdallAttemptPurpose.SignIn, string? ExpectedAccountId = null, string? PendingLinkId = null, - string? Target = null); + string? Target = null, + string? Proof = null); /// /// What a held attempt is for. SignIn is the default, so a cookie written before purposes existed reads as a sign-in. @@ -835,7 +955,9 @@ public enum HeimdallAttemptPurpose LinkConfirm = 3, ListProof = 4, UnlinkProof = 5, - LinkAwaitDirect = 6 + LinkAwaitDirect = 6, + EnrolProof = 7, + UnlinkPasskeyProof = 8 } /// How a Linked accounts action ended. is its closed wire form. @@ -853,7 +975,20 @@ public enum HeimdallLinkOutcome SignInRequired, ProofUnavailable, ListExpired, - ProviderNeeded + ProviderNeeded, + EnrolReady, + EnrolNeedsSignIn, + PasskeyAdded, + PasskeyRevoked, + PasskeyLimit, + PasskeyExpired, + PasskeyFailed, + PasskeyRequired, + PasskeysUnavailable, + RateLimited, + StepUpUnavailable, + StepUpReplayed, + StepUpRateLimited } /// @@ -881,6 +1016,19 @@ public static class HeimdallLinkOutcomes HeimdallLinkOutcome.ProofUnavailable => "proof_unavailable", HeimdallLinkOutcome.ListExpired => "list_expired", HeimdallLinkOutcome.ProviderNeeded => "provider_needed", + HeimdallLinkOutcome.EnrolReady => "enrol_ready", + HeimdallLinkOutcome.EnrolNeedsSignIn => "enrol_needs_sign_in", + HeimdallLinkOutcome.PasskeyAdded => "passkey_added", + HeimdallLinkOutcome.PasskeyRevoked => "passkey_revoked", + HeimdallLinkOutcome.PasskeyLimit => "passkey_limit", + HeimdallLinkOutcome.PasskeyExpired => "passkey_expired", + HeimdallLinkOutcome.PasskeyFailed => "passkey_failed", + HeimdallLinkOutcome.PasskeyRequired => "passkey_required", + HeimdallLinkOutcome.PasskeysUnavailable => "passkeys_unavailable", + HeimdallLinkOutcome.RateLimited => "rate_limited", + HeimdallLinkOutcome.StepUpUnavailable => "step_up_unavailable", + HeimdallLinkOutcome.StepUpReplayed => "step_up_replayed", + HeimdallLinkOutcome.StepUpRateLimited => "step_up_rate_limited", _ => "error" }; @@ -898,17 +1046,37 @@ public static class HeimdallLinkOutcomes "sign_in_required" => "Heimdall no longer accepts that sign-in. Nothing was changed; try again.", "proof_unavailable" => "Fresh proof could not be started, so nothing was changed. Unlinking needs a fresh Google sign-in, and it is not available right now.", "provider_needed" => "Bifrost does not know which logins this account holds right now, so it will not guess. Pick one you know it holds to read the list. Nothing was changed.", + "enrol_ready" => "You signed in again. Add the passkey below within five minutes.", + "enrol_needs_sign_in" => "Adding a passkey needs a fresh sign-in first, and none is current. Start again with \"Sign in again to add a passkey\". Nothing was added.", + "passkey_added" => "The passkey was added. It counts as strong proof after a 48 hour cooling-off; until then it cannot unlink anything.", + "passkey_revoked" => "The passkey was revoked.", + "passkey_limit" => "This account already has the most passkeys it can hold. Revoke one to add another.", + "passkey_expired" => "The passkey check expired or was already used. Nothing was changed; start the unlink again.", + "passkey_failed" => "The passkey could not be verified. Nothing was changed.", + "passkey_required" => "This account has a passkey, so unlinking needs it. Nothing was changed.", + "passkeys_unavailable" => "Passkeys are not available on this server right now. Nothing was changed.", + "rate_limited" => "Too many passkey attempts. Nothing was changed; wait a few minutes.", + "step_up_unavailable" => "Your passkey was verified, but Heimdall would not take Bifrost's proof. Nothing was changed.", + "step_up_replayed" => "Heimdall had already seen that proof. Nothing was changed; start the unlink again.", + "step_up_rate_limited" => "Heimdall is limiting proofs for now. Nothing was changed; wait a few minutes and start the unlink again.", "list_expired" => "The list was out of date. Nothing was changed; read your linked logins again.", "error" => "That could not be completed. Nothing was changed.", _ => null }; } -public sealed record HeimdallLinkStep(Uri? Next, string CookieName, string? CookieValue, HeimdallLinkOutcome? Outcome); +public sealed record HeimdallLinkStep(Uri? Next, string CookieName, string? CookieValue, HeimdallLinkOutcome? Outcome, string? EnrolTicket = null); public sealed record HeimdallConfirmLinkRequest( [property: JsonPropertyName("pendingLinkId")] string PendingLinkId); +public sealed record HeimdallStepUpRequest( + [property: JsonPropertyName("method")] string Method, + [property: JsonPropertyName("nonce")] string Nonce); + +public sealed record HeimdallStepUpResponse( + [property: JsonPropertyName("accessToken")] string? AccessToken); + public sealed record HeimdallConfirmLinkResponse( [property: JsonPropertyName("status")] string? Status); diff --git a/src/Bifrost.Web/Features/Passkeys/PasskeyCeremonies.cs b/src/Bifrost.Web/Features/Passkeys/PasskeyCeremonies.cs new file mode 100644 index 0000000..ddbb36f --- /dev/null +++ b/src/Bifrost.Web/Features/Passkeys/PasskeyCeremonies.cs @@ -0,0 +1,139 @@ +using Bifrost.Web.Features.Shared; + +namespace Bifrost.Web.Features.Passkeys; + +public enum CeremonyKind +{ + /// Permission to enrol one passkey, earned by a fresh Heimdall sign-in. Held by id in a cookie of the browser that signed in. + EnrolTicket, + /// The WebAuthn creation options in flight for an account. + EnrolChallenge, + /// The WebAuthn request options in flight for an account's unlink of one identity. + UnlinkChallenge, + /// The fact "this account's passkey assertion was verified for unlinking this identity". Carries no bearer and no credential. + UnlinkProof +} + +/// +/// The short-lived single-use server-side state of the passkey flows, in this process only (like the Linked accounts list): +/// a restart empties it and every flow is simply begun again. Every entry is bound to an account and a kind, and the unlink +/// kinds to the identity they are for; Take removes the entry whether or not the caller then succeeds, so a +/// challenge, a ticket or an assertion fact can be spent once. A challenge kind has one live entry per account: a newer one +/// replaces the older. A full table refuses new entries rather than grow. +/// +public sealed class PasskeyCeremonies(TimeProvider clock) +{ + public static readonly TimeSpan TicketLifetime = TimeSpan.FromMinutes(5); + public static readonly TimeSpan ChallengeLifetime = TimeSpan.FromMinutes(2); + public static readonly TimeSpan ProofLifetime = TimeSpan.FromMinutes(5); + public const int MaxLive = 2048; + + public sealed record Entry(string Id, CeremonyKind Kind, string Account, string? Subject, string? Data, DateTimeOffset ExpiresAt); + + private readonly Dictionary _held = new(StringComparer.Ordinal); + private readonly object _gate = new(); + + /// Adds an entry and returns its id, or null when the table is full. + public string? Issue(CeremonyKind kind, string account, string? subject, string? data, TimeSpan lifetime) + { + lock (_gate) + { + Purge(); + if (kind != CeremonyKind.UnlinkProof) + { + foreach (var old in _held.Values.Where(x => x.Kind == kind && x.Account == account).ToList()) + { + _held.Remove(old.Id); + } + } + + if (_held.Count >= MaxLive) + { + return null; + } + + var id = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)) + .Replace('+', '-').Replace('/', '_').TrimEnd('='); + _held[id] = new Entry(id, kind, account, subject, data, clock.GetUtcNow() + lifetime); + return id; + } + } + + /// The live entry, without spending it. null finds the account's one current entry of that kind. + public Entry? Peek(CeremonyKind kind, string account, string? subject, string? id) + { + lock (_gate) + { + Purge(); + return Find(kind, account, subject, id); + } + } + + /// The live entry, removed: it can be taken once. + public Entry? Take(CeremonyKind kind, string account, string? subject, string? id) + { + lock (_gate) + { + Purge(); + var entry = Find(kind, account, subject, id); + if (entry is not null) + { + _held.Remove(entry.Id); + } + + return entry; + } + } + + public void Discard(string? id) + { + if (id is not null) + { + lock (_gate) + { + _held.Remove(id); + } + } + } + + private Entry? Find(CeremonyKind kind, string account, string? subject, string? id) + { + var entry = id is null + ? _held.Values.FirstOrDefault(x => x.Kind == kind && x.Account == account) + : _held.GetValueOrDefault(id); + return entry is not null && entry.Kind == kind + && string.Equals(entry.Account, account, StringComparison.Ordinal) + && string.Equals(entry.Subject, subject, StringComparison.Ordinal) + ? entry + : null; + } + + private void Purge() + { + var now = clock.GetUtcNow(); + foreach (var dead in _held.Values.Where(x => x.ExpiresAt <= now).ToList()) + { + _held.Remove(dead.Id); + } + } +} + +/// +/// The budgets on the passkey ceremonies, per Heimdall account, on the shared . Two separate +/// ones: asking for options or starting an enrolment sign-in (anyone holding the session can do that), and verifying a +/// registration or an assertion, which is spent only after the caller holds what the verification needs (a live ticket, or one of +/// the account's own strong credentials and a live challenge). Junk from a session thief that names no credential of the account, or +/// arrives with no live challenge, spends nothing; junk naming one of the account's credentials spends one permit per challenge that +/// was asked for, because the first such post takes the challenge. A session holder can still exhaust both budgets by asking for +/// challenges over and over: a lockout the same session already has through revoking passkeys, not a bypass. Budgets are per process. +/// +public sealed class PasskeyRateLimit(TimeProvider clock) +{ + public const int Permits = 20; + public static readonly TimeSpan Window = TimeSpan.FromMinutes(10); + + private readonly FixedWindowBudget _options = new(Permits, Window, 10_000, clock); + private readonly FixedWindowBudget _verifications = new(Permits, Window, 10_000, clock); + + public bool TryAcquire(string account, bool verification = false) => (verification ? _verifications : _options).Spend(account) is null; +} diff --git a/src/Bifrost.Web/Features/Passkeys/PasskeyRelyingParty.cs b/src/Bifrost.Web/Features/Passkeys/PasskeyRelyingParty.cs new file mode 100644 index 0000000..38aae13 --- /dev/null +++ b/src/Bifrost.Web/Features/Passkeys/PasskeyRelyingParty.cs @@ -0,0 +1,54 @@ +using Bifrost.Web.Configuration; +using Fido2NetLib; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Features.Passkeys; + +/// +/// The relying party every passkey ceremony runs as. RP ID and origin come from and from +/// nothing the browser sends: the public base URL must be https and its host must be the expected host, otherwise passkeys +/// are unavailable (the page offers none and every ceremony endpoint refuses). A passkey is bound to the RP ID by the +/// authenticator, so a wrong or http origin would enrol keys that cannot be used or can be phished. +/// +public sealed class PasskeyRelyingParty +{ + public const string Name = "Bifrost"; + + public PasskeyRelyingParty(IOptions hostOptions, ILogger logger) + { + var host = hostOptions.Value; + if (host.ExpectedHost.Contains(':') || System.Net.IPAddress.TryParse(host.ExpectedHost.Trim('[', ']'), out _)) + { + logger.LogWarning("Passkeys are unavailable: Host:ExpectedHost must be a bare DNS host name, with no port and not an IP address (an IP address is not a valid RP ID)."); + return; + } + + if (!Uri.TryCreate(host.PublicBaseUrl, UriKind.Absolute, out var url) + || url.Scheme != Uri.UriSchemeHttps + || string.IsNullOrWhiteSpace(host.ExpectedHost) + || !string.Equals(url.Host, host.ExpectedHost, StringComparison.OrdinalIgnoreCase)) + { + logger.LogWarning("Passkeys are unavailable: Host:PublicBaseUrl must be https and its host must equal Host:ExpectedHost."); + return; + } + + Origin = url.GetLeftPart(UriPartial.Authority); + RpId = url.Host.ToLowerInvariant(); + Fido2 = new Fido2(new Fido2Configuration + { + RPID = RpId, + RPName = Name, + Origins = new HashSet([Origin], StringComparer.Ordinal), + ChallengeSize = 32, + Timeout = 120_000 + }); + } + + public bool IsAvailable => Fido2 is not null; + + public string? Origin { get; } + + public string? RpId { get; } + + public IFido2? Fido2 { get; } +} diff --git a/src/Bifrost.Web/Features/Passkeys/PasskeyService.cs b/src/Bifrost.Web/Features/Passkeys/PasskeyService.cs new file mode 100644 index 0000000..367d13c --- /dev/null +++ b/src/Bifrost.Web/Features/Passkeys/PasskeyService.cs @@ -0,0 +1,470 @@ +using System.Globalization; +using System.Text; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Heimdall; +using Fido2NetLib; +using Fido2NetLib.Objects; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.EntityFrameworkCore; + +namespace Bifrost.Web.Features.Passkeys; + +/// One passkey as the Linked accounts page shows it: a label, its cooling-off state, never a key. +public sealed record PasskeyView(Guid Id, string Label, DateTimeOffset CreatedUtc, DateTimeOffset StrongFromUtc, DateTimeOffset? LastUsedUtc, bool IsStrong); + +/// The base64url fields a browser posts after navigator.credentials.get (an assertion) or .create (an enrolment). +public sealed record PasskeyFields( + string? Id, string? RawId, string? AuthenticatorData, string? ClientDataJson, string? Signature, string? UserHandle, + string? AttestationObject, string? Transports); + +/// +/// Passkeys for accounts that have a Heimdall account id (docs/passkeys.md). Enrolment needs a ticket earned by a fresh +/// Heimdall sign-in and starts a credential in a 48 hour cooling-off; an assertion for an unlink needs a credential that is +/// the session's own, not revoked and past its cooling-off, and records a single-use fact for that account and identity. +/// The WebAuthn parsing and verification (CBOR, COSE, signatures, origin, RP ID, challenge, user verification, sign count) +/// is Fido2NetLib's; this class decides who may begin, what is bound to what, and what is kept. Outcomes are the closed set +/// of the page words itself. +/// +public sealed class PasskeyService( + BifrostDbContext db, + PasskeyRelyingParty relyingParty, + PasskeyCeremonies ceremonies, + PasskeyRateLimit rateLimit, + TimeProvider clock, + ILogger logger) +{ + public const int MaxActive = 5; + public const int MaxLabel = 40; + public static readonly TimeSpan CoolingOff = TimeSpan.FromHours(48); + + private static readonly HashSet TransportNames = new(StringComparer.Ordinal) + { + "usb", "nfc", "ble", "internal", "hybrid", "smart-card" + }; + + public bool IsAvailable => relyingParty.IsAvailable; + + private Task AccountAsync(string heimdallAccountId, CancellationToken ct) => + string.IsNullOrEmpty(heimdallAccountId) + ? Task.FromResult(null) + : db.UserAccounts.SingleOrDefaultAsync(x => x.HeimdallAccountId == heimdallAccountId, ct); + + private IQueryable Active(Guid userId) => + db.PasskeyCredentials.Where(x => x.UserAccountId == userId && x.RevokedUtc == null); + + public async Task> ListAsync(string heimdallAccountId, CancellationToken ct) + { + if (await AccountAsync(heimdallAccountId, ct) is not { } user) + { + return []; + } + + var now = clock.GetUtcNow(); + var rows = await Active(user.Id).ToListAsync(ct); + return rows.OrderBy(x => x.CreatedUtc) + .Select(x => new PasskeyView(x.Id, x.Label, x.CreatedUtc, x.StrongFromUtc, x.LastUsedUtc, x.StrongFromUtc <= now)) + .ToList(); + } + + /// Whether a fresh enrolment ticket is held by this browser for this account. + public bool HasTicket(string heimdallAccountId, string? ticketId) => + ticketId is not null && ceremonies.Peek(CeremonyKind.EnrolTicket, heimdallAccountId, null, ticketId) is not null; + + /// Why a fresh sign-in for enrolment may not even start, or null when it may. Spends a ceremony permit. + public async Task PreflightEnrolStartAsync(string heimdallAccountId, CancellationToken ct) + { + if (!relyingParty.IsAvailable) + { + return HeimdallLinkOutcome.PasskeysUnavailable; + } + + if (await AccountAsync(heimdallAccountId, ct) is not { } user) + { + return HeimdallLinkOutcome.Error; + } + + if (await Active(user.Id).CountAsync(ct) >= MaxActive) + { + return HeimdallLinkOutcome.PasskeyLimit; + } + + return rateLimit.TryAcquire(heimdallAccountId) ? null : HeimdallLinkOutcome.RateLimited; + } + + /// The creation options for one enrolment, bound to the account; needs the ticket the fresh sign-in earned. + public async Task<(HeimdallLinkOutcome? Refusal, string? OptionsJson)> BeginEnrolAsync(string heimdallAccountId, string? ticketId, CancellationToken ct) + { + if (!relyingParty.IsAvailable) + { + return (HeimdallLinkOutcome.PasskeysUnavailable, null); + } + + if (!HasTicket(heimdallAccountId, ticketId) || await AccountAsync(heimdallAccountId, ct) is not { } user) + { + return (HeimdallLinkOutcome.EnrolNeedsSignIn, null); + } + + if (!rateLimit.TryAcquire(heimdallAccountId)) + { + return (HeimdallLinkOutcome.RateLimited, null); + } + + var existing = await Active(user.Id).Select(x => x.CredentialId).ToListAsync(ct); + if (existing.Count >= MaxActive) + { + return (HeimdallLinkOutcome.PasskeyLimit, null); + } + + var options = relyingParty.Fido2!.RequestNewCredential(new RequestNewCredentialParams + { + User = new Fido2User { Id = user.Id.ToByteArray(), Name = Label(user.DisplayName, "Bifrost member"), DisplayName = Label(user.DisplayName, "Bifrost member") }, + ExcludeCredentials = existing.Select(x => new PublicKeyCredentialDescriptor(x)).ToList(), + AuthenticatorSelection = new AuthenticatorSelection + { + ResidentKey = ResidentKeyRequirement.Preferred, + UserVerification = UserVerificationRequirement.Required + }, + AttestationPreference = AttestationConveyancePreference.None + }); + var json = options.ToJson(); + return ceremonies.Issue(CeremonyKind.EnrolChallenge, heimdallAccountId, null, json, PasskeyCeremonies.ChallengeLifetime) is null + ? (HeimdallLinkOutcome.Error, null) + : (null, json); + } + + /// + /// Verifies a registration and stores the credential, cooling off for . The ticket and the + /// challenge are both spent here, whatever the outcome: a failed attempt needs a fresh sign-in and a new challenge. + /// + public async Task EnrolAsync( + string heimdallAccountId, string? ticketId, PasskeyFields fields, string? label, CancellationToken ct) + { + if (!relyingParty.IsAvailable) + { + return HeimdallLinkOutcome.PasskeysUnavailable; + } + + // The caller's own preconditions first, spending nothing: someone with only the session has no ticket, so junk from them + // can neither burn the member's ticket and challenge nor spend the verification permits. + if (!HasTicket(heimdallAccountId, ticketId) || await AccountAsync(heimdallAccountId, ct) is not { } user) + { + return HeimdallLinkOutcome.EnrolNeedsSignIn; + } + + if (await Active(user.Id).CountAsync(ct) >= MaxActive) + { + return HeimdallLinkOutcome.PasskeyLimit; + } + + if (!TryDecode(fields.RawId, out var rawId) || !TryDecode(fields.AttestationObject, out var attestation) + || !TryDecode(fields.ClientDataJson, out var clientData) || fields.Id is null) + { + return HeimdallLinkOutcome.PasskeyFailed; + } + + if (!rateLimit.TryAcquire(heimdallAccountId, verification: true)) + { + return HeimdallLinkOutcome.RateLimited; + } + + // Now both are spent, whatever the outcome. + var ticket = ceremonies.Take(CeremonyKind.EnrolTicket, heimdallAccountId, null, ticketId); + var challenge = ceremonies.Take(CeremonyKind.EnrolChallenge, heimdallAccountId, null, null); + if (ticket is null) + { + return HeimdallLinkOutcome.EnrolNeedsSignIn; + } + + if (challenge?.Data is null) + { + return HeimdallLinkOutcome.PasskeyExpired; + } + + try + { + var registered = await relyingParty.Fido2!.MakeNewCredentialAsync(new MakeNewCredentialParams + { + AttestationResponse = new AuthenticatorAttestationRawResponse + { + Id = fields.Id, + RawId = rawId, + Type = PublicKeyCredentialType.PublicKey, + ClientExtensionResults = new AuthenticationExtensionsClientOutputs(), + Response = new AuthenticatorAttestationRawResponse.AttestationResponse + { + AttestationObject = attestation, + ClientDataJson = clientData, + Transports = Transports(fields.Transports) + } + }, + OriginalOptions = CredentialCreateOptions.FromJson(challenge.Data), + IsCredentialIdUniqueToUserCallback = async (parameters, token) => + !await db.PasskeyCredentials.AnyAsync(x => x.CredentialId == parameters.CredentialId, token) + }, ct); + + var now = clock.GetUtcNow(); + db.PasskeyCredentials.Add(new PasskeyCredential + { + UserAccountId = user.Id, + CredentialId = registered.Id, + PublicKey = registered.PublicKey, + SignCount = registered.SignCount, + Transports = string.Join(',', Transports(fields.Transports).Select(x => TransportName(x))), + AaGuid = registered.AaGuid == Guid.Empty ? null : registered.AaGuid, + Label = Label(label, "Passkey"), + CreatedUtc = now, + StrongFromUtc = now + CoolingOff + }); + await db.SaveChangesAsync(ct); + return HeimdallLinkOutcome.PasskeyAdded; + } + catch (Exception error) when (error is Fido2VerificationException or DbUpdateException or FormatException or System.Text.Json.JsonException) + { + logger.LogWarning("Passkey enrolment refused: {Reason}", error is Fido2VerificationException f ? f.Code.ToString() : error.GetType().Name); + return HeimdallLinkOutcome.PasskeyFailed; + } + } + + /// Revokes one of the session's own passkeys. Needs no strong proof; the page shows the revocation. + public async Task RevokeAsync(string heimdallAccountId, Guid credentialId, CancellationToken ct) + { + if (await AccountAsync(heimdallAccountId, ct) is not { } user) + { + return HeimdallLinkOutcome.Error; + } + + var credential = await Active(user.Id).SingleOrDefaultAsync(x => x.Id == credentialId, ct); + if (credential is null) + { + return HeimdallLinkOutcome.PasskeyFailed; + } + + credential.RevokedUtc = clock.GetUtcNow(); + await db.SaveChangesAsync(ct); + return HeimdallLinkOutcome.PasskeyRevoked; + } + + private IQueryable Strong(Guid userId) + { + var now = clock.GetUtcNow(); + return Active(userId).Where(x => x.StrongFromUtc <= now); + } + + public async Task HasStrongAsync(string heimdallAccountId, CancellationToken ct) => + await AccountAsync(heimdallAccountId, ct) is { } user && await Strong(user.Id).AnyAsync(ct); + + /// When the first cooling-off credential becomes strong, or null when none is cooling. + public async Task NextStrongAtAsync(string heimdallAccountId, CancellationToken ct) + { + if (await AccountAsync(heimdallAccountId, ct) is not { } user) + { + return null; + } + + var now = clock.GetUtcNow(); + var cooling = await Active(user.Id).Where(x => x.StrongFromUtc > now).Select(x => x.StrongFromUtc).ToListAsync(ct); + return cooling.Count == 0 ? null : cooling.Min(); + } + + /// The request options for unlinking one identity: only the account's strong passkeys may answer, with user verification. + public async Task<(HeimdallLinkOutcome? Refusal, string? OptionsJson)> BeginAssertionAsync(string heimdallAccountId, string identityId, CancellationToken ct) + { + if (!relyingParty.IsAvailable) + { + return (HeimdallLinkOutcome.PasskeysUnavailable, null); + } + + if (!rateLimit.TryAcquire(heimdallAccountId)) + { + return (HeimdallLinkOutcome.RateLimited, null); + } + + if (await AccountAsync(heimdallAccountId, ct) is not { } user) + { + return (HeimdallLinkOutcome.Error, null); + } + + var strong = await Strong(user.Id).ToListAsync(ct); + if (strong.Count == 0) + { + return (HeimdallLinkOutcome.PasskeyRequired, null); + } + + var options = relyingParty.Fido2!.GetAssertionOptions(new GetAssertionOptionsParams + { + AllowedCredentials = strong.Select(x => new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PublicKey, x.CredentialId, Transports(x.Transports))).ToList(), + UserVerification = UserVerificationRequirement.Required + }); + var json = options.ToJson(); + return ceremonies.Issue(CeremonyKind.UnlinkChallenge, heimdallAccountId, identityId, json, PasskeyCeremonies.ChallengeLifetime) is null + ? (HeimdallLinkOutcome.Error, null) + : (null, json); + } + + /// + /// Verifies an assertion made for unlinking . The challenge is spent first. On success the + /// credential's sign count and last use are recorded and a single-use fact for this account and identity is issued; its id + /// (not a credential, not a bearer) is what the fresh Heimdall sign-in's attempt carries. + /// + public async Task<(HeimdallLinkOutcome? Refusal, string? FactId)> VerifyAssertionAsync( + string heimdallAccountId, string identityId, PasskeyFields fields, CancellationToken ct) + { + if (!relyingParty.IsAvailable) + { + return (HeimdallLinkOutcome.PasskeysUnavailable, null); + } + + // The caller's own preconditions first, spending nothing: a decodable assertion naming one of this account's strong, + // un-revoked credentials. Junk from someone holding only the session fails here and burns neither the member's challenge + // nor the verification permits. + if (await AccountAsync(heimdallAccountId, ct) is not { } user + || !TryDecode(fields.RawId, out var rawId) || !TryDecode(fields.AuthenticatorData, out var authenticatorData) + || !TryDecode(fields.ClientDataJson, out var clientData) || !TryDecode(fields.Signature, out var signature) + || fields.Id is null) + { + return (HeimdallLinkOutcome.PasskeyFailed, null); + } + + byte[]? userHandle = null; + if (!string.IsNullOrEmpty(fields.UserHandle) && !TryDecode(fields.UserHandle, out userHandle)) + { + return (HeimdallLinkOutcome.PasskeyFailed, null); + } + + // The credential must be this session's own, not revoked and past its cooling-off. Anything else is not a strong proof. + var credential = await Strong(user.Id).SingleOrDefaultAsync(x => x.CredentialId == rawId, ct); + if (credential is null) + { + return (HeimdallLinkOutcome.PasskeyFailed, null); + } + + // Without a live challenge for this account and identity nothing can verify, so no permit is spent either: junk naming the + // account's own credential spends at most one permit per challenge that was asked for (the first junk then takes it). + if (ceremonies.Peek(CeremonyKind.UnlinkChallenge, heimdallAccountId, identityId, null) is null) + { + return (HeimdallLinkOutcome.PasskeyExpired, null); + } + + if (!rateLimit.TryAcquire(heimdallAccountId, verification: true)) + { + return (HeimdallLinkOutcome.RateLimited, null); + } + + // Spent from here on, whatever the outcome. + var challenge = ceremonies.Take(CeremonyKind.UnlinkChallenge, heimdallAccountId, identityId, null); + if (challenge?.Data is null) + { + return (HeimdallLinkOutcome.PasskeyExpired, null); + } + + try + { + var result = await relyingParty.Fido2!.MakeAssertionAsync(new MakeAssertionParams + { + AssertionResponse = new AuthenticatorAssertionRawResponse + { + Id = fields.Id, + RawId = rawId, + Type = PublicKeyCredentialType.PublicKey, + ClientExtensionResults = new AuthenticationExtensionsClientOutputs(), + Response = new AuthenticatorAssertionRawResponse.AssertionResponse + { + AuthenticatorData = authenticatorData, + ClientDataJson = clientData, + Signature = signature, + UserHandle = userHandle + } + }, + OriginalOptions = AssertionOptions.FromJson(challenge.Data), + StoredPublicKey = credential.PublicKey, + StoredSignatureCounter = (uint)credential.SignCount, + IsUserHandleOwnerOfCredentialIdCallback = (parameters, _) => + Task.FromResult(parameters.UserHandle.AsSpan().SequenceEqual(user.Id.ToByteArray()) && parameters.CredentialId.AsSpan().SequenceEqual(credential.CredentialId)) + }, ct); + + credential.SignCount = result.SignCount; + credential.LastUsedUtc = clock.GetUtcNow(); + await db.SaveChangesAsync(ct); + } + catch (Exception error) when (error is Fido2VerificationException or FormatException or System.Text.Json.JsonException) + { + logger.LogWarning("Passkey assertion refused: {Reason}", error is Fido2VerificationException f ? f.Code.ToString() : error.GetType().Name); + return (HeimdallLinkOutcome.PasskeyFailed, null); + } + + var fact = ceremonies.Issue(CeremonyKind.UnlinkProof, heimdallAccountId, identityId, credential.Id.ToString(), PasskeyCeremonies.ProofLifetime); + return fact is null ? (HeimdallLinkOutcome.Error, null) : (null, fact); + } + + public void DiscardFact(string? factId) => ceremonies.Discard(factId); + + /// + /// Whether the credential an assertion fact was made with is still this account's own, un-revoked and strong. Asked in the + /// completion, before Heimdall is: a passkey revoked between the assertion and the completion no longer proves anything. + /// + public async Task StillStrongAsync(string heimdallAccountId, string? credentialId, CancellationToken ct) => + Guid.TryParse(credentialId, out var id) && await AccountAsync(heimdallAccountId, ct) is { } user + && await Strong(user.Id).AnyAsync(x => x.Id == id, ct); + + /// A label from a form field: trimmed, control characters dropped, bounded; the fallback when nothing is left. + private static string Label(string? value, string fallback) + { + // By rune and by text element, never by UTF-16 unit: a cut inside a surrogate pair is not valid text and Postgres refuses it. + // Control and format characters (bidi overrides, joiners, zero-width marks) are dropped; a lone surrogate becomes U+FFFD. + var runes = (value ?? string.Empty).EnumerateRunes() + .Where(r => Rune.GetUnicodeCategory(r) is not (UnicodeCategory.Control or UnicodeCategory.Format)) + .Take(MaxLabel * 4); + var cleaned = string.Concat(runes.Select(r => r.ToString())).Trim(); + var elements = new StringInfo(cleaned); + if (elements.LengthInTextElements > MaxLabel) + { + cleaned = elements.SubstringByTextElements(0, MaxLabel); + } + + return cleaned.Length == 0 ? fallback : cleaned; + } + + private static bool TryDecode(string? value, out byte[] bytes) + { + bytes = []; + if (string.IsNullOrEmpty(value) || value.Length > 16_384) + { + return false; + } + + try + { + bytes = WebEncoders.Base64UrlDecode(value); + return bytes.Length > 0; + } + catch (FormatException) + { + return false; + } + } + + private static AuthenticatorTransport[] Transports(string? csv) + { + var parsed = new List(); + foreach (var name in (csv ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Distinct()) + { + try + { + // Only names of the closed set reach the converter; one it does not know is dropped. + if (TransportNames.Contains(name)) + { + parsed.Add(System.Text.Json.JsonSerializer.Deserialize($"\"{name}\"")); + } + } + catch (System.Text.Json.JsonException) + { + } + } + + return parsed.ToArray(); + } + + private static string TransportName(AuthenticatorTransport transport) => + System.Text.Json.JsonSerializer.Serialize(transport).Trim('"'); +} diff --git a/src/Bifrost.Web/Features/Shared/BifrostCookies.cs b/src/Bifrost.Web/Features/Shared/BifrostCookies.cs index 1c099e8..0a99cf0 100644 --- a/src/Bifrost.Web/Features/Shared/BifrostCookies.cs +++ b/src/Bifrost.Web/Features/Shared/BifrostCookies.cs @@ -12,6 +12,7 @@ public static class BifrostCookies public const string Antiforgery = HostPrefix + "bifrost_antiforgery"; public const string Voter = HostPrefix + "bifrost_voter"; public const string HeimdallAttemptPrefix = HostPrefix + "bifrost_heimdall_"; + public const string PasskeyTicket = HostPrefix + "bifrost_passkey_ticket"; public const string Path = "/"; } diff --git a/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml index c27ccc6..12790f3 100644 --- a/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml +++ b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml @@ -61,7 +61,23 @@ { linked @linkedAt.UtcDateTime.ToString("dd MMM yyyy") UTC } - @if (Model.CanProve) + @if (Model.HasStrongPasskey) + { + @if (Model.PasskeysAvailable) + { +
+ + + + + + + + +
+ } + } + else if (Model.HasGoogle) {
@@ -76,17 +92,31 @@

This is your only login. Heimdall will not remove the last one.

} - @if (!Model.CanProve) + @if (Model.HasStrongPasskey && !Model.PasskeysAvailable) { -

- Unlinking needs a fresh Google sign-in as proof. Discord and Patreon cannot give that proof, and no Google login - is linked to this account, so nothing can be unlinked from here yet. Linking Google first makes it possible - once Heimdall has Google set up. +

+ This account has a passkey, so unlinking needs it, but passkeys are not available on this server right now (a + configuration fault the operator must fix), so nothing can be unlinked from here at the moment. Nothing is lost: + try again later. If you cannot wait, revoke your passkeys below; unlinking then falls back to a fresh Google sign-in, + if a Google login is linked to this account.

} + else if (Model.HasStrongPasskey) + { +

Unlinking asks for one of your passkeys, then signs you in to Heimdall once more and removes only the login you picked.

+ } + else if (Model.HasGoogle) + { +

Unlinking asks you to sign in with Google again as proof, then removes only the login you picked. A passkey, once it has cooled off, is stronger proof and takes over from Google.

+ } else { -

Unlinking asks you to sign in with Google again as proof, then removes only the login you picked.

+

+ Unlinking needs strong proof, and none is available. Add a passkey below@(Model.NextStrongAt is { } waiting + ? $" (your newest one counts from {waiting.UtcDateTime:dd MMM yyyy HH:mm} UTC)" + : " (it counts after a 48 hour cooling-off)"), + or link a Google login once Heimdall has Google set up. Discord and Patreon cannot give that proof. +

} @@ -94,6 +124,80 @@
} +

Passkeys

+ @if (!Model.PasskeysAvailable) + { +

Passkeys are not available on this server right now, so none can be added or used. You can still revoke the ones listed.

+ } + @if (Model.PasskeysAvailable) + { +

+ A passkey is the strong proof for unlinking a login: it stays on your device, and a site that is not Bifrost cannot use it. + A new passkey is added only right after you sign in to Heimdall again, and it counts as strong proof 48 hours later, so + someone who borrows your open browser cannot add one and use it at once. You can revoke a passkey here at any time. +

+ } + @if (Model.Passkeys.Count > 0) + { +
    + @foreach (var key in Model.Passkeys) + { +
  • + @key.Label + @if (key.IsStrong) + { + active + } + else + { + cooling off until @key.StrongFromUtc.UtcDateTime.ToString("dd MMM yyyy HH:mm") UTC; cannot unlink yet + } + added @key.CreatedUtc.UtcDateTime.ToString("dd MMM yyyy") UTC + @if (key.LastUsedUtc is { } used) + { + last used @used.UtcDateTime.ToString("dd MMM yyyy") UTC + } +
    + + +
    +
  • + } +
+ } + + @if (Model.PasskeysAvailable) + { + if (Model.PasskeyLimitReached) + { +

This is the most passkeys an account can hold. Revoke one to add another.

+ } + else if (Model.CanEnrol) + { +
+ + + + + + + +
+ + } + else if (Model.Identities is not null) + { +
+ +
+ } + else + { +

Read your linked logins first; adding a passkey then asks you to sign in to Heimdall once more.

+ } + } +

+ @if (Model.Identities is not null) {

Link another login

@@ -117,3 +221,10 @@ Back to membership status + +@section Scripts { + @if (Model.HasHeimdallAccount && Model.PasskeysAvailable) + { + + } +} diff --git a/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs index 27b6d6d..e70d4e1 100644 --- a/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs +++ b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs @@ -2,6 +2,8 @@ using System.Security.Claims; using Bifrost.Web.Configuration; using Bifrost.Web.Features.Heimdall; using Bifrost.Web.Features.Membership; +using Bifrost.Web.Features.Passkeys; +using Bifrost.Web.Features.Shared; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; @@ -20,6 +22,7 @@ namespace Bifrost.Web.Pages.Membership; public sealed class LinkedAccountsModel( HeimdallAuthService heimdallAuthService, LinkedAccountsSnapshots snapshots, + PasskeyService passkeys, IOptions hostOptions, ILogger logger) : PageModel { @@ -41,17 +44,41 @@ public sealed class LinkedAccountsModel( /// The logins the last read found, or null when there is none or it has expired. public IReadOnlyList? Identities { get; private set; } - /// Whether a fresh Google sign-in can be had for this account: unlinking needs one, and only Google can give it. - public bool CanProve => Identities?.Any(x => x.Provider == HeimdallAuthService.StepUpProvider) == true; + /// Whether a fresh Google sign-in can be had for this account: without a strong passkey, unlinking needs one, and only Google can give it. + public bool HasGoogle => Identities?.Any(x => x.Provider == HeimdallAuthService.StepUpProvider) == true; + + /// The account's passkeys that are not revoked, with their cooling-off state. + public IReadOnlyList Passkeys { get; private set; } = []; + + /// Whether a passkey past its cooling-off exists: unlinking then needs it and no longer accepts Google as the proof. + public bool HasStrongPasskey => Passkeys.Any(x => x.IsStrong); + + /// When the first passkey still cooling off becomes strong. + public DateTimeOffset? NextStrongAt => Passkeys.Where(x => !x.IsStrong).Select(x => (DateTimeOffset?)x.StrongFromUtc).Min(); + + /// Whether this browser holds a live ticket from a fresh sign-in, so a passkey can be added now. + public bool CanEnrol { get; private set; } + + /// Whether this server can run passkey ceremonies at all (https and the host it expects). + public bool PasskeysAvailable => passkeys.IsAvailable; + + public bool PasskeyLimitReached => Passkeys.Count >= PasskeyService.MaxActive; + + public bool CanUnlink => HasStrongPasskey || HasGoogle; public IReadOnlyList LinkableProviders => HeimdallAuthService.LinkableProviders; public static string ProviderName(string provider) => ProviderNames.GetValueOrDefault(provider, provider); - public void OnGet() + public async Task OnGetAsync(CancellationToken cancellationToken) { ResultMessage = HeimdallLinkOutcomes.Wording(Request.Query[HeimdallLinkOutcomes.ResultParameter].ToString()); Identities = AccountId is { } id ? snapshots.Get(id) : null; + if (AccountId is { } account) + { + Passkeys = await passkeys.ListAsync(account, cancellationToken); + CanEnrol = passkeys.HasTicket(account, Request.Cookies[BifrostCookies.PasskeyTicket]); + } } /// Reads the account's logins: an ordinary Heimdall sign-in, then the list inside its completion. @@ -92,25 +119,156 @@ public sealed class LinkedAccountsModel( /// Unlinks one login with fresh proof. The identity must be on the list this account was last shown and the account must /// hold a Google login to prove with; otherwise Heimdall is not asked, and the page says so. ///
- public Task OnPostUnlinkAsync(string? identityId, CancellationToken cancellationToken) + public async Task OnPostUnlinkAsync(string? identityId, CancellationToken cancellationToken) { var shown = AccountId is { } id ? snapshots.Get(id) : null; if (shown is null || identityId is null || !shown.Any(x => x.Id == identityId)) { - return Task.FromResult(Done(HeimdallLinkOutcome.ListExpired)); + return Done(HeimdallLinkOutcome.ListExpired); + } + + // A strong passkey replaces Google as the proof: a member who has one is never asked for the weaker one. + if (await passkeys.HasStrongAsync(AccountId!, cancellationToken)) + { + return Done(HeimdallLinkOutcome.PasskeyRequired); } if (!shown.Any(x => x.Provider == HeimdallAuthService.StepUpProvider)) { - return Task.FromResult(Done(HeimdallLinkOutcome.ProofUnavailable)); + return Done(HeimdallLinkOutcome.ProofUnavailable); } - return BeginAsync( + return await BeginAsync( (account, ct) => heimdallAuthService.StartUnlinkAsync(account, identityId, hostOptions.Value.PublicBaseUrl, Request.Cookies, ct), HeimdallLinkOutcome.ProofUnavailable, cancellationToken); } + /// Begins the fresh sign-in whose completion issues the enrolment ticket. Needs a current list: the proof provider is never guessed. + public async Task OnPostPasskeyEnrolStartAsync(CancellationToken cancellationToken) + { + if (AccountId is not { } id) + { + return Done(HeimdallLinkOutcome.Error); + } + + if (snapshots.Get(id) is null) + { + return Done(HeimdallLinkOutcome.ListExpired); + } + + if (await passkeys.PreflightEnrolStartAsync(id, cancellationToken) is { } refused) + { + return Done(refused); + } + + return await BeginAsync( + (account, ct) => heimdallAuthService.StartEnrolProofAsync(account, hostOptions.Value.PublicBaseUrl, Request.Cookies, ct), + HeimdallLinkOutcome.Error, + cancellationToken); + } + + /// The WebAuthn creation options for the ticket this browser holds, as JSON for the page's script. + public async Task OnPostPasskeyEnrolOptionsAsync(CancellationToken cancellationToken) + { + if (AccountId is not { } id) + { + return Options(HeimdallLinkOutcome.Error, null); + } + + var (refusal, json) = await passkeys.BeginEnrolAsync(id, Request.Cookies[BifrostCookies.PasskeyTicket], cancellationToken); + return Options(refusal, json); + } + + /// Verifies the registration the script posted. The ticket cookie is spent whatever the outcome. + public async Task OnPostPasskeyEnrolAsync( + string? id, string? rawId, string? attestationObject, string? clientDataJson, string? transports, string? label, CancellationToken cancellationToken) + { + if (AccountId is not { } account) + { + return Done(HeimdallLinkOutcome.Error); + } + + var outcome = await passkeys.EnrolAsync( + account, Request.Cookies[BifrostCookies.PasskeyTicket], + new PasskeyFields(id, rawId, null, clientDataJson, null, null, attestationObject, transports), label, cancellationToken); + Response.Cookies.Delete(BifrostCookies.PasskeyTicket, HeimdallAttemptCookie.DeleteOptions()); + return Done(outcome); + } + + public async Task OnPostPasskeyRevokeAsync(Guid credentialId, CancellationToken cancellationToken) => + AccountId is { } id ? Done(await passkeys.RevokeAsync(id, credentialId, cancellationToken)) : Done(HeimdallLinkOutcome.Error); + + /// The WebAuthn request options for unlinking one identity, as JSON for the page's script. + public async Task OnPostPasskeyChallengeAsync(string? identityId, CancellationToken cancellationToken) + { + if (AccountId is not { } id) + { + return Options(HeimdallLinkOutcome.Error, null); + } + + if (snapshots.Get(id) is not { } shown || identityId is null || !shown.Any(x => x.Id == identityId)) + { + return Options(HeimdallLinkOutcome.ListExpired, null); + } + + var (refusal, json) = await passkeys.BeginAssertionAsync(id, identityId, cancellationToken); + return Options(refusal, json); + } + + /// + /// Unlinks one login on a passkey assertion. The assertion is verified here; only then does Bifrost start the fresh Heimdall sign-in + /// whose completion asks Heimdall for the step-up and unlinks. A refused or unverifiable assertion starts no sign-in. + /// + public async Task OnPostUnlinkPasskeyAsync( + string? identityId, string? id, string? rawId, string? authenticatorData, string? clientDataJson, string? signature, string? userHandle, + CancellationToken cancellationToken) + { + var shown = AccountId is { } account ? snapshots.Get(account) : null; + if (shown is null || identityId is null || !shown.Any(x => x.Id == identityId)) + { + return Done(HeimdallLinkOutcome.ListExpired); + } + + var (refusal, factId) = await passkeys.VerifyAssertionAsync( + AccountId!, identityId, new PasskeyFields(id, rawId, authenticatorData, clientDataJson, signature, userHandle, null, null), cancellationToken); + if (refusal is { } failed) + { + return Done(failed); + } + + try + { + var started = await heimdallAuthService.StartUnlinkPasskeyAsync( + AccountId!, identityId, factId!, hostOptions.Value.PublicBaseUrl, Request.Cookies, cancellationToken); + Response.Cookies.Append(started.CookieName, started.CookieValue, HeimdallAttemptCookie.Options()); + return Redirect(started.AuthorizationUrl.ToString()); + } + catch (HeimdallAuthException error) + { + logger.LogWarning("Linked accounts start refused: {Reason}", error.Message); + passkeys.DiscardFact(factId); + return Done(HeimdallLinkOutcome.ProofUnavailable); + } + } + + /// JSON for the page's script: the options, or only a closed outcome code with the status that fits. + private IActionResult Options(HeimdallLinkOutcome? refusal, string? json) + { + if (refusal is null && json is not null) + { + return Content(json, "application/json"); + } + + var status = refusal == HeimdallLinkOutcome.RateLimited ? StatusCodes.Status429TooManyRequests : StatusCodes.Status400BadRequest; + return new ContentResult + { + StatusCode = status, + ContentType = "application/json", + Content = "{\"error\":\"" + HeimdallLinkOutcomes.Code(refusal ?? HeimdallLinkOutcome.Error) + "\"}" + }; + } + private IActionResult Done(HeimdallLinkOutcome outcome) => RedirectToPage(null, new Dictionary { [HeimdallLinkOutcomes.ResultParameter] = HeimdallLinkOutcomes.Code(outcome) }); diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 8fc0ee5..48c0f74 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -159,6 +159,10 @@ builder.Services.AddHttpClient((serviceProvider, client) = HeimdallMemberClient.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)) .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) => HeimdallTokenVerifier.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)); @@ -281,6 +285,8 @@ var app = builder.Build(); var appJsonSerializerOptions = CreateAppJsonSerializerOptions(); app.Services.GetRequiredService().Validate(); +// Resolved now so a misconfigured RP (not https, host differs, a port in ExpectedHost) is logged once at boot, not on the first ceremony. +_ = app.Services.GetRequiredService(); await using (var scope = app.Services.CreateAsyncScope()) { await scope.ServiceProvider @@ -856,6 +862,19 @@ app.MapPost("/auth/heimdall/complete", async ( return Results.Redirect(step.Next.ToString()); } + if (step.EnrolTicket is not null) + { + // The ticket id goes only to the browser that completed the fresh sign-in; the server holds what it is bound to. + httpContext.Response.Cookies.Append(BifrostCookies.PasskeyTicket, step.EnrolTicket, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax, + Path = BifrostCookies.Path, + MaxAge = Bifrost.Web.Features.Passkeys.PasskeyCeremonies.TicketLifetime + }); + } + httpContext.Response.Cookies.Delete(step.CookieName, HeimdallAttemptCookie.DeleteOptions()); return Results.LocalRedirect(HeimdallLinkOutcomes.Redirect(step.Outcome!.Value)); } diff --git a/src/Bifrost.Web/packages.lock.json b/src/Bifrost.Web/packages.lock.json index d969a56..796baa1 100644 --- a/src/Bifrost.Web/packages.lock.json +++ b/src/Bifrost.Web/packages.lock.json @@ -8,6 +8,19 @@ "resolved": "2.4.0", "contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ==" }, + "Fido2": { + "type": "Direct", + "requested": "[4.3.3, )", + "resolved": "4.3.3", + "contentHash": "++G8ohXlhp9AH49FXIvYqziXUAEpuCtT+z5HBevX6+Pl6sYz9zfDHGTgvne6t3UEhM0hudesgMudyNG7RlpaEA==", + "dependencies": { + "Fido2.Models": "4.3.3", + "Microsoft.Extensions.Http": "9.0.0", + "Microsoft.IdentityModel.JsonWebTokens": "8.2.0", + "NSec.Cryptography": "25.4.0", + "System.Formats.Cbor": "9.0.24" + } + }, "MessagePack": { "type": "Direct", "requested": "[3.1.12, )", @@ -111,11 +124,24 @@ "resolved": "8.0.5", "contentHash": "0f1B50Ss7rqxXiaBJyzUu9bWFOO2/zSlifZ/UNMdiIpDYe4cY4LQQicP4nirK1OS31I43rn062UIJ1Q9bpmHpg==" }, + "Fido2.Models": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "G/Q8MYgptOwMrbWgDoGsy90u3J5GTdNAHub8aZRg/mQrUETzB5CqFzstGdTXE9UhL0qD6sRynOoPUnqCwKRnMw==", + "dependencies": { + "Microsoft.Bcl.Memory": "9.0.14" + } + }, "Humanizer.Core": { "type": "Transitive", "resolved": "2.14.1", "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" }, + "libsodium": { + "type": "Transitive", + "resolved": "1.0.20.1", + "contentHash": "pkiceEqJDQFHjbga3k/oPfTVX9g+GKJZ1VrkuiLhaymt9YNw1Dr7cX9hNuZuNaWcr9WD0moW55k4pMwzQ7JaZQ==" + }, "MessagePack.Annotations": { "type": "Transitive", "resolved": "3.1.12", @@ -139,6 +165,11 @@ "System.Formats.Asn1": "10.0.2" } }, + "Microsoft.Bcl.Memory": { + "type": "Transitive", + "resolved": "9.0.14", + "contentHash": "laQCcjTM2FnbyznkAu9hwBuQh3z2/OtJKwjGkJOUevylFpFXZIUVq/+MU2jM0HQm1n5wsjjwtLIDOiWJ6Nai3A==" + }, "Microsoft.CodeAnalysis.Analyzers": { "type": "Transitive", "resolved": "3.3.3", @@ -226,26 +257,43 @@ "Microsoft.Extensions.Primitives": "8.0.0" } }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "YIMO9T3JL8MeEXgVozKt2v79hquo/EFtnY0vgxmLnUvk1Rei/halI7kOWZL2RBeV9FMGzgM9LZA8CVaNwFMaNA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0" + } + }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==", + "resolved": "9.0.0", + "contentHash": "lqvd7W3FGKUO1+ZoUEMaZ5XDJeWvjpy2/M/ptCGz3tXLD4HWVaSzjufsAsjemasBEg+2SxXVtYVvGt5r2nKDlg==", "dependencies": { - "Microsoft.Extensions.Primitives": "8.0.0" + "Microsoft.Extensions.Primitives": "9.0.0" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "RiScL99DcyngY9zJA2ROrri7Br8tn5N4hP4YNvGdTN/bvg1A3dwvDOxHnNZ3Im7x2SJ5i4LkX1uPiR/MfSFBLQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.0" } }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "8.0.1", - "contentHash": "BmANAnR5Xd4Oqw7yQ75xOAYODybZQRzdeNucg7kS5wWKd2PNnMdYtJ2Vciy0QLylRmv42DGl5+AFL9izA6F1Rw==", + "resolved": "9.0.0", + "contentHash": "MCPrg7v3QgNMr0vX4vzRXvkNGgLg8vKWX0nKCWUxu2uPyMsaRgiRc1tHBnbTcfJMhMKj2slE/j2M9oGkd25DNw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "8.0.2", - "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==" + "resolved": "9.0.0", + "contentHash": "+6f2qv2a3dLwd5w6JanPIPs47CxRbnk+ZocMJUhv9NxP88VlOcJYZs9jY+MYSjxvady08bUZn6qgiNh7DadGgg==" }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", @@ -256,37 +304,83 @@ "System.Text.Json": "8.0.0" } }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "0CF9ZrNw5RAlRfbZuVIvzzhP8QeWqHiUmMBU/2H7Nmit8/vwP3/SbHeEctth7D4Gz2fBnEbokPc1NU8/j/1ZLw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.0", + "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.0" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "1K8P7XzuzX8W8pmXcZjcrqS6x5eSSdvhQohmcpgiQNY/HlDAlnrhR9dvlURfFz428A+RTCJpUyB+aKTA6AgVcQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0", + "System.Diagnostics.DiagnosticSource": "9.0.0" + } + }, + "Microsoft.Extensions.Http": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "DqI4q54U4hH7bIAq9M5a/hl5Odr/KBAoaZ0dcT4OgutD8dook34CbkvAfAIzkMVjYXiL+E5ul9etwwqiX4PHGw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "Microsoft.Extensions.Diagnostics": "9.0.0", + "Microsoft.Extensions.Logging": "9.0.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0" + } + }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "8.0.1", - "contentHash": "4x+pzsQEbqxhNf1QYRr5TDkLP9UsLT3A6MdRKDDEgrW7h1ljiEPgTNhKYUhNCCAaVpQECVQ+onA91PTPnIp6Lw==", + "resolved": "9.0.0", + "contentHash": "crjWyORoug0kK7RSNJBTeSE6VX8IQgLf3nUpTB9m62bPXp/tzbnOsnbe8TXEG0AASNaKZddnpHKw7fET8E++Pg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "8.0.1", - "Microsoft.Extensions.Logging.Abstractions": "8.0.2", - "Microsoft.Extensions.Options": "8.0.2" + "Microsoft.Extensions.DependencyInjection": "9.0.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "8.0.2", - "contentHash": "nroMDjS7hNBPtkZqVBbSiQaQjWRDxITI8Y7XnDs97rqG3EbzVTNLZQf7bIeUJcaHOV8bca47s1Uxq94+2oGdxA==", + "resolved": "9.0.0", + "contentHash": "g0UfujELzlLbHoVG8kPKVBaW470Ewi+jnptGS9KUi6jcb+k2StujtK3m26DFSGGwQ/+bVgZfsWqNzlP6YOejvw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "System.Diagnostics.DiagnosticSource": "9.0.0" } }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "8.0.2", - "contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==", + "resolved": "9.0.0", + "contentHash": "y2146b3jrPI3Q0lokKXdKLpmXqakYbDIPDV6r3M8SqvSf45WwOTzkyfDpxnZXJsJQEpAsAqjUq5Pu8RCJMjubg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", - "Microsoft.Extensions.Primitives": "8.0.0" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "Ob3FXsXkcSMQmGZi7qP07EQ39kZpSBlTcAZLbJLdI4FIf0Jug8biv2HTavWmnTirchctPlq9bl/26CXtQRguzA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", + "Microsoft.Extensions.Configuration.Binder": "9.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" + "resolved": "9.0.0", + "contentHash": "N3qEBzmLMYiASUlKxxFIISP4AiwuPTHF5uCh+2CWSwwzAJiIYx0kBJsS30cp1nvhSySFAVi30jecD307jV+8Kg==" }, "Microsoft.IdentityModel.Abstractions": { "type": "Transitive", @@ -340,6 +434,14 @@ "System.CodeDom": "4.4.0" } }, + "NSec.Cryptography": { + "type": "Transitive", + "resolved": "25.4.0", + "contentHash": "Z3wPpG0xefMLmhn9T+Ka/EzAmMQPT0THL/5E4lf9cXI/N9rbGH0G23ZFpRcPnD0ast2k7ieG6QSVNIjmfPCXBQ==", + "dependencies": { + "libsodium": "[1.0.20.1, 1.0.21)" + } + }, "OpenIddict.Abstractions": { "type": "Transitive", "resolved": "7.7.1", @@ -448,11 +550,21 @@ "System.Composition.Runtime": "6.0.0" } }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "ddppcFpnbohLWdYKr/ZeLZHmmI+DXFgZ3Snq+/E7SwcdW4UnvxmaugkwGywvGVWkHPGCSZjCP+MLzu23AL5SDw==" + }, "System.Formats.Asn1": { "type": "Transitive", "resolved": "10.0.2", "contentHash": "9Hee6iQ74pDgdRNIL6lO8Vlx2onVWd5aDnqwk4Pvo0h5FklBd7QwcQDKgxeLuhiy0o3ibzUVO0KFQe0/hPXU+Q==" }, + "System.Formats.Cbor": { + "type": "Transitive", + "resolved": "9.0.24", + "contentHash": "SEfEvHu6qXcXoDsDKagddLDf8A9iHIhHUD0hGemdmwlP206KLLCI5ksskOpeXMgNpQCs2diB0fOBKcnjySHFUQ==" + }, "System.IO.Pipelines": { "type": "Transitive", "resolved": "6.0.3", diff --git a/src/Bifrost.Web/wwwroot/js/passkeys.js b/src/Bifrost.Web/wwwroot/js/passkeys.js new file mode 100644 index 0000000..3d898f0 --- /dev/null +++ b/src/Bifrost.Web/wwwroot/js/passkeys.js @@ -0,0 +1,125 @@ +// The Linked accounts page's passkey ceremonies (docs/passkeys.md). First-party, no dependency. It asks the server for +// WebAuthn options, calls navigator.credentials, and posts back only base64url strings in a same-origin form. Nothing it +// reads from the options is shown or evaluated; the server checks the origin, the challenge and the signature. +(function () { + "use strict"; + + var status = document.getElementById("passkey-status"); + + function say(text) { + if (status) { + status.textContent = text; + } + } + + function toBytes(value) { + var padded = value.replace(/-/g, "+").replace(/_/g, "/"); + while (padded.length % 4) { + padded += "="; + } + var raw = atob(padded); + var bytes = new Uint8Array(raw.length); + for (var i = 0; i < raw.length; i++) { + bytes[i] = raw.charCodeAt(i); + } + return bytes.buffer; + } + + function toText(buffer) { + var bytes = new Uint8Array(buffer); + var raw = ""; + for (var i = 0; i < bytes.length; i++) { + raw += String.fromCharCode(bytes[i]); + } + return btoa(raw).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } + + function token(form) { + var field = form.querySelector("input[name=__RequestVerificationToken]"); + return field ? field.value : ""; + } + + // Posts to a handler of this page with the form's antiforgery token and returns the parsed JSON, or throws. + function options(handler, form, fields) { + return fetch(location.pathname + "?handler=" + handler, { + method: "POST", + credentials: "same-origin", + headers: { "RequestVerificationToken": token(form), "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(fields) + }).then(function (response) { + return response.json().then(function (body) { + if (!response.ok) { + throw new Error(body && body.error ? body.error : "error"); + } + return body; + }); + }); + } + + function descriptors(list) { + return (list || []).map(function (item) { + var copy = { type: item.type, id: toBytes(item.id) }; + if (item.transports) { + copy.transports = item.transports; + } + return copy; + }); + } + + function set(form, name, value) { + form.elements[name].value = value; + } + + function failed(error) { + var code = error && error.message ? error.message : "error"; + location.assign(location.pathname + "?result=" + (/^[a-z_]+$/.test(code) && code !== "error" ? encodeURIComponent(code) : "passkey_failed")); + } + + document.querySelectorAll("form[data-passkey-enrol]").forEach(function (form) { + form.addEventListener("submit", function (event) { + if (form.dataset.ready === "1") { + return; + } + event.preventDefault(); + say("Waiting for your device..."); + options("PasskeyEnrolOptions", form, {}).then(function (created) { + created.challenge = toBytes(created.challenge); + created.user.id = toBytes(created.user.id); + created.excludeCredentials = descriptors(created.excludeCredentials); + return navigator.credentials.create({ publicKey: created }); + }).then(function (credential) { + set(form, "id", credential.id); + set(form, "rawId", toText(credential.rawId)); + set(form, "attestationObject", toText(credential.response.attestationObject)); + set(form, "clientDataJson", toText(credential.response.clientDataJSON)); + set(form, "transports", credential.response.getTransports ? credential.response.getTransports().join(",") : ""); + form.dataset.ready = "1"; + form.submit(); + }).catch(failed); + }); + }); + + document.querySelectorAll("form[data-passkey-unlink]").forEach(function (form) { + form.addEventListener("submit", function (event) { + if (form.dataset.ready === "1") { + return; + } + event.preventDefault(); + say("Waiting for your device..."); + options("PasskeyChallenge", form, { identityId: form.elements.identityId.value }).then(function (requested) { + requested.challenge = toBytes(requested.challenge); + requested.allowCredentials = descriptors(requested.allowCredentials); + return navigator.credentials.get({ publicKey: requested }); + }).then(function (credential) { + set(form, "id", credential.id); + set(form, "rawId", toText(credential.rawId)); + set(form, "authenticatorData", toText(credential.response.authenticatorData)); + set(form, "clientDataJson", toText(credential.response.clientDataJSON)); + set(form, "signature", toText(credential.response.signature)); + set(form, "userHandle", credential.response.userHandle ? toText(credential.response.userHandle) : ""); + form.dataset.ready = "1"; + form.submit(); + }).catch(failed); + }); + }); +})(); diff --git a/tests/Bifrost.Web.Tests/CookieHardeningTests.cs b/tests/Bifrost.Web.Tests/CookieHardeningTests.cs index 287e8ac..10e2804 100644 --- a/tests/Bifrost.Web.Tests/CookieHardeningTests.cs +++ b/tests/Bifrost.Web.Tests/CookieHardeningTests.cs @@ -36,6 +36,7 @@ public sealed class CookieHardeningTests(CookieHardeningTests.OAuthConfiguredFac Assert.Equal("__Host-bifrost_antiforgery", BifrostCookies.Antiforgery); Assert.Equal("__Host-bifrost_voter", VoterKeyResolver.CookieName); Assert.Equal("__Host-bifrost_heimdall_", HeimdallAttemptCookie.Prefix); + Assert.Equal("__Host-bifrost_passkey_ticket", BifrostCookies.PasskeyTicket); Assert.All(HeimdallAttemptCookie.SlotNames, x => Assert.StartsWith("__Host-", x, StringComparison.Ordinal)); Assert.Equal("/", BifrostCookies.Path); Assert.Equal("/", VoterKeyResolver.CookiePath); diff --git a/tests/Bifrost.Web.Tests/Fixtures/passkey-script-harness.cjs b/tests/Bifrost.Web.Tests/Fixtures/passkey-script-harness.cjs new file mode 100644 index 0000000..638242d --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/passkey-script-harness.cjs @@ -0,0 +1,96 @@ +// Runs wwwroot/js/passkeys.js against a simulated browser, once per scenario, and prints what it did as JSON: +// every fetch (URL, headers, body), the arguments to navigator.credentials, the form values at the submit, and any navigation. +// Usage: node passkey-script-harness.cjs +const fs = require("fs"); +const vm = require("vm"); + +const source = fs.readFileSync(process.argv[2], "utf8"); +const buf = (...bytes) => Uint8Array.from(bytes).buffer; + +function form(names, extra = {}) { + const elements = {}; + for (const name of names) { + elements[name] = { value: "" }; + } + Object.assign(elements, extra); + return { + elements, + dataset: {}, + handler: null, + addEventListener(type, fn) { + if (type === "submit") this.handler = fn; + }, + querySelector() { + return { value: "TOKEN" }; + }, + submit() { + this.submitted = Object.fromEntries(Object.entries(this.elements).map(([k, v]) => [k, v.value])); + }, + }; +} + +async function run(name, { kind, fetchReply, credentialsReply }) { + const enrol = kind === "enrol"; + const theForm = enrol + ? form(["id", "rawId", "attestationObject", "clientDataJson", "transports", "label"]) + : form(["id", "rawId", "authenticatorData", "clientDataJson", "signature", "userHandle"], { identityId: { value: "IDENTITY" } }); + const log = { name, fetches: [], credentialCall: null, navigations: [], status: "" }; + const statusEl = { textContent: "" }; + const document = { + getElementById: (id) => (id === "passkey-status" ? statusEl : null), + querySelectorAll: (selector) => (selector === (enrol ? "form[data-passkey-enrol]" : "form[data-passkey-unlink]") ? [theForm] : []), + }; + const location = { pathname: "/Membership/LinkedAccounts", assign: (url) => log.navigations.push(url) }; + const fetch = async (url, init) => { + log.fetches.push({ url, method: init.method, headers: init.headers, body: String(init.body), credentials: init.credentials }); + return { ok: fetchReply.ok, json: async () => fetchReply.body }; + }; + const call = async (publicKey) => { + log.credentialCall = { + challengeIsBuffer: publicKey.challenge instanceof ArrayBuffer, + challengeBytes: Array.from(new Uint8Array(publicKey.challenge)), + userIdBytes: publicKey.user ? Array.from(new Uint8Array(publicKey.user.id)) : null, + listIdsAreBuffers: (publicKey.excludeCredentials || publicKey.allowCredentials).every((x) => x.id instanceof ArrayBuffer), + }; + if (credentialsReply === "reject") throw new Error("NotAllowedError"); + return credentialsReply; + }; + const navigator = { + credentials: enrol ? { create: ({ publicKey }) => call(publicKey) } : { get: ({ publicKey }) => call(publicKey) }, + }; + vm.runInNewContext(source, { document, location, fetch, navigator, URLSearchParams, Uint8Array, ArrayBuffer, atob, btoa, Promise, Error }); + await theForm.handler({ preventDefault() {} }); + await new Promise((r) => setTimeout(r, 20)); + log.submitted = theForm.submitted || null; + log.status = statusEl.textContent; + return log; +} + +(async () => { + const creationOptions = { + ok: true, + body: { challenge: "AQID", user: { id: "BAUG" }, excludeCredentials: [{ type: "public-key", id: "BwgJ" }] }, + }; + const requestOptions = { + ok: true, + body: { challenge: "AQID", allowCredentials: [{ type: "public-key", id: "BwgJ", transports: ["internal"] }] }, + }; + const created = { + id: "CRED-ID", + rawId: buf(251, 255, 254), + response: { attestationObject: buf(1, 2, 3), clientDataJSON: buf(4, 5), getTransports: () => ["internal", "hybrid"] }, + }; + const asserted = { + id: "CRED-ID", + rawId: buf(251, 255, 254), + response: { authenticatorData: buf(9, 9), clientDataJSON: buf(4, 5), signature: buf(7, 7, 7), userHandle: buf(1, 1) }, + }; + const out = [ + await run("enrol", { kind: "enrol", fetchReply: creationOptions, credentialsReply: created }), + await run("unlink", { kind: "unlink", fetchReply: requestOptions, credentialsReply: asserted }), + await run("refused", { kind: "enrol", fetchReply: { ok: false, body: { error: "enrol_needs_sign_in" } }, credentialsReply: created }), + await run("hostile-error", { kind: "unlink", fetchReply: { ok: false, body: { error: "" } }, credentialsReply: asserted }), + await run("cancelled", { kind: "unlink", fetchReply: requestOptions, credentialsReply: "reject" }), + ]; + console.log(JSON.stringify(out)); +})(); diff --git a/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs b/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs index d45e883..e1c90ca 100644 --- a/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs @@ -532,7 +532,7 @@ public sealed class HeimdallGoogleLinkTests [Theory] [InlineData("9")] [InlineData("-1")] - [InlineData("7")] + [InlineData("99")] public async Task An_attempt_cookie_with_an_undefined_purpose_is_no_attempt_and_asks_Heimdall_for_nothing(string purpose) { using var host = new HeimdallHost(realSessions: true); diff --git a/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs b/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs index 98a8993..bd2348a 100644 --- a/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs +++ b/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs @@ -587,8 +587,9 @@ public sealed class LinkedAccountsTests var post = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!); Assert.DoesNotContain(">Unlink<", html); - Assert.Contains("fresh Google sign-in", html); - Assert.Contains("no Google login", html); + Assert.Contains("Unlinking needs strong proof", html); + Assert.Contains("Add a passkey below", html); + Assert.Contains("link a Google login", html); Assert.Equal(starts, host.Heimdall.StartBodies.Count); Assert.Equal(Page + "?result=proof_unavailable", Location(post.Response)); Assert.Null(post.Cookie); diff --git a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs index 6d902cb..0451330 100644 --- a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs +++ b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs @@ -28,6 +28,7 @@ public sealed class MigrationDiscoveryTests "20261111100000_ConsentReceiptsOneLivePerVersion", "20261111110000_AddMembershipSuspensionReason", "20261111120000_ClearLegacyConsentReceipts", + "20261113120000_AddPasskeyCredentials", ]; // Database.GetMigrations() reads assembly metadata only; no connection is opened. diff --git a/tests/Bifrost.Web.Tests/PasskeyScriptTests.cs b/tests/Bifrost.Web.Tests/PasskeyScriptTests.cs new file mode 100644 index 0000000..2a269e2 --- /dev/null +++ b/tests/Bifrost.Web.Tests/PasskeyScriptTests.cs @@ -0,0 +1,99 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Bifrost.Web.Tests; + +/// +/// Runs the page's first-party passkey script (src/Bifrost.Web/wwwroot/js/passkeys.js) under Node against a simulated browser +/// (Fixtures/passkey-script-harness.cjs) and pins what it posts: the antiforgery header, base64url strings only, and no navigation +/// text that did not come from a closed code. Node is required: a missing Node fails these tests. +/// +public sealed partial class PasskeyScriptTests +{ + private sealed record Fetch(string Url, string Method, Dictionary Headers, string Body, string Credentials); + + private sealed record Call(string Name, Fetch[] Fetches, JsonElement CredentialCall, string[] Navigations, Dictionary? Submitted, string Status); + + [GeneratedRegex("^[A-Za-z0-9_-]*$")] + private static partial Regex Base64Url(); + + private static string ScriptPath() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Bifrost.slnx"))) + { + dir = dir.Parent; + } + + return Path.Combine(dir!.FullName, "src", "Bifrost.Web", "wwwroot", "js", "passkeys.js"); + } + + private static async Task> RunAsync() + { + var start = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true }; + start.ArgumentList.Add(Path.Combine(AppContext.BaseDirectory, "Fixtures", "passkey-script-harness.cjs")); + start.ArgumentList.Add(ScriptPath()); + using var process = Process.Start(start)!; + var output = process.StandardOutput.ReadToEndAsync(); + var errors = process.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + await process.WaitForExitAsync(timeout.Token); + Assert.True(process.ExitCode == 0, await errors); + return JsonSerializer.Deserialize(await output, new JsonSerializerOptions(JsonSerializerDefaults.Web))!.ToDictionary(x => x.Name); + } + + [Fact] + public async Task Enrolment_posts_the_antiforgery_header_to_this_page_and_only_base64url_fields_after_the_ceremony() + { + var run = (await RunAsync())["enrol"]; + + var fetch = Assert.Single(run.Fetches); + Assert.Equal("/Membership/LinkedAccounts?handler=PasskeyEnrolOptions", fetch.Url); + Assert.Equal("POST", fetch.Method); + Assert.Equal("same-origin", fetch.Credentials); + Assert.Equal("TOKEN", fetch.Headers["RequestVerificationToken"]); + Assert.Equal("", fetch.Body); + Assert.True(run.CredentialCall.GetProperty("challengeIsBuffer").GetBoolean()); + Assert.True(run.CredentialCall.GetProperty("listIdsAreBuffers").GetBoolean()); + Assert.Equal([4, 5, 6], run.CredentialCall.GetProperty("userIdBytes").EnumerateArray().Select(x => x.GetInt32())); + Assert.Equal("CRED-ID", run.Submitted!["id"]); + Assert.Equal("-__-", run.Submitted["rawId"]); // 251,255,254 in base64url without padding + Assert.Matches(Base64Url(), run.Submitted["attestationObject"]); + Assert.Matches(Base64Url(), run.Submitted["clientDataJson"]); + Assert.Equal("internal,hybrid", run.Submitted["transports"]); + Assert.Empty(run.Navigations); + } + + [Fact] + public async Task Unlinking_asks_for_a_challenge_for_the_chosen_identity_only_and_posts_the_assertion_as_base64url() + { + var run = (await RunAsync())["unlink"]; + + var fetch = Assert.Single(run.Fetches); + Assert.Equal("/Membership/LinkedAccounts?handler=PasskeyChallenge", fetch.Url); + Assert.Equal("identityId=IDENTITY", fetch.Body); + Assert.Equal("TOKEN", fetch.Headers["RequestVerificationToken"]); + Assert.True(run.CredentialCall.GetProperty("listIdsAreBuffers").GetBoolean()); + foreach (var name in new[] { "rawId", "authenticatorData", "clientDataJson", "signature", "userHandle" }) + { + Assert.Matches(Base64Url(), run.Submitted![name]); + Assert.NotEmpty(run.Submitted[name]); + } + + Assert.Equal("IDENTITY", run.Submitted!["identityId"]); + Assert.Empty(run.Navigations); + } + + [Theory] + [InlineData("refused", "?result=enrol_needs_sign_in")] + [InlineData("hostile-error", "?result=passkey_failed")] + [InlineData("cancelled", "?result=passkey_failed")] + public async Task A_refusal_or_a_cancelled_ceremony_submits_nothing_and_navigates_only_to_a_closed_result_code(string scenario, string expected) + { + var run = (await RunAsync())[scenario]; + + Assert.Null(run.Submitted); + Assert.Equal(["/Membership/LinkedAccounts" + expected], run.Navigations); + } +} diff --git a/tests/Bifrost.Web.Tests/PasskeyTests.cs b/tests/Bifrost.Web.Tests/PasskeyTests.cs new file mode 100644 index 0000000..3fe1381 --- /dev/null +++ b/tests/Bifrost.Web.Tests/PasskeyTests.cs @@ -0,0 +1,1153 @@ +using System.Net; +using System.Text.Json; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Passkeys; +using Bifrost.Web.Features.Shared; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Tests; + +/// +/// Passkeys on the Linked accounts page from the member's side: a fake Heimdall that models the step-up route of its service contract, +/// and a software authenticator that makes real attestations and assertions. Enrolment needs a fresh Heimdall sign-in and cools off for +/// 48 hours; unlinking with a strong passkey verifies an assertion at Bifrost, then asks Heimdall for an app-asserted step-up inside the +/// completion that holds the bearer. +/// +public sealed class PasskeyTests +{ + private const string Page = "/Membership/LinkedAccounts"; + + private sealed record Member(string Account, string Session, string Discord, string Patreon); + + private static string? Location(HttpResponseMessage response) => response.Headers.Location?.OriginalString; + + private static string Ticket(string ticket) => $"{BifrostCookies.PasskeyTicket}={ticket}"; + + private static async Task Finish(HeimdallHost host, Member member, StartedAttempt attempt, string? token = null, string? account = null) + { + var code = host.CodeFor(attempt, account ?? member.Account, token); + return await host.PostCompleteAsync( + new Dictionary { ["attempt"] = attempt.Nonce, ["code"] = code }, + $"{BifrostCookies.Session}={member.Session}; {attempt.CookieName}={attempt.Cookie}"); + } + + /// A signed-in member whose Heimdall account holds Discord and Patreon (and Google when asked), with the list read. + private static async Task NewMember(HeimdallHost host, bool google = false) + { + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var discord = host.Heimdall.AddIdentity("discord", "D"); + var patreon = host.Heimdall.AddIdentity("patreon", "P"); + if (google) + { + host.Heimdall.AddIdentity("google", "G"); + } + + var member = new Member(account, session, discord, patreon); + await ReadList(host, member); + return member; + } + + private static async Task ReadList(HeimdallHost host, Member member) + { + var start = await host.StartListAsync(member.Session, "discord"); + var done = await Finish(host, member, start); + Assert.Equal(Page + "?result=listed", Location(done)); + } + + private static async Task JsonOf(StartedAttempt call) => await call.Response.Content.ReadAsStringAsync(); + + /// The fresh sign-in that earns the ticket; returns the ticket cookie value. + private static async Task SignInForEnrolment(HeimdallHost host, Member member) + { + var before = host.Heimdall.StartBodies.Count; + var start = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolStart"); + Assert.Equal(before + 1, host.Heimdall.StartBodies.Count); + var done = await Finish(host, member, start); + Assert.Equal(Page + "?result=enrol_ready", Location(done)); + return HeimdallHost.CookieValue(done, BifrostCookies.PasskeyTicket)!; + } + + private static async Task Enrol(HeimdallHost host, Member member, SoftwareAuthenticator authenticator, string ticket, string label = "Test key") + { + var options = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolOptions", extraCookies: Ticket(ticket)); + Assert.Equal(HttpStatusCode.OK, options.Response.StatusCode); + var fields = authenticator.Register(await JsonOf(options), label); + return (await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrol", fields, extraCookies: Ticket(ticket))).Response; + } + + private static async Task AddPasskey(HeimdallHost host, Member member, SoftwareAuthenticator authenticator) + { + var ticket = await SignInForEnrolment(host, member); + var done = await Enrol(host, member, authenticator, ticket); + Assert.Equal(Page + "?result=passkey_added", Location(done)); + } + + /// Waits out the cooling-off and reads the list again (the list lives five minutes). + private static async Task CoolOff(HeimdallHost host, Member member) + { + host.Clock.Advance(PasskeyService.CoolingOff); + await ReadList(host, member); + } + + private static async Task<(string Json, StartedAttempt Call)> Challenge(HeimdallHost host, Member member, string identityId) + { + var call = await host.PostLinkedAccountsAsync(member.Session, "PasskeyChallenge", new Dictionary { ["identityId"] = identityId }); + return (await JsonOf(call), call); + } + + private static async Task Unlink(HeimdallHost host, Member member, SoftwareAuthenticator authenticator, string identityId) + { + var (json, call) = await Challenge(host, member, identityId); + Assert.Equal(HttpStatusCode.OK, call.Response.StatusCode); + return await PostAssertion(host, member, authenticator.Assert(json), identityId); + } + + private static Task PostAssertion(HeimdallHost host, Member member, Dictionary fields, string identityId) + { + fields["identityId"] = identityId; + return host.PostLinkedAccountsAsync(member.Session, "UnlinkPasskey", fields); + } + + private static string ErrorOf(string json) => JsonDocument.Parse(json).RootElement.GetProperty("error").GetString()!; + + private static Task> Credentials(HeimdallHost host) => + host.Db(db => db.PasskeyCredentials.ToListAsync()); + + // ---- enrolment ---- + + [Theory] + [InlineData(SoftwareAuthenticator.Kind.Es256)] + [InlineData(SoftwareAuthenticator.Kind.EdDsa)] + public async Task A_passkey_is_enrolled_after_a_fresh_ordinary_sign_in_and_starts_cooling_off(SoftwareAuthenticator.Kind kind) + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var authenticator = new SoftwareAuthenticator(kind); + + var ticket = await SignInForEnrolment(host, member); + var done = await Enrol(host, member, authenticator, ticket, "My laptop"); + + Assert.Equal(Page + "?result=passkey_added", Location(done)); + var stored = Assert.Single(await Credentials(host)); + Assert.Equal(authenticator.CredentialId, stored.CredentialId); + Assert.Equal(host.Clock.Now, stored.CreatedUtc); + Assert.Equal(host.Clock.Now + TimeSpan.FromHours(48), stored.StrongFromUtc); + Assert.Null(stored.RevokedUtc); + Assert.Equal("My laptop", stored.Label); + Assert.Equal((await host.Account(member.Account))!.Id, stored.UserAccountId); + // The sign-in that earned the ticket was ordinary (no step-up) through a provider the account holds, and minted nothing. + Assert.DoesNotContain("stepUp", host.Heimdall.StartBodies[^1]); + Assert.Empty(host.Heimdall.OrphanMints); + var (_, html) = await host.GetLinkedAccountsAsync(member.Session); + Assert.Contains("cooling off until", html); + Assert.Contains("cannot unlink yet", html); + Assert.Contains("My <b>laptop</b>", html); + Assert.DoesNotContain("My laptop", html); + } + + [Fact] + public async Task A_session_alone_cannot_start_an_enrolment_because_the_ticket_needs_a_fresh_sign_in() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + + var options = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolOptions"); + var forged = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolOptions", extraCookies: Ticket("not-a-ticket-0123456789abcdefghijklmnopqrstuvw")); + + Assert.Equal(HttpStatusCode.BadRequest, options.Response.StatusCode); + Assert.Equal("enrol_needs_sign_in", ErrorOf(await JsonOf(options))); + Assert.Equal("enrol_needs_sign_in", ErrorOf(await JsonOf(forged))); + Assert.Empty(await Credentials(host)); + } + + [Fact] + public async Task A_registration_without_a_ticket_is_refused_even_with_a_valid_challenge() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var authenticator = new SoftwareAuthenticator(); + var ticket = await SignInForEnrolment(host, member); + var options = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolOptions", extraCookies: Ticket(ticket)); + + var fields = authenticator.Register(await JsonOf(options)); + var withoutTicket = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrol", fields); + + Assert.Equal(Page + "?result=enrol_needs_sign_in", Location(withoutTicket.Response)); + Assert.Empty(await Credentials(host)); + } + + [Fact] + public async Task A_ticket_is_single_use_and_does_not_work_for_another_session() + { + using var host = new HeimdallHost(realSessions: true); + var victim = await NewMember(host); + var other = await NewMember(host); + var ticket = await SignInForEnrolment(host, victim); + + var otherSession = await host.PostLinkedAccountsAsync(other.Session, "PasskeyEnrolOptions", extraCookies: Ticket(ticket)); + var done = await Enrol(host, victim, new SoftwareAuthenticator(), ticket); + var again = await host.PostLinkedAccountsAsync(victim.Session, "PasskeyEnrolOptions", extraCookies: Ticket(ticket)); + + Assert.Equal("enrol_needs_sign_in", ErrorOf(await JsonOf(otherSession))); + Assert.Equal(Page + "?result=passkey_added", Location(done)); + Assert.Equal("enrol_needs_sign_in", ErrorOf(await JsonOf(again))); + Assert.Single(await Credentials(host)); + } + + [Fact] + public async Task The_ticket_expires_after_five_minutes() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var ticket = await SignInForEnrolment(host, member); + + host.Clock.Advance(PasskeyCeremonies.TicketLifetime + TimeSpan.FromSeconds(1)); + var options = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolOptions", extraCookies: Ticket(ticket)); + + Assert.Equal("enrol_needs_sign_in", ErrorOf(await JsonOf(options))); + } + + [Fact] + public async Task The_enrolment_sign_in_proven_by_another_account_earns_no_ticket() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var start = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolStart"); + + var done = await Finish(host, member, start, account: HeimdallShapes.NewAccountId()); + + Assert.Null(HeimdallHost.CookieValue(done, BifrostCookies.PasskeyTicket)); + Assert.Equal(Page + "?result=error", Location(done)); + } + + [Fact] + public async Task The_ticket_cookie_is_host_only_http_only_and_short_lived() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var start = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolStart"); + + var done = await Finish(host, member, start); + + var header = HeimdallHost.SetCookieHeader(done, BifrostCookies.PasskeyTicket)!.ToLowerInvariant(); + Assert.StartsWith("__host-bifrost_passkey_ticket=", header); + Assert.Contains("secure", header); + Assert.Contains("httponly", header); + Assert.Contains("path=/", header); + Assert.Contains("max-age=300", header); + Assert.Contains("samesite=lax", header); + Assert.DoesNotContain("domain", header); + } + + [Fact] + public async Task A_registration_from_another_origin_or_for_another_RP_or_without_user_verification_is_refused() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + + foreach (var tamper in new Action[] + { + a => a.Origin = "https://bifrost.evil.example", + a => a.RpId = "evil.example", + a => a.UserVerified = false + }) + { + var authenticator = new SoftwareAuthenticator(); + tamper(authenticator); + var ticket = await SignInForEnrolment(host, member); + var done = await Enrol(host, member, authenticator, ticket); + Assert.Equal(Page + "?result=passkey_failed", Location(done)); + } + + Assert.Empty(await Credentials(host)); + } + + [Fact] + public async Task A_registration_answering_an_older_challenge_is_refused() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var ticket = await SignInForEnrolment(host, member); + var first = await JsonOf(await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolOptions", extraCookies: Ticket(ticket))); + await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolOptions", extraCookies: Ticket(ticket)); + + var fields = new SoftwareAuthenticator().Register(first); + var done = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrol", fields, extraCookies: Ticket(ticket)); + + Assert.Equal(Page + "?result=passkey_failed", Location(done.Response)); + Assert.Empty(await Credentials(host)); + } + + [Fact] + public async Task A_credential_id_another_account_already_holds_cannot_be_enrolled_again() + { + using var host = new HeimdallHost(realSessions: true); + var first = await NewMember(host); + var second = await NewMember(host); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, first, authenticator); + + var ticket = await SignInForEnrolment(host, second); + var done = await Enrol(host, second, authenticator, ticket); + + Assert.Equal(Page + "?result=passkey_failed", Location(done)); + Assert.Single(await Credentials(host)); + } + + [Fact] + public async Task An_account_holds_at_most_five_passkeys_and_a_sixth_starts_no_sign_in() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + for (var i = 0; i < PasskeyService.MaxActive; i++) + { + await AddPasskey(host, member, new SoftwareAuthenticator()); + await ReadList(host, member); + } + + var starts = host.Heimdall.StartBodies.Count; + var sixth = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolStart"); + var (_, html) = await host.GetLinkedAccountsAsync(member.Session); + + Assert.Equal(Page + "?result=passkey_limit", Location(sixth.Response)); + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Contains("most passkeys an account can hold", html); + } + + [Fact] + public async Task Revoking_a_passkey_needs_no_strong_proof_is_the_members_own_and_frees_the_slot() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var stranger = await NewMember(host); + await AddPasskey(host, member, new SoftwareAuthenticator()); + var id = (await Credentials(host)).Single().Id; + + var byStranger = await host.PostLinkedAccountsAsync(stranger.Session, "PasskeyRevoke", new Dictionary { ["credentialId"] = id.ToString() }); + Assert.Equal(Page + "?result=passkey_failed", Location(byStranger.Response)); + Assert.Null((await Credentials(host)).Single().RevokedUtc); + + var revoke = await host.PostLinkedAccountsAsync(member.Session, "PasskeyRevoke", new Dictionary { ["credentialId"] = id.ToString() }); + + Assert.Equal(Page + "?result=passkey_revoked", Location(revoke.Response)); + Assert.Equal(host.Clock.Now, (await Credentials(host)).Single().RevokedUtc); + var (_, html) = await host.GetLinkedAccountsAsync(member.Session); + Assert.DoesNotContain("cooling off until", html); + } + + [Fact] + public async Task Ceremonies_are_rate_limited_per_account() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + + StartedAttempt? last = null; + for (var i = 0; i < PasskeyRateLimit.Permits + 1; i++) + { + last = await host.PostLinkedAccountsAsync(member.Session, "PasskeyChallenge", new Dictionary { ["identityId"] = member.Patreon }); + } + + Assert.Equal(HttpStatusCode.TooManyRequests, last!.Response.StatusCode); + Assert.Equal("rate_limited", ErrorOf(await JsonOf(last))); + } + + [Theory] + [InlineData("PasskeyEnrolStart")] + [InlineData("PasskeyEnrolOptions")] + [InlineData("PasskeyEnrol")] + [InlineData("PasskeyRevoke")] + [InlineData("PasskeyChallenge")] + [InlineData("UnlinkPasskey")] + public async Task Every_passkey_post_needs_the_antiforgery_token(string handler) + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var starts = host.Heimdall.StartBodies.Count; + + var call = await host.PostLinkedAccountsAsync(member.Session, handler, new Dictionary { ["identityId"] = member.Patreon }, antiforgery: false); + + Assert.Equal(HttpStatusCode.BadRequest, call.Response.StatusCode); + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Empty(host.Heimdall.StepUps); + } + + [Fact] + public async Task Passkeys_are_unavailable_unless_the_origin_is_https_and_its_host_is_the_expected_one() + { + foreach (var publicBase in new[] { "http://localhost", "https://other.example" }) + { + using var host = new HeimdallHost(realSessions: true, publicBaseUrl: publicBase); + var member = await NewMember(host); + + var options = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolOptions"); + var start = await host.PostLinkedAccountsAsync(member.Session, "PasskeyEnrolStart"); + var (_, html) = await host.GetLinkedAccountsAsync(member.Session); + + Assert.Equal("passkeys_unavailable", ErrorOf(await JsonOf(options))); + Assert.Equal(Page + "?result=passkeys_unavailable", Location(start.Response)); + Assert.Contains("Passkeys are not available on this server", html); + Assert.DoesNotContain("passkeys.js", html); + } + } + + [Fact] + public async Task A_session_with_no_Heimdall_account_has_no_passkeys_and_every_ceremony_refuses() + { + using var host = new HeimdallHost(realSessions: true); + var attempt = await host.StartAsync(provider: "discord"); + var native = (await host.PostLinkedAccountsAsync(null, "PasskeyEnrolOptions")).Response; + + Assert.NotEqual(HttpStatusCode.OK, native.StatusCode); + Assert.Empty(await Credentials(host)); + Assert.NotNull(attempt); + } + + // ---- assertion and unlink ---- + + [Theory] + [InlineData(SoftwareAuthenticator.Kind.Es256)] + [InlineData(SoftwareAuthenticator.Kind.EdDsa)] + public async Task A_strong_passkey_unlinks_through_an_app_asserted_step_up_inside_the_completion_that_holds_the_bearer(SoftwareAuthenticator.Kind kind) + { + using var host = new HeimdallHost(realSessions: true, traceHttp: true); + var member = await NewMember(host); + var authenticator = new SoftwareAuthenticator(kind); + await AddPasskey(host, member, authenticator); + await CoolOff(host, member); + var starts = host.Heimdall.StartBodies.Count; + + var attempt = await Unlink(host, member, authenticator, member.Patreon); + + // The assertion alone starts an ordinary fresh sign-in (no step-up asked of a provider) and asks Heimdall for nothing else yet. + Assert.Equal(starts + 1, host.Heimdall.StartBodies.Count); + Assert.DoesNotContain("stepUp", host.Heimdall.StartBodies[^1]); + Assert.Empty(host.Heimdall.StepUps); + Assert.Empty(host.Heimdall.Unlinks); + Assert.Empty(host.Heimdall.OrphanMints); + + var bearer = host.Token(member.Account); + var done = await Finish(host, member, attempt, bearer); + + Assert.Equal(Page + "?result=unlinked", Location(done)); + var stepUp = Assert.Single(host.Heimdall.StepUps); + Assert.Equal(HeimdallHost.AppSecret, stepUp.Secret); + Assert.Equal("Bearer " + bearer, stepUp.Authorization); + var body = JsonDocument.Parse(stepUp.Body).RootElement; + Assert.Equal("bifrost-passkey", body.GetProperty("method").GetString()); + Assert.Matches("^[A-Za-z0-9_-]{43}$", body.GetProperty("nonce").GetString()!); + var unlink = Assert.Single(host.Heimdall.Unlinks); + Assert.Equal(member.Patreon, unlink.Id); + Assert.NotEqual("Bearer " + bearer, unlink.Authorization); + var minted = JsonDocument.Parse(System.Text.Encoding.UTF8.GetString( + Microsoft.AspNetCore.WebUtilities.WebEncoders.Base64UrlDecode(unlink.Authorization!["Bearer ".Length..].Split('.')[1]))).RootElement; + Assert.Equal("bifrost-passkey", minted.GetProperty("step_up_method").GetString()); + Assert.DoesNotContain(host.Heimdall.Identities, x => (string?)x["id"] == member.Patreon); + + // No bearer, minted or not, reaches a cookie, a redirect, a page or a log. + var minted2 = unlink.Authorization["Bearer ".Length..]; + var seen = string.Join("\n", done.Headers.Select(x => x.Key + "=" + string.Join(",", x.Value))) + "\n" + attempt.Response.Headers + + "\n" + string.Join("\n", host.Logs.Lines) + "\n" + (await host.GetLinkedAccountsAsync(member.Session)).Html; + Assert.DoesNotContain(bearer, seen); + Assert.DoesNotContain(minted2, seen); + Assert.DoesNotContain("PUID-CANARY", seen); + var held = host.Services.GetRequiredService().CreateProtector("Bifrost.Heimdall.Attempt.v1").Unprotect(attempt.Cookie!); + Assert.DoesNotContain("eyJ", held); + } + + [Fact] + public async Task A_stolen_session_cannot_unlink_without_the_passkey() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, member, authenticator); + await CoolOff(host, member); + var starts = host.Heimdall.StartBodies.Count; + + // No challenge was ever asked for, or the thief signs with its own key: nothing is verified, no sign-in starts. + var noChallenge = await PostAssertion(host, member, new Dictionary(), member.Patreon); + var (json, _) = await Challenge(host, member, member.Patreon); + var thief = new SoftwareAuthenticator(); + var fields = thief.Assert(json); + var withOwnKey = await PostAssertion(host, member, fields, member.Patreon); + var viaGoogleRoute = await host.StartUnlinkAsync(member.Session, member.Patreon); + + Assert.Equal(Page + "?result=passkey_failed", Location(noChallenge.Response)); + Assert.Equal(Page + "?result=passkey_failed", Location(withOwnKey.Response)); + Assert.Equal(Page + "?result=passkey_required", Location(viaGoogleRoute.Response)); + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Empty(host.Heimdall.StepUps); + Assert.Empty(host.Heimdall.Unlinks); + } + + [Fact] + public async Task An_assertion_cannot_be_replayed_and_a_double_submit_starts_one_sign_in() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, member, authenticator); + await CoolOff(host, member); + var (json, _) = await Challenge(host, member, member.Patreon); + var fields = authenticator.Assert(json); + var starts = host.Heimdall.StartBodies.Count; + + var both = await Task.WhenAll(PostAssertion(host, member, new(fields), member.Patreon), PostAssertion(host, member, new(fields), member.Patreon)); + var replay = await PostAssertion(host, member, new(fields), member.Patreon); + + Assert.Equal(starts + 1, host.Heimdall.StartBodies.Count); + Assert.Single(both, x => Location(x.Response)!.StartsWith("https://heimdall.test")); + Assert.Equal(Page + "?result=passkey_expired", Location(replay.Response)); + } + + [Fact] + public async Task A_challenge_for_one_identity_does_not_unlink_another() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host, google: true); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, member, authenticator); + await CoolOff(host, member); + var (json, _) = await Challenge(host, member, member.Patreon); + var starts = host.Heimdall.StartBodies.Count; + + var other = await PostAssertion(host, member, authenticator.Assert(json), member.Discord); + + Assert.Equal(Page + "?result=passkey_expired", Location(other.Response)); + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + } + + [Fact] + public async Task An_assertion_for_another_account_or_origin_or_without_user_verification_is_refused() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, member, authenticator); + await CoolOff(host, member); + var other = await NewMember(host); + var otherKey = new SoftwareAuthenticator(); + await AddPasskey(host, other, otherKey); + await CoolOff(host, other); + await ReadList(host, member); + var starts = host.Heimdall.StartBodies.Count; + + // Another account's session, with its own strong passkey, presents this member's credential for its own challenge. + var (otherJson, _) = await Challenge(host, other, other.Patreon); + Assert.Contains("challenge", otherJson); + var borrowed = await PostAssertion(host, other, authenticator.Assert(otherJson), other.Patreon); + Assert.Equal(Page + "?result=passkey_failed", Location(borrowed.Response)); + + foreach (var tamper in new Action[] + { + a => a.Origin = "https://bifrost.evil.example", + a => a.RpId = "evil.example", + a => a.UserVerified = false + }) + { + var (json, _) = await Challenge(host, member, member.Patreon); + var tampered = authenticator.Assert(json); + var clone = new SoftwareAuthenticator(); + // Same key material is needed to sign, so tamper on the real authenticator and restore afterwards. + var origin = (authenticator.Origin, authenticator.RpId, authenticator.UserVerified); + tamper(authenticator); + tampered = authenticator.Assert(json); + (authenticator.Origin, authenticator.RpId, authenticator.UserVerified) = origin; + var done = await PostAssertion(host, member, tampered, member.Patreon); + Assert.Equal(Page + "?result=passkey_failed", Location(done.Response)); + Assert.NotNull(clone); + } + + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + } + + [Fact] + public async Task A_sign_count_that_does_not_advance_is_refused_as_a_cloned_authenticator() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host, google: true); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, member, authenticator); + await CoolOff(host, member); + + authenticator.SignCount = 4; // the next use is 5 + var first = await Unlink(host, member, authenticator, member.Patreon); + Assert.StartsWith("https://heimdall.test", Location(first.Response)); + Assert.Equal(5, (await Credentials(host)).Single().SignCount); + Assert.NotNull((await Credentials(host)).Single().LastUsedUtc); + + authenticator.SignCount = 4; // a use that counts 5 again + var (json, _) = await Challenge(host, member, member.Discord); + var same = await PostAssertion(host, member, authenticator.Assert(json), member.Discord); + authenticator.SignCount = 2; // counts 3: lower + var (json2, _) = await Challenge(host, member, member.Discord); + var lower = await PostAssertion(host, member, authenticator.Assert(json2), member.Discord); + authenticator.SignCount = 5; // counts 6: higher + var (json3, _) = await Challenge(host, member, member.Discord); + var higher = await PostAssertion(host, member, authenticator.Assert(json3), member.Discord); + + Assert.Equal(Page + "?result=passkey_failed", Location(same.Response)); + Assert.Equal(Page + "?result=passkey_failed", Location(lower.Response)); + Assert.StartsWith("https://heimdall.test", Location(higher.Response)); + Assert.Equal(6, (await Credentials(host)).Single().SignCount); + } + + [Fact] + public async Task A_passkey_counts_as_strong_exactly_at_forty_eight_hours() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, member, authenticator); + + host.Clock.Advance(PasskeyService.CoolingOff - TimeSpan.FromSeconds(1)); + await ReadList(host, member); + var (_, early) = await host.GetLinkedAccountsAsync(member.Session); + var (earlyJson, _) = await Challenge(host, member, member.Patreon); + var earlyGoogle = await host.StartUnlinkAsync(member.Session, member.Patreon); + + Assert.Contains("cooling off until", early); + Assert.DoesNotContain("Unlink with passkey", early); + Assert.Contains("Unlinking needs strong proof", early); + Assert.Contains("(your newest one counts from", early); + Assert.Equal("passkey_required", ErrorOf(earlyJson)); + Assert.Equal(Page + "?result=proof_unavailable", Location(earlyGoogle.Response)); + + host.Clock.Advance(TimeSpan.FromSeconds(1)); + await ReadList(host, member); + var (_, exact) = await host.GetLinkedAccountsAsync(member.Session); + var attempt = await Unlink(host, member, authenticator, member.Patreon); + + Assert.Contains("Unlink with passkey", exact); + Assert.DoesNotContain("cooling off until", exact); + Assert.StartsWith("https://heimdall.test", Location(attempt.Response)); + } + + [Fact] + public async Task A_revoked_passkey_is_not_strong_and_Google_proof_returns() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host, google: true); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, member, authenticator); + await CoolOff(host, member); + var (json, _) = await Challenge(host, member, member.Patreon); + var fields = authenticator.Assert(json); + var id = (await Credentials(host)).Single().Id.ToString(); + await host.PostLinkedAccountsAsync(member.Session, "PasskeyRevoke", new Dictionary { ["credentialId"] = id }); + var starts = host.Heimdall.StartBodies.Count; + + var withRevoked = await PostAssertion(host, member, fields, member.Patreon); + var (_, html) = await host.GetLinkedAccountsAsync(member.Session); + + Assert.Equal(Page + "?result=passkey_failed", Location(withRevoked.Response)); + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.DoesNotContain("Unlink with passkey", html); + Assert.Contains(">Unlink", html); + + // A challenge asked for after the revoke is refused, and one answered by a key revoked in between does not verify. + var (after, _) = await Challenge(host, member, member.Patreon); + Assert.Equal("passkey_required", ErrorOf(after)); + } + + [Fact] + public async Task A_credential_revoked_between_challenge_and_assertion_does_not_verify() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, member, authenticator); + await CoolOff(host, member); + var (json, _) = await Challenge(host, member, member.Patreon); + await host.PostLinkedAccountsAsync(member.Session, "PasskeyRevoke", new Dictionary { ["credentialId"] = (await Credentials(host)).Single().Id.ToString() }); + var starts = host.Heimdall.StartBodies.Count; + + var done = await PostAssertion(host, member, authenticator.Assert(json), member.Patreon); + + Assert.Equal(Page + "?result=passkey_failed", Location(done.Response)); + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + } + + [Fact] + public async Task The_assertion_fact_expires_and_is_spent_once_and_without_it_Heimdall_is_never_asked() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, member, authenticator); + await CoolOff(host, member); + + // Expired: the fresh sign-in comes back after the fact's five minutes. + var late = await Unlink(host, member, authenticator, member.Patreon); + host.Clock.Advance(PasskeyCeremonies.ProofLifetime + TimeSpan.FromSeconds(1)); + var lateDone = await Finish(host, member, late); + Assert.Equal(Page + "?result=passkey_expired", Location(lateDone)); + + // Spent once: the attempt cookie can be presented again with another code, but the fact is gone. + await ReadList(host, member); + authenticator.SignCount = 2; + var once = await Unlink(host, member, authenticator, member.Patreon); + var first = await Finish(host, member, once); + var second = await Finish(host, member, once); + Assert.Equal(Page + "?result=unlinked", Location(first)); + Assert.Equal(Page + "?result=passkey_expired", Location(second)); + + Assert.Single(host.Heimdall.StepUps); + Assert.Single(host.Heimdall.Unlinks); + } + + [Fact] + public async Task The_fact_is_bound_to_the_identity_and_account_it_was_made_for() + { + var ceremonies = new PasskeyCeremonies(new TestClock()); + var fact = ceremonies.Issue(CeremonyKind.UnlinkProof, "account-a", "identity-1", null, TimeSpan.FromMinutes(5))!; + + Assert.Null(ceremonies.Take(CeremonyKind.UnlinkProof, "account-b", "identity-1", fact)); + Assert.Null(ceremonies.Take(CeremonyKind.UnlinkProof, "account-a", "identity-2", fact)); + Assert.Null(ceremonies.Take(CeremonyKind.EnrolTicket, "account-a", "identity-1", fact)); + Assert.NotNull(ceremonies.Take(CeremonyKind.UnlinkProof, "account-a", "identity-1", fact)); + Assert.Null(ceremonies.Take(CeremonyKind.UnlinkProof, "account-a", "identity-1", fact)); + } + + [Theory] + [InlineData(HttpStatusCode.Forbidden, "step_up_unavailable")] + [InlineData(HttpStatusCode.Conflict, "step_up_replayed")] + [InlineData(HttpStatusCode.TooManyRequests, "step_up_rate_limited")] + [InlineData(HttpStatusCode.Unauthorized, "sign_in_required")] + [InlineData(HttpStatusCode.InternalServerError, "error")] + public async Task A_Heimdall_step_up_refusal_is_shown_with_a_closed_code_and_nothing_is_unlinked_or_retried(HttpStatusCode status, string code) + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, member, authenticator); + await CoolOff(host, member); + var attempt = await Unlink(host, member, authenticator, member.Patreon); + host.Heimdall.StepUpStatus = status; + + var done = await Finish(host, member, attempt); + + Assert.Equal(Page + "?result=" + code, Location(done)); + Assert.Single(host.Heimdall.StepUps); + Assert.Empty(host.Heimdall.Unlinks); + Assert.Contains(host.Heimdall.Identities, x => (string?)x["id"] == member.Patreon); + Assert.DoesNotContain("HEIMDALL FREE TEXT", await (await host.GetLinkedAccountsAsync(member.Session, "?result=" + code)).Response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task A_Heimdall_that_is_down_for_the_step_up_changes_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, member, authenticator); + await CoolOff(host, member); + var attempt = await Unlink(host, member, authenticator, member.Patreon); + host.Heimdall.StepUpDown = true; + + var done = await Finish(host, member, attempt); + + Assert.Equal(Page + "?result=error", Location(done)); + Assert.Empty(host.Heimdall.Unlinks); + } + + [Fact] + public async Task Every_step_up_carries_a_fresh_nonce_so_the_replay_guard_never_fires() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host, google: true); + var authenticator = new SoftwareAuthenticator(); + await AddPasskey(host, member, authenticator); + await CoolOff(host, member); + var results = new List(); + foreach (var identity in new[] { member.Patreon, member.Discord }) + { + var attempt = await Unlink(host, member, authenticator, identity); + // Same Heimdall session (sid) for both: the fake keys replay on sid and nonce. + results.Add(Location(await Finish(host, member, attempt, host.Token(member.Account, mutate: c => c["sid"] = "one-session")))); + await ReadList(host, member); + } + + var nonces = host.Heimdall.StepUps.Select(x => JsonDocument.Parse(x.Body).RootElement.GetProperty("nonce").GetString()).ToList(); + Assert.Equal(2, nonces.Distinct().Count()); + Assert.All(results, x => Assert.Equal(Page + "?result=unlinked", x)); + } + + [Fact] + public async Task With_a_strong_passkey_the_Google_route_is_closed_and_without_one_it_is_as_before() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host, google: true); + + var before = await host.StartUnlinkAsync(member.Session, member.Patreon); + Assert.StartsWith("https://heimdall.test", Location(before.Response)); + Assert.Contains("stepUp", host.Heimdall.StartBodies[^1]); + + await AddPasskey(host, member, new SoftwareAuthenticator()); + var cooling = await host.StartUnlinkAsync(member.Session, member.Patreon); + Assert.StartsWith("https://heimdall.test", Location(cooling.Response)); + + await CoolOff(host, member); + var starts = host.Heimdall.StartBodies.Count; + var after = await host.StartUnlinkAsync(member.Session, member.Patreon); + + Assert.Equal(Page + "?result=passkey_required", Location(after.Response)); + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + } + + [Fact] + public async Task The_passkey_script_is_served_first_party_and_is_the_only_script_the_page_adds() + { + using var host = new HeimdallHost(realSessions: true); + var member = await NewMember(host); + + var (_, html) = await host.GetLinkedAccountsAsync(member.Session); + using var client = host.NewClient(); + var script = await client.GetAsync("/js/passkeys.js"); + var source = await script.Content.ReadAsStringAsync(); + + Assert.Contains("src=\"/js/passkeys.js", html); + Assert.DoesNotContain("