14 KiB
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 nativeBifrostIdentityor 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 |
| Transitive weight | Heavier than the repository's other packages: Microsoft.Extensions.Http 9.0.0 pulls Microsoft.Extensions.* 9.0.0, so the lockfile moves seven existing Microsoft.Extensions.* packages from 8.x to 9.0.0 (app-local on top of the net8 shared framework, a supported combination), plus Microsoft.IdentityModel.JsonWebTokens, NSec.Cryptography and the native libsodium package (the Linux x64 glibc and musl binaries ship in the package's runtimes/). 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.
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). No new configuration key exists.
State
PasskeyCredentials(migration20261113120000_AddPasskeyCredentials, additive, applied at boot like the others): perUserAccountid,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. 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.
Enrolment
- 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,ExpectedAccountIdthe session's account). - 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. - The page's script asks for creation options (antiforgery header), calls
navigator.credentials.create(user verification required, resident key preferred,noneattestation, existing credentials excluded) and posts the base64url result. Bifrost spends the ticket and the challenge, verifies, and stores the credential withStrongFromUtc= now + 48 hours.
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).
- The script asks for request options for the chosen identity (
allowCredentialsis the account's strong passkeys; user verification required). One live challenge per account, bound to the identity. - 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). - 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. - 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 app secret, the bearer and{method: "bifrost-passkey", nonce}go toPOST /v1/apps/bifrost/step-up(nonce: 32 fresh random bytes, base64url, 43 characters); the returnedaccessTokengoes 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 |
| 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 |
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. - 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-passkeyis Bifrost's word, gated by the app secret and a live bearer (Heimdalldocs/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: theEnrolProofandUnlinkPasskeyProofattempts,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 inSupport/HeimdallTestKit.cs.