| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222 |
- 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<JwtConfig> 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<PublicKeyCredentialDescriptor>(), authenticatorSelection, AttestationConveyancePreference.None, extensions);
- _optionsCache.Set(options.User.Name, options);
-
- return options;
- }
- public async Task<Fido2.CredentialMakeResult> RegisterCredential(string login, AuthenticatorAttestationRawResponse attestationResponse)
- {
- // 2. Create callback so that lib can verify credential id is unique to this user
- static Task<bool> 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<CredentialCreateOptions>(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<AssertionOptions> 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<string>());
- }
- 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<AssertionOptions>(loginName, options));
- return options;
- }
- public async Task<string> VerifyCredential(AuthenticatorAssertionRawResponse assertionResponse)
- {
- // 1. Get the assertion options we sent the client
- var optionsWithName = _optionsCache.Get<OptionsWithName<AssertionOptions>>(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<bool> 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(_jwtConfig.Host)
- .Audience(_jwtConfig.Host)
- .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);
- }
- }
|