using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using Fido2NetLib; using Fido2NetLib.Objects; using JWT.Algorithms; using JWT.Builder; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; namespace Passwordless; public class CredentialManager { private readonly IFido2 _fido2; private readonly OptionsCache _optionsCache; private readonly JsonSerializerOptions _jsonOptions; private readonly JwtConfig _jwtConfig; private readonly RSA _rsa; public CredentialManager(IFido2 fido2, OptionsCache optionsCache, IOptions jwtOptions) { _fido2 = fido2; _optionsCache = optionsCache; _jwtConfig = jwtOptions.Value; _rsa = ToRsa(_jwtConfig.Key!); _jsonOptions = new JsonSerializerOptions() { WriteIndented = true, }; } public CredentialCreateOptions BuildCredentialOptions(string login) { var loginDisplay = Encoding.UTF8.GetString(Base64UrlConverter.Decode(login)); var loginName = NameTransform.ToFileName(loginDisplay); var user = new Fido2User { DisplayName = loginDisplay, Id = Base64UrlConverter.Decode(login), Name = loginName, }; var authenticatorSelection = new AuthenticatorSelection { UserVerification = UserVerificationRequirement.Discouraged, RequireResidentKey = false, }; var extensions = new AuthenticationExtensionsClientInputs { Extensions = true, UserVerificationMethod = false, }; var options = _fido2.RequestNewCredential(user, new List(), authenticatorSelection, AttestationConveyancePreference.None, extensions); _optionsCache.Set(options.User.Name, options); return options; } public async Task RegisterCredential(string login, AuthenticatorAttestationRawResponse attestationResponse) { // 2. Create callback so that lib can verify credential id is unique to this user static Task Callback(IsCredentialIdUniqueToUserParams args, CancellationToken cancellationToken) { return Task.FromResult(!File.Exists($"./data/{args.User.Name}.json")); } var loginDisplay = Encoding.UTF8.GetString(Base64UrlConverter.Decode(login)); var loginName = NameTransform.ToFileName(loginDisplay); var options = _optionsCache.Get(loginName); var success = await _fido2.MakeNewCredentialAsync(attestationResponse, options, Callback); if (success.Status == "ok") { await using var fileStream = File.OpenWrite($"./data/{success.Result!.User.Name}.json"); await JsonSerializer.SerializeAsync(fileStream, success.Result, _jsonOptions); } return success; } public async Task BuildAssertionOptions(string login) { var loginDisplay = Encoding.UTF8.GetString(Base64UrlConverter.Decode(login)); var loginName = NameTransform.ToFileName(loginDisplay); byte[] credentialId; if (File.Exists($"./data/{loginName}.json")) { await using var fileStream = File.OpenRead($"./data/{loginName}.json"); var accountInfo = (JsonObject)(await JsonSerializer.DeserializeAsync(fileStream, typeof(JsonObject), _jsonOptions))!; credentialId = Convert.FromBase64String(accountInfo["CredentialId"]?.GetValue()); } else { throw new ArgumentException("Username was not registered"); } var extensions = new AuthenticationExtensionsClientInputs() { Extensions = true, UserVerificationMethod = false, }; var descriptor = new PublicKeyCredentialDescriptor() { Id = credentialId, Type = PublicKeyCredentialType.PublicKey, }; var options = _fido2.GetAssertionOptions( new[] { descriptor }, UserVerificationRequirement.Discouraged, extensions ); _optionsCache.Set(Convert.ToBase64String(credentialId), new OptionsWithName(loginName, options)); return options; } public async Task VerifyCredential(AuthenticatorAssertionRawResponse assertionResponse) { // 1. Get the assertion options we sent the client var optionsWithName = _optionsCache.Get>(Convert.ToBase64String(assertionResponse.Id)); AttestationVerificationSuccess assertionVerification; if (File.Exists($"./data/{optionsWithName.Name}.json")) { await using var fileStream1 = File.OpenRead($"./data/{optionsWithName.Name}.json"); assertionVerification = (AttestationVerificationSuccess)(await JsonSerializer.DeserializeAsync(fileStream1, typeof(AttestationVerificationSuccess), _jsonOptions))!; } else { throw new ArgumentException("Username was not registered"); } // 3. Get credential counter from database var storedCounter = assertionVerification.Counter; // 4. Create callback to check if the user handle owns the credentialId Task Callback(IsUserHandleOwnerOfCredentialIdParams args, CancellationToken cancellationToken) { return Task.FromResult(assertionVerification.CredentialId.SequenceEqual(args.CredentialId)); } // 5. Make the assertion var result = await _fido2.MakeAssertionAsync(assertionResponse, optionsWithName.Options, assertionVerification.PublicKey, storedCounter, Callback); if (result.Status != "ok") { throw new Exception($"Verification failed: {result.ErrorMessage}"); } assertionVerification.Counter = result.Counter; await using var fileStream2 = File.OpenWrite($"./data/{optionsWithName.Name}.json"); await JsonSerializer.SerializeAsync(fileStream2, assertionVerification, _jsonOptions); // we could also sign with a certificate from the Windows cert store // // var store = new X509Store(StoreName.My); // store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly); // var collection = store.Certificates.Find(X509FindType.FindByThumbprint, "CE63FA2B6BCD8460150E7FBF9B138E9ED9852E3A", false); // var certificate = collection[0]; var token = JwtBuilder.Create() .WithAlgorithm(new RS256Algorithm(_rsa, _rsa)) .Issuer("http://localhost:5172") .Audience("http://localhost:5172") .IssuedAt(DateTime.UtcNow) .ExpirationTime(DateTime.UtcNow.AddHours(1)) .Subject(optionsWithName.Name) .AddClaim("permissions", new string[] { "MagicClaim", "Foo", "Test" }) .AddClaim("roles", new string[] { "admin", "grunt" }) .AddClaim("scope", "passkey") .Encode(); // TODO: ??? // if (result.DevicePublicKey is not null) // creds.DevicePublicKeys.Add(result.DevicePublicKey); return token; } private RSA ToRsa(JsonWebKey key) { var rsaParameters = new RSAParameters { // PUBLIC KEY PARAMETERS // n parameter - public modulus Modulus = Base64UrlEncoder.DecodeBytes(key.N), // e parameter - public exponent Exponent = Base64UrlEncoder.DecodeBytes(key.E), // PRIVATE KEY PARAMETERS (optional) // d parameter - the private exponent value for the RSA key D = Base64UrlEncoder.DecodeBytes(key.D), // dp parameter - CRT exponent of the first factor DP = Base64UrlEncoder.DecodeBytes(key.DP), // dq parameter - CRT exponent of the second factor DQ = Base64UrlEncoder.DecodeBytes(key.DQ), // p parameter - first prime factor P = Base64UrlEncoder.DecodeBytes(key.P), // q parameter - second prime factor Q = Base64UrlEncoder.DecodeBytes(key.Q), // qi parameter - CRT coefficient of the second factor InverseQ = Base64UrlEncoder.DecodeBytes(key.QI) }; return RSA.Create(rsaParameters); } }