ci/woodpecker/push/main-amd64-build Pipeline was successful
- 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).
217 lines
10 KiB
C#
217 lines
10 KiB
C#
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";
|
||
}
|