Files
MoShiro/backend/src/MoShiro.Backend.Application/Auth/PasskeyService.cs
T
justme e014a19554
ci/woodpecker/push/main-amd64-build Pipeline was successful
docs: reorganize into design, security, api, ops and dev; drop stale designs; refresh the diagrams [CI]
- Group docs by category with an index (docs/README.md); fix every link,
  code comment and the protocol conformance test that read docs/protocol.md.
- access.md and security.md rewritten to what is implemented: removed the
  unadopted member-proof chain, roles and recovery key that do not exist,
  the old implementation order and the budgets; gaps now carry a status.
- enrollment, deployment, api: the install flow is the static, hash-checked
  script with environment variables; no default admin.
- README and architecture: new topology and update-flow diagrams (operator
  passkey, CI signing, registry feed, auth and trust modules).
2026-10-08 19:37:56 +08:00

217 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string> Transports, long CreatedAtMs, long? LastUsedAtMs);
/// <summary>这次请求的 WebAuthn 依赖方:<c>RpId</c> 是域名,<c>Origins</c> 是浏览器会填进 <c>clientDataJSON.origin</c> 的来源。</summary>
public sealed record PasskeyContext(string RpId, IReadOnlySet<string> Origins);
public sealed record CreationOptions(string Challenge, string RpId, string RpName, string UserId, string UserName, IReadOnlyList<string> ExcludeCredentials, int TimeoutMs);
public sealed record RequestOptions(string Challenge, string RpId, int TimeoutMs, IReadOnlyList<string>? AllowCredentials = null);
public sealed record PasskeyRegistration(string Name, string CredentialId, string AttestationObject, string ClientDataJson, IReadOnlyList<string>? Transports);
public sealed record PasskeyAssertion(string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature);
/// <summary>
/// passkey 的注册与登录(WebAuthn,ES256,attestation 当 none)。挑战只在内存里,一次有效、5 分钟过期;
/// 登录是「可发现凭证」:不需要先输用户名,凭证 id 找到用户。验证断言的纯函数在 <see cref="WebAuthnAssertion"/>(节点以后也用)。
/// 设计见 docs/security/access.md 第 4 节。
/// </summary>
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<string, PendingChallenge> _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<Result<CreationOptions>> BeginRegistrationAsync(Principal principal, PasskeyContext context, CancellationToken ct)
{
if (principal.UserId is not { } userId || principal.Username is not { } username)
{
return Result<CreationOptions>.Fail(MoShiroErrors.Forbidden, "passkeys belong to a signed-in user, not to an API token");
}
IReadOnlyList<PasskeyRecord> existing = await _store.ListPasskeysAsync(userId, ct);
if (existing.Count >= MaxPasskeysPerUser)
{
return ValidationError.Fail<CreationOptions>("passkeys", $"at most {MaxPasskeysPerUser} passkeys per user");
}
string challenge = Issue(RegisterPurpose, userId);
return Result<CreationOptions>.Ok(new CreationOptions(challenge, context.RpId, "MoShiro", Base64Url.Encode(Encoding.UTF8.GetBytes(userId)), username, [.. existing.Select(p => p.Id)], 60_000));
}
public async ValueTask<Result<PasskeyRecord>> RegisterAsync(Principal principal, PasskeyRegistration request, PasskeyContext context, CancellationToken ct)
{
if (principal.UserId is not { } userId)
{
return Result<PasskeyRecord>.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<PasskeyRecord>("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<PasskeyRecord>("credential", "malformed registration");
}
// 挑战在 clientDataJSON 里:先取出来,一次有效
if (!TryTakeChallenge(clientData, RegisterPurpose, userId, out byte[] challenge))
{
return Result<PasskeyRecord>.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<PasskeyRecord>.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<PasskeyRecord>.Ok(record)
: Result<PasskeyRecord>.Fail(MoShiroErrors.NameConflict, "this passkey is already registered");
}
public RequestOptions BeginLogin(PasskeyContext context) => new(Issue(LoginPurpose, null), context.RpId, 60_000);
/// <summary>验证断言并建立会话。失败一律 unauthorized,不区分原因。</summary>
public async ValueTask<Result<LoginResult>> LoginAsync(PasskeyAssertion request, PasskeyContext context, CancellationToken ct)
{
PasskeyRecord? passkey = await VerifyAsync(request, context, LoginPurpose, null, ct);
return passkey is null
? Result<LoginResult>.Fail(MoShiroErrors.Unauthorized, "invalid credentials")
: await _auth.CreateSessionForAsync(passkey.UserId, AuthMethods.Passkey, ct);
}
/// <summary>验证一份断言:挑战(一次有效、种类和用户对得上)、签名、计数器;通过则记下使用并返回那个 passkey。</summary>
private async ValueTask<PasskeyRecord?> 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<IReadOnlyList<PasskeyRecord>> ListAsync(string userId, CancellationToken ct) => _store.ListPasskeysAsync(userId, ct);
public ValueTask<bool> 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;
}
/// <summary>从 clientDataJSON 取出 challenge,按它找到并移除(一次有效)。</summary>
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";
}