WebAuthnService.cs
1 using System.Text; 2 using System.Text.Json; 3 using GUNRPG.Application.Identity; 4 using GUNRPG.Application.Identity.Dtos; 5 using GUNRPG.Application.Results; 6 using GUNRPG.Core.Identity; 7 using Fido2NetLib; 8 using Fido2NetLib.Objects; 9 using Microsoft.AspNetCore.Identity; 10 using Microsoft.Extensions.Options; 11 12 namespace GUNRPG.Infrastructure.Identity; 13 14 /// <summary> 15 /// WebAuthn registration and authentication service backed by Fido2NetLib. 16 /// Handles challenge generation, credential verification, signature counter tracking, 17 /// and replay protection. 18 /// 19 /// Origin validation is performed during startup via options validation. 20 /// </summary> 21 public sealed class WebAuthnService : IWebAuthnService 22 { 23 private readonly IFido2 _fido2; 24 private readonly Fido2Configuration _fido2Config; 25 private readonly LiteDbWebAuthnStore _store; 26 private readonly UserManager<ApplicationUser> _userManager; 27 28 public WebAuthnService( 29 IFido2 fido2, 30 IOptions<Fido2Configuration> fido2Config, 31 LiteDbWebAuthnStore store, 32 UserManager<ApplicationUser> userManager) 33 { 34 _fido2 = fido2; 35 _fido2Config = fido2Config.Value; 36 _store = store; 37 _userManager = userManager; 38 } 39 40 // ── Registration ───────────────────────────────────────────────────────── 41 42 public async Task<ServiceResult<string>> BeginRegistrationAsync(string username, CancellationToken ct = default) 43 { 44 if (string.IsNullOrWhiteSpace(username)) 45 return Err(WebAuthnErrorCode.InvalidRequest, "Username is required."); 46 47 var user = await _userManager.FindByNameAsync(username); 48 if (user is null) 49 { 50 // Create the user on first WebAuthn registration 51 user = new ApplicationUser 52 { 53 UserName = username, 54 AccountId = Guid.NewGuid(), 55 }; 56 var result = await _userManager.CreateAsync(user); 57 if (!result.Succeeded) 58 return Err(WebAuthnErrorCode.InternalError, 59 string.Join("; ", result.Errors.Select(e => e.Description))); 60 } 61 62 var existingCredentials = _store.GetCredentialsByUserId(user.Id) 63 .Select(c => new PublicKeyCredentialDescriptor(Base64UrlDecode(c.Id))) 64 .ToList(); 65 66 var fido2User = new Fido2User 67 { 68 Id = Encoding.UTF8.GetBytes(user.Id), 69 Name = username, 70 DisplayName = username, 71 }; 72 73 var options = _fido2.RequestNewCredential(new RequestNewCredentialParams 74 { 75 User = fido2User, 76 ExcludeCredentials = existingCredentials, 77 AuthenticatorSelection = AuthenticatorSelection.Default, 78 AttestationPreference = AttestationConveyancePreference.None, 79 }); 80 81 _store.StoreChallenge(username, options.Challenge); 82 return ServiceResult<string>.Success(options.ToJson()); 83 } 84 85 public async Task<ServiceResult<string>> CompleteRegistrationAsync( 86 string username, 87 string attestationResponseJson, 88 CancellationToken ct = default) 89 { 90 var user = await _userManager.FindByNameAsync(username); 91 if (user is null) 92 return Err(WebAuthnErrorCode.UserNotFound, $"User '{username}' not found."); 93 94 var challenge = _store.ConsumeChallenge(username); 95 if (challenge is null) 96 return Err(WebAuthnErrorCode.ChallengeMissing, 97 "No pending registration challenge. Restart registration."); 98 99 AuthenticatorAttestationRawResponse rawResponse; 100 try 101 { 102 rawResponse = JsonSerializer.Deserialize<AuthenticatorAttestationRawResponse>(attestationResponseJson) 103 ?? throw new JsonException("Null response"); 104 } 105 catch (JsonException ex) 106 { 107 return Err(WebAuthnErrorCode.InvalidRequest, $"Malformed attestation response: {ex.Message}"); 108 } 109 110 var options = CredentialCreateOptions.Create( 111 _fido2Config, 112 challenge, 113 new Fido2User 114 { 115 Id = Encoding.UTF8.GetBytes(user.Id), 116 Name = username, 117 DisplayName = username, 118 }, 119 AuthenticatorSelection.Default, 120 AttestationConveyancePreference.None, 121 excludeCredentials: [], 122 extensions: null, 123 pubKeyCredParams: PubKeyCredParam.Defaults); 124 125 try 126 { 127 var credential = await _fido2.MakeNewCredentialAsync( 128 new MakeNewCredentialParams 129 { 130 AttestationResponse = rawResponse, 131 OriginalOptions = options, 132 IsCredentialIdUniqueToUserCallback = IsCredentialIdUniqueToUserAsync, 133 }, ct); 134 135 var storedCredential = new WebAuthnCredential 136 { 137 Id = Base64UrlEncode(credential.Id), 138 UserId = user.Id, 139 PublicKey = credential.PublicKey, 140 SignatureCounter = credential.SignCount, 141 AaGuid = credential.AaGuid, 142 Transports = credential.Transports?.Select(t => t.ToString()).ToList() ?? [], 143 RegisteredAt = DateTimeOffset.UtcNow, 144 }; 145 146 _store.UpsertCredential(storedCredential); 147 return ServiceResult<string>.Success(user.Id); 148 } 149 catch (Fido2VerificationException ex) 150 { 151 return Err(WebAuthnErrorCode.AttestationFailed, ex.Message); 152 } 153 } 154 155 // ── Authentication ─────────────────────────────────────────────────────── 156 157 public async Task<ServiceResult<string>> BeginLoginAsync(string username, CancellationToken ct = default) 158 { 159 var user = await _userManager.FindByNameAsync(username); 160 if (user is null) 161 return Err(WebAuthnErrorCode.UserNotFound, $"User '{username}' not found."); 162 163 var credentials = _store.GetCredentialsByUserId(user.Id) 164 .Select(c => new PublicKeyCredentialDescriptor(Base64UrlDecode(c.Id))) 165 .ToList(); 166 167 if (credentials.Count == 0) 168 return Err(WebAuthnErrorCode.CredentialNotFound, 169 $"No WebAuthn credentials registered for '{username}'."); 170 171 var options = _fido2.GetAssertionOptions(new GetAssertionOptionsParams 172 { 173 AllowedCredentials = credentials, 174 UserVerification = UserVerificationRequirement.Preferred, 175 }); 176 177 _store.StoreChallenge(username, options.Challenge); 178 return ServiceResult<string>.Success(options.ToJson()); 179 } 180 181 public async Task<ServiceResult<string>> CompleteLoginAsync( 182 string username, 183 string assertionResponseJson, 184 CancellationToken ct = default) 185 { 186 var user = await _userManager.FindByNameAsync(username); 187 if (user is null) 188 return Err(WebAuthnErrorCode.UserNotFound, $"User '{username}' not found."); 189 190 var challenge = _store.ConsumeChallenge(username); 191 if (challenge is null) 192 return Err(WebAuthnErrorCode.ChallengeMissing, 193 "No pending authentication challenge. Restart login."); 194 195 AuthenticatorAssertionRawResponse rawResponse; 196 try 197 { 198 rawResponse = JsonSerializer.Deserialize<AuthenticatorAssertionRawResponse>(assertionResponseJson) 199 ?? throw new JsonException("Null response"); 200 } 201 catch (JsonException ex) 202 { 203 return Err(WebAuthnErrorCode.InvalidRequest, $"Malformed assertion response: {ex.Message}"); 204 } 205 206 var credentialId = Base64UrlEncode(rawResponse.RawId); 207 var storedCredential = _store.GetCredentialById(credentialId); 208 if (storedCredential is null || storedCredential.UserId != user.Id) 209 return Err(WebAuthnErrorCode.CredentialNotFound, 210 "Credential not registered or belongs to a different user."); 211 212 var assertionOptions = AssertionOptions.Create( 213 _fido2Config, 214 challenge, 215 allowedCredentials: [], 216 userVerification: UserVerificationRequirement.Preferred, 217 extensions: null); 218 219 try 220 { 221 var result = await _fido2.MakeAssertionAsync( 222 new MakeAssertionParams 223 { 224 AssertionResponse = rawResponse, 225 OriginalOptions = assertionOptions, 226 StoredPublicKey = storedCredential.PublicKey, 227 StoredSignatureCounter = storedCredential.SignatureCounter, 228 IsUserHandleOwnerOfCredentialIdCallback = IsUserHandleOwnerOfCredentialIdAsync, 229 }, ct); 230 231 // Verify signature counter increased (replay / authenticator clone protection) 232 if (storedCredential.SignatureCounter > 0 && result.SignCount <= storedCredential.SignatureCounter) 233 return Err(WebAuthnErrorCode.CounterRegression, 234 $"Signature counter did not increase (stored={storedCredential.SignatureCounter}, received={result.SignCount}). " + 235 "The authenticator may be cloned."); 236 237 // Update signature counter and last-used timestamp 238 storedCredential.SignatureCounter = result.SignCount; 239 storedCredential.LastUsedAt = DateTimeOffset.UtcNow; 240 _store.UpsertCredential(storedCredential); 241 242 return ServiceResult<string>.Success(user.Id); 243 } 244 catch (Fido2VerificationException ex) 245 { 246 return Err(WebAuthnErrorCode.AssertionFailed, ex.Message); 247 } 248 } 249 250 // ── Discoverable (usernameless) Authentication ──────────────────────────── 251 252 public Task<ServiceResult<(string SessionId, string OptionsJson)>> BeginDiscoverableLoginAsync( 253 CancellationToken ct = default) 254 { 255 var sessionId = Guid.NewGuid().ToString("N"); 256 257 var options = _fido2.GetAssertionOptions(new GetAssertionOptionsParams 258 { 259 AllowedCredentials = [], // empty -> browser discovers resident credentials 260 UserVerification = UserVerificationRequirement.Preferred, 261 }); 262 263 _store.StoreChallenge($"discoverable:{sessionId}", options.Challenge); 264 return Task.FromResult( 265 ServiceResult<(string, string)>.Success((sessionId, options.ToJson()))); 266 } 267 268 public async Task<ServiceResult<string>> CompleteDiscoverableLoginAsync( 269 string sessionId, 270 string assertionResponseJson, 271 CancellationToken ct = default) 272 { 273 if (string.IsNullOrWhiteSpace(sessionId)) 274 return Err(WebAuthnErrorCode.InvalidRequest, "Session ID is required."); 275 276 var challenge = _store.ConsumeChallenge($"discoverable:{sessionId}"); 277 if (challenge is null) 278 return Err(WebAuthnErrorCode.ChallengeMissing, 279 "No pending authentication challenge. Restart login."); 280 281 AuthenticatorAssertionRawResponse rawResponse; 282 try 283 { 284 rawResponse = JsonSerializer.Deserialize<AuthenticatorAssertionRawResponse>(assertionResponseJson) 285 ?? throw new JsonException("Null response"); 286 } 287 catch (JsonException ex) 288 { 289 return Err(WebAuthnErrorCode.InvalidRequest, $"Malformed assertion response: {ex.Message}"); 290 } 291 292 var credentialId = Base64UrlEncode(rawResponse.RawId); 293 var storedCredential = _store.GetCredentialById(credentialId); 294 if (storedCredential is null) 295 return Err(WebAuthnErrorCode.CredentialNotFound, "Credential not found."); 296 297 var user = await _userManager.FindByIdAsync(storedCredential.UserId); 298 if (user is null) 299 return Err(WebAuthnErrorCode.UserNotFound, 300 $"User account for credential no longer exists."); 301 302 var assertionOptions = AssertionOptions.Create( 303 _fido2Config, 304 challenge, 305 allowedCredentials: [], 306 userVerification: UserVerificationRequirement.Preferred, 307 extensions: null); 308 309 try 310 { 311 var result = await _fido2.MakeAssertionAsync( 312 new MakeAssertionParams 313 { 314 AssertionResponse = rawResponse, 315 OriginalOptions = assertionOptions, 316 StoredPublicKey = storedCredential.PublicKey, 317 StoredSignatureCounter = storedCredential.SignatureCounter, 318 IsUserHandleOwnerOfCredentialIdCallback = IsUserHandleOwnerOfCredentialIdAsync, 319 }, ct); 320 321 if (storedCredential.SignatureCounter > 0 && result.SignCount <= storedCredential.SignatureCounter) 322 return Err(WebAuthnErrorCode.CounterRegression, 323 $"Signature counter did not increase (stored={storedCredential.SignatureCounter}, received={result.SignCount}). " + 324 "The authenticator may be cloned."); 325 326 storedCredential.SignatureCounter = result.SignCount; 327 storedCredential.LastUsedAt = DateTimeOffset.UtcNow; 328 _store.UpsertCredential(storedCredential); 329 330 return ServiceResult<string>.Success(storedCredential.UserId); 331 } 332 catch (Fido2VerificationException ex) 333 { 334 return Err(WebAuthnErrorCode.AssertionFailed, ex.Message); 335 } 336 } 337 338 // ── Delegates ───────────────────────────────────────────────────────────── 339 340 private Task<bool> IsCredentialIdUniqueToUserAsync(IsCredentialIdUniqueToUserParams args, CancellationToken ct) 341 { 342 var credId = Base64UrlEncode(args.CredentialId.ToArray()); 343 var existing = _store.GetCredentialById(credId); 344 return Task.FromResult(existing is null); 345 } 346 347 private Task<bool> IsUserHandleOwnerOfCredentialIdAsync(IsUserHandleOwnerOfCredentialIdParams args, CancellationToken ct) 348 { 349 var userId = Encoding.UTF8.GetString(args.UserHandle.ToArray()); 350 var credId = Base64UrlEncode(args.CredentialId.ToArray()); 351 var credential = _store.GetCredentialById(credId); 352 return Task.FromResult(credential?.UserId == userId); 353 } 354 355 // ── Helpers ─────────────────────────────────────────────────────────────── 356 357 /// <summary>Builds a typed error result embedding the <see cref="WebAuthnErrorCode"/> in the message.</summary> 358 private static ServiceResult<string> Err(WebAuthnErrorCode code, string message) => 359 ServiceResult<string>.InvalidState($"{code}: {message}"); 360 361 private static string Base64UrlEncode(byte[] data) => 362 Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_'); 363 364 private static byte[] Base64UrlDecode(string value) 365 { 366 value = value.Replace('-', '+').Replace('_', '/'); 367 switch (value.Length % 4) 368 { 369 case 2: value += "=="; break; 370 case 3: value += "="; break; 371 } 372 return Convert.FromBase64String(value); 373 } 374 }