222 lines
9.2 KiB
C#
222 lines
9.2 KiB
C#
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
|
|
{
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public class WebAuthnService: IWebAuthnService
|
|
{
|
|
private readonly Fido2 _fido2;
|
|
private readonly WebAuthnOptions _options;
|
|
|
|
public WebAuthnService(IOptions<WebAuthnOptions> 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;
|
|
|
|
/// <summary>
|
|
/// Build the PublicKeyCredentialCreationOptions that the browser
|
|
/// will hand to <c>navigator.credentials.create()</c>.
|
|
/// </summary>
|
|
/// <param name="username">HR+ username (becomes the WebAuthn user.name + user.displayName).</param>
|
|
/// <param name="userId">Stable per-user byte array. We use the username's UTF-8 bytes — fine for v1 because usernames are unique.</param>
|
|
/// <param name="existingCredentialIds">If the user is re-enrolling on a new device, the existing credential ID is passed via <c>excludeCredentials</c> so the authenticator refuses to re-enroll the same one. For v1 we don't track this (one-device-only); pass an empty list.</param>
|
|
public CredentialCreateOptions BuildCreationOptions(string username, byte[] userId, IReadOnlyList<byte[]> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify the attestation returned by the browser and return the
|
|
/// stored credential fields. Throws on validation failure.
|
|
/// </summary>
|
|
public async Task<StoredBiometricCredential> FinishRegistrationAsync(
|
|
AuthenticatorAttestationRawResponse attestationResponse,
|
|
CredentialCreateOptions originalOptions,
|
|
Func<byte[], Task<bool>> 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
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build the PublicKeyCredentialRequestOptions that the browser will
|
|
/// hand to <c>navigator.credentials.get()</c>. Only the stored
|
|
/// credential for this user is allowed.
|
|
/// </summary>
|
|
public AssertionOptions BuildRequestOptions(byte[] storedCredentialId)
|
|
{
|
|
var allowed = new List<PublicKeyCredentialDescriptor>
|
|
{
|
|
new() { Id = storedCredentialId }
|
|
};
|
|
return _fido2.GetAssertionOptions(allowed, UserVerificationRequirement.Preferred);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Same as <see cref="BuildRequestOptions"/> but returns a
|
|
/// browser-ready JSON string directly.
|
|
///
|
|
/// Fido2NetLib 1.0.0-alpha's <c>AssertionOptions.ToJson()</c> serializes
|
|
/// <c>AllowCredentials[].Id</c> as PascalCase (the C# property is
|
|
/// <c>Id</c> with no <c>[JsonProperty("id")]</c> attribute — the
|
|
/// attribute was added in v1.1.0+). The browser's
|
|
/// @simplewebauthn/browser <c>toPublicKeyCredentialDescriptor</c>
|
|
/// does <c>const { id } = descriptor</c>, gets <c>undefined</c>, then
|
|
/// passes it to <c>base64URLStringToBuffer(id)</c> which does
|
|
/// <c>id.replace(...)</c> 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, <c>allowCredentials[].id</c> as base64url, <c>type: "public-key"</c>,
|
|
/// <c>userVerification: "preferred"</c>.
|
|
/// </summary>
|
|
public string BuildRequestOptionsJson(byte[] storedCredentialId)
|
|
{
|
|
var options = _fido2.GetAssertionOptions(
|
|
new List<PublicKeyCredentialDescriptor> { 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify the assertion returned by the browser. Returns the new
|
|
/// signature counter to persist. Throws on validation failure.
|
|
/// </summary>
|
|
public async Task<uint> 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;
|
|
}
|
|
}
|
|
}
|