From dad45faeac8b2ef1587160f008cf7123de44781a Mon Sep 17 00:00:00 2001 From: Heimdall Date: Fri, 13 Nov 2026 20:48:37 +0000 Subject: [PATCH 1/7] Passkeys: Fido2NetLib, credential store, enrolment behind a fresh sign-in, assertion and app-asserted step-up for unlink Heimdall-backed members enrol WebAuthn passkeys on the Linked accounts page (ticket from a fresh ordinary Heimdall sign-in, 48 h cooling-off, cap 5, FixedWindowBudget on ceremonies) and unlink with a strong passkey: the assertion is verified at Bifrost, then inside the completion that holds the bearer Bifrost calls Heimdall's POST /v1/apps/bifrost/step-up and the unlink with the returned token. The Google step-up closes once a strong passkey exists. Migration AddPasskeyCredentials is additive. Tests use a software authenticator and a fake Heimdall step-up route modeled on the contract. Co-Authored-By: Claude Sonnet 5.5 --- src/Bifrost.Web/Bifrost.Web.csproj | 1 + src/Bifrost.Web/Data/BifrostDbContext.cs | 11 + .../20261113120000_AddPasskeyCredentials.cs | 48 + .../BifrostDbContextModelSnapshot.cs | 64 ++ src/Bifrost.Web/Domain/BifrostModels.cs | 35 + .../Features/Heimdall/HeimdallAuthService.cs | 205 ++++- .../Features/Passkeys/PasskeyCeremonies.cs | 133 +++ .../Features/Passkeys/PasskeyRelyingParty.cs | 48 + .../Features/Passkeys/PasskeyService.cs | 435 ++++++++++ .../Features/Shared/BifrostCookies.cs | 1 + .../Pages/Membership/LinkedAccounts.cshtml | 113 ++- .../Pages/Membership/LinkedAccounts.cshtml.cs | 172 +++- src/Bifrost.Web/Program.cs | 17 + src/Bifrost.Web/packages.lock.json | 156 +++- src/Bifrost.Web/wwwroot/js/passkeys.js | 125 +++ .../Bifrost.Web.Tests/CookieHardeningTests.cs | 1 + .../Fixtures/passkey-script-harness.cjs | 96 ++ .../HeimdallGoogleLinkTests.cs | 2 +- .../Bifrost.Web.Tests/LinkedAccountsTests.cs | 5 +- .../MigrationDiscoveryTests.cs | 1 + tests/Bifrost.Web.Tests/PasskeyScriptTests.cs | 99 +++ tests/Bifrost.Web.Tests/PasskeyTests.cs | 821 ++++++++++++++++++ .../Support/HeimdallTestKit.cs | 118 +++ .../Support/SoftwareAuthenticator.cs | 156 ++++ tests/Bifrost.Web.Tests/packages.lock.json | 154 +++- 25 files changed, 2907 insertions(+), 110 deletions(-) create mode 100644 src/Bifrost.Web/Data/Migrations/20261113120000_AddPasskeyCredentials.cs create mode 100644 src/Bifrost.Web/Features/Passkeys/PasskeyCeremonies.cs create mode 100644 src/Bifrost.Web/Features/Passkeys/PasskeyRelyingParty.cs create mode 100644 src/Bifrost.Web/Features/Passkeys/PasskeyService.cs create mode 100644 src/Bifrost.Web/wwwroot/js/passkeys.js create mode 100644 tests/Bifrost.Web.Tests/Fixtures/passkey-script-harness.cjs create mode 100644 tests/Bifrost.Web.Tests/PasskeyScriptTests.cs create mode 100644 tests/Bifrost.Web.Tests/PasskeyTests.cs create mode 100644 tests/Bifrost.Web.Tests/Support/SoftwareAuthenticator.cs diff --git a/src/Bifrost.Web/Bifrost.Web.csproj b/src/Bifrost.Web/Bifrost.Web.csproj index 177c194..8e26e7b 100644 --- a/src/Bifrost.Web/Bifrost.Web.csproj +++ b/src/Bifrost.Web/Bifrost.Web.csproj @@ -13,6 +13,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index d227c42..f710aa8 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -45,6 +45,7 @@ public sealed class BifrostDbContext(DbContextOptions options) public DbSet ConsentReceipts => Set(); public DbSet MindKeys => Set(); + public DbSet PasskeyCredentials => Set(); public DbSet MindNonces => Set(); @@ -333,6 +334,16 @@ public sealed class BifrostDbContext(DbContextOptions options) .OnDelete(DeleteBehavior.Cascade); }); + modelBuilder.Entity(entity => + { + entity.HasIndex(x => x.CredentialId).IsUnique(); + entity.HasIndex(x => x.UserAccountId); + entity.HasOne(x => x.UserAccount) + .WithMany() + .HasForeignKey(x => x.UserAccountId) + .OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity(entity => { entity.HasIndex(x => new { x.PersonaId, x.PublicKey }).IsUnique(); diff --git a/src/Bifrost.Web/Data/Migrations/20261113120000_AddPasskeyCredentials.cs b/src/Bifrost.Web/Data/Migrations/20261113120000_AddPasskeyCredentials.cs new file mode 100644 index 0000000..fd1c2e0 --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20261113120000_AddPasskeyCredentials.cs @@ -0,0 +1,48 @@ +using System; +using Bifrost.Web.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + // Hand-written like AddConsentReceipts: no dotnet-ef on the authoring host; the snapshot was edited to match. Additive only. + [DbContext(typeof(BifrostDbContext))] + [Migration("20261113120000_AddPasskeyCredentials")] + public partial class AddPasskeyCredentials : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PasskeyCredentials", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserAccountId = table.Column(type: "uuid", nullable: false), + CredentialId = table.Column(type: "bytea", nullable: false), + PublicKey = table.Column(type: "bytea", nullable: false), + SignCount = table.Column(type: "bigint", nullable: false), + Transports = table.Column(type: "text", nullable: false), + AaGuid = table.Column(type: "uuid", nullable: true), + Label = table.Column(type: "text", nullable: false), + CreatedUtc = table.Column(type: "timestamp with time zone", nullable: false), + StrongFromUtc = table.Column(type: "timestamp with time zone", nullable: false), + RevokedUtc = table.Column(type: "timestamp with time zone", nullable: true), + LastUsedUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PasskeyCredentials", x => x.Id); + table.ForeignKey("FK_PasskeyCredentials_UserAccounts_UserAccountId", x => x.UserAccountId, "UserAccounts", "Id", onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateIndex(name: "IX_PasskeyCredentials_CredentialId", table: "PasskeyCredentials", column: "CredentialId", unique: true); + migrationBuilder.CreateIndex(name: "IX_PasskeyCredentials_UserAccountId", table: "PasskeyCredentials", column: "UserAccountId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "PasskeyCredentials"); + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index 042e2f0..ab4ef16 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -1470,6 +1470,59 @@ namespace Bifrost.Web.Data.Migrations b.ToTable("ConsentReceipts"); }); + modelBuilder.Entity("Bifrost.Web.Domain.PasskeyCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AaGuid") + .HasColumnType("uuid"); + + b.Property("CreatedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialId") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUsedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("RevokedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SignCount") + .HasColumnType("bigint"); + + b.Property("StrongFromUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Transports") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CredentialId") + .IsUnique(); + + b.HasIndex("UserAccountId"); + + b.ToTable("PasskeyCredentials"); + }); + modelBuilder.Entity("Bifrost.Web.Domain.MindKey", b => { b.Property("Id") @@ -2183,6 +2236,17 @@ namespace Bifrost.Web.Data.Migrations b.Navigation("UserAccount"); }); + modelBuilder.Entity("Bifrost.Web.Domain.PasskeyCredential", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany() + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b => { b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") diff --git a/src/Bifrost.Web/Domain/BifrostModels.cs b/src/Bifrost.Web/Domain/BifrostModels.cs index 00e3f9c..7449a6b 100644 --- a/src/Bifrost.Web/Domain/BifrostModels.cs +++ b/src/Bifrost.Web/Domain/BifrostModels.cs @@ -1177,6 +1177,41 @@ public sealed class ConsentReceipt public UserAccount? UserAccount { get; set; } } +/// +/// A WebAuthn passkey a Heimdall-backed member enrolled (docs/passkeys.md). Holds only what verifying an assertion needs: +/// the credential id, the public key and the sign count. No secret exists to hold. It counts as strong proof only from +/// (the cooling-off after enrolment) and only while not revoked. +/// +public sealed class PasskeyCredential +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid UserAccountId { get; set; } + + public byte[] CredentialId { get; set; } = []; + + public byte[] PublicKey { get; set; } = []; + + public long SignCount { get; set; } + + // Comma-separated WebAuthn transport hints the authenticator reported at enrolment; a hint for the browser, never trusted. + public string Transports { get; set; } = string.Empty; + + public Guid? AaGuid { get; set; } + + public string Label { get; set; } = string.Empty; + + public DateTimeOffset CreatedUtc { get; set; } + + public DateTimeOffset StrongFromUtc { get; set; } + + public DateTimeOffset? RevokedUtc { get; set; } + + public DateTimeOffset? LastUsedUtc { get; set; } + + public UserAccount? UserAccount { get; set; } +} + /// A Mind's registered Ed25519 public key. Bifrost never holds a private key. public sealed class MindKey { diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index 12e028e..aa0b410 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -8,6 +8,7 @@ using Bifrost.Web.Configuration; using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Features.Membership; +using Bifrost.Web.Features.Passkeys; using Bifrost.Web.Features.Shared; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.WebUtilities; @@ -33,7 +34,8 @@ public sealed class HeimdallAuthService( IDataProtectionProvider dataProtectionProvider, TimeProvider timeProvider, HeimdallMemberClient memberClient, - LinkedAccountsSnapshots snapshots) + LinkedAccountsSnapshots snapshots, + PasskeyCeremonies ceremonies) { public const string ErrorRedirect = "/?auth=heimdall-error"; public const string AppSecretHeader = "x-heimdall-app-secret"; @@ -89,7 +91,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 +147,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 +201,8 @@ public sealed class HeimdallAuthService( IEnumerable> requestCookies, bool stepUp, string? target, - CancellationToken cancellationToken) => + CancellationToken cancellationToken, + string? proof = null) => StartAsync( provider, callbackBaseUrl, @@ -208,7 +213,8 @@ public sealed class HeimdallAuthService( purpose, sessionAccountId, stepUp: stepUp, - target: target); + target: target, + proof: proof); /// /// Reads the account's logins: an ordinary sign-in, then GET /v1/me/identities inside its completion. Through a provider on @@ -250,6 +256,29 @@ public sealed class HeimdallAuthService( StartProofAsync( HeimdallAttemptPurpose.UnlinkProof, StepUpProvider, sessionAccountId, callbackBaseUrl, requestCookies, true, identityId, cancellationToken); + /// + /// The fresh sign-in that earns an enrolment ticket (docs/passkeys.md): an ordinary sign-in through a provider the account + /// holds, proving the account to this browser's session. Its completion issues the ticket; no passkey exists before it. + /// + public Task StartEnrolProofAsync( + string sessionAccountId, string callbackBaseUrl, + IEnumerable> requestCookies, CancellationToken cancellationToken) => + StartProofAsync( + HeimdallAttemptPurpose.EnrolProof, ProofProviderFor(sessionAccountId, null), + sessionAccountId, callbackBaseUrl, requestCookies, false, null, cancellationToken); + + /// + /// Unlinks one login on a verified passkey assertion: an ordinary fresh sign-in as the account (never a guessed provider), and + /// inside its completion, with that bearer, Heimdall's app-asserted step-up and then the unlink. is the + /// id of the single-use assertion fact; the attempt carries it and nothing else about the passkey. + /// + public Task StartUnlinkPasskeyAsync( + string sessionAccountId, string identityId, string factId, string callbackBaseUrl, + IEnumerable> requestCookies, CancellationToken cancellationToken) => + StartProofAsync( + HeimdallAttemptPurpose.UnlinkPasskeyProof, ProofProviderFor(sessionAccountId, null), + sessionAccountId, callbackBaseUrl, requestCookies, false, identityId, cancellationToken, factId); + /// /// The providers Heimdall's bifrost profile admits (src/app-profiles.ts, identityProviders): a link or a proof can use them. /// Twitch is offered for sign-in here but the profile refuses it, so it is not linkable. @@ -472,19 +501,38 @@ 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)); + 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 +540,69 @@ public sealed class HeimdallAuthService( } } + /// + /// Heimdall's unlink with a bearer that carries fresh proof, and what the list becomes. Only Heimdall's definitive refusals leave + /// the account as the list showed it. Any other end (not found, a lost answer, a 500, a malformed 200) may have applied the + /// unlink, so the list is dropped rather than trusted. + /// + private async Task UnlinkAsync(string accountId, string bearer, string? identityId, CancellationToken cancellationToken) + { + var unlinked = await memberClient.UnlinkAsync(bearer, identityId ?? string.Empty, cancellationToken); + if (unlinked.Call == HeimdallMemberCall.Ok) + { + snapshots.Put(accountId, unlinked.Identities!); + } + else if (unlinked.Call is not (HeimdallMemberCall.SignInRequired or HeimdallMemberCall.StepUpRequired or HeimdallMemberCall.LastIdentity)) + { + snapshots.Remove(accountId); + } + + return unlinked.Call == HeimdallMemberCall.Ok ? HeimdallLinkOutcome.Unlinked : OutcomeOf(unlinked.Call); + } + + /// The method label Heimdall's bifrost profile admits for an app-asserted passkey proof. + public const string PasskeyStepUpMethod = "bifrost-passkey"; + + /// + /// POST /v1/apps/{app}/step-up (Heimdall docs/service-contract.md): the app secret, the member's live bearer and a fresh random + /// nonce; Heimdall answers a token for the same session that carries the proof. Called only from the completion that holds the + /// bearer, after a verified passkey assertion fact was spent. Each refusal is a closed outcome; nothing is retried here. + /// + private async Task<(HeimdallLinkOutcome? Refusal, string? AccessToken)> StepUpAsync(string bearer, CancellationToken cancellationToken) + { + var nonce = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32)); + try + { + using var message = new HttpRequestMessage( + HttpMethod.Post, $"/v1/apps/{Uri.EscapeDataString(heimdallOptions.Value.AppSlug)}/step-up") + { + Content = JsonContent.Create(new HeimdallStepUpRequest(PasskeyStepUpMethod, nonce)) + }; + message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer); + using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (response.StatusCode == HttpStatusCode.Created) + { + var minted = await response.Content.ReadFromJsonAsync(cancellationToken); + return string.IsNullOrWhiteSpace(minted?.AccessToken) ? (HeimdallLinkOutcome.Error, null) : (null, minted.AccessToken); + } + + var error = await HeimdallRefusal.CodeAsync(response, cancellationToken); + return ((int)response.StatusCode, error) switch + { + (401, "sign_in_required") => (HeimdallLinkOutcome.SignInRequired, null), + (429, _) => (HeimdallLinkOutcome.StepUpRateLimited, null), + (409, "step_up_nonce_replayed") => (HeimdallLinkOutcome.StepUpReplayed, null), + (401 or 403 or 400, _) => (HeimdallLinkOutcome.StepUpUnavailable, null), + _ => (HeimdallLinkOutcome.Error, null) + }; + } + catch (Exception exception) when (exception is HttpRequestException or JsonException or NotSupportedException or TaskCanceledException + && !cancellationToken.IsCancellationRequested) + { + return (HeimdallLinkOutcome.Error, null); + } + } + private static HeimdallLinkOutcome OutcomeOf(HeimdallMemberCall call) => call switch { HeimdallMemberCall.SignInRequired => HeimdallLinkOutcome.SignInRequired, @@ -623,7 +734,7 @@ public sealed class HeimdallAuthService( private string Protect(HeimdallAttempt attempt) => _protector.Protect(JsonSerializer.Serialize(new AttemptWire( attempt.Nonce, attempt.Provider, attempt.RequiresMemberAccess, attempt.ReturnPath.Value, attempt.ExpiresAt, - attempt.Purpose, attempt.ExpectedAccountId, attempt.PendingLinkId, attempt.Target))); + attempt.Purpose, attempt.ExpectedAccountId, attempt.PendingLinkId, attempt.Target, attempt.Proof))); /// The live attempt a cookie value protects when it belongs to this nonce, else null. private HeimdallAttempt? Unprotect(string? cookieValue, string? nonce) => @@ -647,7 +758,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 +775,8 @@ public sealed class HeimdallAuthService( HeimdallAttemptPurpose Purpose = HeimdallAttemptPurpose.SignIn, string? ExpectedAccountId = null, string? PendingLinkId = null, - string? Target = null); + string? Target = null, + string? Proof = null); private async Task UpsertUserAccountAsync( HeimdallVerifiedIdentity identity, @@ -820,7 +932,8 @@ public sealed record HeimdallAttempt( HeimdallAttemptPurpose Purpose = HeimdallAttemptPurpose.SignIn, string? ExpectedAccountId = null, string? PendingLinkId = null, - string? Target = null); + string? Target = null, + string? Proof = null); /// /// What a held attempt is for. SignIn is the default, so a cookie written before purposes existed reads as a sign-in. @@ -835,7 +948,9 @@ public enum HeimdallAttemptPurpose LinkConfirm = 3, ListProof = 4, UnlinkProof = 5, - LinkAwaitDirect = 6 + LinkAwaitDirect = 6, + EnrolProof = 7, + UnlinkPasskeyProof = 8 } /// How a Linked accounts action ended. is its closed wire form. @@ -853,7 +968,20 @@ public enum HeimdallLinkOutcome SignInRequired, ProofUnavailable, ListExpired, - ProviderNeeded + ProviderNeeded, + EnrolReady, + EnrolNeedsSignIn, + PasskeyAdded, + PasskeyRevoked, + PasskeyLimit, + PasskeyExpired, + PasskeyFailed, + PasskeyRequired, + PasskeysUnavailable, + RateLimited, + StepUpUnavailable, + StepUpReplayed, + StepUpRateLimited } /// @@ -881,6 +1009,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 +1039,37 @@ public static class HeimdallLinkOutcomes "sign_in_required" => "Heimdall no longer accepts that sign-in. Nothing was changed; try again.", "proof_unavailable" => "Fresh proof could not be started, so nothing was changed. Unlinking needs a fresh Google sign-in, and it is not available right now.", "provider_needed" => "Bifrost does not know which logins this account holds right now, so it will not guess. Pick one you know it holds to read the list. Nothing was changed.", + "enrol_ready" => "You signed in again. Add the passkey below within five minutes.", + "enrol_needs_sign_in" => "Adding a passkey needs a fresh sign-in first, and none is current. Start again with \"Sign in again to add a passkey\". Nothing was added.", + "passkey_added" => "The passkey was added. It counts as strong proof after a 48 hour cooling-off; until then it cannot unlink anything.", + "passkey_revoked" => "The passkey was revoked.", + "passkey_limit" => "This account already has the most passkeys it can hold. Revoke one to add another.", + "passkey_expired" => "The passkey check expired or was already used. Nothing was changed; start the unlink again.", + "passkey_failed" => "The passkey could not be verified. Nothing was changed.", + "passkey_required" => "This account has a passkey, so unlinking needs it. Nothing was changed.", + "passkeys_unavailable" => "Passkeys are not available on this server right now. Nothing was changed.", + "rate_limited" => "Too many passkey attempts. Nothing was changed; wait a few minutes.", + "step_up_unavailable" => "Your passkey was verified, but Heimdall would not take Bifrost's proof. Nothing was changed.", + "step_up_replayed" => "Heimdall had already seen that proof. Nothing was changed; start the unlink again.", + "step_up_rate_limited" => "Heimdall is limiting proofs for now. Nothing was changed; wait a few minutes and start the unlink again.", "list_expired" => "The list was out of date. Nothing was changed; read your linked logins again.", "error" => "That could not be completed. Nothing was changed.", _ => null }; } -public sealed record HeimdallLinkStep(Uri? Next, string CookieName, string? CookieValue, HeimdallLinkOutcome? Outcome); +public sealed record HeimdallLinkStep(Uri? Next, string CookieName, string? CookieValue, HeimdallLinkOutcome? Outcome, string? EnrolTicket = null); public sealed record HeimdallConfirmLinkRequest( [property: JsonPropertyName("pendingLinkId")] string PendingLinkId); +public sealed record HeimdallStepUpRequest( + [property: JsonPropertyName("method")] string Method, + [property: JsonPropertyName("nonce")] string Nonce); + +public sealed record HeimdallStepUpResponse( + [property: JsonPropertyName("accessToken")] string? AccessToken); + public sealed record HeimdallConfirmLinkResponse( [property: JsonPropertyName("status")] string? Status); diff --git a/src/Bifrost.Web/Features/Passkeys/PasskeyCeremonies.cs b/src/Bifrost.Web/Features/Passkeys/PasskeyCeremonies.cs new file mode 100644 index 0000000..a3c1fe0 --- /dev/null +++ b/src/Bifrost.Web/Features/Passkeys/PasskeyCeremonies.cs @@ -0,0 +1,133 @@ +using Bifrost.Web.Features.Shared; + +namespace Bifrost.Web.Features.Passkeys; + +public enum CeremonyKind +{ + /// Permission to enrol one passkey, earned by a fresh Heimdall sign-in. Held by id in a cookie of the browser that signed in. + EnrolTicket, + /// The WebAuthn creation options in flight for an account. + EnrolChallenge, + /// The WebAuthn request options in flight for an account's unlink of one identity. + UnlinkChallenge, + /// The fact "this account's passkey assertion was verified for unlinking this identity". Carries no bearer and no credential. + UnlinkProof +} + +/// +/// The short-lived single-use server-side state of the passkey flows, in this process only (like the Linked accounts list): +/// a restart empties it and every flow is simply begun again. Every entry is bound to an account and a kind, and the unlink +/// kinds to the identity they are for; Take removes the entry whether or not the caller then succeeds, so a +/// challenge, a ticket or an assertion fact can be spent once. A challenge kind has one live entry per account: a newer one +/// replaces the older. A full table refuses new entries rather than grow. +/// +public sealed class PasskeyCeremonies(TimeProvider clock) +{ + public static readonly TimeSpan TicketLifetime = TimeSpan.FromMinutes(5); + public static readonly TimeSpan ChallengeLifetime = TimeSpan.FromMinutes(2); + public static readonly TimeSpan ProofLifetime = TimeSpan.FromMinutes(5); + public const int MaxLive = 2048; + + public sealed record Entry(string Id, CeremonyKind Kind, string Account, string? Subject, string? Data, DateTimeOffset ExpiresAt); + + private readonly Dictionary _held = new(StringComparer.Ordinal); + private readonly object _gate = new(); + + /// Adds an entry and returns its id, or null when the table is full. + public string? Issue(CeremonyKind kind, string account, string? subject, string? data, TimeSpan lifetime) + { + lock (_gate) + { + Purge(); + if (kind != CeremonyKind.UnlinkProof) + { + foreach (var old in _held.Values.Where(x => x.Kind == kind && x.Account == account).ToList()) + { + _held.Remove(old.Id); + } + } + + if (_held.Count >= MaxLive) + { + return null; + } + + var id = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)) + .Replace('+', '-').Replace('/', '_').TrimEnd('='); + _held[id] = new Entry(id, kind, account, subject, data, clock.GetUtcNow() + lifetime); + return id; + } + } + + /// The live entry, without spending it. null finds the account's one current entry of that kind. + public Entry? Peek(CeremonyKind kind, string account, string? subject, string? id) + { + lock (_gate) + { + Purge(); + return Find(kind, account, subject, id); + } + } + + /// The live entry, removed: it can be taken once. + public Entry? Take(CeremonyKind kind, string account, string? subject, string? id) + { + lock (_gate) + { + Purge(); + var entry = Find(kind, account, subject, id); + if (entry is not null) + { + _held.Remove(entry.Id); + } + + return entry; + } + } + + public void Discard(string? id) + { + if (id is not null) + { + lock (_gate) + { + _held.Remove(id); + } + } + } + + private Entry? Find(CeremonyKind kind, string account, string? subject, string? id) + { + var entry = id is null + ? _held.Values.FirstOrDefault(x => x.Kind == kind && x.Account == account) + : _held.GetValueOrDefault(id); + return entry is not null && entry.Kind == kind + && string.Equals(entry.Account, account, StringComparison.Ordinal) + && string.Equals(entry.Subject, subject, StringComparison.Ordinal) + ? entry + : null; + } + + private void Purge() + { + var now = clock.GetUtcNow(); + foreach (var dead in _held.Values.Where(x => x.ExpiresAt <= now).ToList()) + { + _held.Remove(dead.Id); + } + } +} + +/// +/// The one budget on the passkey ceremonies, per Heimdall account, on the shared : every +/// options request, enrolment, assertion and enrolment start spends a permit. +/// +public sealed class PasskeyRateLimit(TimeProvider clock) +{ + public const int Permits = 20; + public static readonly TimeSpan Window = TimeSpan.FromMinutes(10); + + private readonly FixedWindowBudget _budget = new(Permits, Window, 10_000, clock); + + public bool TryAcquire(string account) => _budget.Spend(account) is null; +} diff --git a/src/Bifrost.Web/Features/Passkeys/PasskeyRelyingParty.cs b/src/Bifrost.Web/Features/Passkeys/PasskeyRelyingParty.cs new file mode 100644 index 0000000..67febcd --- /dev/null +++ b/src/Bifrost.Web/Features/Passkeys/PasskeyRelyingParty.cs @@ -0,0 +1,48 @@ +using Bifrost.Web.Configuration; +using Fido2NetLib; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Features.Passkeys; + +/// +/// The relying party every passkey ceremony runs as. RP ID and origin come from and from +/// nothing the browser sends: the public base URL must be https and its host must be the expected host, otherwise passkeys +/// are unavailable (the page offers none and every ceremony endpoint refuses). A passkey is bound to the RP ID by the +/// authenticator, so a wrong or http origin would enrol keys that cannot be used or can be phished. +/// +public sealed class PasskeyRelyingParty +{ + public const string Name = "Bifrost"; + + public PasskeyRelyingParty(IOptions hostOptions, ILogger logger) + { + var host = hostOptions.Value; + if (!Uri.TryCreate(host.PublicBaseUrl, UriKind.Absolute, out var url) + || url.Scheme != Uri.UriSchemeHttps + || string.IsNullOrWhiteSpace(host.ExpectedHost) + || !string.Equals(url.Host, host.ExpectedHost, StringComparison.OrdinalIgnoreCase)) + { + logger.LogWarning("Passkeys are unavailable: Host:PublicBaseUrl must be https and its host must equal Host:ExpectedHost."); + return; + } + + Origin = url.GetLeftPart(UriPartial.Authority); + RpId = url.Host.ToLowerInvariant(); + Fido2 = new Fido2(new Fido2Configuration + { + RPID = RpId, + RPName = Name, + Origins = new HashSet([Origin], StringComparer.Ordinal), + ChallengeSize = 32, + Timeout = 120_000 + }); + } + + public bool IsAvailable => Fido2 is not null; + + public string? Origin { get; } + + public string? RpId { get; } + + public IFido2? Fido2 { get; } +} diff --git a/src/Bifrost.Web/Features/Passkeys/PasskeyService.cs b/src/Bifrost.Web/Features/Passkeys/PasskeyService.cs new file mode 100644 index 0000000..cc6b52b --- /dev/null +++ b/src/Bifrost.Web/Features/Passkeys/PasskeyService.cs @@ -0,0 +1,435 @@ +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Heimdall; +using Fido2NetLib; +using Fido2NetLib.Objects; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.EntityFrameworkCore; + +namespace Bifrost.Web.Features.Passkeys; + +/// One passkey as the Linked accounts page shows it: a label, its cooling-off state, never a key. +public sealed record PasskeyView(Guid Id, string Label, DateTimeOffset CreatedUtc, DateTimeOffset StrongFromUtc, DateTimeOffset? LastUsedUtc, bool IsStrong); + +/// The base64url fields a browser posts after navigator.credentials.get (an assertion) or .create (an enrolment). +public sealed record PasskeyFields( + string? Id, string? RawId, string? AuthenticatorData, string? ClientDataJson, string? Signature, string? UserHandle, + string? AttestationObject, string? Transports); + +/// +/// Passkeys for accounts that have a Heimdall account id (docs/passkeys.md). Enrolment needs a ticket earned by a fresh +/// Heimdall sign-in and starts a credential in a 48 hour cooling-off; an assertion for an unlink needs a credential that is +/// the session's own, not revoked and past its cooling-off, and records a single-use fact for that account and identity. +/// The WebAuthn parsing and verification (CBOR, COSE, signatures, origin, RP ID, challenge, user verification, sign count) +/// is Fido2NetLib's; this class decides who may begin, what is bound to what, and what is kept. Outcomes are the closed set +/// of the page words itself. +/// +public sealed class PasskeyService( + BifrostDbContext db, + PasskeyRelyingParty relyingParty, + PasskeyCeremonies ceremonies, + PasskeyRateLimit rateLimit, + TimeProvider clock, + ILogger logger) +{ + public const int MaxActive = 5; + public const int MaxLabel = 40; + public static readonly TimeSpan CoolingOff = TimeSpan.FromHours(48); + + private static readonly HashSet TransportNames = new(StringComparer.Ordinal) + { + "usb", "nfc", "ble", "internal", "hybrid", "smart-card" + }; + + public bool IsAvailable => relyingParty.IsAvailable; + + private Task AccountAsync(string heimdallAccountId, CancellationToken ct) => + string.IsNullOrEmpty(heimdallAccountId) + ? Task.FromResult(null) + : db.UserAccounts.SingleOrDefaultAsync(x => x.HeimdallAccountId == heimdallAccountId, ct); + + private IQueryable Active(Guid userId) => + db.PasskeyCredentials.Where(x => x.UserAccountId == userId && x.RevokedUtc == null); + + public async Task> ListAsync(string heimdallAccountId, CancellationToken ct) + { + if (await AccountAsync(heimdallAccountId, ct) is not { } user) + { + return []; + } + + var now = clock.GetUtcNow(); + var rows = await Active(user.Id).ToListAsync(ct); + return rows.OrderBy(x => x.CreatedUtc) + .Select(x => new PasskeyView(x.Id, x.Label, x.CreatedUtc, x.StrongFromUtc, x.LastUsedUtc, x.StrongFromUtc <= now)) + .ToList(); + } + + /// Whether a fresh enrolment ticket is held by this browser for this account. + public bool HasTicket(string heimdallAccountId, string? ticketId) => + ticketId is not null && ceremonies.Peek(CeremonyKind.EnrolTicket, heimdallAccountId, null, ticketId) is not null; + + /// Why a fresh sign-in for enrolment may not even start, or null when it may. Spends a ceremony permit. + public async Task PreflightEnrolStartAsync(string heimdallAccountId, CancellationToken ct) + { + if (!relyingParty.IsAvailable) + { + return HeimdallLinkOutcome.PasskeysUnavailable; + } + + if (await AccountAsync(heimdallAccountId, ct) is not { } user) + { + return HeimdallLinkOutcome.Error; + } + + if (await Active(user.Id).CountAsync(ct) >= MaxActive) + { + return HeimdallLinkOutcome.PasskeyLimit; + } + + return rateLimit.TryAcquire(heimdallAccountId) ? null : HeimdallLinkOutcome.RateLimited; + } + + /// The creation options for one enrolment, bound to the account; needs the ticket the fresh sign-in earned. + public async Task<(HeimdallLinkOutcome? Refusal, string? OptionsJson)> BeginEnrolAsync(string heimdallAccountId, string? ticketId, CancellationToken ct) + { + if (!relyingParty.IsAvailable) + { + return (HeimdallLinkOutcome.PasskeysUnavailable, null); + } + + if (!rateLimit.TryAcquire(heimdallAccountId)) + { + return (HeimdallLinkOutcome.RateLimited, null); + } + + if (!HasTicket(heimdallAccountId, ticketId) || await AccountAsync(heimdallAccountId, ct) is not { } user) + { + return (HeimdallLinkOutcome.EnrolNeedsSignIn, null); + } + + var existing = await Active(user.Id).Select(x => x.CredentialId).ToListAsync(ct); + if (existing.Count >= MaxActive) + { + return (HeimdallLinkOutcome.PasskeyLimit, null); + } + + var options = relyingParty.Fido2!.RequestNewCredential(new RequestNewCredentialParams + { + User = new Fido2User { Id = user.Id.ToByteArray(), Name = Label(user.DisplayName, "Bifrost member"), DisplayName = Label(user.DisplayName, "Bifrost member") }, + ExcludeCredentials = existing.Select(x => new PublicKeyCredentialDescriptor(x)).ToList(), + AuthenticatorSelection = new AuthenticatorSelection + { + ResidentKey = ResidentKeyRequirement.Preferred, + UserVerification = UserVerificationRequirement.Required + }, + AttestationPreference = AttestationConveyancePreference.None + }); + var json = options.ToJson(); + return ceremonies.Issue(CeremonyKind.EnrolChallenge, heimdallAccountId, null, json, PasskeyCeremonies.ChallengeLifetime) is null + ? (HeimdallLinkOutcome.Error, null) + : (null, json); + } + + /// + /// Verifies a registration and stores the credential, cooling off for . The ticket and the + /// challenge are both spent here, whatever the outcome: a failed attempt needs a fresh sign-in and a new challenge. + /// + public async Task EnrolAsync( + string heimdallAccountId, string? ticketId, PasskeyFields fields, string? label, CancellationToken ct) + { + if (!relyingParty.IsAvailable) + { + return HeimdallLinkOutcome.PasskeysUnavailable; + } + + if (!rateLimit.TryAcquire(heimdallAccountId)) + { + return HeimdallLinkOutcome.RateLimited; + } + + var ticket = ticketId is null ? null : ceremonies.Take(CeremonyKind.EnrolTicket, heimdallAccountId, null, ticketId); + var challenge = ceremonies.Take(CeremonyKind.EnrolChallenge, heimdallAccountId, null, null); + if (ticket is null || await AccountAsync(heimdallAccountId, ct) is not { } user) + { + return HeimdallLinkOutcome.EnrolNeedsSignIn; + } + + if (challenge?.Data is null) + { + return HeimdallLinkOutcome.PasskeyExpired; + } + + 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; + } + + try + { + var registered = await relyingParty.Fido2!.MakeNewCredentialAsync(new MakeNewCredentialParams + { + AttestationResponse = new AuthenticatorAttestationRawResponse + { + Id = fields.Id, + RawId = rawId, + Type = PublicKeyCredentialType.PublicKey, + ClientExtensionResults = new AuthenticationExtensionsClientOutputs(), + Response = new AuthenticatorAttestationRawResponse.AttestationResponse + { + AttestationObject = attestation, + ClientDataJson = clientData, + Transports = Transports(fields.Transports) + } + }, + OriginalOptions = CredentialCreateOptions.FromJson(challenge.Data), + IsCredentialIdUniqueToUserCallback = async (parameters, token) => + !await db.PasskeyCredentials.AnyAsync(x => x.CredentialId == parameters.CredentialId, token) + }, ct); + + var now = clock.GetUtcNow(); + db.PasskeyCredentials.Add(new PasskeyCredential + { + UserAccountId = user.Id, + CredentialId = registered.Id, + PublicKey = registered.PublicKey, + SignCount = registered.SignCount, + Transports = string.Join(',', Transports(fields.Transports).Select(x => TransportName(x))), + AaGuid = registered.AaGuid == Guid.Empty ? null : registered.AaGuid, + Label = Label(label, "Passkey"), + CreatedUtc = now, + StrongFromUtc = now + CoolingOff + }); + await db.SaveChangesAsync(ct); + return HeimdallLinkOutcome.PasskeyAdded; + } + catch (Exception error) when (error is Fido2VerificationException or DbUpdateException or FormatException or System.Text.Json.JsonException) + { + logger.LogWarning("Passkey enrolment refused: {Reason}", error is Fido2VerificationException f ? f.Code.ToString() : error.GetType().Name); + return HeimdallLinkOutcome.PasskeyFailed; + } + } + + /// Revokes one of the session's own passkeys. Needs no strong proof; the page shows the revocation. + public async Task RevokeAsync(string heimdallAccountId, Guid credentialId, CancellationToken ct) + { + if (await AccountAsync(heimdallAccountId, ct) is not { } user) + { + return HeimdallLinkOutcome.Error; + } + + var credential = await Active(user.Id).SingleOrDefaultAsync(x => x.Id == credentialId, ct); + if (credential is null) + { + return HeimdallLinkOutcome.PasskeyFailed; + } + + credential.RevokedUtc = clock.GetUtcNow(); + await db.SaveChangesAsync(ct); + return HeimdallLinkOutcome.PasskeyRevoked; + } + + private IQueryable Strong(Guid userId) + { + var now = clock.GetUtcNow(); + return Active(userId).Where(x => x.StrongFromUtc <= now); + } + + public async Task HasStrongAsync(string heimdallAccountId, CancellationToken ct) => + await AccountAsync(heimdallAccountId, ct) is { } user && await Strong(user.Id).AnyAsync(ct); + + /// When the first cooling-off credential becomes strong, or null when none is cooling. + public async Task NextStrongAtAsync(string heimdallAccountId, CancellationToken ct) + { + if (await AccountAsync(heimdallAccountId, ct) is not { } user) + { + return null; + } + + var now = clock.GetUtcNow(); + var cooling = await Active(user.Id).Where(x => x.StrongFromUtc > now).Select(x => x.StrongFromUtc).ToListAsync(ct); + return cooling.Count == 0 ? null : cooling.Min(); + } + + /// The request options for unlinking one identity: only the account's strong passkeys may answer, with user verification. + public async Task<(HeimdallLinkOutcome? Refusal, string? OptionsJson)> BeginAssertionAsync(string heimdallAccountId, string identityId, CancellationToken ct) + { + if (!relyingParty.IsAvailable) + { + return (HeimdallLinkOutcome.PasskeysUnavailable, null); + } + + if (!rateLimit.TryAcquire(heimdallAccountId)) + { + return (HeimdallLinkOutcome.RateLimited, null); + } + + if (await AccountAsync(heimdallAccountId, ct) is not { } user) + { + return (HeimdallLinkOutcome.Error, null); + } + + var strong = await Strong(user.Id).ToListAsync(ct); + if (strong.Count == 0) + { + return (HeimdallLinkOutcome.PasskeyRequired, null); + } + + var options = relyingParty.Fido2!.GetAssertionOptions(new GetAssertionOptionsParams + { + AllowedCredentials = strong.Select(x => new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PublicKey, x.CredentialId, Transports(x.Transports))).ToList(), + UserVerification = UserVerificationRequirement.Required + }); + var json = options.ToJson(); + return ceremonies.Issue(CeremonyKind.UnlinkChallenge, heimdallAccountId, identityId, json, PasskeyCeremonies.ChallengeLifetime) is null + ? (HeimdallLinkOutcome.Error, null) + : (null, json); + } + + /// + /// Verifies an assertion made for unlinking . The challenge is spent first. On success the + /// credential's sign count and last use are recorded and a single-use fact for this account and identity is issued; its id + /// (not a credential, not a bearer) is what the fresh Heimdall sign-in's attempt carries. + /// + public async Task<(HeimdallLinkOutcome? Refusal, string? FactId)> VerifyAssertionAsync( + string heimdallAccountId, string identityId, PasskeyFields fields, CancellationToken ct) + { + if (!relyingParty.IsAvailable) + { + return (HeimdallLinkOutcome.PasskeysUnavailable, null); + } + + if (!rateLimit.TryAcquire(heimdallAccountId)) + { + return (HeimdallLinkOutcome.RateLimited, null); + } + + var challenge = ceremonies.Take(CeremonyKind.UnlinkChallenge, heimdallAccountId, identityId, null); + if (challenge?.Data is null) + { + return (HeimdallLinkOutcome.PasskeyExpired, null); + } + + 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); + } + + 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, null, PasskeyCeremonies.ProofLifetime); + return fact is null ? (HeimdallLinkOutcome.Error, null) : (null, fact); + } + + public void DiscardFact(string? factId) => ceremonies.Discard(factId); + + /// A label from a form field: trimmed, control characters dropped, bounded; the fallback when nothing is left. + private static string Label(string? value, string fallback) + { + var cleaned = new string((value ?? string.Empty).Where(c => !char.IsControl(c)).ToArray()).Trim(); + if (cleaned.Length > MaxLabel) + { + cleaned = cleaned[..MaxLabel]; + } + + return cleaned.Length == 0 ? fallback : cleaned; + } + + private static bool TryDecode(string? value, out byte[] bytes) + { + bytes = []; + if (string.IsNullOrEmpty(value) || value.Length > 16_384) + { + return false; + } + + try + { + bytes = WebEncoders.Base64UrlDecode(value); + return bytes.Length > 0; + } + catch (FormatException) + { + return false; + } + } + + private static AuthenticatorTransport[] Transports(string? csv) + { + var parsed = new List(); + foreach (var name in (csv ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Distinct()) + { + try + { + // Only names of the closed set reach the converter; one it does not know is dropped. + if (TransportNames.Contains(name)) + { + parsed.Add(System.Text.Json.JsonSerializer.Deserialize($"\"{name}\"")); + } + } + catch (System.Text.Json.JsonException) + { + } + } + + return parsed.ToArray(); + } + + private static string TransportName(AuthenticatorTransport transport) => + System.Text.Json.JsonSerializer.Serialize(transport).Trim('"'); +} diff --git a/src/Bifrost.Web/Features/Shared/BifrostCookies.cs b/src/Bifrost.Web/Features/Shared/BifrostCookies.cs index 1c099e8..0a99cf0 100644 --- a/src/Bifrost.Web/Features/Shared/BifrostCookies.cs +++ b/src/Bifrost.Web/Features/Shared/BifrostCookies.cs @@ -12,6 +12,7 @@ public static class BifrostCookies public const string Antiforgery = HostPrefix + "bifrost_antiforgery"; public const string Voter = HostPrefix + "bifrost_voter"; public const string HeimdallAttemptPrefix = HostPrefix + "bifrost_heimdall_"; + public const string PasskeyTicket = HostPrefix + "bifrost_passkey_ticket"; public const string Path = "/"; } diff --git a/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml index c27ccc6..123357d 100644 --- a/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml +++ b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml @@ -61,7 +61,20 @@ { linked @linkedAt.UtcDateTime.ToString("dd MMM yyyy") UTC } - @if (Model.CanProve) + @if (Model.HasStrongPasskey) + { +
+ + + + + + + + +
+ } + else if (Model.HasGoogle) {
@@ -76,17 +89,22 @@

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

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

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

+

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

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

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

} else { -

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

+

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

} @@ -94,6 +112,78 @@
} +

Passkeys

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

Passkeys are not available on this server right now.

+ } + else + { +

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

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

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

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

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

+ } + +

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

Link another login

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