ow
This commit is contained in:
1 parent
3461a2e921
commit
ddfbd7d0f2
17 files changed
+523
-241
No files matched your search
@@ -1,4 +1,5 @@
|
||||
using CloudBuilder.Security.Data;
|
||||
using CloudBuilder.Core.Application.Options;
|
||||
using CloudBuilder.Security.Data;
|
||||
using Fido2NetLib;
|
||||
using Fido2NetLib.Objects;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -6,39 +7,26 @@ 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,
|
||||
/// Wraps the Fido2 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
|
||||
public class WebAuthnService : IWebAuthnService
|
||||
{
|
||||
private readonly Fido2 _fido2;
|
||||
private readonly IFido2 _fido2;
|
||||
private readonly WebAuthnOptions _options;
|
||||
|
||||
public WebAuthnService(IOptions<WebAuthnOptions> options)
|
||||
public WebAuthnService(IOptions<WebAuthnOptions> options, IFido2 fido2)
|
||||
{
|
||||
_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);
|
||||
_fido2 = fido2;
|
||||
}
|
||||
|
||||
public string Origin => _options.Origin;
|
||||
@@ -48,12 +36,9 @@ namespace CloudBuilder.Security.Service
|
||||
/// 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
|
||||
var user = new Fido2User
|
||||
{
|
||||
Name = username,
|
||||
Id = userId,
|
||||
@@ -61,20 +46,23 @@ namespace CloudBuilder.Security.Service
|
||||
};
|
||||
|
||||
var exclude = existingCredentialIds
|
||||
.Select(id => new PublicKeyCredentialDescriptor { Id = id })
|
||||
.Select(id => new PublicKeyCredentialDescriptor(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);
|
||||
var authSel = new AuthenticatorSelection
|
||||
{
|
||||
// Prefer the platform authenticator (Touch ID / Windows Hello / fingerprint)
|
||||
AuthenticatorAttachment = AuthenticatorAttachment.Platform,
|
||||
UserVerification = UserVerificationRequirement.Preferred
|
||||
};
|
||||
|
||||
return _fido2.RequestNewCredential(new RequestNewCredentialParams
|
||||
{
|
||||
User = user,
|
||||
ExcludeCredentials = exclude,
|
||||
AuthenticatorSelection = authSel,
|
||||
AttestationPreference = AttestationConveyancePreference.None
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -86,29 +74,24 @@ namespace CloudBuilder.Security.Service
|
||||
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)
|
||||
var makeResult = await _fido2.MakeNewCredentialAsync(new MakeNewCredentialParams
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Fido2 attestation verification failed: " +
|
||||
(makeResult?.ErrorMessage ?? "unknown error"));
|
||||
AttestationResponse = attestationResponse,
|
||||
OriginalOptions = originalOptions,
|
||||
IsCredentialIdUniqueToUserCallback = (args, ct) => Task.FromResult(true)
|
||||
});
|
||||
|
||||
|
||||
var isUnique = await isCredentialIdUnique(makeResult.Id);
|
||||
if (!isUnique)
|
||||
{
|
||||
throw new InvalidOperationException("Credential id duplicate");
|
||||
}
|
||||
|
||||
return new StoredBiometricCredential
|
||||
{
|
||||
CredentialId = success.CredentialId,
|
||||
PublicKey = success.PublicKey
|
||||
CredentialId = makeResult.Id,
|
||||
PublicKey = makeResult.PublicKey
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,36 +103,28 @@ namespace CloudBuilder.Security.Service
|
||||
public AssertionOptions BuildRequestOptions(byte[] storedCredentialId)
|
||||
{
|
||||
var allowed = new List<PublicKeyCredentialDescriptor>
|
||||
{
|
||||
new() { Id = storedCredentialId }
|
||||
};
|
||||
return _fido2.GetAssertionOptions(allowed, UserVerificationRequirement.Preferred);
|
||||
{
|
||||
new PublicKeyCredentialDescriptor(storedCredentialId)
|
||||
};
|
||||
|
||||
return _fido2.GetAssertionOptions(new GetAssertionOptionsParams
|
||||
{
|
||||
AllowedCredentials = allowed,
|
||||
UserVerification = 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 options = _fido2.GetAssertionOptions(new GetAssertionOptionsParams
|
||||
{
|
||||
AllowedCredentials = new List<PublicKeyCredentialDescriptor> { new PublicKeyCredentialDescriptor(storedCredentialId) },
|
||||
UserVerification = UserVerificationRequirement.Preferred
|
||||
});
|
||||
|
||||
var result = new
|
||||
{
|
||||
@@ -157,12 +132,12 @@ namespace CloudBuilder.Security.Service
|
||||
rpId = options.RpId,
|
||||
allowCredentials = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
id = Fido2NetLib.Base64Url.Encode(storedCredentialId),
|
||||
type = "public-key",
|
||||
new
|
||||
{
|
||||
id = MyBase64Url.Encode(storedCredentialId),
|
||||
type = "public-key",
|
||||
}
|
||||
},
|
||||
},
|
||||
userVerification = options.UserVerification?.ToString().ToLower() ?? "preferred",
|
||||
timeout = options.Timeout,
|
||||
};
|
||||
@@ -180,35 +155,17 @@ namespace CloudBuilder.Security.Service
|
||||
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)
|
||||
var result = await _fido2.MakeAssertionAsync(new MakeAssertionParams
|
||||
{
|
||||
throw new InvalidOperationException("Fido2 assertion verification returned null");
|
||||
}
|
||||
AssertionResponse = assertionResponse,
|
||||
OriginalOptions = originalOptions,
|
||||
StoredPublicKey = storedPublicKey,
|
||||
StoredSignatureCounter = storedCounter,
|
||||
IsUserHandleOwnerOfCredentialIdCallback = (args, ct) => Task.FromResult(true)
|
||||
});
|
||||
|
||||
// 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");
|
||||
}
|
||||
uint newCounter = result.SignCount;
|
||||
|
||||
// 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(
|
||||
@@ -218,4 +175,4 @@ namespace CloudBuilder.Security.Service
|
||||
return newCounter;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user