using System.Security.Cryptography; using System.Text; using Fleet.Core; using Fleet.Identity; using MoShiro.Protocol; namespace MoShiro.Backend.Application; public sealed record PasskeyRecord(string Id, string UserId, string Name, byte[] PublicKeySpki, long SignCount, IReadOnlyList Transports, long CreatedAtMs, long? LastUsedAtMs); /// 这次请求的 WebAuthn 依赖方:RpId 是域名,Origins 是浏览器会填进 clientDataJSON.origin 的来源。 public sealed record PasskeyContext(string RpId, IReadOnlySet Origins); public sealed record CreationOptions(string Challenge, string RpId, string RpName, string UserId, string UserName, IReadOnlyList ExcludeCredentials, int TimeoutMs); public sealed record RequestOptions(string Challenge, string RpId, int TimeoutMs, IReadOnlyList? AllowCredentials = null); public sealed record PasskeyRegistration(string Name, string CredentialId, string AttestationObject, string ClientDataJson, IReadOnlyList? Transports); public sealed record PasskeyAssertion(string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature); /// /// passkey 的注册与登录(WebAuthn,ES256,attestation 当 none)。挑战只在内存里,一次有效、5 分钟过期; /// 登录是「可发现凭证」:不需要先输用户名,凭证 id 找到用户。验证断言的纯函数在 (节点以后也用)。 /// 设计见 docs/security/access.md 第 4 节。 /// public sealed class PasskeyService { private const int ChallengeBytes = 32; private const int MaxPasskeysPerUser = 10; private const int MaxPendingChallenges = 4096; private static readonly TimeSpan s_challengeLifetime = TimeSpan.FromMinutes(5); private readonly IAuthStore _store; private readonly AuthService _auth; private readonly TimeProvider _time; private readonly object _gate = new(); private readonly Dictionary _challenges = []; private sealed record PendingChallenge(string Purpose, string? UserId, long ExpiresAtMs); private const string RegisterPurpose = "register"; private const string LoginPurpose = "login"; public PasskeyService(IAuthStore store, AuthService auth, TimeProvider time) { _store = store; _auth = auth; _time = time; } public async ValueTask> BeginRegistrationAsync(Principal principal, PasskeyContext context, CancellationToken ct) { if (principal.UserId is not { } userId || principal.Username is not { } username) { return Result.Fail(MoShiroErrors.Forbidden, "passkeys belong to a signed-in user, not to an API token"); } IReadOnlyList existing = await _store.ListPasskeysAsync(userId, ct); if (existing.Count >= MaxPasskeysPerUser) { return ValidationError.Fail("passkeys", $"at most {MaxPasskeysPerUser} passkeys per user"); } string challenge = Issue(RegisterPurpose, userId); return Result.Ok(new CreationOptions(challenge, context.RpId, "MoShiro", Base64Url.Encode(Encoding.UTF8.GetBytes(userId)), username, [.. existing.Select(p => p.Id)], 60_000)); } public async ValueTask> RegisterAsync(Principal principal, PasskeyRegistration request, PasskeyContext context, CancellationToken ct) { if (principal.UserId is not { } userId) { return Result.Fail(MoShiroErrors.Forbidden, "passkeys belong to a signed-in user, not to an API token"); } string name = request.Name.Trim(); if (name.Length is 0 or > 64) { return ValidationError.Fail("name", "must be 1-64 characters"); } if (!TryDecode(request.AttestationObject, out byte[] attestation) || !TryDecode(request.ClientDataJson, out byte[] clientData) || !TryDecode(request.CredentialId, out byte[] id)) { return ValidationError.Fail("credential", "malformed registration"); } // 挑战在 clientDataJSON 里:先取出来,一次有效 if (!TryTakeChallenge(clientData, RegisterPurpose, userId, out byte[] challenge)) { return Result.Fail(MoShiroErrors.ValidationFailed, "the registration challenge is unknown, expired or already used"); } RegisteredCredential? credential = WebAuthnRegistration.Verify(attestation, clientData, context.RpId, challenge, context.Origins.Contains, requireUserVerification: true); if (credential is null || !credential.CredentialId.AsSpan().SequenceEqual(id)) { return Result.Fail(MoShiroErrors.ValidationFailed, "the registration could not be verified (ES256 with user verification is required)"); } string credentialId = Base64Url.Encode(credential.CredentialId); var record = new PasskeyRecord(credentialId, userId, name, credential.PublicKeySpki, credential.SignCount, request.Transports?.Take(8).ToArray() ?? [], _time.GetUtcNow().ToUnixTimeMilliseconds(), null); return await _store.AddPasskeyAsync(record, ct) ? Result.Ok(record) : Result.Fail(MoShiroErrors.NameConflict, "this passkey is already registered"); } public RequestOptions BeginLogin(PasskeyContext context) => new(Issue(LoginPurpose, null), context.RpId, 60_000); /// 验证断言并建立会话。失败一律 unauthorized,不区分原因。 public async ValueTask> LoginAsync(PasskeyAssertion request, PasskeyContext context, CancellationToken ct) { PasskeyRecord? passkey = await VerifyAsync(request, context, LoginPurpose, null, ct); return passkey is null ? Result.Fail(MoShiroErrors.Unauthorized, "invalid credentials") : await _auth.CreateSessionForAsync(passkey.UserId, AuthMethods.Passkey, ct); } /// 验证一份断言:挑战(一次有效、种类和用户对得上)、签名、计数器;通过则记下使用并返回那个 passkey。 private async ValueTask VerifyAsync(PasskeyAssertion request, PasskeyContext context, string purpose, string? userId, CancellationToken ct) { if (!TryDecode(request.CredentialId, out _) || !TryDecode(request.AuthenticatorData, out byte[] authData) || !TryDecode(request.ClientDataJson, out byte[] clientData) || !TryDecode(request.Signature, out byte[] signature)) { return null; } if (!TryTakeChallenge(clientData, purpose, userId, out byte[] challenge)) { return null; } PasskeyRecord? passkey = await _store.FindPasskeyAsync(request.CredentialId, ct); if (passkey is null || !WebAuthnAssertion.Verify(passkey.PublicKeySpki, authData, clientData, signature, context.RpId, challenge, context.Origins.Contains, requireUserVerification: true, out uint signCount)) { return null; } // 计数器只增不减;很多同步的 passkey 一直报 0,两边都是 0 时不检查 if ((signCount != 0 || passkey.SignCount != 0) && signCount <= passkey.SignCount) { return null; } await _store.TouchPasskeyAsync(passkey.Id, signCount, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct); return passkey; } public ValueTask> ListAsync(string userId, CancellationToken ct) => _store.ListPasskeysAsync(userId, ct); public ValueTask DeleteAsync(string userId, string passkeyId, CancellationToken ct) => _store.DeletePasskeyAsync(userId, passkeyId, ct); private string Issue(string purpose, string? userId) { byte[] challenge = RandomNumberGenerator.GetBytes(ChallengeBytes); string key = Base64Url.Encode(challenge); long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); lock (_gate) { if (_challenges.Count >= MaxPendingChallenges) { foreach (string stale in _challenges.Where(p => p.Value.ExpiresAtMs <= now).Select(p => p.Key).ToList()) { _challenges.Remove(stale); } if (_challenges.Count >= MaxPendingChallenges) { _challenges.Remove(_challenges.OrderBy(p => p.Value.ExpiresAtMs).First().Key); // 淘汰最旧的 } } _challenges[key] = new PendingChallenge(purpose, userId, now + (long)s_challengeLifetime.TotalMilliseconds); } return key; } /// 从 clientDataJSON 取出 challenge,按它找到并移除(一次有效)。 private bool TryTakeChallenge(byte[] clientDataJson, string purpose, string? userId, out byte[] challenge) { challenge = []; string? text; try { using var document = System.Text.Json.JsonDocument.Parse(clientDataJson); text = document.RootElement.GetProperty("challenge").GetString(); } catch (Exception ex) when (ex is System.Text.Json.JsonException or KeyNotFoundException or InvalidOperationException) { return false; } if (text is null || !TryDecode(text, out byte[] raw)) { return false; } long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); lock (_gate) { if (!_challenges.Remove(Base64Url.Encode(raw), out PendingChallenge? pending) || pending.ExpiresAtMs <= now || pending.Purpose != purpose || pending.UserId != userId) { return false; } } challenge = raw; return true; } private static bool TryDecode(string text, out byte[] data) => Base64Url.TryDecode(text, out data) && data.Length > 0; } public static class AuthMethods { public const string Password = "password"; public const string Passkey = "passkey"; }