feat!: sensitive actions need a fresh passkey touch (elevation windows), plus login page hint cleanup [CI]

This commit is contained in:
justme committed 2026-10-07 22:29:24 +08:00
1 parent 1379b8c7cb
commit ff45ad086e
27 files changed
+1305 -58

No files matched your search

@@ -13,7 +13,7 @@ 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);
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);
@@ -33,16 +33,23 @@ public sealed class PasskeyService
private readonly IAuthStore _store;
private readonly AuthService _auth;
private readonly ElevationService _elevation;
private readonly TimeProvider _time;
private readonly object _gate = new();
private readonly Dictionary<string, PendingChallenge> _challenges = [];
private sealed record PendingChallenge(bool Registration, string? UserId, long ExpiresAtMs);
private sealed record PendingChallenge(string Purpose, string? UserId, long ExpiresAtMs);
public PasskeyService(IAuthStore store, AuthService auth, TimeProvider time)
private const string RegisterPurpose = "register";
private const string LoginPurpose = "login";
private static string ElevatePurpose(ElevationClass elevation) => "elevate:" + Elevation.IdOf(elevation);
public PasskeyService(IAuthStore store, AuthService auth, ElevationService elevation, TimeProvider time)
{
_store = store;
_auth = auth;
_elevation = elevation;
_time = time;
}
@@ -59,7 +66,7 @@ public sealed class PasskeyService
return ValidationError.Fail<CreationOptions>("passkeys", $"at most {MaxPasskeysPerUser} passkeys per user");
}
string challenge = Issue(registration: true, userId);
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));
}
@@ -82,7 +89,7 @@ public sealed class PasskeyService
}
// 挑战在 clientDataJSON 里:先取出来,一次有效
if (!TryTakeChallenge(clientData, registration: true, userId, out byte[] challenge))
if (!TryTakeChallenge(clientData, RegisterPurpose, userId, out byte[] challenge))
{
return Result<PasskeyRecord>.Fail(MoShiroErrors.ValidationFailed, "the registration challenge is unknown, expired or already used");
}
@@ -100,43 +107,82 @@ public sealed class PasskeyService
: Result<PasskeyRecord>.Fail(MoShiroErrors.NameConflict, "this passkey is already registered");
}
public RequestOptions BeginLogin(PasskeyContext context) => new(Issue(registration: false, null), context.RpId, 60_000);
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)
{
Result<LoginResult> denied = Result<LoginResult>.Fail(MoShiroErrors.Unauthorized, "invalid credentials");
if (!TryDecode(request.CredentialId, out _) || !TryDecode(request.AuthenticatorData, out byte[] authData) || !TryDecode(request.ClientDataJson, out byte[] clientData) || !TryDecode(request.Signature, out byte[] signature))
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 让浏览器只选它们。没有 passkey 的用户没法提权。</summary>
public async ValueTask<Result<RequestOptions>> BeginElevationAsync(Principal principal, ElevationClass elevation, PasskeyContext context, CancellationToken ct)
{
if (principal.UserId is not { } userId || principal.SessionId is null)
{
return denied;
return Result<RequestOptions>.Fail(MoShiroErrors.Forbidden, "only a signed-in user can elevate; API keys carry their capabilities already");
}
if (!TryTakeChallenge(clientData, registration: false, null, out byte[] challenge))
IReadOnlyList<PasskeyRecord> own = await _store.ListPasskeysAsync(userId, ct);
if (own.Count == 0)
{
return denied;
return Result<RequestOptions>.Fail(MoShiroErrors.ValidationFailed, "register a passkey first (Users, My passkeys)");
}
return Result<RequestOptions>.Ok(new RequestOptions(Issue(ElevatePurpose(elevation), userId), context.RpId, 60_000, [.. own.Select(p => p.Id)]));
}
/// <summary>验证提权的断言(必须是这个用户自己的 passkey)并给这个会话开窗口。失败一律 unauthorized。</summary>
public async ValueTask<Result<ElevationWindow>> ElevateAsync(Principal principal, ElevationClass elevation, PasskeyAssertion request, PasskeyContext context, CancellationToken ct)
{
if (principal.UserId is not { } userId || principal.SessionId is not { } sessionId)
{
return Result<ElevationWindow>.Fail(MoShiroErrors.Forbidden, "only a signed-in user can elevate; API keys carry their capabilities already");
}
PasskeyRecord? passkey = await VerifyAsync(request, context, ElevatePurpose(elevation), userId, ct);
return passkey is null || passkey.UserId != userId
? Result<ElevationWindow>.Fail(MoShiroErrors.Unauthorized, "invalid credentials")
: Result<ElevationWindow>.Ok(new ElevationWindow(elevation, _elevation.Grant(sessionId, elevation)));
}
/// <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 denied;
return null;
}
// 计数器只增不减;很多同步的 passkey 一直报 0,两边都是 0 时不检查
if ((signCount != 0 || passkey.SignCount != 0) && signCount <= passkey.SignCount)
{
return denied;
return null;
}
await _store.TouchPasskeyAsync(passkey.Id, signCount, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
return await _auth.CreateSessionForAsync(passkey.UserId, AuthMethods.Passkey, 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(bool registration, string? userId)
private string Issue(string purpose, string? userId)
{
byte[] challenge = RandomNumberGenerator.GetBytes(ChallengeBytes);
string key = Base64Url.Encode(challenge);
@@ -156,14 +202,14 @@ public sealed class PasskeyService
}
}
_challenges[key] = new PendingChallenge(registration, userId, now + (long)s_challengeLifetime.TotalMilliseconds);
_challenges[key] = new PendingChallenge(purpose, userId, now + (long)s_challengeLifetime.TotalMilliseconds);
}
return key;
}
/// <summary>从 clientDataJSON 取出 challenge,按它找到并移除(一次有效)。</summary>
private bool TryTakeChallenge(byte[] clientDataJson, bool registration, string? userId, out byte[] challenge)
private bool TryTakeChallenge(byte[] clientDataJson, string purpose, string? userId, out byte[] challenge)
{
challenge = [];
string? text;
@@ -185,7 +231,7 @@ public sealed class PasskeyService
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
lock (_gate)
{
if (!_challenges.Remove(Base64Url.Encode(raw), out PendingChallenge? pending) || pending.ExpiresAtMs <= now || pending.Registration != registration || pending.UserId != userId)
if (!_challenges.Remove(Base64Url.Encode(raw), out PendingChallenge? pending) || pending.ExpiresAtMs <= now || pending.Purpose != purpose || pending.UserId != userId)
{
return false;
}