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;
}
}
}