Bifrost/src/Bifrost.Web/Features/Passkeys/PasskeyService.cs
Heimdall 425af74500 Passkeys: WebAuthn enrolment under a fresh sign-in, 48 h cooling-off, assertion proof at unlink (#85)
Delta Soul clean at 73f97ed (no blocking findings; one Low: challenge check and spend not atomic).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 00:37:56 +00:00

470 lines
21 KiB
C#

using System.Globalization;
using System.Text;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Heimdall;
using Fido2NetLib;
using Fido2NetLib.Objects;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore;
namespace Bifrost.Web.Features.Passkeys;
/// <summary>One passkey as the Linked accounts page shows it: a label, its cooling-off state, never a key.</summary>
public sealed record PasskeyView(Guid Id, string Label, DateTimeOffset CreatedUtc, DateTimeOffset StrongFromUtc, DateTimeOffset? LastUsedUtc, bool IsStrong);
/// <summary>The base64url fields a browser posts after navigator.credentials.get (an assertion) or .create (an enrolment).</summary>
public sealed record PasskeyFields(
string? Id, string? RawId, string? AuthenticatorData, string? ClientDataJson, string? Signature, string? UserHandle,
string? AttestationObject, string? Transports);
/// <summary>
/// Passkeys for accounts that have a Heimdall account id (docs/passkeys.md). Enrolment needs a ticket earned by a fresh
/// Heimdall sign-in and starts a credential in a 48 hour cooling-off; an assertion for an unlink needs a credential that is
/// the session's own, not revoked and past its cooling-off, and records a single-use fact for that account and identity.
/// The WebAuthn parsing and verification (CBOR, COSE, signatures, origin, RP ID, challenge, user verification, sign count)
/// is Fido2NetLib's; this class decides who may begin, what is bound to what, and what is kept. Outcomes are the closed set
/// of <see cref="HeimdallLinkOutcome"/> the page words itself.
/// </summary>
public sealed class PasskeyService(
BifrostDbContext db,
PasskeyRelyingParty relyingParty,
PasskeyCeremonies ceremonies,
PasskeyRateLimit rateLimit,
TimeProvider clock,
ILogger<PasskeyService> logger)
{
public const int MaxActive = 5;
public const int MaxLabel = 40;
public static readonly TimeSpan CoolingOff = TimeSpan.FromHours(48);
private static readonly HashSet<string> TransportNames = new(StringComparer.Ordinal)
{
"usb", "nfc", "ble", "internal", "hybrid", "smart-card"
};
public bool IsAvailable => relyingParty.IsAvailable;
private Task<UserAccount?> AccountAsync(string heimdallAccountId, CancellationToken ct) =>
string.IsNullOrEmpty(heimdallAccountId)
? Task.FromResult<UserAccount?>(null)
: db.UserAccounts.SingleOrDefaultAsync(x => x.HeimdallAccountId == heimdallAccountId, ct);
private IQueryable<PasskeyCredential> Active(Guid userId) =>
db.PasskeyCredentials.Where(x => x.UserAccountId == userId && x.RevokedUtc == null);
public async Task<IReadOnlyList<PasskeyView>> ListAsync(string heimdallAccountId, CancellationToken ct)
{
if (await AccountAsync(heimdallAccountId, ct) is not { } user)
{
return [];
}
var now = clock.GetUtcNow();
var rows = await Active(user.Id).ToListAsync(ct);
return rows.OrderBy(x => x.CreatedUtc)
.Select(x => new PasskeyView(x.Id, x.Label, x.CreatedUtc, x.StrongFromUtc, x.LastUsedUtc, x.StrongFromUtc <= now))
.ToList();
}
/// <summary>Whether a fresh enrolment ticket is held by this browser for this account.</summary>
public bool HasTicket(string heimdallAccountId, string? ticketId) =>
ticketId is not null && ceremonies.Peek(CeremonyKind.EnrolTicket, heimdallAccountId, null, ticketId) is not null;
/// <summary>Why a fresh sign-in for enrolment may not even start, or null when it may. Spends a ceremony permit.</summary>
public async Task<HeimdallLinkOutcome?> PreflightEnrolStartAsync(string heimdallAccountId, CancellationToken ct)
{
if (!relyingParty.IsAvailable)
{
return HeimdallLinkOutcome.PasskeysUnavailable;
}
if (await AccountAsync(heimdallAccountId, ct) is not { } user)
{
return HeimdallLinkOutcome.Error;
}
if (await Active(user.Id).CountAsync(ct) >= MaxActive)
{
return HeimdallLinkOutcome.PasskeyLimit;
}
return rateLimit.TryAcquire(heimdallAccountId) ? null : HeimdallLinkOutcome.RateLimited;
}
/// <summary>The creation options for one enrolment, bound to the account; needs the ticket the fresh sign-in earned.</summary>
public async Task<(HeimdallLinkOutcome? Refusal, string? OptionsJson)> BeginEnrolAsync(string heimdallAccountId, string? ticketId, CancellationToken ct)
{
if (!relyingParty.IsAvailable)
{
return (HeimdallLinkOutcome.PasskeysUnavailable, null);
}
if (!HasTicket(heimdallAccountId, ticketId) || await AccountAsync(heimdallAccountId, ct) is not { } user)
{
return (HeimdallLinkOutcome.EnrolNeedsSignIn, null);
}
if (!rateLimit.TryAcquire(heimdallAccountId))
{
return (HeimdallLinkOutcome.RateLimited, null);
}
var existing = await Active(user.Id).Select(x => x.CredentialId).ToListAsync(ct);
if (existing.Count >= MaxActive)
{
return (HeimdallLinkOutcome.PasskeyLimit, null);
}
var options = relyingParty.Fido2!.RequestNewCredential(new RequestNewCredentialParams
{
User = new Fido2User { Id = user.Id.ToByteArray(), Name = Label(user.DisplayName, "Bifrost member"), DisplayName = Label(user.DisplayName, "Bifrost member") },
ExcludeCredentials = existing.Select(x => new PublicKeyCredentialDescriptor(x)).ToList(),
AuthenticatorSelection = new AuthenticatorSelection
{
ResidentKey = ResidentKeyRequirement.Preferred,
UserVerification = UserVerificationRequirement.Required
},
AttestationPreference = AttestationConveyancePreference.None
});
var json = options.ToJson();
return ceremonies.Issue(CeremonyKind.EnrolChallenge, heimdallAccountId, null, json, PasskeyCeremonies.ChallengeLifetime) is null
? (HeimdallLinkOutcome.Error, null)
: (null, json);
}
/// <summary>
/// Verifies a registration and stores the credential, cooling off for <see cref="CoolingOff"/>. The ticket and the
/// challenge are both spent here, whatever the outcome: a failed attempt needs a fresh sign-in and a new challenge.
/// </summary>
public async Task<HeimdallLinkOutcome> EnrolAsync(
string heimdallAccountId, string? ticketId, PasskeyFields fields, string? label, CancellationToken ct)
{
if (!relyingParty.IsAvailable)
{
return HeimdallLinkOutcome.PasskeysUnavailable;
}
// The caller's own preconditions first, spending nothing: someone with only the session has no ticket, so junk from them
// can neither burn the member's ticket and challenge nor spend the verification permits.
if (!HasTicket(heimdallAccountId, ticketId) || await AccountAsync(heimdallAccountId, ct) is not { } user)
{
return HeimdallLinkOutcome.EnrolNeedsSignIn;
}
if (await Active(user.Id).CountAsync(ct) >= MaxActive)
{
return HeimdallLinkOutcome.PasskeyLimit;
}
if (!TryDecode(fields.RawId, out var rawId) || !TryDecode(fields.AttestationObject, out var attestation)
|| !TryDecode(fields.ClientDataJson, out var clientData) || fields.Id is null)
{
return HeimdallLinkOutcome.PasskeyFailed;
}
if (!rateLimit.TryAcquire(heimdallAccountId, verification: true))
{
return HeimdallLinkOutcome.RateLimited;
}
// Now both are spent, whatever the outcome.
var ticket = ceremonies.Take(CeremonyKind.EnrolTicket, heimdallAccountId, null, ticketId);
var challenge = ceremonies.Take(CeremonyKind.EnrolChallenge, heimdallAccountId, null, null);
if (ticket is null)
{
return HeimdallLinkOutcome.EnrolNeedsSignIn;
}
if (challenge?.Data is null)
{
return HeimdallLinkOutcome.PasskeyExpired;
}
try
{
var registered = await relyingParty.Fido2!.MakeNewCredentialAsync(new MakeNewCredentialParams
{
AttestationResponse = new AuthenticatorAttestationRawResponse
{
Id = fields.Id,
RawId = rawId,
Type = PublicKeyCredentialType.PublicKey,
ClientExtensionResults = new AuthenticationExtensionsClientOutputs(),
Response = new AuthenticatorAttestationRawResponse.AttestationResponse
{
AttestationObject = attestation,
ClientDataJson = clientData,
Transports = Transports(fields.Transports)
}
},
OriginalOptions = CredentialCreateOptions.FromJson(challenge.Data),
IsCredentialIdUniqueToUserCallback = async (parameters, token) =>
!await db.PasskeyCredentials.AnyAsync(x => x.CredentialId == parameters.CredentialId, token)
}, ct);
var now = clock.GetUtcNow();
db.PasskeyCredentials.Add(new PasskeyCredential
{
UserAccountId = user.Id,
CredentialId = registered.Id,
PublicKey = registered.PublicKey,
SignCount = registered.SignCount,
Transports = string.Join(',', Transports(fields.Transports).Select(x => TransportName(x))),
AaGuid = registered.AaGuid == Guid.Empty ? null : registered.AaGuid,
Label = Label(label, "Passkey"),
CreatedUtc = now,
StrongFromUtc = now + CoolingOff
});
await db.SaveChangesAsync(ct);
return HeimdallLinkOutcome.PasskeyAdded;
}
catch (Exception error) when (error is Fido2VerificationException or DbUpdateException or FormatException or System.Text.Json.JsonException)
{
logger.LogWarning("Passkey enrolment refused: {Reason}", error is Fido2VerificationException f ? f.Code.ToString() : error.GetType().Name);
return HeimdallLinkOutcome.PasskeyFailed;
}
}
/// <summary>Revokes one of the session's own passkeys. Needs no strong proof; the page shows the revocation.</summary>
public async Task<HeimdallLinkOutcome> RevokeAsync(string heimdallAccountId, Guid credentialId, CancellationToken ct)
{
if (await AccountAsync(heimdallAccountId, ct) is not { } user)
{
return HeimdallLinkOutcome.Error;
}
var credential = await Active(user.Id).SingleOrDefaultAsync(x => x.Id == credentialId, ct);
if (credential is null)
{
return HeimdallLinkOutcome.PasskeyFailed;
}
credential.RevokedUtc = clock.GetUtcNow();
await db.SaveChangesAsync(ct);
return HeimdallLinkOutcome.PasskeyRevoked;
}
private IQueryable<PasskeyCredential> Strong(Guid userId)
{
var now = clock.GetUtcNow();
return Active(userId).Where(x => x.StrongFromUtc <= now);
}
public async Task<bool> HasStrongAsync(string heimdallAccountId, CancellationToken ct) =>
await AccountAsync(heimdallAccountId, ct) is { } user && await Strong(user.Id).AnyAsync(ct);
/// <summary>When the first cooling-off credential becomes strong, or null when none is cooling.</summary>
public async Task<DateTimeOffset?> NextStrongAtAsync(string heimdallAccountId, CancellationToken ct)
{
if (await AccountAsync(heimdallAccountId, ct) is not { } user)
{
return null;
}
var now = clock.GetUtcNow();
var cooling = await Active(user.Id).Where(x => x.StrongFromUtc > now).Select(x => x.StrongFromUtc).ToListAsync(ct);
return cooling.Count == 0 ? null : cooling.Min();
}
/// <summary>The request options for unlinking one identity: only the account's strong passkeys may answer, with user verification.</summary>
public async Task<(HeimdallLinkOutcome? Refusal, string? OptionsJson)> BeginAssertionAsync(string heimdallAccountId, string identityId, CancellationToken ct)
{
if (!relyingParty.IsAvailable)
{
return (HeimdallLinkOutcome.PasskeysUnavailable, null);
}
if (!rateLimit.TryAcquire(heimdallAccountId))
{
return (HeimdallLinkOutcome.RateLimited, null);
}
if (await AccountAsync(heimdallAccountId, ct) is not { } user)
{
return (HeimdallLinkOutcome.Error, null);
}
var strong = await Strong(user.Id).ToListAsync(ct);
if (strong.Count == 0)
{
return (HeimdallLinkOutcome.PasskeyRequired, null);
}
var options = relyingParty.Fido2!.GetAssertionOptions(new GetAssertionOptionsParams
{
AllowedCredentials = strong.Select(x => new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PublicKey, x.CredentialId, Transports(x.Transports))).ToList(),
UserVerification = UserVerificationRequirement.Required
});
var json = options.ToJson();
return ceremonies.Issue(CeremonyKind.UnlinkChallenge, heimdallAccountId, identityId, json, PasskeyCeremonies.ChallengeLifetime) is null
? (HeimdallLinkOutcome.Error, null)
: (null, json);
}
/// <summary>
/// Verifies an assertion made for unlinking <paramref name="identityId"/>. The challenge is spent first. On success the
/// credential's sign count and last use are recorded and a single-use fact for this account and identity is issued; its id
/// (not a credential, not a bearer) is what the fresh Heimdall sign-in's attempt carries.
/// </summary>
public async Task<(HeimdallLinkOutcome? Refusal, string? FactId)> VerifyAssertionAsync(
string heimdallAccountId, string identityId, PasskeyFields fields, CancellationToken ct)
{
if (!relyingParty.IsAvailable)
{
return (HeimdallLinkOutcome.PasskeysUnavailable, null);
}
// The caller's own preconditions first, spending nothing: a decodable assertion naming one of this account's strong,
// un-revoked credentials. Junk from someone holding only the session fails here and burns neither the member's challenge
// nor the verification permits.
if (await AccountAsync(heimdallAccountId, ct) is not { } user
|| !TryDecode(fields.RawId, out var rawId) || !TryDecode(fields.AuthenticatorData, out var authenticatorData)
|| !TryDecode(fields.ClientDataJson, out var clientData) || !TryDecode(fields.Signature, out var signature)
|| fields.Id is null)
{
return (HeimdallLinkOutcome.PasskeyFailed, null);
}
byte[]? userHandle = null;
if (!string.IsNullOrEmpty(fields.UserHandle) && !TryDecode(fields.UserHandle, out userHandle))
{
return (HeimdallLinkOutcome.PasskeyFailed, null);
}
// The credential must be this session's own, not revoked and past its cooling-off. Anything else is not a strong proof.
var credential = await Strong(user.Id).SingleOrDefaultAsync(x => x.CredentialId == rawId, ct);
if (credential is null)
{
return (HeimdallLinkOutcome.PasskeyFailed, null);
}
// Without a live challenge for this account and identity nothing can verify, so no permit is spent either: junk naming the
// account's own credential spends at most one permit per challenge that was asked for (the first junk then takes it).
if (ceremonies.Peek(CeremonyKind.UnlinkChallenge, heimdallAccountId, identityId, null) is null)
{
return (HeimdallLinkOutcome.PasskeyExpired, null);
}
if (!rateLimit.TryAcquire(heimdallAccountId, verification: true))
{
return (HeimdallLinkOutcome.RateLimited, null);
}
// Spent from here on, whatever the outcome.
var challenge = ceremonies.Take(CeremonyKind.UnlinkChallenge, heimdallAccountId, identityId, null);
if (challenge?.Data is null)
{
return (HeimdallLinkOutcome.PasskeyExpired, null);
}
try
{
var result = await relyingParty.Fido2!.MakeAssertionAsync(new MakeAssertionParams
{
AssertionResponse = new AuthenticatorAssertionRawResponse
{
Id = fields.Id,
RawId = rawId,
Type = PublicKeyCredentialType.PublicKey,
ClientExtensionResults = new AuthenticationExtensionsClientOutputs(),
Response = new AuthenticatorAssertionRawResponse.AssertionResponse
{
AuthenticatorData = authenticatorData,
ClientDataJson = clientData,
Signature = signature,
UserHandle = userHandle
}
},
OriginalOptions = AssertionOptions.FromJson(challenge.Data),
StoredPublicKey = credential.PublicKey,
StoredSignatureCounter = (uint)credential.SignCount,
IsUserHandleOwnerOfCredentialIdCallback = (parameters, _) =>
Task.FromResult(parameters.UserHandle.AsSpan().SequenceEqual(user.Id.ToByteArray()) && parameters.CredentialId.AsSpan().SequenceEqual(credential.CredentialId))
}, ct);
credential.SignCount = result.SignCount;
credential.LastUsedUtc = clock.GetUtcNow();
await db.SaveChangesAsync(ct);
}
catch (Exception error) when (error is Fido2VerificationException or FormatException or System.Text.Json.JsonException)
{
logger.LogWarning("Passkey assertion refused: {Reason}", error is Fido2VerificationException f ? f.Code.ToString() : error.GetType().Name);
return (HeimdallLinkOutcome.PasskeyFailed, null);
}
var fact = ceremonies.Issue(CeremonyKind.UnlinkProof, heimdallAccountId, identityId, credential.Id.ToString(), PasskeyCeremonies.ProofLifetime);
return fact is null ? (HeimdallLinkOutcome.Error, null) : (null, fact);
}
public void DiscardFact(string? factId) => ceremonies.Discard(factId);
/// <summary>
/// Whether the credential an assertion fact was made with is still this account's own, un-revoked and strong. Asked in the
/// completion, before Heimdall is: a passkey revoked between the assertion and the completion no longer proves anything.
/// </summary>
public async Task<bool> StillStrongAsync(string heimdallAccountId, string? credentialId, CancellationToken ct) =>
Guid.TryParse(credentialId, out var id) && await AccountAsync(heimdallAccountId, ct) is { } user
&& await Strong(user.Id).AnyAsync(x => x.Id == id, ct);
/// <summary>A label from a form field: trimmed, control characters dropped, bounded; the fallback when nothing is left.</summary>
private static string Label(string? value, string fallback)
{
// By rune and by text element, never by UTF-16 unit: a cut inside a surrogate pair is not valid text and Postgres refuses it.
// Control and format characters (bidi overrides, joiners, zero-width marks) are dropped; a lone surrogate becomes U+FFFD.
var runes = (value ?? string.Empty).EnumerateRunes()
.Where(r => Rune.GetUnicodeCategory(r) is not (UnicodeCategory.Control or UnicodeCategory.Format))
.Take(MaxLabel * 4);
var cleaned = string.Concat(runes.Select(r => r.ToString())).Trim();
var elements = new StringInfo(cleaned);
if (elements.LengthInTextElements > MaxLabel)
{
cleaned = elements.SubstringByTextElements(0, MaxLabel);
}
return cleaned.Length == 0 ? fallback : cleaned;
}
private static bool TryDecode(string? value, out byte[] bytes)
{
bytes = [];
if (string.IsNullOrEmpty(value) || value.Length > 16_384)
{
return false;
}
try
{
bytes = WebEncoders.Base64UrlDecode(value);
return bytes.Length > 0;
}
catch (FormatException)
{
return false;
}
}
private static AuthenticatorTransport[] Transports(string? csv)
{
var parsed = new List<AuthenticatorTransport>();
foreach (var name in (csv ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Distinct())
{
try
{
// Only names of the closed set reach the converter; one it does not know is dropped.
if (TransportNames.Contains(name))
{
parsed.Add(System.Text.Json.JsonSerializer.Deserialize<AuthenticatorTransport>($"\"{name}\""));
}
}
catch (System.Text.Json.JsonException)
{
}
}
return parsed.ToArray();
}
private static string TransportName(AuthenticatorTransport transport) =>
System.Text.Json.JsonSerializer.Serialize(transport).Trim('"');
}