using System.Buffers.Text; using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace Fleet.Identity; /// /// 验证一次 WebAuthn 断言(navigator.credentials.get 的结果)。只支持 ES256(P-256 的 ECDSA),纯函数,只用 BCL: /// 面板用它验证 passkey 登录,节点以后用它验证 passkey 签出的委托(docs/access.md 第 7 节)。 /// 不解析 CBOR——注册时的 attestation 解析只在 Backend 里。 /// public static class WebAuthnAssertion { public const byte FlagUserPresent = 0x01; public const byte FlagUserVerified = 0x04; /// 注册时保存的凭证公钥(SPKI)。 /// 这次请求里服务端或签名人规定的挑战原文(不是 base64url)。 /// 校验 clientDataJSON.origin。 /// authenticatorData 里的签名计数器。 public static bool Verify( ReadOnlySpan publicKeySpki, ReadOnlySpan authenticatorData, ReadOnlySpan clientDataJson, ReadOnlySpan signature, string rpId, ReadOnlySpan expectedChallenge, Func 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; } /// clientDataJSON 的 type、challenge、origin 都要对得上。注册和登录共用。 public static bool ClientDataValid(ReadOnlySpan clientDataJson, string expectedType, ReadOnlySpan expectedChallenge, Func 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; } } }