86 lines
3.7 KiB
C#
86 lines
3.7 KiB
C#
using System.Buffers.Text;
|
||
using System.Security.Cryptography;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
|
||
namespace Fleet.Identity;
|
||
|
||
/// <summary>
|
||
/// 验证一次 WebAuthn 断言(<c>navigator.credentials.get</c> 的结果)。只支持 ES256(P-256 的 ECDSA),纯函数,只用 BCL:
|
||
/// 面板用它验证 passkey 登录,节点以后用它验证 passkey 签出的委托(docs/access.md 第 7 节)。
|
||
/// 不解析 CBOR——注册时的 attestation 解析只在 Backend 里。
|
||
/// </summary>
|
||
public static class WebAuthnAssertion
|
||
{
|
||
public const byte FlagUserPresent = 0x01;
|
||
public const byte FlagUserVerified = 0x04;
|
||
|
||
/// <param name="publicKeySpki">注册时保存的凭证公钥(SPKI)。</param>
|
||
/// <param name="expectedChallenge">这次请求里服务端或签名人规定的挑战原文(不是 base64url)。</param>
|
||
/// <param name="originAllowed">校验 <c>clientDataJSON.origin</c>。</param>
|
||
/// <param name="signCount">authenticatorData 里的签名计数器。</param>
|
||
public static bool Verify(
|
||
ReadOnlySpan<byte> publicKeySpki, ReadOnlySpan<byte> authenticatorData, ReadOnlySpan<byte> clientDataJson, ReadOnlySpan<byte> signature,
|
||
string rpId, ReadOnlySpan<byte> expectedChallenge, Func<string, bool> originAllowed, bool requireUserVerification, out uint signCount)
|
||
{
|
||
signCount = 0;
|
||
if (authenticatorData.Length < 37 || !CryptographicOperations.FixedTimeEquals(authenticatorData[..32], SHA256.HashData(Encoding.UTF8.GetBytes(rpId))))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
byte flags = authenticatorData[32];
|
||
if ((flags & FlagUserPresent) == 0 || (requireUserVerification && (flags & FlagUserVerified) == 0))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (!ClientDataValid(clientDataJson, "webauthn.get", expectedChallenge, originAllowed))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
try
|
||
{
|
||
using ECDsa key = ECDsa.Create();
|
||
key.ImportSubjectPublicKeyInfo(publicKeySpki, out _);
|
||
byte[] signed = [.. authenticatorData, .. SHA256.HashData(clientDataJson)];
|
||
if (!key.VerifyData(signed, signature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence))
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
catch (CryptographicException)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
signCount = (uint)((authenticatorData[33] << 24) | (authenticatorData[34] << 16) | (authenticatorData[35] << 8) | authenticatorData[36]);
|
||
return true;
|
||
}
|
||
|
||
/// <summary><c>clientDataJSON</c> 的 type、challenge、origin 都要对得上。注册和登录共用。</summary>
|
||
public static bool ClientDataValid(ReadOnlySpan<byte> clientDataJson, string expectedType, ReadOnlySpan<byte> expectedChallenge, Func<string, bool> originAllowed)
|
||
{
|
||
try
|
||
{
|
||
using JsonDocument document = JsonDocument.Parse(clientDataJson.ToArray());
|
||
JsonElement root = document.RootElement;
|
||
if (root.ValueKind != JsonValueKind.Object
|
||
|| root.GetProperty("type").GetString() != expectedType
|
||
|| root.GetProperty("origin").GetString() is not { } origin || !originAllowed(origin)
|
||
|| root.GetProperty("challenge").GetString() is not { } challenge)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
byte[] decoded = Base64Url.DecodeFromChars(challenge);
|
||
return CryptographicOperations.FixedTimeEquals(decoded, expectedChallenge);
|
||
}
|
||
catch (Exception ex) when (ex is JsonException or KeyNotFoundException or FormatException or InvalidOperationException)
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
}
|