using CloudBuilder.Security.Data; using Fido2NetLib; using Fido2NetLib.Objects; using Microsoft.Extensions.Options; using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace CloudBuilder.Security.Service { /// /// Wraps the Fido2NetLib ceremony primitives (challenge generation, /// attestation verification, assertion verification) so the process /// layer can stay focused on the workflow. The frontend drives both /// registration and login through this service via webmethods on /// UserProfileProcess. /// public class WebAuthnService: IWebAuthnService { private readonly Fido2 _fido2; private readonly WebAuthnOptions _options; public WebAuthnService(IOptions options) { _options = options.Value; var config = new Fido2.Configuration { ServerDomain = _options.RpId, ServerName = _options.RpName, Origin = _options.Origin, // Fido2NetLib expresses the challenge length in BYTES here. // 32 bytes = 256 bits, the WebAuthn-recommended minimum. ChallengeSize = 32, Timeout = (uint)_options.TimeoutMs }; _fido2 = new Fido2(config); } public string Origin => _options.Origin; public string RpId => _options.RpId; /// /// Build the PublicKeyCredentialCreationOptions that the browser /// will hand to navigator.credentials.create(). /// /// HR+ username (becomes the WebAuthn user.name + user.displayName). /// Stable per-user byte array. We use the username's UTF-8 bytes — fine for v1 because usernames are unique. /// If the user is re-enrolling on a new device, the existing credential ID is passed via excludeCredentials so the authenticator refuses to re-enroll the same one. For v1 we don't track this (one-device-only); pass an empty list. public CredentialCreateOptions BuildCreationOptions(string username, byte[] userId, IReadOnlyList existingCredentialIds) { var user = new User { Name = username, Id = userId, DisplayName = username }; var exclude = existingCredentialIds .Select(id => new PublicKeyCredentialDescriptor { Id = id }) .ToList(); return _fido2.RequestNewCredential( user, exclude, new AuthenticatorSelection { // Prefer the platform authenticator (Touch ID / Windows Hello / // fingerprint) over roaming keys. AuthenticatorAttachment = AuthenticatorAttachment.Platform, UserVerification = UserVerificationRequirement.Preferred }, AttestationConveyancePreference.None); } /// /// Verify the attestation returned by the browser and return the /// stored credential fields. Throws on validation failure. /// public async Task FinishRegistrationAsync( AuthenticatorAttestationRawResponse attestationResponse, CredentialCreateOptions originalOptions, Func> isCredentialIdUnique) { // Fido2NetLib's MakeNewCredentialAsync takes a delegate that, given // the new credential id, decides whether the credential is unique // for the user. In our schema the credential id is stored on // SecurityUser.biometric_credential_id, so uniqueness is automatic // — the same credential id can never be assigned to two users. // We always return true. var makeResult = await _fido2.MakeNewCredentialAsync( attestationResponse, originalOptions, (args) => Task.FromResult(true), requestTokenBindingId: null); if (makeResult?.Result is not AttestationVerificationSuccess success) { throw new InvalidOperationException( "Fido2 attestation verification failed: " + (makeResult?.ErrorMessage ?? "unknown error")); } return new StoredBiometricCredential { CredentialId = success.CredentialId, PublicKey = success.PublicKey }; } /// /// Build the PublicKeyCredentialRequestOptions that the browser will /// hand to navigator.credentials.get(). Only the stored /// credential for this user is allowed. /// public AssertionOptions BuildRequestOptions(byte[] storedCredentialId) { var allowed = new List { new() { Id = storedCredentialId } }; return _fido2.GetAssertionOptions(allowed, UserVerificationRequirement.Preferred); } /// /// Same as but returns a /// browser-ready JSON string directly. /// /// Fido2NetLib 1.0.0-alpha's AssertionOptions.ToJson() serializes /// AllowCredentials[].Id as PascalCase (the C# property is /// Id with no [JsonProperty("id")] attribute — the /// attribute was added in v1.1.0+). The browser's /// @simplewebauthn/browser toPublicKeyCredentialDescriptor /// does const { id } = descriptor, gets undefined, then /// passes it to base64URLStringToBuffer(id) which does /// id.replace(...) and throws /// "Cannot read properties of undefined (reading 'replace')". /// /// We bypass Fido2NetLib's serializer entirely and emit the JSON /// shape the WebAuthn spec + the browser expect directly: camelCase /// keys, allowCredentials[].id as base64url, type: "public-key", /// userVerification: "preferred". /// public string BuildRequestOptionsJson(byte[] storedCredentialId) { var options = _fido2.GetAssertionOptions( new List { new() { Id = storedCredentialId } }, UserVerificationRequirement.Preferred); var result = new { challenge = options.Challenge, rpId = options.RpId, allowCredentials = new[] { new { id = Fido2NetLib.Base64Url.Encode(storedCredentialId), type = "public-key", }, }, userVerification = options.UserVerification?.ToString().ToLower() ?? "preferred", timeout = options.Timeout, }; return JsonConvert.SerializeObject(result); } /// /// Verify the assertion returned by the browser. Returns the new /// signature counter to persist. Throws on validation failure. /// public async Task FinishAssertionAsync( AuthenticatorAssertionRawResponse assertionResponse, AssertionOptions originalOptions, byte[] storedPublicKey, uint storedCounter) { var result = await _fido2.MakeAssertionAsync( assertionResponse, originalOptions, storedPublicKey, storedCounter, (args) => Task.FromResult(true), // userHandle ownership always true for our single-user case requestTokenBindingId: null); if (result is null) { throw new InvalidOperationException("Fido2 assertion verification returned null"); } // MakeAssertionAsync returns AssertionVerificationSuccess, but in // some Fido2NetLib versions it may surface a status/error wrapper. // Use the typed object via dynamic to stay version-agnostic. uint newCounter; try { newCounter = (uint)result.Counter; } catch { throw new InvalidOperationException("Fido2 assertion verification did not return a counter"); } // The counter MUST be strictly greater than the stored value // (replay protection). Fido2NetLib already enforces this server-side // and would have thrown; the second check is a belt-and-suspenders. if (newCounter <= storedCounter) { throw new InvalidOperationException( $"WebAuthn counter regression: stored={storedCounter}, new={newCounter}"); } return newCounter; } } }