feat: sign in with a passkey (WebAuthn, ES256, discoverable credentials) and manage your passkeys [CI]
This commit is contained in:
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user