feat: sign in with a passkey (WebAuthn, ES256, discoverable credentials) and manage your passkeys [CI]

This commit is contained in:
justme committed 2026-10-07 21:57:19 +08:00
1 parent ddb6608e32
commit 6385c1b9a3
31 files changed
+2296 -42

No files matched your search

@@ -0,0 +1,177 @@
using System.Security.Cryptography;
using System.Text;
using Fleet.Identity;
namespace MoShiro.Backend.Application;
/// <summary>注册成功后要保存的凭证。</summary>
public sealed record RegisteredCredential(byte[] CredentialId, byte[] PublicKeySpki, uint SignCount);
/// <summary>
/// 解析并校验 WebAuthn 注册(<c>navigator.credentials.create</c> 的结果)。只接受 ES256;attestation 一律当 <c>none</c> 处理:
/// 不验设备证明,信任浏览器的实现(docs/access.md)。CBOR 解析只在 Backend 里,节点不带。
/// </summary>
public static class WebAuthnRegistration
{
public static RegisteredCredential? Verify(
ReadOnlySpan<byte> attestationObject, ReadOnlySpan<byte> clientDataJson, string rpId, ReadOnlySpan<byte> expectedChallenge,
Func<string, bool> originAllowed, bool requireUserVerification)
{
if (!WebAuthnAssertion.ClientDataValid(clientDataJson, "webauthn.create", expectedChallenge, originAllowed))
{
return null;
}
try
{
if (new MiniCbor(attestationObject.ToArray()).ReadValue() is not Dictionary<object, object> attestation
|| attestation.GetValueOrDefault("authData") is not byte[] authData || authData.Length < 55)
{
return null;
}
if (!CryptographicOperations.FixedTimeEquals(authData.AsSpan(0, 32), SHA256.HashData(Encoding.UTF8.GetBytes(rpId))))
{
return null;
}
byte flags = authData[32];
const byte attestedData = 0x40;
if ((flags & WebAuthnAssertion.FlagUserPresent) == 0 || (flags & attestedData) == 0 || (requireUserVerification && (flags & WebAuthnAssertion.FlagUserVerified) == 0))
{
return null;
}
uint signCount = (uint)((authData[33] << 24) | (authData[34] << 16) | (authData[35] << 8) | authData[36]);
int idLength = (authData[53] << 8) | authData[54];
if (idLength is 0 or > 1023 || authData.Length < 55 + idLength)
{
return null;
}
byte[] credentialId = authData[55..(55 + idLength)];
var reader = new MiniCbor(authData[(55 + idLength)..]);
if (reader.ReadValue() is not Dictionary<object, object> cose
|| cose.GetValueOrDefault(1L) is not long kty || kty != 2
|| cose.GetValueOrDefault(3L) is not long alg || alg != -7
|| cose.GetValueOrDefault(-1L) is not long curve || curve != 1
|| cose.GetValueOrDefault(-2L) is not byte[] { Length: 32 } x
|| cose.GetValueOrDefault(-3L) is not byte[] { Length: 32 } y)
{
return null;
}
using ECDsa key = ECDsa.Create(new ECParameters { Curve = ECCurve.NamedCurves.nistP256, Q = new ECPoint { X = x, Y = y } });
return new RegisteredCredential(credentialId, key.ExportSubjectPublicKeyInfo(), signCount);
}
catch (Exception ex) when (ex is InvalidDataException or CryptographicException or ArgumentException or IndexOutOfRangeException)
{
return null;
}
}
}
/// <summary>够用的 CBOR 读取器(RFC 8949):整数、字节串、文本串、数组、映射、布尔和 null。输入有上限,嵌套有深度限制。</summary>
internal sealed class MiniCbor
{
private const int MaxDepth = 8;
private const int MaxItems = 64;
private readonly byte[] _data;
private int _position;
public MiniCbor(byte[] data)
{
_data = data;
}
public object? ReadValue(int depth = 0)
{
if (depth > MaxDepth || _position >= _data.Length)
{
throw new InvalidDataException("cbor too deep or truncated");
}
byte initial = _data[_position++];
int major = initial >> 5;
long argument = ReadArgument(initial & 0x1F);
switch (major)
{
case 0:
return argument;
case 1:
return -1 - argument;
case 2:
return Take(argument);
case 3:
return Encoding.UTF8.GetString(Take(argument));
case 4:
{
CheckCount(argument);
var list = new List<object?>();
for (long i = 0; i < argument; i++)
{
list.Add(ReadValue(depth + 1));
}
return list;
}
case 5:
{
CheckCount(argument);
var map = new Dictionary<object, object>();
for (long i = 0; i < argument; i++)
{
object key = ReadValue(depth + 1) ?? throw new InvalidDataException("null map key");
map[key] = ReadValue(depth + 1)!;
}
return map;
}
case 7:
return initial switch { 0xF4 => false, 0xF5 => true, 0xF6 => null, _ => throw new InvalidDataException("unsupported simple value") };
default:
throw new InvalidDataException("unsupported cbor type");
}
}
private long ReadArgument(int info)
{
if (info < 24)
{
return info;
}
int length = info switch { 24 => 1, 25 => 2, 26 => 4, 27 => 8, _ => throw new InvalidDataException("indefinite lengths are not supported") };
byte[] bytes = Take(length);
long value = 0;
foreach (byte b in bytes)
{
value = (value << 8) | b;
}
return value < 0 ? throw new InvalidDataException("argument too large") : value;
}
private byte[] Take(long length)
{
if (length < 0 || length > _data.Length - _position)
{
throw new InvalidDataException("cbor truncated");
}
byte[] result = _data[_position..(_position + (int)length)];
_position += (int)length;
return result;
}
private static void CheckCount(long count)
{
if (count > MaxItems)
{
throw new InvalidDataException("cbor collection too large");
}
}
}