Passkeys: WebAuthn enrolment under a fresh sign-in, 48 h cooling-off, assertion for unlink via Heimdall step-up #85

Merged
heimdall merged 7 commits from heimdall/passkey-strong-proof into main 2026-11-14 00:37:57 +00:00
29 changed files with 3448 additions and 111 deletions

View file

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

View file

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

View file

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

133
docs/passkeys.md Normal file
View file

@ -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 (`<license type="expression">MIT</license>`) 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`.

View file

@ -13,6 +13,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" />
<PackageReference Include="Fido2" Version="4.3.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
<!-- Direct pins lift transitive packages past their advisories; the lockfile holds the result. -->
<PackageReference Include="Npgsql" Version="8.0.3" />

View file

@ -45,6 +45,7 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
public DbSet<ConsentReceipt> ConsentReceipts => Set<ConsentReceipt>();
public DbSet<MindKey> MindKeys => Set<MindKey>();
public DbSet<PasskeyCredential> PasskeyCredentials => Set<PasskeyCredential>();
public DbSet<MindNonce> MindNonces => Set<MindNonce>();
@ -333,6 +334,16 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<PasskeyCredential>(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<MindKey>(entity =>
{
entity.HasIndex(x => new { x.PersonaId, x.PublicKey }).IsUnique();

View file

@ -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<Guid>(type: "uuid", nullable: false),
UserAccountId = table.Column<Guid>(type: "uuid", nullable: false),
CredentialId = table.Column<byte[]>(type: "bytea", nullable: false),
PublicKey = table.Column<byte[]>(type: "bytea", nullable: false),
SignCount = table.Column<long>(type: "bigint", nullable: false),
Transports = table.Column<string>(type: "text", nullable: false),
AaGuid = table.Column<Guid>(type: "uuid", nullable: true),
Label = table.Column<string>(type: "text", nullable: false),
CreatedUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
StrongFromUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
RevokedUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LastUsedUtc = table.Column<DateTimeOffset>(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");
}
}
}

View file

@ -1470,6 +1470,59 @@ namespace Bifrost.Web.Data.Migrations
b.ToTable("ConsentReceipts");
});
modelBuilder.Entity("Bifrost.Web.Domain.PasskeyCredential", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AaGuid")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<byte[]>("CredentialId")
.IsRequired()
.HasColumnType("bytea");
b.Property<string>("Label")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset?>("LastUsedUtc")
.HasColumnType("timestamp with time zone");
b.Property<byte[]>("PublicKey")
.IsRequired()
.HasColumnType("bytea");
b.Property<DateTimeOffset?>("RevokedUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("SignCount")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("StrongFromUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Transports")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("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<Guid>("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")

View file

@ -1177,6 +1177,41 @@ public sealed class ConsentReceipt
public UserAccount? UserAccount { get; set; }
}
/// <summary>
/// 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
/// <see cref="StrongFromUtc"/> (the cooling-off after enrolment) and only while not revoked.
/// </summary>
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; }
}
/// <summary>A Mind's registered Ed25519 public key. Bifrost never holds a private key.</summary>
public sealed class MindKey
{

View file

@ -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<KeyValuePair<string, string>> 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);
/// <summary>
/// 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);
/// <summary>
/// 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.
/// </summary>
public Task<HeimdallAuthStartResult> StartEnrolProofAsync(
string sessionAccountId, string callbackBaseUrl,
IEnumerable<KeyValuePair<string, string>> requestCookies, CancellationToken cancellationToken) =>
StartProofAsync(
HeimdallAttemptPurpose.EnrolProof, ProofProviderFor(sessionAccountId, null),
sessionAccountId, callbackBaseUrl, requestCookies, false, null, cancellationToken);
/// <summary>
/// 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. <paramref name="factId"/> is the
/// id of the single-use assertion fact; the attempt carries it and nothing else about the passkey.
/// </summary>
public Task<HeimdallAuthStartResult> StartUnlinkPasskeyAsync(
string sessionAccountId, string identityId, string factId, string callbackBaseUrl,
IEnumerable<KeyValuePair<string, string>> requestCookies, CancellationToken cancellationToken) =>
StartProofAsync(
HeimdallAttemptPurpose.UnlinkPasskeyProof, ProofProviderFor(sessionAccountId, null),
sessionAccountId, callbackBaseUrl, requestCookies, false, identityId, cancellationToken, factId);
/// <summary>
/// The providers Heimdall's bifrost profile admits (src/app-profiles.ts, <c>identityProviders</c>): 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(
}
}
/// <summary>
/// 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.
/// </summary>
private async Task<HeimdallLinkOutcome> 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);
}
/// <summary>The method label Heimdall's bifrost profile admits for an app-asserted passkey proof.</summary>
public const string PasskeyStepUpMethod = "bifrost-passkey";
/// <summary>
/// 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.
/// </summary>
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<HeimdallStepUpResponse>(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)));
/// <summary>The live attempt a cookie value protects when it belongs to this nonce, else null.</summary>
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<UserAccount> 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);
/// <summary>
/// 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
}
/// <summary>How a Linked accounts action ended. <see cref="HeimdallLinkOutcomes.Code"/> is its closed wire form.</summary>
@ -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
}
/// <summary>
@ -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);

View file

@ -0,0 +1,139 @@
using Bifrost.Web.Features.Shared;
namespace Bifrost.Web.Features.Passkeys;
public enum CeremonyKind
{
/// <summary>Permission to enrol one passkey, earned by a fresh Heimdall sign-in. Held by id in a cookie of the browser that signed in.</summary>
EnrolTicket,
/// <summary>The WebAuthn creation options in flight for an account.</summary>
EnrolChallenge,
/// <summary>The WebAuthn request options in flight for an account's unlink of one identity.</summary>
UnlinkChallenge,
/// <summary>The fact "this account's passkey assertion was verified for unlinking this identity". Carries no bearer and no credential.</summary>
UnlinkProof
}
/// <summary>
/// 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; <c>Take</c> 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.
/// </summary>
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<string, Entry> _held = new(StringComparer.Ordinal);
private readonly object _gate = new();
/// <summary>Adds an entry and returns its id, or null when the table is full.</summary>
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;
}
}
/// <summary>The live entry, without spending it. <paramref name="id"/> null finds the account's one current entry of that kind.</summary>
public Entry? Peek(CeremonyKind kind, string account, string? subject, string? id)
{
lock (_gate)
{
Purge();
return Find(kind, account, subject, id);
}
}
/// <summary>The live entry, removed: it can be taken once.</summary>
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);
}
}
}
/// <summary>
/// The budgets on the passkey ceremonies, per Heimdall account, on the shared <see cref="FixedWindowBudget"/>. 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.
/// </summary>
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;
}

View file

@ -0,0 +1,54 @@
using Bifrost.Web.Configuration;
using Fido2NetLib;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Features.Passkeys;
/// <summary>
/// The relying party every passkey ceremony runs as. RP ID and origin come from <see cref="BifrostHostOptions"/> 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.
/// </summary>
public sealed class PasskeyRelyingParty
{
public const string Name = "Bifrost";
public PasskeyRelyingParty(IOptions<BifrostHostOptions> hostOptions, ILogger<PasskeyRelyingParty> 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<string>([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; }
}

View file

@ -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;
/// <summary>One passkey as the Linked accounts page shows it: a label, its cooling-off state, never a key.</summary>
public sealed record PasskeyView(Guid Id, string Label, DateTimeOffset CreatedUtc, DateTimeOffset StrongFromUtc, DateTimeOffset? LastUsedUtc, bool IsStrong);
/// <summary>The base64url fields a browser posts after navigator.credentials.get (an assertion) or .create (an enrolment).</summary>
public sealed record PasskeyFields(
string? Id, string? RawId, string? AuthenticatorData, string? ClientDataJson, string? Signature, string? UserHandle,
string? AttestationObject, string? Transports);
/// <summary>
/// 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 <see cref="HeimdallLinkOutcome"/> the page words itself.
/// </summary>
public sealed class PasskeyService(
BifrostDbContext db,
PasskeyRelyingParty relyingParty,
PasskeyCeremonies ceremonies,
PasskeyRateLimit rateLimit,
TimeProvider clock,
ILogger<PasskeyService> logger)
{
public const int MaxActive = 5;
public const int MaxLabel = 40;
public static readonly TimeSpan CoolingOff = TimeSpan.FromHours(48);
private static readonly HashSet<string> TransportNames = new(StringComparer.Ordinal)
{
"usb", "nfc", "ble", "internal", "hybrid", "smart-card"
};
public bool IsAvailable => relyingParty.IsAvailable;
private Task<UserAccount?> AccountAsync(string heimdallAccountId, CancellationToken ct) =>
string.IsNullOrEmpty(heimdallAccountId)
? Task.FromResult<UserAccount?>(null)
: db.UserAccounts.SingleOrDefaultAsync(x => x.HeimdallAccountId == heimdallAccountId, ct);
private IQueryable<PasskeyCredential> Active(Guid userId) =>
db.PasskeyCredentials.Where(x => x.UserAccountId == userId && x.RevokedUtc == null);
public async Task<IReadOnlyList<PasskeyView>> 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();
}
/// <summary>Whether a fresh enrolment ticket is held by this browser for this account.</summary>
public bool HasTicket(string heimdallAccountId, string? ticketId) =>
ticketId is not null && ceremonies.Peek(CeremonyKind.EnrolTicket, heimdallAccountId, null, ticketId) is not null;
/// <summary>Why a fresh sign-in for enrolment may not even start, or null when it may. Spends a ceremony permit.</summary>
public async Task<HeimdallLinkOutcome?> 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;
}
/// <summary>The creation options for one enrolment, bound to the account; needs the ticket the fresh sign-in earned.</summary>
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);
}
/// <summary>
/// Verifies a registration and stores the credential, cooling off for <see cref="CoolingOff"/>. The ticket and the
/// challenge are both spent here, whatever the outcome: a failed attempt needs a fresh sign-in and a new challenge.
/// </summary>
public async Task<HeimdallLinkOutcome> 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;
}
}
/// <summary>Revokes one of the session's own passkeys. Needs no strong proof; the page shows the revocation.</summary>
public async Task<HeimdallLinkOutcome> 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<PasskeyCredential> Strong(Guid userId)
{
var now = clock.GetUtcNow();
return Active(userId).Where(x => x.StrongFromUtc <= now);
}
public async Task<bool> HasStrongAsync(string heimdallAccountId, CancellationToken ct) =>
await AccountAsync(heimdallAccountId, ct) is { } user && await Strong(user.Id).AnyAsync(ct);
/// <summary>When the first cooling-off credential becomes strong, or null when none is cooling.</summary>
public async Task<DateTimeOffset?> 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();
}
/// <summary>The request options for unlinking one identity: only the account's strong passkeys may answer, with user verification.</summary>
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);
}
/// <summary>
/// Verifies an assertion made for unlinking <paramref name="identityId"/>. 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.
/// </summary>
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);
/// <summary>
/// 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.
/// </summary>
public async Task<bool> 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);
/// <summary>A label from a form field: trimmed, control characters dropped, bounded; the fallback when nothing is left.</summary>
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<AuthenticatorTransport>();
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<AuthenticatorTransport>($"\"{name}\""));
}
}
catch (System.Text.Json.JsonException)
{
}
}
return parsed.ToArray();
}
private static string TransportName(AuthenticatorTransport transport) =>
System.Text.Json.JsonSerializer.Serialize(transport).Trim('"');
}

View file

@ -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 = "/";
}

View file

@ -61,7 +61,23 @@
{
<span class="muted">linked @linkedAt.UtcDateTime.ToString("dd MMM yyyy") UTC</span>
}
@if (Model.CanProve)
@if (Model.HasStrongPasskey)
{
@if (Model.PasskeysAvailable)
{
<form method="post" asp-page-handler="UnlinkPasskey" class="inline-actions" data-passkey-unlink>
<input type="hidden" name="identityId" value="@identity.Id" />
<input type="hidden" name="id" />
<input type="hidden" name="rawId" />
<input type="hidden" name="authenticatorData" />
<input type="hidden" name="clientDataJson" />
<input type="hidden" name="signature" />
<input type="hidden" name="userHandle" />
<button class="button secondary" type="submit">Unlink with passkey</button>
</form>
}
}
else if (Model.HasGoogle)
{
<form method="post" asp-page-handler="Unlink" class="inline-actions">
<input type="hidden" name="identityId" value="@identity.Id" />
@ -76,17 +92,31 @@
<p class="muted">This is your only login. Heimdall will not remove the last one.</p>
}
@if (!Model.CanProve)
@if (Model.HasStrongPasskey && !Model.PasskeysAvailable)
{
<p class="muted">
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.
<p class="callout warning">
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.
</p>
}
else if (Model.HasStrongPasskey)
{
<p class="muted">Unlinking asks for one of your passkeys, then signs you in to Heimdall once more and removes only the login you picked.</p>
}
else if (Model.HasGoogle)
{
<p class="muted">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.</p>
}
else
{
<p class="muted">Unlinking asks you to sign in with Google again as proof, then removes only the login you picked.</p>
<p class="muted">
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.
</p>
}
<form method="post" asp-page-handler="Refresh" class="panel-actions">
@ -94,6 +124,80 @@
</form>
}
<h2>Passkeys</h2>
@if (!Model.PasskeysAvailable)
{
<p class="muted">Passkeys are not available on this server right now, so none can be added or used. You can still revoke the ones listed.</p>
}
@if (Model.PasskeysAvailable)
{
<p class="muted">
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.
</p>
}
@if (Model.Passkeys.Count > 0)
{
<ul>
@foreach (var key in Model.Passkeys)
{
<li>
<strong>@key.Label</strong>
@if (key.IsStrong)
{
<span class="muted">active</span>
}
else
{
<span class="muted">cooling off until @key.StrongFromUtc.UtcDateTime.ToString("dd MMM yyyy HH:mm") UTC; cannot unlink yet</span>
}
<span class="muted">added @key.CreatedUtc.UtcDateTime.ToString("dd MMM yyyy") UTC</span>
@if (key.LastUsedUtc is { } used)
{
<span class="muted">last used @used.UtcDateTime.ToString("dd MMM yyyy") UTC</span>
}
<form method="post" asp-page-handler="PasskeyRevoke" class="inline-actions">
<input type="hidden" name="credentialId" value="@key.Id" />
<button class="button secondary" type="submit">Revoke</button>
</form>
</li>
}
</ul>
}
@if (Model.PasskeysAvailable)
{
if (Model.PasskeyLimitReached)
{
<p class="muted">This is the most passkeys an account can hold. Revoke one to add another.</p>
}
else if (Model.CanEnrol)
{
<form method="post" asp-page-handler="PasskeyEnrol" id="passkey-enrol" data-passkey-enrol>
<input type="hidden" name="id" />
<input type="hidden" name="rawId" />
<input type="hidden" name="attestationObject" />
<input type="hidden" name="clientDataJson" />
<input type="hidden" name="transports" />
<label>Name for this passkey <input type="text" name="label" maxlength="40" value="Passkey" /></label>
<button class="button" type="submit">Add a passkey</button>
</form>
<noscript><p class="muted">Adding a passkey needs JavaScript.</p></noscript>
}
else if (Model.Identities is not null)
{
<form method="post" asp-page-handler="PasskeyEnrolStart" class="panel-actions">
<button class="button secondary" type="submit">Sign in again to add a passkey</button>
</form>
}
else
{
<p class="muted">Read your linked logins first; adding a passkey then asks you to sign in to Heimdall once more.</p>
}
}
<p id="passkey-status" class="muted" role="status"></p>
@if (Model.Identities is not null)
{
<h2>Link another login</h2>
@ -117,3 +221,10 @@
<a class="button secondary" asp-page="/Membership/Status">Back to membership status</a>
</div>
</section>
@section Scripts {
@if (Model.HasHeimdallAccount && Model.PasskeysAvailable)
{
<script src="~/js/passkeys.js" asp-append-version="true"></script>
}
}

View file

@ -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<BifrostHostOptions> hostOptions,
ILogger<LinkedAccountsModel> logger) : PageModel
{
@ -41,17 +44,41 @@ public sealed class LinkedAccountsModel(
/// <summary>The logins the last read found, or null when there is none or it has expired.</summary>
public IReadOnlyList<LinkedIdentitySummary>? Identities { get; private set; }
/// <summary>Whether a fresh Google sign-in can be had for this account: unlinking needs one, and only Google can give it.</summary>
public bool CanProve => Identities?.Any(x => x.Provider == HeimdallAuthService.StepUpProvider) == true;
/// <summary>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.</summary>
public bool HasGoogle => Identities?.Any(x => x.Provider == HeimdallAuthService.StepUpProvider) == true;
/// <summary>The account's passkeys that are not revoked, with their cooling-off state.</summary>
public IReadOnlyList<PasskeyView> Passkeys { get; private set; } = [];
/// <summary>Whether a passkey past its cooling-off exists: unlinking then needs it and no longer accepts Google as the proof.</summary>
public bool HasStrongPasskey => Passkeys.Any(x => x.IsStrong);
/// <summary>When the first passkey still cooling off becomes strong.</summary>
public DateTimeOffset? NextStrongAt => Passkeys.Where(x => !x.IsStrong).Select(x => (DateTimeOffset?)x.StrongFromUtc).Min();
/// <summary>Whether this browser holds a live ticket from a fresh sign-in, so a passkey can be added now.</summary>
public bool CanEnrol { get; private set; }
/// <summary>Whether this server can run passkey ceremonies at all (https and the host it expects).</summary>
public bool PasskeysAvailable => passkeys.IsAvailable;
public bool PasskeyLimitReached => Passkeys.Count >= PasskeyService.MaxActive;
public bool CanUnlink => HasStrongPasskey || HasGoogle;
public IReadOnlyList<string> 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]);
}
}
/// <summary>Reads the account's logins: an ordinary Heimdall sign-in, then the list inside its completion.</summary>
@ -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.
/// </summary>
public Task<IActionResult> OnPostUnlinkAsync(string? identityId, CancellationToken cancellationToken)
public async Task<IActionResult> 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);
}
/// <summary>Begins the fresh sign-in whose completion issues the enrolment ticket. Needs a current list: the proof provider is never guessed.</summary>
public async Task<IActionResult> 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);
}
/// <summary>The WebAuthn creation options for the ticket this browser holds, as JSON for the page's script.</summary>
public async Task<IActionResult> 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);
}
/// <summary>Verifies the registration the script posted. The ticket cookie is spent whatever the outcome.</summary>
public async Task<IActionResult> 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<IActionResult> OnPostPasskeyRevokeAsync(Guid credentialId, CancellationToken cancellationToken) =>
AccountId is { } id ? Done(await passkeys.RevokeAsync(id, credentialId, cancellationToken)) : Done(HeimdallLinkOutcome.Error);
/// <summary>The WebAuthn request options for unlinking one identity, as JSON for the page's script.</summary>
public async Task<IActionResult> 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);
}
/// <summary>
/// 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.
/// </summary>
public async Task<IActionResult> 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);
}
}
/// <summary>JSON for the page's script: the options, or only a closed outcome code with the status that fits.</summary>
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<string, string?> { [HeimdallLinkOutcomes.ResultParameter] = HeimdallLinkOutcomes.Code(outcome) });

View file

@ -159,6 +159,10 @@ builder.Services.AddHttpClient<HeimdallMemberClient>((serviceProvider, client) =
HeimdallMemberClient.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value))
.RedactLoggedHeaders(HeimdallAuthService.RedactsHeader);
builder.Services.AddSingleton<LinkedAccountsSnapshots>();
builder.Services.AddSingleton<Bifrost.Web.Features.Passkeys.PasskeyRelyingParty>();
builder.Services.AddSingleton<Bifrost.Web.Features.Passkeys.PasskeyCeremonies>();
builder.Services.AddSingleton<Bifrost.Web.Features.Passkeys.PasskeyRateLimit>();
builder.Services.AddScoped<Bifrost.Web.Features.Passkeys.PasskeyService>();
builder.Services.AddSingleton<HeimdallTokenVerifier>();
builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) =>
HeimdallTokenVerifier.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value));
@ -281,6 +285,8 @@ var app = builder.Build();
var appJsonSerializerOptions = CreateAppJsonSerializerOptions();
app.Services.GetRequiredService<StartupConfigurationValidator>().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<Bifrost.Web.Features.Passkeys.PasskeyRelyingParty>();
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));
}

View file

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

View file

@ -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);
});
});
})();

View file

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

View file

@ -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 <passkeys.js>
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: "<script>alert(1)</script>" } }, credentialsReply: asserted }),
await run("cancelled", { kind: "unlink", fetchReply: requestOptions, credentialsReply: "reject" }),
];
console.log(JSON.stringify(out));
})();

View file

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

View file

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

View file

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

View file

@ -0,0 +1,99 @@
using System.Diagnostics;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Bifrost.Web.Tests;
/// <summary>
/// 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.
/// </summary>
public sealed partial class PasskeyScriptTests
{
private sealed record Fetch(string Url, string Method, Dictionary<string, string> Headers, string Body, string Credentials);
private sealed record Call(string Name, Fetch[] Fetches, JsonElement CredentialCall, string[] Navigations, Dictionary<string, string>? 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<Dictionary<string, Call>> 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<Call[]>(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);
}
}

File diff suppressed because it is too large Load diff

View file

@ -268,6 +268,119 @@ public sealed class FakeHeimdall : HttpMessageHandler
/// <summary>When set, an unlink is applied and then answered with this status (200 answers a body that is not JSON): a lost answer.</summary>
public HttpStatusCode? UnlinkApplyThenFail { get; set; }
/// <summary>Every step-up assertion that reached the route: the app secret header, the Authorization header and the raw body.</summary>
public List<(string? Secret, string? Authorization, string Body)> StepUps { get; } = [];
/// <summary>When set, the step-up route refuses with this status and the contract's error code for it, after the schema and secret checks.</summary>
public HttpStatusCode? StepUpStatus { get; set; }
/// <summary>When true the step-up route throws, as an unreachable Heimdall does.</summary>
public bool StepUpDown { get; set; }
private readonly HashSet<string> _stepUpNonces = new(StringComparer.Ordinal);
private static readonly System.Text.RegularExpressions.Regex NonceShape = new("^[A-Za-z0-9_-]{16,128}$");
/// <summary>
/// POST /v1/apps/bifrost/step-up as Heimdall's service-contract.md describes it: schema (method 1..64, nonce pattern) before the
/// secret; then the app secret; then the bearer (live, for the same app); then the method set; then single-use per session and
/// nonce; a 201 token that copies the bearer's claims and adds step_up_at and step_up_method.
/// </summary>
private HttpResponseMessage StepUpRoute(HttpRequestMessage request, string body)
{
static HttpResponseMessage Json(HttpStatusCode status, string json) =>
new(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
static HttpResponseMessage Refuse(HttpStatusCode status, string error) => Json(status, $$"""{"error":"{{error}}"}""");
var secret = request.Headers.TryGetValues("x-heimdall-app-secret", out var values) ? values.Single() : null;
var authorization = request.Headers.Authorization?.ToString();
StepUps.Add((secret, authorization, body));
if (StepUpDown)
{
throw new HttpRequestException("Heimdall is down");
}
string? method = null;
string? nonce = null;
try
{
var root = JsonDocument.Parse(body).RootElement;
method = root.TryGetProperty("method", out var m) && m.ValueKind == JsonValueKind.String ? m.GetString() : null;
nonce = root.TryGetProperty("nonce", out var n) && n.ValueKind == JsonValueKind.String ? n.GetString() : null;
}
catch (JsonException)
{
}
if (method is not { Length: >= 1 and <= 64 } || nonce is null || !NonceShape.IsMatch(nonce))
{
return Json(HttpStatusCode.BadRequest, """{"statusCode":400,"error":"Bad Request","message":"body does not match the schema"}""");
}
if (secret != RequiredAppSecret)
{
return Refuse(HttpStatusCode.Unauthorized, "app_auth_required");
}
if (StepUpStatus is { } forced)
{
return forced switch
{
HttpStatusCode.Forbidden => Refuse(forced, "step_up_assertion_not_permitted"),
HttpStatusCode.TooManyRequests => Refuse(forced, "step_up_rate_limited"),
HttpStatusCode.Unauthorized => Refuse(forced, "sign_in_required"),
HttpStatusCode.Conflict => Refuse(forced, "step_up_nonce_replayed"),
_ => Json(forced, """{"error":"server_error","detail":"HEIMDALL FREE TEXT"}""")
};
}
Dictionary<string, JsonElement>? claims = null;
try
{
var token = authorization is not null && authorization.StartsWith("Bearer ", StringComparison.Ordinal) ? authorization["Bearer ".Length..] : null;
var parts = token?.Split('.');
if (parts is { Length: 3 })
{
claims = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(WebEncoders.Base64UrlDecode(parts[1]));
}
}
catch (Exception error) when (error is JsonException or FormatException)
{
}
if (claims is null || !claims.TryGetValue("exp", out var exp) || exp.GetInt64() <= Now().ToUnixTimeSeconds() || !claims.TryGetValue("sid", out var sid))
{
return Refuse(HttpStatusCode.Unauthorized, "sign_in_required");
}
if (claims["app"].GetProperty("slug").GetString() != "bifrost")
{
return Refuse(HttpStatusCode.Forbidden, "bearer_app_mismatch");
}
if (method is not ("bifrost-passkey" or "bifrost-email-code"))
{
return Refuse(HttpStatusCode.BadRequest, "unknown_step_up_method");
}
if (!_stepUpNonces.Add(sid.GetString() + ":" + nonce))
{
return Refuse(HttpStatusCode.Conflict, "step_up_nonce_replayed");
}
var minted = claims.ToDictionary(x => x.Key, x => (object?)x.Value);
minted["jti"] = Guid.NewGuid().ToString();
minted["step_up_at"] = Now().ToUnixTimeSeconds();
minted["step_up_method"] = method;
return Json(
HttpStatusCode.Created,
JsonSerializer.Serialize(new
{
accessToken = Published[0].Sign(minted),
claimSet = new { },
stepUp = new { at = Now().ToUnixTimeSeconds(), method, validForSeconds = 600 }
}));
}
/// <summary>Throws instead of answering a member route, as an unreachable Heimdall does.</summary>
public Exception? MemberFailure { get; set; }
@ -488,6 +601,11 @@ public sealed class FakeHeimdall : HttpMessageHandler
return new HttpResponseMessage(StartStatus) { Content = content };
}
if (request.Method == HttpMethod.Post && path == "/v1/apps/bifrost/step-up")
{
return StepUpRoute(request, await request.Content!.ReadAsStringAsync(cancellationToken));
}
if (path.StartsWith("/v1/me/identities", StringComparison.Ordinal))
{
return MemberRoute(request, path);

View file

@ -0,0 +1,156 @@
using System.Formats.Cbor;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.WebUtilities;
using Org.BouncyCastle.Math.EC.Rfc8032;
namespace Bifrost.Web.Tests.Support;
/// <summary>
/// A WebAuthn authenticator in software: it makes real "none" attestations and real assertions (CBOR authenticator data, COSE key,
/// clientDataJSON, a signature the server must verify) from the options JSON the server sent, and it can be told to misbehave:
/// another origin, another RP ID, no user verification, a chosen sign count. The test's only trust is in the crypto it does itself,
/// not in the library under test.
/// </summary>
public sealed class SoftwareAuthenticator
{
public enum Kind
{
Es256,
EdDsa
}
private readonly Kind _kind;
private readonly ECDsa? _ec;
private readonly byte[]? _edSeed;
private readonly byte[] _edPublic = new byte[Ed25519.PublicKeySize];
public SoftwareAuthenticator(Kind kind = Kind.Es256)
{
_kind = kind;
if (kind == Kind.Es256)
{
_ec = ECDsa.Create(ECCurve.NamedCurves.nistP256);
}
else
{
_edSeed = RandomNumberGenerator.GetBytes(Ed25519.SecretKeySize);
Ed25519.GeneratePublicKey(_edSeed, 0, _edPublic, 0);
}
}
public byte[] CredentialId { get; } = RandomNumberGenerator.GetBytes(32);
public uint SignCount { get; set; }
public string Origin { get; set; } = "https://localhost";
public string RpId { get; set; } = "localhost";
public bool UserVerified { get; set; } = true;
private static string B64(byte[] bytes) => WebEncoders.Base64UrlEncode(bytes);
private byte[] CoseKey()
{
var writer = new CborWriter(CborConformanceMode.Lax);
if (_kind == Kind.Es256)
{
var p = _ec!.ExportParameters(false);
writer.WriteStartMap(5);
writer.WriteInt32(1); writer.WriteInt32(2);
writer.WriteInt32(3); writer.WriteInt32(-7);
writer.WriteInt32(-1); writer.WriteInt32(1);
writer.WriteInt32(-2); writer.WriteByteString(p.Q.X!);
writer.WriteInt32(-3); writer.WriteByteString(p.Q.Y!);
}
else
{
writer.WriteStartMap(4);
writer.WriteInt32(1); writer.WriteInt32(1);
writer.WriteInt32(3); writer.WriteInt32(-8);
writer.WriteInt32(-1); writer.WriteInt32(6);
writer.WriteInt32(-2); writer.WriteByteString(_edPublic);
}
writer.WriteEndMap();
return writer.Encode();
}
private byte[] AuthData(bool attested)
{
var data = new List<byte>();
data.AddRange(SHA256.HashData(Encoding.UTF8.GetBytes(RpId)));
// UP always; UV when told; AT for a registration.
data.Add((byte)(0x01 | (UserVerified ? 0x04 : 0) | (attested ? 0x40 : 0)));
data.AddRange(new[] { (byte)(SignCount >> 24), (byte)(SignCount >> 16), (byte)(SignCount >> 8), (byte)SignCount });
if (attested)
{
data.AddRange(new byte[16]);
data.Add((byte)(CredentialId.Length >> 8));
data.Add((byte)CredentialId.Length);
data.AddRange(CredentialId);
data.AddRange(CoseKey());
}
return data.ToArray();
}
private byte[] ClientData(string type, string challenge) =>
JsonSerializer.SerializeToUtf8Bytes(new Dictionary<string, object> { ["type"] = type, ["challenge"] = challenge, ["origin"] = Origin, ["crossOrigin"] = false });
private byte[] Sign(byte[] data) =>
_kind == Kind.Es256
? _ec!.SignData(data, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)
: Ed25519Sign(data);
private byte[] Ed25519Sign(byte[] data)
{
var signature = new byte[Ed25519.SignatureSize];
Ed25519.Sign(_edSeed!, 0, data, 0, data.Length, signature, 0);
return signature;
}
/// <summary>The form fields the page's script posts after navigator.credentials.create, for these creation options.</summary>
public Dictionary<string, string> Register(string optionsJson, string label = "Test key")
{
using var options = JsonDocument.Parse(optionsJson);
var challenge = options.RootElement.GetProperty("challenge").GetString()!;
var attestation = new CborWriter(CborConformanceMode.Lax);
attestation.WriteStartMap(3);
attestation.WriteTextString("fmt"); attestation.WriteTextString("none");
attestation.WriteTextString("attStmt"); attestation.WriteStartMap(0); attestation.WriteEndMap();
attestation.WriteTextString("authData"); attestation.WriteByteString(AuthData(attested: true));
attestation.WriteEndMap();
return new Dictionary<string, string>
{
["id"] = B64(CredentialId),
["rawId"] = B64(CredentialId),
["attestationObject"] = B64(attestation.Encode()),
["clientDataJson"] = B64(ClientData("webauthn.create", challenge)),
["transports"] = "internal",
["label"] = label
};
}
/// <summary>The form fields the page's script posts after navigator.credentials.get, for these request options. Counts one use first.</summary>
public Dictionary<string, string> Assert(string optionsJson, byte[]? userHandle = null)
{
using var options = JsonDocument.Parse(optionsJson);
var challenge = options.RootElement.GetProperty("challenge").GetString()!;
SignCount++; // a real authenticator counts every assertion
var authData = AuthData(attested: false);
var clientData = ClientData("webauthn.get", challenge);
var signature = Sign([.. authData, .. SHA256.HashData(clientData)]);
return new Dictionary<string, string>
{
["id"] = B64(CredentialId),
["rawId"] = B64(CredentialId),
["authenticatorData"] = B64(authData),
["clientDataJson"] = B64(clientData),
["signature"] = B64(signature),
["userHandle"] = userHandle is null ? string.Empty : B64(userHandle)
};
}
}

View file

@ -60,6 +60,31 @@
"resolved": "2.4.0",
"contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ=="
},
"Fido2": {
"type": "Transitive",
"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"
}
},
"Fido2.Models": {
"type": "Transitive",
"resolved": "4.3.3",
"contentHash": "G/Q8MYgptOwMrbWgDoGsy90u3J5GTdNAHub8aZRg/mQrUETzB5CqFzstGdTXE9UhL0qD6sRynOoPUnqCwKRnMw==",
"dependencies": {
"Microsoft.Bcl.Memory": "9.0.14"
}
},
"libsodium": {
"type": "Transitive",
"resolved": "1.0.20.1",
"contentHash": "pkiceEqJDQFHjbga3k/oPfTVX9g+GKJZ1VrkuiLhaymt9YNw1Dr7cX9hNuZuNaWcr9WD0moW55k4pMwzQ7JaZQ=="
},
"MessagePack": {
"type": "Transitive",
"resolved": "3.1.12",
@ -96,6 +121,11 @@
"System.Formats.Asn1": "10.0.2"
}
},
"Microsoft.Bcl.Memory": {
"type": "Transitive",
"resolved": "9.0.14",
"contentHash": "laQCcjTM2FnbyznkAu9hwBuQh3z2/OtJKwjGkJOUevylFpFXZIUVq/+MU2jM0HQm1n5wsjjwtLIDOiWJ6Nai3A=="
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
"resolved": "17.14.1",
@ -153,27 +183,27 @@
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "0J/9YNXTMWSZP2p2+nvl8p71zpSwokZXZuJW+VjdErkegAnFdO1XlqtA62SJtgVYHdKu3uPxJHcMR/r35HwFBA==",
"resolved": "9.0.0",
"contentHash": "YIMO9T3JL8MeEXgVozKt2v79hquo/EFtnY0vgxmLnUvk1Rei/halI7kOWZL2RBeV9FMGzgM9LZA8CVaNwFMaNA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0",
"Microsoft.Extensions.Primitives": "8.0.0"
"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": "8.0.0",
"contentHash": "mBMoXLsr5s1y2zOHWmKsE9veDcx8h1x/c3rz4baEdQKTeDcmQAPNbB54Pi/lhFO3K431eEq6PFbMgLaa6PHFfA==",
"resolved": "9.0.0",
"contentHash": "RiScL99DcyngY9zJA2ROrri7Br8tn5N4hP4YNvGdTN/bvg1A3dwvDOxHnNZ3Im7x2SJ5i4LkX1uPiR/MfSFBLQ==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0"
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0"
}
},
"Microsoft.Extensions.Configuration.CommandLine": {
@ -231,16 +261,16 @@
},
"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",
@ -253,22 +283,22 @@
},
"Microsoft.Extensions.Diagnostics": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "3PZp/YSkIXrF7QK7PfC1bkyRYwqOHpWFad8Qx+4wkuumAeXo1NHaxpS9LboNA9OvNSAu+QOVlXbMyoY+pHSqcw==",
"resolved": "9.0.0",
"contentHash": "0CF9ZrNw5RAlRfbZuVIvzzhP8QeWqHiUmMBU/2H7Nmit8/vwP3/SbHeEctth7D4Gz2fBnEbokPc1NU8/j/1ZLw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "8.0.0",
"Microsoft.Extensions.Diagnostics.Abstractions": "8.0.0",
"Microsoft.Extensions.Options.ConfigurationExtensions": "8.0.0"
"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": "8.0.0",
"contentHash": "JHYCQG7HmugNYUhOl368g+NMxYE/N/AiclCYRNlgCY9eVyiBkOHMwK4x60RYMxv9EL3+rmj1mqHvdCiPpC+D4Q==",
"resolved": "9.0.0",
"contentHash": "1K8P7XzuzX8W8pmXcZjcrqS6x5eSSdvhQohmcpgiQNY/HlDAlnrhR9dvlURfFz428A+RTCJpUyB+aKTA6AgVcQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Microsoft.Extensions.Options": "8.0.0",
"System.Diagnostics.DiagnosticSource": "8.0.0"
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
"Microsoft.Extensions.Options": "9.0.0",
"System.Diagnostics.DiagnosticSource": "9.0.0"
}
},
"Microsoft.Extensions.FileProviders.Abstractions": {
@ -335,22 +365,36 @@
"Microsoft.Extensions.Logging.Abstractions": "8.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.Logging.Configuration": {
@ -418,29 +462,29 @@
},
"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": "8.0.0",
"contentHash": "0f4DMRqEd50zQh+UyJc+/HiBsZ3vhAQALgdkcQEalSH1L2isdC7Yj54M3cyo5e+BeO5fcBQ7Dxly8XiBBcvRgw==",
"resolved": "9.0.0",
"contentHash": "Ob3FXsXkcSMQmGZi7qP07EQ39kZpSBlTcAZLbJLdI4FIf0Jug8biv2HTavWmnTirchctPlq9bl/26CXtQRguzA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0",
"Microsoft.Extensions.Configuration.Binder": "8.0.0",
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Microsoft.Extensions.Options": "8.0.0",
"Microsoft.Extensions.Primitives": "8.0.0"
"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",
@ -527,6 +571,14 @@
"Npgsql": "8.0.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",
@ -616,8 +668,8 @@
},
"System.Diagnostics.DiagnosticSource": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "c9xLpVz6PL9lp/djOWtk5KPDZq3cSYpmXoJQY524EOtuFl5z9ZtsotpsyrDW40U1DRnQSYvcPKEUV0X//u6gkQ=="
"resolved": "9.0.0",
"contentHash": "ddppcFpnbohLWdYKr/ZeLZHmmI+DXFgZ3Snq+/E7SwcdW4UnvxmaugkwGywvGVWkHPGCSZjCP+MLzu23AL5SDw=="
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
@ -629,6 +681,11 @@
"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": "8.0.0",
@ -696,6 +753,7 @@
"type": "Project",
"dependencies": {
"BouncyCastle.Cryptography": "[2.4.0, )",
"Fido2": "[4.3.3, )",
"MessagePack": "[3.1.12, )",
"Microsoft.Extensions.Caching.Memory": "[8.0.1, )",
"Npgsql": "[8.0.3, )",