using System.Security.Cryptography; using System.Text; using Fleet.Identity; namespace MoShiro.Backend.Application; /// 注册成功后要保存的凭证。 public sealed record RegisteredCredential(byte[] CredentialId, byte[] PublicKeySpki, uint SignCount); /// /// 解析并校验 WebAuthn 注册(navigator.credentials.create 的结果)。只接受 ES256;attestation 一律当 none 处理: /// 不验设备证明,信任浏览器的实现(docs/access.md)。CBOR 解析只在 Backend 里,节点不带。 /// public static class WebAuthnRegistration { public static RegisteredCredential? Verify( ReadOnlySpan attestationObject, ReadOnlySpan clientDataJson, string rpId, ReadOnlySpan expectedChallenge, Func originAllowed, bool requireUserVerification) { if (!WebAuthnAssertion.ClientDataValid(clientDataJson, "webauthn.create", expectedChallenge, originAllowed)) { return null; } try { if (new MiniCbor(attestationObject.ToArray()).ReadValue() is not Dictionary 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 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; } } } /// 够用的 CBOR 读取器(RFC 8949):整数、字节串、文本串、数组、映射、布尔和 null。输入有上限,嵌套有深度限制。 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(); for (long i = 0; i < argument; i++) { list.Add(ReadValue(depth + 1)); } return list; } case 5: { CheckCount(argument); var map = new Dictionary(); 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"); } } }