Files
server/test/Identity.IntegrationTest/RequestValidation/VaultAccess/FakeWebAuthnAuthenticator.cs
Ike 90f451bc60 [PM-33417] WebAuthn cache (#7500)
feat: Add WebAuthn Cache
  - Add IWebAuthnChallengeCacheProvider with distributed cache implementation for storing WebAuthn Challenges
  - Inject the cache provider into AssertWebAuthnLoginCredentialCommand and WebAuthnGrantValidator so challenges can be stored
  - Use a static token lifetime for WebAuthnLoginAssertionOptionsTokenable and enable nullable reference types on the tokenable
  - Add unit tests for the cache provider, the assertion command, and the WebAuthn controller; add Identity integration and unit tests for WebAuthnGrantValidator with a FakeWebAuthnAuthenticator helper
2026-04-30 09:21:03 -04:00

110 lines
4.3 KiB
C#

using System.Buffers.Binary;
using System.Formats.Cbor;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Fido2NetLib;
using Fido2NetLib.Objects;
namespace Bit.Identity.IntegrationTest.RequestValidation.VaultAccess;
/// <summary>
/// Minimal in-memory WebAuthn authenticator for integration tests. Generates valid
/// ECDSA P-256 assertions that pass Fido2NetLib verification end-to-end.
/// </summary>
internal sealed class FakeWebAuthnAuthenticator : IDisposable
{
private readonly ECDsa _keyPair;
public byte[] CredentialId { get; } = RandomNumberGenerator.GetBytes(32);
public uint SignatureCounter { get; private set; }
public FakeWebAuthnAuthenticator()
{
_keyPair = ECDsa.Create(ECCurve.NamedCurves.nistP256);
}
/// <summary>
/// Returns the credential's public key as a COSE_Key CBOR map (what Fido2NetLib expects
/// to see in the server-stored public key blob).
/// </summary>
public byte[] GetCosePublicKey()
{
var parameters = _keyPair.ExportParameters(includePrivateParameters: false);
var writer = new CborWriter(CborConformanceMode.Ctap2Canonical);
writer.WriteStartMap(5);
// Per CTAP2 canonical ordering: keys sorted ascending as signed integers, with
// non-negative keys before negative keys.
writer.WriteInt32(1); writer.WriteInt32(2); // kty = EC2
writer.WriteInt32(3); writer.WriteInt32(-7); // alg = ES256
writer.WriteInt32(-1); writer.WriteInt32(1); // crv = P-256
writer.WriteInt32(-2); writer.WriteByteString(parameters.Q.X!);
writer.WriteInt32(-3); writer.WriteByteString(parameters.Q.Y!);
writer.WriteEndMap();
return writer.Encode();
}
/// <summary>
/// Produce a valid assertion for the given challenge and relying-party context.
/// </summary>
public AuthenticatorAssertionRawResponse MakeAssertion(
byte[] challenge,
string rpId,
string origin,
byte[] userHandle)
{
// clientDataJSON per WebAuthn spec
var clientData = new
{
type = "webauthn.get",
challenge = Base64UrlEncode(challenge),
origin,
crossOrigin = false,
};
var clientDataJson = JsonSerializer.SerializeToUtf8Bytes(clientData);
// authenticatorData: rpIdHash (32) || flags (1) || signCount (4, big-endian)
var rpIdHash = SHA256.HashData(Encoding.UTF8.GetBytes(rpId));
const byte flags = 0x05; // UP (0x01) | UV (0x04)
SignatureCounter++;
var counterBytes = new byte[4];
BinaryPrimitives.WriteUInt32BigEndian(counterBytes, SignatureCounter);
var authenticatorData = new byte[rpIdHash.Length + 1 + counterBytes.Length];
Buffer.BlockCopy(rpIdHash, 0, authenticatorData, 0, rpIdHash.Length);
authenticatorData[rpIdHash.Length] = flags;
Buffer.BlockCopy(counterBytes, 0, authenticatorData, rpIdHash.Length + 1, counterBytes.Length);
// Signature covers authenticatorData || SHA256(clientDataJson), encoded as DER
var clientDataHash = SHA256.HashData(clientDataJson);
var toSign = new byte[authenticatorData.Length + clientDataHash.Length];
Buffer.BlockCopy(authenticatorData, 0, toSign, 0, authenticatorData.Length);
Buffer.BlockCopy(clientDataHash, 0, toSign, authenticatorData.Length, clientDataHash.Length);
var signature = _keyPair.SignData(toSign, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
return new AuthenticatorAssertionRawResponse
{
Id = CredentialId,
RawId = CredentialId,
Type = PublicKeyCredentialType.PublicKey,
Extensions = new AuthenticationExtensionsClientOutputs(),
Response = new AuthenticatorAssertionRawResponse.AssertionResponse
{
AuthenticatorData = authenticatorData,
Signature = signature,
ClientDataJson = clientDataJson,
UserHandle = userHandle,
},
};
}
private static string Base64UrlEncode(byte[] input)
=> Convert.ToBase64String(input)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
public void Dispose() => _keyPair.Dispose();
}