ci/woodpecker/push/main-amd64-build Pipeline was successful
- Group docs by category with an index (docs/README.md); fix every link, code comment and the protocol conformance test that read docs/protocol.md. - access.md and security.md rewritten to what is implemented: removed the unadopted member-proof chain, roles and recovery key that do not exist, the old implementation order and the budgets; gaps now carry a status. - enrollment, deployment, api: the install flow is the static, hash-checked script with environment variables; no default admin. - README and architecture: new topology and update-flow diagrams (operator passkey, CI signing, registry feed, auth and trust modules).
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 登录,节点验证操作员集合的签名也用它(docs/security/access.md 第 8 节)。
|
||
/// 不解析 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;
|
||
}
|
||
}
|
||
}
|