Files
MoShiro/backend/src/MoShiro.Backend.Application/Auth/WebAuthnRegistration.cs
T

178 lines
6.3 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.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");
}
}
}