Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Support device public key and passkeys #356

Merged
merged 13 commits into from
Jun 21, 2023
37 changes: 24 additions & 13 deletions Demo/Controller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,12 @@ public JsonResult MakeCredentialOptions([FromForm] string username,
if (!string.IsNullOrEmpty(authType))
authenticatorSelection.AuthenticatorAttachment = authType.ToEnum<AuthenticatorAttachment>();

var exts = new AuthenticationExtensionsClientInputs()
{
Extensions = true,
UserVerificationMethod = true,
var exts = new AuthenticationExtensionsClientInputs()
{
Extensions = true,
UserVerificationMethod = true,
DevicePubKey = new AuthenticationExtensionsDevicePublicKeyInputs() { Attestation = attType },
CredProps = true
};

var options = _fido2.RequestNewCredential(user, existingKeys, authenticatorSelection, attType.ToEnum<AttestationConveyancePreference>(), exts);
Expand Down Expand Up @@ -117,19 +119,23 @@ public async Task<JsonResult> MakeCredential([FromBody] AuthenticatorAttestation
// 3. Store the credentials in db
DemoStorage.AddCredentialToUser(options.User, new StoredCredential
{
Type = success.Result.Type,
Id = success.Result.Id,
Descriptor = new PublicKeyCredentialDescriptor(success.Result.CredentialId),
PublicKey = success.Result.PublicKey,
UserHandle = success.Result.User.Id,
SignatureCounter = success.Result.Counter,
SignCount = success.Result.Counter,
CredType = success.Result.CredType,
RegDate = DateTime.Now,
AaGuid = success.Result.Aaguid
AaGuid = success.Result.Aaguid,
Transports = success.Result.Transports,
BE = success.Result.BE,
BS = success.Result.BS,
AttestationObject = success.Result.AttestationObject,
AttestationClientDataJSON = success.Result.AttestationClientDataJSON,
DevicePublicKeys = new List<byte[]>() { success.Result.DevicePublicKey }
});

// Remove Certificates from success because System.Text.Json cannot serialize them properly. See https://github.com/passwordless-lib/fido2-net-lib/issues/328
success.Result.AttestationCertificate = null;
success.Result.AttestationCertificateChain = null;

// 4. return "ok" to the client
return Json(success);
}
Expand Down Expand Up @@ -157,8 +163,10 @@ public ActionResult AssertionOptionsPost([FromForm] string username, [FromForm]
}

var exts = new AuthenticationExtensionsClientInputs()
{
UserVerificationMethod = true
{
Extensions = true,
UserVerificationMethod = true,
DevicePubKey = new AuthenticationExtensionsDevicePublicKeyInputs()
};

// 3. Create options
Expand Down Expand Up @@ -206,11 +214,14 @@ public async Task<JsonResult> MakeAssertion([FromBody] AuthenticatorAssertionRaw
};

// 5. Make the assertion
var res = await _fido2.MakeAssertionAsync(clientResponse, options, creds.PublicKey, storedCounter, callback, cancellationToken: cancellationToken);
var res = await _fido2.MakeAssertionAsync(clientResponse, options, creds.PublicKey, creds.DevicePublicKeys, storedCounter, callback, cancellationToken: cancellationToken);

// 6. Store the updated counter
DemoStorage.UpdateCounter(res.CredentialId, res.Counter);

if (res.DevicePublicKey is not null)
creds.DevicePublicKeys.Add(res.DevicePublicKey);

// 7. return OK to client
return Json(res);
}
Expand Down
31 changes: 17 additions & 14 deletions Demo/TestController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ namespace Fido2Demo;
public class TestController : Controller
{
/* CONFORMANCE TESTING ENDPOINTS */
private static readonly DevelopmentInMemoryStore DemoStorage = new ();
private static readonly DevelopmentInMemoryStore _demoStorage = new ();

private readonly IFido2 _fido2;
private readonly string _origin;
Expand Down Expand Up @@ -56,15 +56,15 @@ public JsonResult MakeCredentialOptionsTest([FromBody] TEST_MakeCredentialParams
}

// 1. Get user from DB by username (in our example, auto create missing users)
var user = DemoStorage.GetOrAddUser(opts.Username, () => new Fido2User
var user = _demoStorage.GetOrAddUser(opts.Username, () => new Fido2User
{
DisplayName = opts.DisplayName,
Name = opts.Username,
Id = username // byte representation of userID is required
});

// 2. Get user existing keys by username
var existingKeys = DemoStorage.GetCredentialsByUser(user).Select(c => c.Descriptor).ToList();
var existingKeys = _demoStorage.GetCredentialsByUser(user).Select(c => c.Descriptor).ToList();

//var exts = new AuthenticationExtensionsClientInputs() { Extensions = true, UserVerificationIndex = true, Location = true, UserVerificationMethod = true, BiometricAuthenticatorPerformanceBounds = new AuthenticatorBiometricPerfBounds { FAR = float.MaxValue, FRR = float.MaxValue } };
var exts = new AuthenticationExtensionsClientInputs() { };
Expand All @@ -83,7 +83,7 @@ public JsonResult MakeCredentialOptionsTest([FromBody] TEST_MakeCredentialParams

[HttpPost]
[Route("/attestation/result")]
public async Task<JsonResult> MakeCredentialResultTest([FromBody] AuthenticatorAttestationRawResponse attestationResponse, CancellationToken cancellationToken)
public async Task<JsonResult> MakeCredentialResultTestAsync([FromBody] AuthenticatorAttestationRawResponse attestationResponse, CancellationToken cancellationToken)
{

// 1. get the options we sent the client
Expand All @@ -93,20 +93,20 @@ public async Task<JsonResult> MakeCredentialResultTest([FromBody] AuthenticatorA
// 2. Create callback so that lib can verify credential id is unique to this user
IsCredentialIdUniqueToUserAsyncDelegate callback = static async (args, cancellationToken) =>
{
var users = await DemoStorage.GetUsersByCredentialIdAsync(args.CredentialId, cancellationToken);
var users = await _demoStorage.GetUsersByCredentialIdAsync(args.CredentialId, cancellationToken);
return users.Count <= 0;
};

// 2. Verify and make the credentials
var success = await _fido2.MakeNewCredentialAsync(attestationResponse, options, callback, cancellationToken: cancellationToken);

// 3. Store the credentials in db
DemoStorage.AddCredentialToUser(options.User, new StoredCredential
_demoStorage.AddCredentialToUser(options.User, new StoredCredential
{
Descriptor = new PublicKeyCredentialDescriptor(success.Result.CredentialId),
PublicKey = success.Result.PublicKey,
UserHandle = success.Result.User.Id,
SignatureCounter = success.Result.Counter
SignCount = success.Result.Counter
});

// 4. return "ok" to the client
Expand All @@ -119,12 +119,12 @@ public IActionResult AssertionOptionsTest([FromBody] TEST_AssertionClientParams
{
var username = assertionClientParams.Username;
// 1. Get user from DB
var user = DemoStorage.GetUser(username);
var user = _demoStorage.GetUser(username);
if (user == null)
return NotFound("username was not registered");

// 2. Get registered credentials from database
var existingCredentials = DemoStorage.GetCredentialsByUser(user).Select(c => c.Descriptor).ToList();
var existingCredentials = _demoStorage.GetCredentialsByUser(user).Select(c => c.Descriptor).ToList();

var uv = assertionClientParams.UserVerification;
if (null != assertionClientParams.authenticatorSelection)
Expand Down Expand Up @@ -154,30 +154,33 @@ public IActionResult AssertionOptionsTest([FromBody] TEST_AssertionClientParams

[HttpPost]
[Route("/assertion/result")]
public async Task<JsonResult> MakeAssertionTest([FromBody] AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken)
public async Task<JsonResult> MakeAssertionTestAsync([FromBody] AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken)
{
// 1. Get the assertion options we sent the client
var jsonOptions = HttpContext.Session.GetString("fido2.assertionOptions");
var options = AssertionOptions.FromJson(jsonOptions);

// 2. Get registered credential from database
var creds = DemoStorage.GetCredentialById(clientResponse.Id);
var creds = _demoStorage.GetCredentialById(clientResponse.Id);

// 3. Get credential counter from database
var storedCounter = creds.SignatureCounter;

// 4. Create callback to check if userhandle owns the credentialId
IsUserHandleOwnerOfCredentialIdAsync callback = static async (args, cancellationToken) =>
{
var storedCreds = await DemoStorage.GetCredentialsByUserHandleAsync(args.UserHandle, cancellationToken);
var storedCreds = await _demoStorage.GetCredentialsByUserHandleAsync(args.UserHandle, cancellationToken);
return storedCreds.Exists(c => c.Descriptor.Id.SequenceEqual(args.CredentialId));
};

// 5. Make the assertion
var res = await _fido2.MakeAssertionAsync(clientResponse, options, creds.PublicKey, storedCounter, callback, cancellationToken: cancellationToken);
var res = await _fido2.MakeAssertionAsync(clientResponse, options, creds.PublicKey, creds.DevicePublicKeys, storedCounter, callback, cancellationToken: cancellationToken);

// 6. Store the updated counter
DemoStorage.UpdateCounter(res.CredentialId, res.Counter);
_demoStorage.UpdateCounter(res.CredentialId, res.Counter);

if (res.DevicePublicKey is not null)
creds.DevicePublicKeys.Add(res.DevicePublicKey);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand what we are doing here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basically if a new device public key was discovered in the assertion flow, add it to the list of device public keys associated with this credential.

Copy link
Collaborator

@abergs abergs Jun 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... I think I understand @aseigler. Am I right to think that before saving the DPK the RP would perhaps check some policy or trigger a risk based ceremony before trusting that DPK and then saving/updating the storage with res.DevicePublicKey but in our demo we just add it directly to our in memory storage?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, basically same as registering a new credential against an existing user, but way more complicated.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright makes sense, I was just confused about how we used it in our demo. When I get the time I'll add a // TODO: Check policy if we allow new devices, if-so, store it to db


var testRes = new
{
Expand Down
5 changes: 3 additions & 2 deletions Demo/wwwroot/js/custom.register.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,9 @@ async function registerNewCredential(newCredential) {
extensions: newCredential.getClientExtensionResults(),
response: {
AttestationObject: coerceToBase64Url(attestationObject),
clientDataJSON: coerceToBase64Url(clientDataJSON)
}
clientDataJson: coerceToBase64Url(clientDataJSON),
transports: newCredential.response.getTransports(),
},
};

let response;
Expand Down
8 changes: 6 additions & 2 deletions Src/Fido2.Models/AuthenticatorAssertionRawResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,13 @@ public class AssertionResponse
[JsonConverter(typeof(Base64UrlConverter))]
[JsonPropertyName("clientDataJSON")]
public byte[] ClientDataJson { get; set; }

#nullable enable
[JsonPropertyName("userHandle")]
[JsonConverter(typeof(Base64UrlConverter))]
public byte[] UserHandle { get; set; }
public byte[]? UserHandle { get; set; }

[JsonPropertyName("attestationObject")]
[JsonConverter(typeof(Base64UrlConverter))]
public byte[]? AttestationObject { get; set; }
}
}
2 changes: 1 addition & 1 deletion Src/Fido2.Models/AuthenticatorAttestationRawResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public sealed class AuthenticatorAttestationRawResponse
public byte[] RawId { get; set; }

[JsonPropertyName("type")]
public PublicKeyCredentialType? Type { get; set; }
public PublicKeyCredentialType Type { get; set; } = PublicKeyCredentialType.PublicKey;

[JsonPropertyName("response")]
public ResponseData Response { get; set; }
Expand Down
5 changes: 4 additions & 1 deletion Src/Fido2.Models/Exceptions/Fido2ErrorCode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,8 @@ public enum Fido2ErrorCode
InvalidAuthenticatorResponseChallenge,
NonUniqueCredentialId,
AaGuidNotFound,
UnimplementedAlgorithm
UnimplementedAlgorithm,
BackupEligibilityRequirementNotMet,
BackupStateRequirementNotMet,
CredentialAlgorithmRequirementNotMet
}
13 changes: 13 additions & 0 deletions Src/Fido2.Models/Objects/AssertionVerificationResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,17 @@ public class AssertionVerificationResult : Fido2ResponseBase
public byte[] CredentialId { get; set; }

public uint Counter { get; set; }

/// <summary>
/// The latest value of the signature counter in the authenticator data from any ceremony using the public key credential source.
/// </summary>
public uint SignCount { get; set; }
/// <summary>
aseigler marked this conversation as resolved.
Show resolved Hide resolved
/// The latest value of the BS flag in the authenticator data from any ceremony using the public key credential source.
/// </summary>
public bool BS { get; set; }
/// <summary>
///
/// </summary>
public byte[] DevicePublicKey { get; set; }
}
38 changes: 31 additions & 7 deletions Src/Fido2.Models/Objects/AttestationVerificationSuccess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,38 @@ namespace Fido2NetLib.Objects;
/// </summary>
public class AttestationVerificationSuccess : AssertionVerificationResult
{
[JsonConverter(typeof(Base64UrlConverter))]
public byte[] PublicKey { get; set; }

public Fido2User User { get; set; }
public string CredType { get; set; }
public System.Guid Aaguid { get; set; }
#nullable enable
public X509Certificate2? AttestationCertificate { get; set; }
#nullable disable
public X509Certificate2[] AttestationCertificateChain { get; set; }
/// <summary>
/// The type of the public key credential source.
/// </summary>
public PublicKeyCredentialType Type { get; set; } = PublicKeyCredentialType.PublicKey;
/// <summary>
/// The Credential ID of the public key credential source.
/// </summary>
public byte[] Id { get; set; }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to use the same JsonConverter as we do on PublicKey?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already done in AuthenticatorAttestationRawResponse, no?

/// <summary>
/// The credential public key of the public key credential source.
/// </summary>
[JsonConverter(typeof(Base64UrlConverter))]
public byte[] PublicKey { get; set; }
/// <summary>
/// The value returned from getTransports() when the public key credential source was registered.
/// </summary>
public AuthenticatorTransport[] Transports { get; set; }
/// <summary>
/// The value of the BE flag when the public key credential source was created.
/// </summary>
public bool BE { get; set; }
/// <summary>
/// The value of the attestationObject attribute when the public key credential source was registered.
/// Storing this enables the Relying Party to reference the credential's attestation statement at a later time.
/// </summary>
public byte[] AttestationObject { get; set; }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to use JsonConverter on this and AttestationClientDataJSON?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already done in AuthenticatorAttestationRawResponse, no?

/// <summary>
/// The value of the clientDataJSON attribute when the public key credential source was registered.
/// Storing this in combination with the above attestationObject item enables the Relying Party to re-verify the attestation signature at a later time.
/// </summary>
public byte[] AttestationClientDataJSON { get; set; }
}
16 changes: 16 additions & 0 deletions Src/Fido2.Models/Objects/AuthenticationExtensionsClientInputs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,21 @@ public sealed class AuthenticationExtensionsClientInputs
[JsonPropertyName("uvm")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? UserVerificationMethod { get; set; }

#nullable enable
/// <summary>
/// This extension enables use of a user verification method.
/// https://www.w3.org/TR/webauthn/#sctn-uvm-extension
/// </summary>
[JsonPropertyName("devicePubKey")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public AuthenticationExtensionsDevicePublicKeyInputs? DevicePubKey { get; set; }

/// <summary>
/// This client registration extension facilitates reporting certain credential properties known by the client to the requesting WebAuthn Relying Party upon creation of a public key credential source as a result of a registration ceremony.
/// </summary>
[JsonPropertyName("credProps")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? CredProps { get; set; }
}

15 changes: 15 additions & 0 deletions Src/Fido2.Models/Objects/AuthenticationExtensionsClientOutputs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,19 @@ public class AuthenticationExtensionsClientOutputs
[JsonPropertyName("uvm")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ulong[][]? UserVerificationMethod { get; set; }

/// <summary>
/// This authenticator registration extension and authentication extension provides a Relying Party with a "device continuity" signal for backup eligible credentials.
/// https://w3c.github.io/webauthn/#sctn-device-publickey-extension
/// </summary>
[JsonPropertyName("devicePubKey")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public AuthenticationExtensionsDevicePublicKeyOutputs? DevicePubKey { get; set; }

/// <summary>
/// This client registration extension facilitates reporting certain credential properties known by the client to the requesting WebAuthn Relying Party upon creation of a public key credential source as a result of a registration ceremony.
/// </summary>
[JsonPropertyName("credProps")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? CredProps { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System;
using System.Text.Json.Serialization;

namespace Fido2NetLib.Objects
aseigler marked this conversation as resolved.
Show resolved Hide resolved
{
public sealed class AuthenticationExtensionsDevicePublicKeyInputs
{
[JsonPropertyName("attestation")]
public string Attestation { get; set; } = "none";

[JsonPropertyName("attestationFormats")]
public string[] AttestationFormats { get; set; } = Array.Empty<string>();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;

namespace Fido2NetLib.Objects
aseigler marked this conversation as resolved.
Show resolved Hide resolved
{
public sealed class AuthenticationExtensionsDevicePublicKeyOutputs
{
[JsonConverter(typeof(Base64UrlConverter))]
[JsonPropertyName("authenticatorOutput")]
public byte[] AuthenticatorOutput { get; set; }

[JsonConverter(typeof(Base64UrlConverter))]
[JsonPropertyName("signature")]
public byte[] Signature { get; set; }
}
}
4 changes: 3 additions & 1 deletion Src/Fido2.Models/Objects/PublicKeyCredentialType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,7 @@ namespace Fido2NetLib.Objects;
public enum PublicKeyCredentialType
{
[EnumMember(Value = "public-key")]
PublicKey
PublicKey,
[EnumMember(Value = "invalid")]
aseigler marked this conversation as resolved.
Show resolved Hide resolved
Invalid
}
Loading