CredentialManager.cs 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. using System.Security.Cryptography;
  2. using System.Security.Cryptography.X509Certificates;
  3. using System.Text;
  4. using System.Text.Json;
  5. using System.Text.Json.Nodes;
  6. using Fido2NetLib;
  7. using Fido2NetLib.Objects;
  8. using JWT.Algorithms;
  9. using JWT.Builder;
  10. using Microsoft.Extensions.Options;
  11. using Microsoft.IdentityModel.Tokens;
  12. namespace Passwordless;
  13. public class CredentialManager
  14. {
  15. private readonly IFido2 _fido2;
  16. private readonly OptionsCache _optionsCache;
  17. private readonly JsonSerializerOptions _jsonOptions;
  18. private readonly JwtConfig _jwtConfig;
  19. private readonly RSA _rsa;
  20. public CredentialManager(IFido2 fido2, OptionsCache optionsCache, IOptions<JwtConfig> jwtOptions)
  21. {
  22. _fido2 = fido2;
  23. _optionsCache = optionsCache;
  24. _jwtConfig = jwtOptions.Value;
  25. _rsa = ToRsa(_jwtConfig.Key!);
  26. _jsonOptions = new JsonSerializerOptions()
  27. {
  28. WriteIndented = true,
  29. };
  30. }
  31. public CredentialCreateOptions BuildCredentialOptions(string login)
  32. {
  33. var loginDisplay = Encoding.UTF8.GetString(Base64UrlConverter.Decode(login));
  34. var loginName = NameTransform.ToFileName(loginDisplay);
  35. var user = new Fido2User
  36. {
  37. DisplayName = loginDisplay,
  38. Id = Base64UrlConverter.Decode(login),
  39. Name = loginName,
  40. };
  41. var authenticatorSelection = new AuthenticatorSelection
  42. {
  43. UserVerification = UserVerificationRequirement.Discouraged,
  44. RequireResidentKey = false,
  45. };
  46. var extensions = new AuthenticationExtensionsClientInputs
  47. {
  48. Extensions = true,
  49. UserVerificationMethod = false,
  50. };
  51. var options = _fido2.RequestNewCredential(user, new List<PublicKeyCredentialDescriptor>(), authenticatorSelection, AttestationConveyancePreference.None, extensions);
  52. _optionsCache.Set(options.User.Name, options);
  53. return options;
  54. }
  55. public async Task<Fido2.CredentialMakeResult> RegisterCredential(string login, AuthenticatorAttestationRawResponse attestationResponse)
  56. {
  57. // 2. Create callback so that lib can verify credential id is unique to this user
  58. static Task<bool> Callback(IsCredentialIdUniqueToUserParams args, CancellationToken cancellationToken)
  59. {
  60. return Task.FromResult(!File.Exists($"./data/{args.User.Name}.json"));
  61. }
  62. var loginDisplay = Encoding.UTF8.GetString(Base64UrlConverter.Decode(login));
  63. var loginName = NameTransform.ToFileName(loginDisplay);
  64. var options = _optionsCache.Get<CredentialCreateOptions>(loginName);
  65. var success = await _fido2.MakeNewCredentialAsync(attestationResponse, options, Callback);
  66. if (success.Status == "ok")
  67. {
  68. await using var fileStream = File.OpenWrite($"./data/{success.Result!.User.Name}.json");
  69. await JsonSerializer.SerializeAsync(fileStream, success.Result, _jsonOptions);
  70. }
  71. return success;
  72. }
  73. public async Task<AssertionOptions> BuildAssertionOptions(string login)
  74. {
  75. var loginDisplay = Encoding.UTF8.GetString(Base64UrlConverter.Decode(login));
  76. var loginName = NameTransform.ToFileName(loginDisplay);
  77. byte[] credentialId;
  78. if (File.Exists($"./data/{loginName}.json"))
  79. {
  80. await using var fileStream = File.OpenRead($"./data/{loginName}.json");
  81. var accountInfo =
  82. (JsonObject)(await JsonSerializer.DeserializeAsync(fileStream, typeof(JsonObject), _jsonOptions))!;
  83. credentialId = Convert.FromBase64String(accountInfo["CredentialId"]?.GetValue<string>());
  84. }
  85. else
  86. {
  87. throw new ArgumentException("Username was not registered");
  88. }
  89. var extensions = new AuthenticationExtensionsClientInputs()
  90. {
  91. Extensions = true,
  92. UserVerificationMethod = false,
  93. };
  94. var descriptor = new PublicKeyCredentialDescriptor()
  95. {
  96. Id = credentialId,
  97. Type = PublicKeyCredentialType.PublicKey,
  98. };
  99. var options = _fido2.GetAssertionOptions(
  100. new[] { descriptor },
  101. UserVerificationRequirement.Discouraged,
  102. extensions
  103. );
  104. _optionsCache.Set(Convert.ToBase64String(credentialId), new OptionsWithName<AssertionOptions>(loginName, options));
  105. return options;
  106. }
  107. public async Task<string> VerifyCredential(AuthenticatorAssertionRawResponse assertionResponse)
  108. {
  109. // 1. Get the assertion options we sent the client
  110. var optionsWithName = _optionsCache.Get<OptionsWithName<AssertionOptions>>(Convert.ToBase64String(assertionResponse.Id));
  111. AttestationVerificationSuccess assertionVerification;
  112. if (File.Exists($"./data/{optionsWithName.Name}.json"))
  113. {
  114. await using var fileStream1 = File.OpenRead($"./data/{optionsWithName.Name}.json");
  115. assertionVerification =
  116. (AttestationVerificationSuccess)(await JsonSerializer.DeserializeAsync(fileStream1,
  117. typeof(AttestationVerificationSuccess), _jsonOptions))!;
  118. }
  119. else
  120. {
  121. throw new ArgumentException("Username was not registered");
  122. }
  123. // 3. Get credential counter from database
  124. var storedCounter = assertionVerification.Counter;
  125. // 4. Create callback to check if the user handle owns the credentialId
  126. Task<bool> Callback(IsUserHandleOwnerOfCredentialIdParams args, CancellationToken cancellationToken)
  127. {
  128. return Task.FromResult(assertionVerification.CredentialId.SequenceEqual(args.CredentialId));
  129. }
  130. // 5. Make the assertion
  131. var result = await _fido2.MakeAssertionAsync(assertionResponse, optionsWithName.Options, assertionVerification.PublicKey, storedCounter, Callback);
  132. if (result.Status != "ok")
  133. {
  134. throw new Exception($"Verification failed: {result.ErrorMessage}");
  135. }
  136. assertionVerification.Counter = result.Counter;
  137. await using var fileStream2 = File.OpenWrite($"./data/{optionsWithName.Name}.json");
  138. await JsonSerializer.SerializeAsync(fileStream2, assertionVerification, _jsonOptions);
  139. // we could also sign with a certificate from the Windows cert store
  140. //
  141. // var store = new X509Store(StoreName.My);
  142. // store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly);
  143. // var collection = store.Certificates.Find(X509FindType.FindByThumbprint, "CE63FA2B6BCD8460150E7FBF9B138E9ED9852E3A", false);
  144. // var certificate = collection[0];
  145. var token = JwtBuilder.Create()
  146. .WithAlgorithm(new RS256Algorithm(_rsa, _rsa))
  147. .Issuer("http://localhost:5172")
  148. .Audience("http://localhost:5172")
  149. .IssuedAt(DateTime.UtcNow)
  150. .ExpirationTime(DateTime.UtcNow.AddHours(1))
  151. .Subject(optionsWithName.Name)
  152. .AddClaim("permissions", new string[] { "MagicClaim", "Foo", "Test" })
  153. .AddClaim("roles", new string[] { "admin", "grunt" })
  154. .AddClaim("scope", "passkey")
  155. .Encode();
  156. // TODO: ???
  157. // if (result.DevicePublicKey is not null)
  158. // creds.DevicePublicKeys.Add(result.DevicePublicKey);
  159. return token;
  160. }
  161. private RSA ToRsa(JsonWebKey key)
  162. {
  163. var rsaParameters = new RSAParameters
  164. {
  165. // PUBLIC KEY PARAMETERS
  166. // n parameter - public modulus
  167. Modulus = Base64UrlEncoder.DecodeBytes(key.N),
  168. // e parameter - public exponent
  169. Exponent = Base64UrlEncoder.DecodeBytes(key.E),
  170. // PRIVATE KEY PARAMETERS (optional)
  171. // d parameter - the private exponent value for the RSA key
  172. D = Base64UrlEncoder.DecodeBytes(key.D),
  173. // dp parameter - CRT exponent of the first factor
  174. DP = Base64UrlEncoder.DecodeBytes(key.DP),
  175. // dq parameter - CRT exponent of the second factor
  176. DQ = Base64UrlEncoder.DecodeBytes(key.DQ),
  177. // p parameter - first prime factor
  178. P = Base64UrlEncoder.DecodeBytes(key.P),
  179. // q parameter - second prime factor
  180. Q = Base64UrlEncoder.DecodeBytes(key.Q),
  181. // qi parameter - CRT coefficient of the second factor
  182. InverseQ = Base64UrlEncoder.DecodeBytes(key.QI)
  183. };
  184. return RSA.Create(rsaParameters);
  185. }
  186. }