This commit is contained in:
owenchen committed 2026-08-20 13:23:26 +08:00
1 parent 3461a2e921
commit ddfbd7d0f2
17 files changed
+523 -241

No files matched your search

@@ -0,0 +1,73 @@
using Fido2NetLib;
using Fido2NetLib.Objects;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace CloudBuilder.Security.Service
{
/// <summary>
/// 登录阶段,下发给前端的断言选项模型,对应 CredentialGetOptions
/// </summary>
public sealed class AssertionOptionsModel
{
[JsonPropertyName("challenge")]
public string Challenge { get; init; }
/// <summary>
/// 依赖方ID,域名,例如 localhost / example.com
/// </summary>
[JsonPropertyName("rpId")]
public string RpId { get; init; }
/// <summary>
/// 允许使用的凭证列表,告诉浏览器该用户有哪些已绑定凭证
/// </summary>
[JsonPropertyName("allowCredentials")]
public PublicKeyCredentialDescriptor[] AllowCredentials { get; init; }
/// <summary>
/// 用户验证策略:preferred|required|discouraged
/// </summary>
[JsonPropertyName("userVerification")]
public UserVerificationRequirement UserVerification { get; init; }
/// <summary>
/// 超时毫秒
/// </summary>
[JsonPropertyName("timeout")]
public ulong Timeout { get; init; }
// 可选扩展字段,如果业务用到可以加上
//[JsonPropertyName("extensions")]
//public AuthenticationExtensionsClientInputs? Extensions { get; init; }
/// <summary>
/// 和Fido2NetLib原生风格对齐,从JSON字符串反序列化对象
/// </summary>
/// <param name="json">后端输出给前端的原始json</param>
/// <returns></returns>
public static AssertionOptionsModel FromJson(string json)
{
var opt = new JsonSerializerOptions
{
Converters = { new Base64UrlConverter() },
PropertyNameCaseInsensitive = false
};
return JsonSerializer.Deserialize<AssertionOptionsModel>(json, opt)!;
}
/// <summary>
/// 序列化为给前端消费的JSON(challenge/id自动Base64Url编码)
/// </summary>
public string ToJson()
{
var opt = new JsonSerializerOptions
{
Converters = { new Base64UrlConverter() },
WriteIndented = false
};
return JsonSerializer.Serialize(this, opt);
}
}
}
@@ -12,7 +12,10 @@ using CloudBuilder.Security.Publisher;
using CloudBuilder.Security.Service.Authorization;
using CloudBuilder.Security.Service.Publisher;
using DocumentFormat.OpenXml.Spreadsheet;
using EnvDTE;
using Fido2NetLib;
using Fido2NetLib.Objects;
using Fido2NetLib.Serialization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -21,6 +24,9 @@ using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Serialization;
using System.Buffers.Text;
using System.ComponentModel.DataAnnotations;
using System.IdentityModel.Tokens.Jwt;
using System.Runtime;
@@ -627,7 +633,8 @@ namespace CloudBuilder.Security.Service
// case-sensitive by default and looks for `Response` (PascalCase) in
// the JSON, while the browser sends `response` (lowercase).
// Newtonsoft.Json's default ContractResolver is case-insensitive.
raw = JsonConvert.DeserializeObject<AuthenticatorAttestationRawResponse>(dto.AttestationJson);
AuthenticatorAttestationRawResponseModel oldModel = JsonConvert.DeserializeObject<AuthenticatorAttestationRawResponseModel>(dto.AttestationJson);
raw = ToNewAuthenticatorAttestationRawResponse(oldModel);
}
catch (Exception ex)
{
@@ -668,8 +675,8 @@ namespace CloudBuilder.Security.Service
// BiometricRegistrationResultDto.CredentialIdBase64Url) and
// the public key can be decoded back to byte[] for the
// assertion verification path.
user.BiometricCredentialId = Fido2NetLib.Base64Url.Encode(stored.CredentialId);
user.BiometricPublicKey = Fido2NetLib.Base64Url.Encode(stored.PublicKey);
user.BiometricCredentialId = MyBase64Url.Encode(stored.CredentialId);
user.BiometricPublicKey = MyBase64Url.Encode(stored.PublicKey);
user.BiometricCounter = 0;
user.BiometricDeviceName = dto.AttestationJson.Length > 0 ? "(set on the device)" : null;
user.BiometricEnrolledAt = DateTime.Now;
@@ -679,7 +686,7 @@ namespace CloudBuilder.Security.Service
return new BiometricRegistrationResultDto
{
CredentialIdBase64Url = Fido2NetLib.Base64Url.Encode(stored.CredentialId)
CredentialIdBase64Url = MyBase64Url.Encode(stored.CredentialId)
};
}
@@ -703,7 +710,7 @@ namespace CloudBuilder.Security.Service
// PascalCase serializer. See WebAuthnService.BuildRequestOptionsJson
// for why the wire shape has to be hand-built.
var optionsJson = webAuthnService.BuildRequestOptionsJson(
Fido2NetLib.Base64Url.Decode(user.BiometricCredentialId));
MyBase64Url.Decode(user.BiometricCredentialId));
return new BiometricLoginChallengeDto
{
RequestOptionsJson = optionsJson,
@@ -748,7 +755,8 @@ namespace CloudBuilder.Security.Service
{
token = Newtonsoft.Json.Linq.JToken.Parse((string)v.Value!);
}
originalOptions = AssertionOptions.FromJson(token.ToString());
AssertionOptionsModel assertionOptionsModel = AssertionOptionsModel.FromJson(token.ToString());
originalOptions = ConvertToLibAssertionOptions(assertionOptionsModel);
}
catch (Exception ex)
{
@@ -773,7 +781,13 @@ namespace CloudBuilder.Security.Service
// string (1.0.0-alpha shape) the reflection write will fail at
// runtime with a clear ArgumentException — see the catch below
// for the workaround.
raw = JsonConvert.DeserializeObject<AuthenticatorAssertionRawResponse>(dto.AssertionJson);
AuthenticatorAssertionRawResponseModel model =
JsonConvert.DeserializeObject<AuthenticatorAssertionRawResponseModel>(dto.AssertionJson);
raw = AssertionModelConverter.ToLibAuthenticatorAssertionRawResponse(model);
//raw = JsonConvert.DeserializeObject<AuthenticatorAssertionRawResponse>(dto.AssertionJson);
WebAuthnAssertionPatcher.PatchResponseToByteArrays(raw);
// DIAG: log the actual runtime property type so we can confirm
@@ -804,7 +818,7 @@ namespace CloudBuilder.Security.Service
// user.BiometricPublicKey is stored as base64url; decode to
// byte[] for Fido2NetLib's MakeAssertionAsync signature.
var newCounter = webAuthnService
.FinishAssertionAsync(raw, originalOptions, Fido2NetLib.Base64Url.Decode(user.BiometricPublicKey), (uint)user.BiometricCounter)
.FinishAssertionAsync(raw, originalOptions, MyBase64Url.Decode(user.BiometricPublicKey), (uint)user.BiometricCounter)
.GetAwaiter().GetResult();
// Persist counter + last-used timestamp.
@@ -822,6 +836,51 @@ namespace CloudBuilder.Security.Service
throw;
}
}
public static AuthenticatorAttestationRawResponse ToNewAuthenticatorAttestationRawResponse(AuthenticatorAttestationRawResponseModel oldModel)
{
if (oldModel == null)
throw new ArgumentNullException(nameof(oldModel));
if (oldModel.Response == null)
throw new ArgumentNullException(nameof(oldModel.Response), "oldModel.Response 不能为null");
var raw = new AuthenticatorAttestationRawResponse
{
// v4 Id字段是base64url字符串,由RawId字节编码而来
Id = Base64Url.EncodeToString(oldModel.RawId),
RawId = oldModel.RawId,
Type = PublicKeyCredentialType.PublicKey,
ClientExtensionResults = new AuthenticationExtensionsClientOutputs(),
Response = new AuthenticatorAttestationRawResponse.AttestationResponse
{
AttestationObject = oldModel.Response.AttestationObject,
ClientDataJson = oldModel.Response.ClientDataJson,
Transports = Array.Empty<AuthenticatorTransport>()
}
};
return raw;
}
public static AssertionOptions ConvertToLibAssertionOptions(AssertionOptionsModel model)
{
if (model == null)
throw new ArgumentNullException(nameof(model));
var assertionOptions = new AssertionOptions
{
Challenge = MyBase64Url.Decode(model.Challenge)!,
RpId = model.RpId,
Timeout = model.Timeout,
//数组转IReadOnlyList,直接赋值
AllowCredentials = model.AllowCredentials ?? Array.Empty<PublicKeyCredentialDescriptor>(),
UserVerification = model.UserVerification,
//Model不包含的库字段,填充默认空值
Hints = Array.Empty<PublicKeyCredentialHint>(),
Extensions = null
};
return assertionOptions;
}
// ===== Epayslip biometric verification (step-up auth, not a new login) =====
//
@@ -872,7 +931,7 @@ namespace CloudBuilder.Security.Service
return new BiometricLoginChallengeDto
{
RequestOptionsJson = webAuthnService.BuildRequestOptionsJson(
Fido2NetLib.Base64Url.Decode(user.BiometricCredentialId))
MyBase64Url.Decode(user.BiometricCredentialId))
};
}
@@ -886,86 +945,6 @@ namespace CloudBuilder.Security.Service
// why the options string needs unwrapping); only the persistence
// step is different -- we do not mint a new session.
public bool FinishEpayslipBiometricAssertion(string assertionJson, string requestOptionsJson)
{
if (string.IsNullOrWhiteSpace(assertionJson) || string.IsNullOrWhiteSpace(requestOptionsJson))
throw new ValidationException("assertionJson and requestOptionsJson are required");
var username = service.UserId;
if (string.IsNullOrWhiteSpace(username))
throw new ValidationException("User not found.");
var user = repository.DetachedEntities.FirstOrDefault(x => x.UserName == username);
if (user == null)
throw new ValidationException("User not found.");
if (string.IsNullOrEmpty(user.BiometricCredentialId) || string.IsNullOrEmpty(user.BiometricPublicKey))
throw new ValidationException("Biometric not enrolled for this user");
AssertionOptions originalOptions;
try
{
// Same unwrap as FinishBiometricLogin: the frontend
// double-JSON-encodes the options string, so the top-level
// JToken is a JSON string of a JSON object. Unwrap one layer
// before handing to Fido2NetLib's FromJson.
var token = Newtonsoft.Json.Linq.JToken.Parse(requestOptionsJson);
if (token is Newtonsoft.Json.Linq.JValue v && v.Type == Newtonsoft.Json.Linq.JTokenType.String)
{
token = Newtonsoft.Json.Linq.JToken.Parse((string)v.Value!);
}
originalOptions = AssertionOptions.FromJson(token.ToString());
}
catch (Exception ex)
{
//logger.LogError("FinishEpayslipBiometricAssertion_ERROR_1: " + ex.ToString());
throw new ValidationException("Invalid requestOptionsJson: " + ex.Message);
}
AuthenticatorAssertionRawResponse raw;
try
{
// See WebAuthnAssertionPatcher for why this is needed.
raw = JsonConvert.DeserializeObject<AuthenticatorAssertionRawResponse>(assertionJson);
WebAuthnAssertionPatcher.PatchResponseToByteArrays(raw);
}
catch (Exception ex)
{
//logger.LogError("FinishEpayslipBiometricAssertion_ERROR_2: " + ex.ToString());
throw new ValidationException("Invalid assertionJson: " + ex.Message);
}
if (raw == null)
throw new ValidationException("Invalid assertionJson");
if (raw.Response == null)
throw new ValidationException("assertionJson is missing the response field");
try
{
// user.BiometricPublicKey is stored as base64url; decode to
// byte[] for Fido2NetLib's MakeAssertionAsync signature.
var newCounter = webAuthnService
.FinishAssertionAsync(raw, originalOptions, Fido2NetLib.Base64Url.Decode(user.BiometricPublicKey), (uint)user.BiometricCounter)
.GetAwaiter().GetResult();
user.BiometricCounter = (int)newCounter;
user.BiometricLastUsedAt = DateTime.Now;
repository.UpdateNow(user);
// Stamp the unlock. Default duration is 5 minutes -- see
// EpayslipBiometricUnlockStore.DefaultDuration.
//epayslipUnlockStore.Unlock(username);
//logger.LogInformation("FinishEpayslipBiometricAssertion: unlocked for username={username}", username);
return true;
}
catch (Exception ex)
{
//logger.LogError("FinishEpayslipBiometricAssertion_ERROR_3: " + ex.ToString());
throw;
}
}
[PermissionOperate(name: SecurityPermissionPolicy.SecurityUser, operate: Operate.Find)]
public void RemoveBiometric()
{
@@ -987,4 +966,172 @@ namespace CloudBuilder.Security.Service
repository.UpdateNow(user);
}
}
public class AuthenticatorAttestationRawResponseModel
{
public class ResponseData
{
[JsonConverter(typeof(Base64UrlConverterModel))]
public byte[] AttestationObject { get; set; }
[JsonConverter(typeof(Base64UrlConverterModel))]
public byte[] ClientDataJson { get; set; }
}
[JsonConverter(typeof(Base64UrlConverterModel))]
public byte[] Id { get; set; }
[JsonConverter(typeof(Base64UrlConverterModel))]
public byte[] RawId { get; set; }
public string Type { get; set; }
public ResponseData Response { get; set; }
}
public class Base64UrlConverterModel : JsonConverter<byte[]>
{
private readonly Required requirement = Required.DisallowNull;
public Base64UrlConverterModel()
{
}
public Base64UrlConverterModel(Required required = Required.DisallowNull)
{
requirement = required;
}
public override void WriteJson(JsonWriter writer, byte[] value, JsonSerializer serializer)
{
writer.WriteValue(Base64Url.EncodeToString(value));
}
public override byte[] ReadJson(JsonReader reader, Type objectType, byte[] existingValue, bool hasExistingValue, JsonSerializer serializer)
{
byte[] result = null;
if (reader.Value == null && requirement == Required.AllowNull)
{
return result;
}
if (reader.Value == null)
{
throw new Fido2VerificationException("json value must not be null");
}
if (Type.GetType("System.String") != reader.ValueType)
{
throw new Fido2VerificationException("json valuetype must be string");
}
try
{
return Base64Url.DecodeFromChars((string)reader.Value);
}
catch (FormatException)
{
throw new Fido2VerificationException("json value must be valid base64 encoded string");
}
}
}
public class AuthenticatorAssertionRawResponseModel
{
public string Id { get; set; } = string.Empty;
public string RawId { get; set; } = string.Empty;
public AssertionResponseModel Response { get; set; } = new();
public PublicKeyCredentialType Type { get; set; }
public AuthenticationExtensionsClientOutputs ClientExtensionResults { get; set; } = new();
public sealed class AssertionResponseModel
{
public string AuthenticatorData { get; set; } = string.Empty;
public string Signature { get; set; } = string.Empty;
public string ClientDataJSON { get; set; } = string.Empty;
public string? UserHandle { get; set; }
}
}
public static class AssertionModelConverter
{
/// <summary>
/// 把simplewebauthn过渡Model,转为Fido2NetLib v4.1原生 AuthenticatorAssertionRawResponse
/// </summary>
/// <param name="model">过渡模型</param>
/// <exception cref="ArgumentNullException"></exception>
/// <returns>Fido2NetLib原生 AuthenticatorAssertionRawResponse</returns>
public static AuthenticatorAssertionRawResponse ToLibAuthenticatorAssertionRawResponse(
AuthenticatorAssertionRawResponseModel model)
{
if (model == null) throw new ArgumentNullException(nameof(model));
if (model.Response == null) throw new ArgumentNullException(nameof(model.Response));
return new AuthenticatorAssertionRawResponse
{
Id = model.Id,
RawId = Base64Url.DecodeFromChars(model.RawId),
Type = model.Type,
ClientExtensionResults = model.ClientExtensionResults,
Response = new AuthenticatorAssertionRawResponse.AssertionResponse
{
AuthenticatorData = Base64Url.DecodeFromChars(model.Response.AuthenticatorData),
Signature = Base64Url.DecodeFromChars(model.Response.Signature),
ClientDataJson = Base64Url.DecodeFromChars(model.Response.ClientDataJSON),
UserHandle = string.IsNullOrEmpty(model.Response.UserHandle)
? null
: Base64Url.DecodeFromChars(model.Response.UserHandle)
}
};
}
}
/*
public class AssertionOptionsModel
{
[JsonProperty("status")]
public string Status { get; set; } = "ok";
[JsonProperty("errorMessage")]
public string ErrorMessage { get; set; } = string.Empty;
[JsonConverter(typeof(Base64UrlConverterModel))]
public byte[] Challenge { get; set; }
public uint Timeout { get; set; }
public string RpId { get; set; }
public IEnumerable<PublicKeyCredentialDescriptor> AllowCredentials { get; set; }
public UserVerificationRequirement UserVerification { get; set; }
internal static AssertionOptionsModel Create(byte[] challenge, IEnumerable<PublicKeyCredentialDescriptor> allowedCredentials, UserVerificationRequirement userVerification)
{
return new AssertionOptionsModel
{
Challenge = challenge,
AllowCredentials = (allowedCredentials ?? new List<PublicKeyCredentialDescriptor>()),
UserVerification = userVerification
};
}
public string ToJson()
{
return JsonConvert.SerializeObject(this);
}
public static AssertionOptionsModel FromJson(string json)
{
return JsonConvert.DeserializeObject<AssertionOptionsModel>(json);
}
}
*/
}
@@ -13,6 +13,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Fido2" Version="4.0.1" />
<PackageReference Include="Fido2.AspNet" Version="4.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="8.0.0" />
<PackageReference Include="ClosedXML" Version="0.102.1" />
@@ -21,13 +23,14 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Microsoft.VisualStudio.OLE.Interop" Version="17.8.37221" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="8.0.0" />
<PackageReference Include="Fido2NetLib" Version="1.0.0-alpha" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="9.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.0" />
</ItemGroup>
@@ -67,7 +67,7 @@ namespace CloudBuilder.Security.Service
if (current is byte[]) return; // already decoded by the converter
if (current is string s && !string.IsNullOrEmpty(s))
{
try { prop.SetValue(target, Fido2NetLib.Base64Url.Decode(s)); }
try { prop.SetValue(target, MyBase64Url.Decode(s)); }
catch { /* leave original value; Fido2NetLib will surface a clearer error */ }
}
return;
@@ -78,7 +78,7 @@ namespace CloudBuilder.Security.Service
{
if (current is string s && !string.IsNullOrEmpty(s))
{
try { prop.SetValue(target, Fido2NetLib.Base64Url.Decode(s)); } catch { }
try { prop.SetValue(target, MyBase64Url.Decode(s)); } catch { }
}
return;
}
@@ -89,4 +89,34 @@ namespace CloudBuilder.Security.Service
// InvalidCastException that we can diagnose from the stack trace.
}
}
/// <summary>
/// 跨平台 Base64Url,行为等价旧Fido2NetLib.Base64Url,Windows+Linux完全兼容
/// </summary>
public static class MyBase64Url
{
public static byte[] Decode(string input)
{
if (string.IsNullOrEmpty(input))
return null;
// base64url → 标准base64
input = input.Replace('-', '+').Replace('_', '/');
switch (input.Length % 4)
{
case 2: input += "=="; break;
case 3: input += "="; break;
}
return Convert.FromBase64String(input);
}
public static string Encode(byte[] data)
{
if (data == null) return null;
return Convert.ToBase64String(data)
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('=');
}
}
}
@@ -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;
}
}
}
}