178 lines
6.5 KiB
C#
178 lines
6.5 KiB
C#
using CloudBuilder.Core.Application.Options;
|
|
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.Threading.Tasks;
|
|
|
|
namespace CloudBuilder.Security.Service
|
|
{
|
|
/// <summary>
|
|
/// 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
|
|
{
|
|
private readonly IFido2 _fido2;
|
|
private readonly WebAuthnOptions _options;
|
|
|
|
public WebAuthnService(IOptions<WebAuthnOptions> options, IFido2 fido2)
|
|
{
|
|
_options = options.Value;
|
|
_fido2 = fido2;
|
|
}
|
|
|
|
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>
|
|
public CredentialCreateOptions BuildCreationOptions(string username, byte[] userId, IReadOnlyList<byte[]> existingCredentialIds)
|
|
{
|
|
var user = new Fido2User
|
|
{
|
|
Name = username,
|
|
Id = userId,
|
|
DisplayName = username
|
|
};
|
|
|
|
var exclude = existingCredentialIds
|
|
.Select(id => new PublicKeyCredentialDescriptor(id))
|
|
.ToList();
|
|
|
|
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>
|
|
/// 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)
|
|
{
|
|
var makeResult = await _fido2.MakeNewCredentialAsync(new MakeNewCredentialParams
|
|
{
|
|
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 = makeResult.Id,
|
|
PublicKey = makeResult.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 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.
|
|
/// </summary>
|
|
public string BuildRequestOptionsJson(byte[] storedCredentialId)
|
|
{
|
|
var options = _fido2.GetAssertionOptions(new GetAssertionOptionsParams
|
|
{
|
|
AllowedCredentials = new List<PublicKeyCredentialDescriptor> { new PublicKeyCredentialDescriptor(storedCredentialId) },
|
|
UserVerification = UserVerificationRequirement.Preferred
|
|
});
|
|
|
|
var result = new
|
|
{
|
|
challenge = options.Challenge,
|
|
rpId = options.RpId,
|
|
allowCredentials = new[]
|
|
{
|
|
new
|
|
{
|
|
id = MyBase64Url.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(new MakeAssertionParams
|
|
{
|
|
AssertionResponse = assertionResponse,
|
|
OriginalOptions = originalOptions,
|
|
StoredPublicKey = storedPublicKey,
|
|
StoredSignatureCounter = storedCounter,
|
|
IsUserHandleOwnerOfCredentialIdCallback = (args, ct) => Task.FromResult(true)
|
|
});
|
|
|
|
uint newCounter = result.SignCount;
|
|
|
|
if (newCounter <= storedCounter)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"WebAuthn counter regression: stored={storedCounter}, new={newCounter}");
|
|
}
|
|
|
|
return newCounter;
|
|
}
|
|
}
|
|
} |