diff --git a/NEWS b/NEWS index 1a2f0beea..646f00cff 100644 --- a/NEWS +++ b/NEWS @@ -1,7 +1,33 @@ -== Version 2.1.1 (unreleased) == +== Version 2.2.0 (unreleased) == `webauthn-server-core`: +Changes: + +* Changed internal structure of `RegistrationResult` and `AssertionResult`. This + may affect you if you use Jackson or similar tools to serialize these values + to JSON, for example. This is not an officially supported use case and thus + does not warrant a major version bump. +* Removed methods `RegistrationResult.toBuilder()` and + `AssertionResult.toBuilder()`. Both had package-private return types, and thus + were not usable by outside callers. + +New features: + +* (Experimental) Added support for the new `BE` (backup eligible) and `BS` + (backup state) flags in authenticator data: + ** Added `BE` and `BS` properties to `AuthenticatorDataFlags`, reflecting the + respective flags (bits 0x08 and 0x10). + ** Added methods `isBackupEligible()` and `isBackedUp()` to + `RegistrationResult` and `AssertionResult`, reflecting respectively the `BE` + and `BS` flags. + ** Added properties `backupEligible` and `backupState`, getters + `isBackupEligible()` and `isBackedUp()`, and corresponding builder methods + to `RegisteredCredential`. `RelyingParty.finishAssertion(...)` will now + validate that if `RegisteredCredential.isBackupEligible()` is present, then + the `BE` flag of any assertion of that credential must match the stored + value. + Fixes: * Fixed TPM attestation verification rejecting attestation certificates with TPM diff --git a/webauthn-server-core/src/jmh/java/com/yubico/webauthn/benchmark/RelyingPartyBenchmark.java b/webauthn-server-core/src/jmh/java/com/yubico/webauthn/benchmark/RelyingPartyBenchmark.java index baa459975..95849d634 100644 --- a/webauthn-server-core/src/jmh/java/com/yubico/webauthn/benchmark/RelyingPartyBenchmark.java +++ b/webauthn-server-core/src/jmh/java/com/yubico/webauthn/benchmark/RelyingPartyBenchmark.java @@ -71,6 +71,8 @@ public void finishRegistration(Blackhole bh, RegistrationState state) throws RegistrationFailedException { final RegistrationResult result = state.rp.finishRegistration(state.fro); bh.consume(result.getKeyId()); + bh.consume(result.isBackupEligible()); + bh.consume(result.isBackedUp()); bh.consume(result.getSignatureCount()); bh.consume(result.getAaguid()); bh.consume(result.getPublicKeyCose()); @@ -80,6 +82,8 @@ public void finishRegistration(Blackhole bh, RegistrationState state) @Benchmark public void finishAssertion(Blackhole bh, AssertionState state) throws AssertionFailedException { final AssertionResult result = state.rp.finishAssertion(state.fao); + bh.consume(result.isBackupEligible()); + bh.consume(result.isBackedUp()); bh.consume(result.getSignatureCount()); bh.consume(result.getAuthenticatorExtensionOutputs()); bh.consume(result.getCredential().getCredentialId()); diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResult.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResult.java index 55ab5e9c0..b42ff37f9 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResult.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionResult.java @@ -25,36 +25,45 @@ package com.yubico.webauthn; import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; -import com.yubico.internal.util.ExceptionUtil; import com.yubico.webauthn.data.AuthenticatorAssertionExtensionOutputs; +import com.yubico.webauthn.data.AuthenticatorAssertionResponse; import com.yubico.webauthn.data.AuthenticatorData; import com.yubico.webauthn.data.ByteArray; import com.yubico.webauthn.data.ClientAssertionExtensionOutputs; +import com.yubico.webauthn.data.PublicKeyCredential; import com.yubico.webauthn.data.PublicKeyCredentialRequestOptions; import com.yubico.webauthn.data.UserIdentity; import java.util.Optional; -import lombok.Builder; +import lombok.AccessLevel; +import lombok.Getter; import lombok.NonNull; import lombok.Value; /** The result of a call to {@link RelyingParty#finishAssertion(FinishAssertionOptions)}. */ @Value -@Builder(toBuilder = true) public class AssertionResult { /** true if the assertion was verified successfully. */ private final boolean success; + @JsonProperty + @Getter(AccessLevel.NONE) + private final PublicKeyCredential + credentialResponse; + /** * The {@link RegisteredCredential} that was returned by {@link * CredentialRepository#lookup(ByteArray, ByteArray)} and whose public key was used to * successfully verify the assertion signature. * - *

NOTE: The {@link RegisteredCredential#getSignatureCount() signature count} in this object - * will reflect the signature counter state before the assertion operation, not the new - * counter value. When updating your database state, use the signature counter from {@link - * #getSignatureCount()} instead. + *

NOTE: The {@link RegisteredCredential#getSignatureCount() signature count}, {@link + * RegisteredCredential#isBackupEligible() backup eligibility} and {@link + * RegisteredCredential#isBackedUp() backup state} properties in this object will reflect the + * state before the assertion operation, not the new state. When updating your database + * state, use the signature counter and backup state from {@link #getSignatureCount()}, {@link + * #isBackupEligible()} and {@link #isBackedUp()} instead. */ private final RegisteredCredential credential; @@ -65,16 +74,6 @@ public class AssertionResult { */ @NonNull private final String username; - /** - * The new signature - * count of the credential used for the assertion. - * - *

You should update this value in your database. - * - * @see AuthenticatorData#getSignatureCounter() - */ - private final long signatureCount; - /** * true if and only if at least one of the following is true: * @@ -96,65 +95,20 @@ public class AssertionResult { */ private final boolean signatureCounterValid; - private final ClientAssertionExtensionOutputs clientExtensionOutputs; - - private final AuthenticatorAssertionExtensionOutputs authenticatorExtensionOutputs; - - private AssertionResult( - boolean success, - @NonNull @JsonProperty("credential") RegisteredCredential credential, - @NonNull String username, - long signatureCount, - boolean signatureCounterValid, - ClientAssertionExtensionOutputs clientExtensionOutputs, - AuthenticatorAssertionExtensionOutputs authenticatorExtensionOutputs) { - this( - success, - credential, - username, - null, - null, - signatureCount, - signatureCounterValid, - clientExtensionOutputs, - authenticatorExtensionOutputs); - } - @JsonCreator - private AssertionResult( + AssertionResult( @JsonProperty("success") boolean success, + @NonNull @JsonProperty("credentialResponse") + PublicKeyCredential + credentialResponse, @NonNull @JsonProperty("credential") RegisteredCredential credential, @NonNull @JsonProperty("username") String username, - @JsonProperty("credentialId") ByteArray credentialId, // TODO: Delete in next major release - @JsonProperty("userHandle") ByteArray userHandle, // TODO: Delete in next major release - @JsonProperty("signatureCount") long signatureCount, - @JsonProperty("signatureCounterValid") boolean signatureCounterValid, - @JsonProperty("clientExtensionOutputs") - ClientAssertionExtensionOutputs clientExtensionOutputs, - @JsonProperty("authenticatorExtensionOutputs") - AuthenticatorAssertionExtensionOutputs authenticatorExtensionOutputs) { + @JsonProperty("signatureCounterValid") boolean signatureCounterValid) { this.success = success; + this.credentialResponse = credentialResponse; this.credential = credential; this.username = username; - - if (credentialId != null) { - ExceptionUtil.assure( - credential.getCredentialId().equals(credentialId), - "Legacy credentialId is present and does not equal credential.credentialId"); - } - if (userHandle != null) { - ExceptionUtil.assure( - credential.getUserHandle().equals(userHandle), - "Legacy userHandle is present and does not equal credential.userHandle"); - } - - this.signatureCount = signatureCount; this.signatureCounterValid = signatureCounterValid; - this.clientExtensionOutputs = - clientExtensionOutputs == null || clientExtensionOutputs.getExtensionIds().isEmpty() - ? null - : clientExtensionOutputs; - this.authenticatorExtensionOutputs = authenticatorExtensionOutputs; } /** @@ -168,6 +122,7 @@ private AssertionResult( * getCredentialId()} instead. */ @Deprecated + @JsonIgnore public ByteArray getCredentialId() { return credential.getCredentialId(); } @@ -183,10 +138,76 @@ public ByteArray getCredentialId() { * getUserHandle()} instead. */ @Deprecated + @JsonIgnore public ByteArray getUserHandle() { return credential.getUserHandle(); } + /** + * Check whether the asserted credential is backup eligible, using the BE flag in the authenticator data. + * + *

You SHOULD store this value in your representation of the corresponding {@link + * RegisteredCredential} if no value is stored yet. {@link CredentialRepository} implementations + * SHOULD set this value as the {@link + * RegisteredCredential.RegisteredCredentialBuilder#backupEligible(Boolean) + * backupEligible(Boolean)} value when reconstructing that {@link RegisteredCredential}. + * + * @return true if and only if the created credential is backup eligible. NOTE that + * this is only a hint and not a guarantee, unless backed by a trusted authenticator + * attestation. + * @see Backup Eligible in §4. + * Terminology + * @see BE flag in §6.1. Authenticator + * Data + * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change as + * the standard matures. + */ + @Deprecated + @JsonIgnore + public boolean isBackupEligible() { + return credentialResponse.getResponse().getParsedAuthenticatorData().getFlags().BE; + } + + /** + * Get the current backup state of the + * asserted credential, using the BS + * flag in the authenticator data. + * + *

You SHOULD update this value in your representation of a {@link RegisteredCredential}. + * {@link CredentialRepository} implementations SHOULD set this value as the {@link + * RegisteredCredential.RegisteredCredentialBuilder#backupState(Boolean) backupState(Boolean)} + * value when reconstructing that {@link RegisteredCredential}. + * + * @return true if and only if the created credential is believed to currently be + * backed up. NOTE that this is only a hint and not a guarantee, unless backed by a trusted + * authenticator attestation. + * @see Backup State in §4. Terminology + * @see BS flag in §6.1. Authenticator + * Data + * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change as + * the standard matures. + */ + @Deprecated + @JsonIgnore + public boolean isBackedUp() { + return credentialResponse.getResponse().getParsedAuthenticatorData().getFlags().BS; + } + + /** + * The new signature + * count of the credential used for the assertion. + * + *

You should update this value in your database. + * + * @see AuthenticatorData#getSignatureCounter() + */ + @JsonIgnore + public long getSignatureCount() { + return credentialResponse.getResponse().getParsedAuthenticatorData().getSignatureCounter(); + } + /** * The client @@ -200,8 +221,10 @@ public ByteArray getUserHandle() { * @see ClientAssertionExtensionOutputs * @see #getAuthenticatorExtensionOutputs() () */ + @JsonIgnore public Optional getClientExtensionOutputs() { - return Optional.ofNullable(clientExtensionOutputs); + return Optional.of(credentialResponse.getClientExtensionResults()) + .filter(ceo -> !ceo.getExtensionIds().isEmpty()); } /** @@ -217,65 +240,9 @@ public Optional getClientExtensionOutputs() { * @see AuthenticatorAssertionExtensionOutputs * @see #getClientExtensionOutputs() */ + @JsonIgnore public Optional getAuthenticatorExtensionOutputs() { - return Optional.ofNullable(authenticatorExtensionOutputs); - } - - static AssertionResultBuilder.MandatoryStages builder() { - return new AssertionResultBuilder.MandatoryStages(); - } - - static class AssertionResultBuilder { - public static class MandatoryStages { - private final AssertionResultBuilder builder = new AssertionResultBuilder(); - - public Step2 success(boolean success) { - builder.success(success); - return new Step2(); - } - - public class Step2 { - public Step3 credential(RegisteredCredential credential) { - builder.credential(credential); - return new Step3(); - } - } - - public class Step3 { - public Step4 username(String username) { - builder.username(username); - return new Step4(); - } - } - - public class Step4 { - public Step5 signatureCount(long signatureCount) { - builder.signatureCount(signatureCount); - return new Step5(); - } - } - - public class Step5 { - public Step6 signatureCounterValid(boolean signatureCounterValid) { - builder.signatureCounterValid(signatureCounterValid); - return new Step6(); - } - } - - public class Step6 { - public Step7 clientExtensionOutputs( - ClientAssertionExtensionOutputs clientExtensionOutputs) { - builder.clientExtensionOutputs(clientExtensionOutputs); - return new Step7(); - } - } - - public class Step7 { - public AssertionResultBuilder assertionExtensionOutputs( - AuthenticatorAssertionExtensionOutputs authenticatorExtensionOutputs) { - return builder.authenticatorExtensionOutputs(authenticatorExtensionOutputs); - } - } - } + return AuthenticatorAssertionExtensionOutputs.fromAuthenticatorData( + credentialResponse.getResponse().getParsedAuthenticatorData()); } } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionSteps.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionSteps.java index c2388207e..c0dc4a499 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionSteps.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionSteps.java @@ -31,7 +31,6 @@ import com.yubico.webauthn.FinishRegistrationSteps.Step19; import com.yubico.webauthn.FinishRegistrationSteps.Step20; import com.yubico.webauthn.FinishRegistrationSteps.Step21; -import com.yubico.webauthn.data.AuthenticatorAssertionExtensionOutputs; import com.yubico.webauthn.data.AuthenticatorAssertionResponse; import com.yubico.webauthn.data.ByteArray; import com.yubico.webauthn.data.COSEAlgorithmIdentifier; @@ -398,7 +397,7 @@ public Step17 nextStep() { } @Value - class Step17 implements Step { + class Step17 implements Step { private final String username; private final RegisteredCredential credential; @@ -414,6 +413,31 @@ public void validate() { } } + @Override + public PendingStep16 nextStep() { + return new PendingStep16(username, credential); + } + } + + @Value + // Step 16 in editor's draft as of 2022-11-09 https://w3c.github.io/webauthn/ + // TODO: Finalize this when spec matures + class PendingStep16 implements Step { + private final String username; + private final RegisteredCredential credential; + + @Override + public void validate() { + assure( + !credential.isBackupEligible().isPresent() + || response.getResponse().getParsedAuthenticatorData().getFlags().BE + == credential.isBackupEligible().get(), + "Backup eligibility must not change; Stored: BE=%s, received: BE=%s for credential: %s", + credential.isBackupEligible(), + response.getResponse().getParsedAuthenticatorData().getFlags().BE, + credential.getCredentialId()); + } + @Override public Step18 nextStep() { return new Step18(username, credential); @@ -553,18 +577,7 @@ public Finished nextStep() { @Override public Optional result() { return Optional.of( - AssertionResult.builder() - .success(true) - .credential(credential) - .username(username) - .signatureCount(assertionSignatureCount) - .signatureCounterValid(signatureCounterValid) - .clientExtensionOutputs(response.getClientExtensionResults()) - .assertionExtensionOutputs( - AuthenticatorAssertionExtensionOutputs.fromAuthenticatorData( - response.getResponse().getParsedAuthenticatorData()) - .orElse(null)) - .build()); + new AssertionResult(true, response, credential, username, signatureCounterValid)); } } } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishRegistrationSteps.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishRegistrationSteps.java index 3aa0a2906..fe127aa06 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishRegistrationSteps.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishRegistrationSteps.java @@ -34,14 +34,12 @@ import com.yubico.webauthn.data.AttestationObject; import com.yubico.webauthn.data.AttestationType; import com.yubico.webauthn.data.AuthenticatorAttestationResponse; -import com.yubico.webauthn.data.AuthenticatorRegistrationExtensionOutputs; import com.yubico.webauthn.data.AuthenticatorSelectionCriteria; import com.yubico.webauthn.data.ByteArray; import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs; import com.yubico.webauthn.data.CollectedClientData; import com.yubico.webauthn.data.PublicKeyCredential; import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions; -import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; import com.yubico.webauthn.data.UserVerificationRequirement; import java.io.IOException; import java.security.InvalidAlgorithmParameterException; @@ -640,43 +638,8 @@ public Finished nextStep() { @Override public Optional result() { return Optional.of( - RegistrationResult.builder() - .keyId(keyId()) - .aaguid( - response - .getResponse() - .getAttestation() - .getAuthenticatorData() - .getAttestedCredentialData() - .get() - .getAaguid()) - .attestationTrusted(attestationTrusted) - .attestationType(attestationType) - .publicKeyCose( - response - .getResponse() - .getAttestation() - .getAuthenticatorData() - .getAttestedCredentialData() - .get() - .getCredentialPublicKey()) - .signatureCount( - response.getResponse().getParsedAuthenticatorData().getSignatureCounter()) - .clientExtensionOutputs(response.getClientExtensionResults()) - .authenticatorExtensionOutputs( - AuthenticatorRegistrationExtensionOutputs.fromAuthenticatorData( - response.getResponse().getParsedAuthenticatorData()) - .orElse(null)) - .attestationTrustPath(attestationTrustPath.orElse(null)) - .build()); - } - - private PublicKeyCredentialDescriptor keyId() { - return PublicKeyCredentialDescriptor.builder() - .id(response.getId()) - .type(response.getType()) - .transports(response.getResponse().getTransports()) - .build(); + new RegistrationResult( + response, attestationTrusted, attestationType, attestationTrustPath)); } } } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegisteredCredential.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegisteredCredential.java index 41012537a..eeba1d362 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegisteredCredential.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegisteredCredential.java @@ -33,7 +33,10 @@ import com.yubico.webauthn.data.COSEAlgorithmIdentifier; import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; import com.yubico.webauthn.data.UserIdentity; +import java.util.Optional; +import lombok.AccessLevel; import lombok.Builder; +import lombok.Getter; import lombok.NonNull; import lombok.Value; @@ -95,16 +98,116 @@ public final class RegisteredCredential { */ @Builder.Default private final long signatureCount = 0; + /** + * The state of the BE flag when + * this credential was registered, if known. + * + *

If absent, it is not known whether or not this credential is backup eligible. + * + *

If present and true, the credential is backup eligible: it can be backed up in + * some way, most commonly by syncing the private key to a cloud account. + * + *

If present and false, the credential is not backup eligible: it cannot be + * backed up in any way. + * + *

{@link CredentialRepository} implementations SHOULD set this to the first known value + * returned by {@link RegistrationResult#isBackupEligible()} or {@link + * AssertionResult#isBackupEligible()}, if known. If unknown, {@link CredentialRepository} + * implementations SHOULD set this to null or not set this value. + * + * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change as + * the standard matures. + */ + @Deprecated + @Getter(AccessLevel.NONE) + @Builder.Default + private final Boolean backupEligible = null; + + /** + * The last known state of the BS + * flag for this credential, if known. + * + *

If absent, the backup state of the credential is not known. + * + *

If present and true, the credential is believed to be currently backed up. + * + *

If present and false, the credential is believed to not be currently backed up. + * + *

{@link CredentialRepository} implementations SHOULD set this to the most recent value + * returned by {@link AssertionResult#isBackedUp()} or {@link RegistrationResult#isBackedUp()}, if + * known. If unknown, {@link CredentialRepository} implementations SHOULD set this to null + * or not set this value. + * + * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change as + * the standard matures. + */ + @Deprecated + @Getter(AccessLevel.NONE) + @Builder.Default + private final Boolean backupState = null; + @JsonCreator private RegisteredCredential( @NonNull @JsonProperty("credentialId") ByteArray credentialId, @NonNull @JsonProperty("userHandle") ByteArray userHandle, @NonNull @JsonProperty("publicKeyCose") ByteArray publicKeyCose, - @JsonProperty("signatureCount") long signatureCount) { + @JsonProperty("signatureCount") long signatureCount, + @JsonProperty("backupEligible") Boolean backupEligible, + @JsonProperty("backupState") Boolean backupState) { this.credentialId = credentialId; this.userHandle = userHandle; this.publicKeyCose = publicKeyCose; this.signatureCount = signatureCount; + this.backupEligible = backupEligible; + this.backupState = backupState; + } + + /** + * The state of the BE flag when + * this credential was registered, if known. + * + *

If absent, it is not known whether or not this credential is backup eligible. + * + *

If present and true, the credential is backup eligible: it can be backed up in + * some way, most commonly by syncing the private key to a cloud account. + * + *

If present and false, the credential is not backup eligible: it cannot be + * backed up in any way. + * + *

{@link CredentialRepository} implementations SHOULD set this to the first known value + * returned by {@link RegistrationResult#isBackupEligible()} or {@link + * AssertionResult#isBackupEligible()}, if known. If unknown, {@link CredentialRepository} + * implementations SHOULD set this to null or not set this value. + * + * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change as + * the standard matures. + */ + @Deprecated + public Optional isBackupEligible() { + return Optional.ofNullable(backupEligible); + } + + /** + * The last known state of the BS + * flag for this credential, if known. + * + *

If absent, the backup state of the credential is not known. + * + *

If present and true, the credential is believed to be currently backed up. + * + *

If present and false, the credential is believed to not be currently backed up. + * + *

{@link CredentialRepository} implementations SHOULD set this to the most recent value + * returned by {@link AssertionResult#isBackedUp()} or {@link RegistrationResult#isBackedUp()}, if + * known. If unknown, {@link CredentialRepository} implementations SHOULD set this to null + * or not set this value. + * + * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change as + * the standard matures. + */ + @Deprecated + public Optional isBackedUp() { + return Optional.ofNullable(backupState); } public static RegisteredCredentialBuilder.MandatoryStages builder() { diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java index 1e4eb01b7..88bbcfdbb 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RegistrationResult.java @@ -31,6 +31,7 @@ import com.yubico.webauthn.RelyingParty.RelyingPartyBuilder; import com.yubico.webauthn.attestation.AttestationTrustSource; import com.yubico.webauthn.data.AttestationType; +import com.yubico.webauthn.data.AuthenticatorAttestationResponse; import com.yubico.webauthn.data.AuthenticatorRegistrationExtensionOutputs; import com.yubico.webauthn.data.ByteArray; import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs; @@ -42,39 +43,20 @@ import java.util.List; import java.util.Optional; import java.util.stream.Collectors; -import lombok.Builder; +import lombok.AccessLevel; +import lombok.Getter; import lombok.NonNull; import lombok.Value; /** The result of a call to {@link RelyingParty#finishRegistration(FinishRegistrationOptions)}. */ @Value -@Builder(toBuilder = true) public class RegistrationResult { - /** - * The credential - * ID and transports - * of the created credential. - * - * @see Credential - * ID - * @see 5.8.3. - * Credential Descriptor (dictionary PublicKeyCredentialDescriptor) - * @see PublicKeyCredential#getId() - */ - @NonNull private final PublicKeyCredentialDescriptor keyId; - - /** - * The aaguid - * reported in the of the - * created credential. - * - *

This MAY be an AAGUID consisting of only zeroes. - */ - @NonNull private final ByteArray aaguid; + @JsonProperty + @Getter(AccessLevel.NONE) + private final PublicKeyCredential< + AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> + credential; /** * true if and only if the attestation signature was successfully linked to a trusted @@ -104,123 +86,166 @@ public class RegistrationResult { // JavaDoc on getter private final List attestationTrustPath; - /** - * The public key of the created credential. - * - *

This is used in {@link RelyingParty#finishAssertion(FinishAssertionOptions)} to verify the - * authentication signatures. - * - * @see RegisteredCredential#getPublicKeyCose() - */ - @NonNull private final ByteArray publicKeyCose; - - /** - * The signature count returned with the created credential. - * - *

This is used in {@link RelyingParty#finishAssertion(FinishAssertionOptions)} to verify the - * validity of future signature counter values. - * - * @see RegisteredCredential#getSignatureCount() - */ - private final long signatureCount; - - private final ClientRegistrationExtensionOutputs clientExtensionOutputs; - - private final AuthenticatorRegistrationExtensionOutputs authenticatorExtensionOutputs; - - private RegistrationResult( - @NonNull PublicKeyCredentialDescriptor keyId, - @NonNull ByteArray aaguid, + RegistrationResult( + PublicKeyCredential + credential, boolean attestationTrusted, @NonNull AttestationType attestationType, - List attestationTrustPath, - @NonNull ByteArray publicKeyCose, - Long signatureCount, - ClientRegistrationExtensionOutputs clientExtensionOutputs, - AuthenticatorRegistrationExtensionOutputs authenticatorExtensionOutputs) { - this.keyId = keyId; - this.aaguid = aaguid; + Optional> attestationTrustPath) { + this.credential = credential; this.attestationTrusted = attestationTrusted; this.attestationType = attestationType; - this.attestationTrustPath = attestationTrustPath; - this.publicKeyCose = publicKeyCose; - this.signatureCount = signatureCount == null ? 0 : signatureCount; - this.clientExtensionOutputs = - clientExtensionOutputs == null || clientExtensionOutputs.getExtensionIds().isEmpty() - ? null - : clientExtensionOutputs; - this.authenticatorExtensionOutputs = authenticatorExtensionOutputs; + this.attestationTrustPath = attestationTrustPath.orElse(null); } @JsonCreator private static RegistrationResult fromJson( - @NonNull @JsonProperty("keyId") PublicKeyCredentialDescriptor keyId, - @NonNull @JsonProperty("aaguid") ByteArray aaguid, + @NonNull @JsonProperty("credential") + PublicKeyCredential + credential, @JsonProperty("attestationTrusted") boolean attestationTrusted, @NonNull @JsonProperty("attestationType") AttestationType attestationType, - @JsonProperty("attestationTrustPath") List attestationTrustPath, - @NonNull @JsonProperty("publicKeyCose") ByteArray publicKeyCose, - @JsonProperty("signatureCount") Long signatureCount, - @JsonProperty("clientExtensionOutputs") - ClientRegistrationExtensionOutputs clientExtensionOutputs, - @JsonProperty("authenticatorExtensionOutputs") - AuthenticatorRegistrationExtensionOutputs authenticatorExtensionOutputs) { + @NonNull @JsonProperty("attestationTrustPath") Optional> attestationTrustPath) { return new RegistrationResult( - keyId, - aaguid, + credential, attestationTrusted, attestationType, - attestationTrustPath.stream() - .map( - pem -> { - try { - return CertificateParser.parsePem(pem); - } catch (CertificateException e) { - throw new RuntimeException(e); - } - }) - .collect(Collectors.toList()), - publicKeyCose, - signatureCount, - clientExtensionOutputs, - authenticatorExtensionOutputs); + attestationTrustPath.map( + atp -> + atp.stream() + .map( + pem -> { + try { + return CertificateParser.parsePem(pem); + } catch (CertificateException e) { + throw new RuntimeException(e); + } + }) + .collect(Collectors.toList()))); } /** - * The attestation - * trust path for the created credential, if any. + * Check whether the created credential is backup eligible, using the BE flag in the authenticator data. * - *

If present, this may be useful for looking up attestation metadata from external sources. - * The attestation trust path has been successfully verified as trusted if and only if {@link - * #isAttestationTrusted()} is true. + *

You SHOULD store this value in your representation of a {@link RegisteredCredential}. {@link + * CredentialRepository} implementations SHOULD set this value as the {@link + * RegisteredCredential.RegisteredCredentialBuilder#backupEligible(Boolean) + * backupEligible(Boolean)} value when reconstructing that {@link RegisteredCredential}. * - *

You can ignore this if authenticator attestation is not relevant to your application. + * @return true if and only if the created credential is backup eligible. NOTE that + * this is only a hint and not a guarantee, unless backed by a trusted authenticator + * attestation. + * @see Backup Eligible in §4. + * Terminology + * @see BE flag in §6.1. Authenticator + * Data + * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change as + * the standard matures. + */ + @Deprecated + @JsonIgnore + public boolean isBackupEligible() { + return credential.getResponse().getParsedAuthenticatorData().getFlags().BE; + } + + /** + * Get the current backup state of the + * created credential, using the BS + * flag in the authenticator data. + * + *

You SHOULD store this value in your representation of a {@link RegisteredCredential}. {@link + * CredentialRepository} implementations SHOULD set this value as the {@link + * RegisteredCredential.RegisteredCredentialBuilder#backupState(Boolean) backupState(Boolean)} + * value when reconstructing that {@link RegisteredCredential}. + * + * @return true if and only if the created credential is believed to currently be + * backed up. NOTE that this is only a hint and not a guarantee, unless backed by a trusted + * authenticator attestation. + * @see Backup State in §4. Terminology + * @see BS flag in §6.1. Authenticator + * Data + * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change as + * the standard matures. + */ + @Deprecated + @JsonIgnore + public boolean isBackedUp() { + return credential.getResponse().getParsedAuthenticatorData().getFlags().BS; + } + + /** + * The signature count returned with the created credential. + * + *

This is used in {@link RelyingParty#finishAssertion(FinishAssertionOptions)} to verify the + * validity of future signature counter values. * + * @see RegisteredCredential#getSignatureCount() + */ + @JsonIgnore + public long getSignatureCount() { + return credential.getResponse().getParsedAuthenticatorData().getSignatureCounter(); + } + + /** + * The credential + * ID and transports + * of the created credential. + * + * @see Credential + * ID * @see Attestation - * trust path + * href="https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#dictionary-credential-descriptor">5.8.3. + * Credential Descriptor (dictionary PublicKeyCredentialDescriptor) + * @see PublicKeyCredential#getId() */ @JsonIgnore - public Optional> getAttestationTrustPath() { - return Optional.ofNullable(attestationTrustPath); + public PublicKeyCredentialDescriptor getKeyId() { + return PublicKeyCredentialDescriptor.builder() + .id(credential.getId()) + .type(credential.getType()) + .transports(credential.getResponse().getTransports()) + .build(); } - @JsonProperty("attestationTrustPath") - private Optional> getAttestationTrustPathJson() { - return getAttestationTrustPath() - .map( - x5c -> - x5c.stream() - .map( - cert -> { - try { - return new ByteArray(cert.getEncoded()).getBase64(); - } catch (CertificateEncodingException e) { - throw new RuntimeException(e); - } - }) - .collect(Collectors.toList())); + /** + * The aaguid + * reported in the of the + * created credential. + * + *

This MAY be an AAGUID consisting of only zeroes. + */ + @JsonIgnore + public ByteArray getAaguid() { + return credential + .getResponse() + .getAttestation() + .getAuthenticatorData() + .getAttestedCredentialData() + .get() + .getAaguid(); + } + + /** + * The public key of the created credential. + * + *

This is used in {@link RelyingParty#finishAssertion(FinishAssertionOptions)} to verify the + * authentication signatures. + * + * @see RegisteredCredential#getPublicKeyCose() + */ + @JsonIgnore + public ByteArray getPublicKeyCose() { + return credential + .getResponse() + .getAttestation() + .getAuthenticatorData() + .getAttestedCredentialData() + .get() + .getCredentialPublicKey(); } /** @@ -236,8 +261,10 @@ private Optional> getAttestationTrustPathJson() { * @see ClientRegistrationExtensionOutputs * @see #getAuthenticatorExtensionOutputs() () */ + @JsonIgnore public Optional getClientExtensionOutputs() { - return Optional.ofNullable(clientExtensionOutputs); + return Optional.ofNullable(credential.getClientExtensionResults()) + .filter(ceo -> !ceo.getExtensionIds().isEmpty()); } /** @@ -253,8 +280,10 @@ public Optional getClientExtensionOutputs() * @see AuthenticatorRegistrationExtensionOutputs * @see #getClientExtensionOutputs() */ + @JsonIgnore public Optional getAuthenticatorExtensionOutputs() { - return Optional.ofNullable(authenticatorExtensionOutputs); + return AuthenticatorRegistrationExtensionOutputs.fromAuthenticatorData( + credential.getResponse().getParsedAuthenticatorData()); } /** @@ -274,73 +303,47 @@ public Optional getAuthenticatorExten * href="https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#discoverable-credential">Discoverable * Credential */ + @JsonIgnore public Optional isDiscoverable() { return getClientExtensionOutputs() .flatMap(outputs -> outputs.getCredProps()) .flatMap(credProps -> credProps.getRk()); } - static RegistrationResultBuilder.MandatoryStages builder() { - return new RegistrationResultBuilder.MandatoryStages(); + /** + * The attestation + * trust path for the created credential, if any. + * + *

If present, this may be useful for looking up attestation metadata from external sources. + * The attestation trust path has been successfully verified as trusted if and only if {@link + * #isAttestationTrusted()} is true. + * + *

You can ignore this if authenticator attestation is not relevant to your application. + * + * @see Attestation + * trust path + */ + @JsonIgnore + public Optional> getAttestationTrustPath() { + return Optional.ofNullable(attestationTrustPath); } - static class RegistrationResultBuilder { - static class MandatoryStages { - private final RegistrationResultBuilder builder = new RegistrationResultBuilder(); - - Step2 keyId(PublicKeyCredentialDescriptor keyId) { - builder.keyId(keyId); - return new Step2(); - } - - class Step2 { - Step3 aaguid(ByteArray aaguid) { - builder.aaguid(aaguid); - return new Step3(); - } - } - - class Step3 { - Step4 attestationTrusted(boolean attestationTrusted) { - builder.attestationTrusted(attestationTrusted); - return new Step4(); - } - } - - class Step4 { - Step5 attestationType(AttestationType attestationType) { - builder.attestationType(attestationType); - return new Step5(); - } - } - - class Step5 { - Step6 publicKeyCose(ByteArray publicKeyCose) { - builder.publicKeyCose(publicKeyCose); - return new Step6(); - } - } - - class Step6 { - Step7 signatureCount(long signatureCount) { - builder.signatureCount(signatureCount); - return new Step7(); - } - } - - class Step7 { - Step8 clientExtensionOutputs(ClientRegistrationExtensionOutputs clientExtensionOutputs) { - builder.clientExtensionOutputs(clientExtensionOutputs); - return new Step8(); - } - } - - class Step8 { - RegistrationResultBuilder authenticatorExtensionOutputs( - AuthenticatorRegistrationExtensionOutputs authenticatorExtensionOutputs) { - return builder.authenticatorExtensionOutputs(authenticatorExtensionOutputs); - } - } - } + @JsonProperty("attestationTrustPath") + private Optional> getAttestationTrustPathJson() { + return getAttestationTrustPath() + .map( + x5c -> + x5c.stream() + .map( + cert -> { + try { + return new ByteArray(cert.getEncoded()).getBase64(); + } catch (CertificateEncodingException e) { + throw new RuntimeException(e); + } + }) + .collect(Collectors.toList())); } } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AuthenticatorAssertionResponse.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AuthenticatorAssertionResponse.java index f4925bff6..f168f9a9b 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AuthenticatorAssertionResponse.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AuthenticatorAssertionResponse.java @@ -72,6 +72,11 @@ public class AuthenticatorAssertionResponse implements AuthenticatorResponse { */ private final ByteArray userHandle; + // This overrides the default getter in AuthenticatorResponse which re-parses the authenticator + // data on every invocation. This "optimization" has no measurable impact on performance, but it + // seems rude to obviously waste cycles for nothing. + private final transient AuthenticatorData parsedAuthenticatorData; + @NonNull @Getter(onMethod = @__({@Override})) private final transient CollectedClientData clientData; @@ -85,6 +90,7 @@ private AuthenticatorAssertionResponse( @JsonProperty("userHandle") final ByteArray userHandle) throws IOException, Base64UrlException { this.authenticatorData = authenticatorData; + this.parsedAuthenticatorData = new AuthenticatorData(authenticatorData); this.clientDataJSON = clientDataJSON; this.signature = signature; this.userHandle = userHandle; diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AuthenticatorAttestationResponse.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AuthenticatorAttestationResponse.java index 918ca1f3a..22ba3bce3 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AuthenticatorAttestationResponse.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AuthenticatorAttestationResponse.java @@ -113,6 +113,14 @@ private AuthenticatorAttestationResponse( this.clientData = new CollectedClientData(clientDataJSON); } + // The default getter in AuthenticatorResponse re-parses the authenticator data on every + // invocation. This "optimization" override has no measurable impact on performance, but it seems + // rude to obviously waste cycles for nothing. + @Override + public AuthenticatorData getParsedAuthenticatorData() { + return attestation.getAuthenticatorData(); + } + public static AuthenticatorAttestationResponseBuilder.MandatoryStages builder() { return new AuthenticatorAttestationResponseBuilder.MandatoryStages(); } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AuthenticatorDataFlags.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AuthenticatorDataFlags.java index 567ab2134..3d123010b 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AuthenticatorDataFlags.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/data/AuthenticatorDataFlags.java @@ -45,6 +45,30 @@ public final class AuthenticatorDataFlags { /** User verified */ public final boolean UV; + /** + * Backup eligible: the credential can and is allowed to be backed up. + * + *

NOTE that this is only a hint and not a guarantee, unless backed by a trusted authenticator + * attestation. + * + * @see §6.1. Authenticator Data + * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change as + * the standard matures. + */ + @Deprecated public final boolean BE; + + /** + * Backup status: the credential is currently backed up. + * + *

NOTE that this is only a hint and not a guarantee, unless backed by a trusted authenticator + * attestation. + * + * @see §6.1. Authenticator Data + * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change as + * the standard matures. + */ + @Deprecated public final boolean BS; + /** * Attested credential data present. * @@ -68,20 +92,27 @@ public AuthenticatorDataFlags(@JsonProperty("value") byte value) { UP = (value & Bitmasks.UP) != 0; UV = (value & Bitmasks.UV) != 0; + BE = (value & Bitmasks.BE) != 0; + BS = (value & Bitmasks.BS) != 0; AT = (value & Bitmasks.AT) != 0; ED = (value & Bitmasks.ED) != 0; + + if (BS && !BE) { + throw new IllegalArgumentException( + String.format("Flag combination is invalid: BE=0, BS=1 in flags: 0x%02x", value)); + } } private static final class Bitmasks { static final byte UP = 0x01; static final byte UV = 0x04; + static final byte BE = 0x08; + static final byte BS = 0x10; static final byte AT = 0x40; static final byte ED = -0x80; /* Reserved bits */ - // final boolean RFU1 = (value & 0x02) > 0; - // final boolean RFU2_1 = (value & 0x08) > 0; - // final boolean RFU2_2 = (value & 0x10) > 0; - // static final boolean RFU2_3 = (value & 0x20) > 0; + // static final byte RFU1 = 0x02; + // static final byte RFU2 = 0x20; } } diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/Generators.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/Generators.scala index 15d346337..6053501c3 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/Generators.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/Generators.scala @@ -2,13 +2,13 @@ package com.yubico.webauthn import com.yubico.webauthn.data.AssertionExtensionInputs import com.yubico.webauthn.data.AttestationType -import com.yubico.webauthn.data.AuthenticatorAssertionExtensionOutputs -import com.yubico.webauthn.data.AuthenticatorRegistrationExtensionOutputs +import com.yubico.webauthn.data.AuthenticatorAssertionResponse +import com.yubico.webauthn.data.AuthenticatorAttestationResponse import com.yubico.webauthn.data.ByteArray import com.yubico.webauthn.data.ClientAssertionExtensionOutputs import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs import com.yubico.webauthn.data.Generators._ -import com.yubico.webauthn.data.PublicKeyCredentialDescriptor +import com.yubico.webauthn.data.PublicKeyCredential import com.yubico.webauthn.data.UserVerificationRequirement import org.bouncycastle.asn1.x500.X500Name import org.scalacheck.Arbitrary @@ -17,56 +17,47 @@ import org.scalacheck.Gen import java.security.cert.X509Certificate import scala.jdk.CollectionConverters.SeqHasAsJava +import scala.jdk.OptionConverters.RichOption object Generators { implicit val arbitraryAssertionResult: Arbitrary[AssertionResult] = Arbitrary( for { - authenticatorExtensionOutputs <- - arbitrary[Option[AuthenticatorAssertionExtensionOutputs]] - clientExtensionOutputs <- arbitrary[ClientAssertionExtensionOutputs] + credentialResponse <- + arbitrary[PublicKeyCredential[ + AuthenticatorAssertionResponse, + ClientAssertionExtensionOutputs, + ]] credential <- arbitrary[RegisteredCredential] - signatureCount <- arbitrary[Long] signatureCounterValid <- arbitrary[Boolean] success <- arbitrary[Boolean] username <- arbitrary[String] - } yield AssertionResult - .builder() - .success(success) - .credential(credential) - .username(username) - .signatureCount(signatureCount) - .signatureCounterValid(signatureCounterValid) - .clientExtensionOutputs(clientExtensionOutputs) - .assertionExtensionOutputs(authenticatorExtensionOutputs.orNull) - .build() + } yield new AssertionResult( + success, + credentialResponse, + credential, + username, + signatureCounterValid, + ) ) implicit val arbitraryRegistrationResult: Arbitrary[RegistrationResult] = Arbitrary( for { - aaguid <- byteArray(16) + credential <- + arbitrary[PublicKeyCredential[ + AuthenticatorAttestationResponse, + ClientRegistrationExtensionOutputs, + ]] attestationTrusted <- arbitrary[Boolean] attestationTrustPath <- generateAttestationCertificateChain attestationType <- arbitrary[AttestationType] - authenticatorExtensionOutputs <- - arbitrary[Option[AuthenticatorRegistrationExtensionOutputs]] - clientExtensionOutputs <- arbitrary[ClientRegistrationExtensionOutputs] - keyId <- arbitrary[PublicKeyCredentialDescriptor] - publicKeyCose <- arbitrary[ByteArray] - signatureCount <- arbitrary[Long] - } yield RegistrationResult - .builder() - .keyId(keyId) - .aaguid(aaguid) - .attestationTrusted(attestationTrusted) - .attestationType(attestationType) - .publicKeyCose(publicKeyCose) - .signatureCount(signatureCount) - .clientExtensionOutputs(clientExtensionOutputs) - .authenticatorExtensionOutputs(authenticatorExtensionOutputs.orNull) - .attestationTrustPath(attestationTrustPath.asJava) - .build() + } yield new RegistrationResult( + credential, + attestationTrusted, + attestationType, + Some(attestationTrustPath.asJava).toJava, + ) ) implicit val arbitraryRegisteredCredential: Arbitrary[RegisteredCredential] = diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyAssertionSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyAssertionSpec.scala index 604343faf..8f9e309c7 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyAssertionSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyAssertionSpec.scala @@ -31,6 +31,7 @@ import com.upokecenter.cbor.CBORObject import com.yubico.internal.util.JacksonCodecs import com.yubico.webauthn.data.AssertionExtensionInputs import com.yubico.webauthn.data.AuthenticatorAssertionResponse +import com.yubico.webauthn.data.AuthenticatorDataFlags import com.yubico.webauthn.data.AuthenticatorTransport import com.yubico.webauthn.data.ByteArray import com.yubico.webauthn.data.ClientAssertionExtensionOutputs @@ -1468,7 +1469,10 @@ class RelyingPartyAssertionSpec .toArray ) val (checkFails, checkSucceeds) = - checks[FinishAssertionSteps#Step18, FinishAssertionSteps#Step17]( + checks[ + FinishAssertionSteps#PendingStep16, + FinishAssertionSteps#Step17, + ]( _.begin.next.next.next.next.next.next.next.next.next.next.next ) @@ -1498,6 +1502,80 @@ class RelyingPartyAssertionSpec } } + describe("(NOT YET MATURE) 16. If the credential backup state is used as part of Relying Party business logic or policy, let currentBe and currentBs be the values of the BE and BS bits, respectively, of the flags in authData. Compare currentBe and currentBs with credentialRecord.BE and credentialRecord.BS and apply Relying Party policy, if any.") { + it( + "Fails if BE=0 in the stored credential and BE=1 in the assertion." + ) { + forAll( + authenticatorDataBytes( + Gen.option(Extensions.authenticatorAssertionExtensionOutputs()), + rpIdHashGen = Gen.const(sha256(Defaults.rpId.getId)), + backupFlagsGen = arbitrary[Boolean].map(bs => (true, bs)), + ) + ) { authData => + val step: FinishAssertionSteps#PendingStep16 = finishAssertion( + authenticatorData = authData, + credentialRepository = Some( + Helpers.CredentialRepository.withUser( + Defaults.user, + RegisteredCredential + .builder() + .credentialId(Defaults.credentialId) + .userHandle(Defaults.userHandle) + .publicKeyCose(getPublicKeyBytes(Defaults.credentialKey)) + .backupEligible(false) + .backupState(false) + .build(), + ) + ), + ).begin.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[IllegalArgumentException] + step.tryNext shouldBe a[Failure[_]] + } + } + + it( + "Fails if BE=1 in the stored credential and BE=0 in the assertion." + ) { + forAll( + authenticatorDataBytes( + Gen.option(Extensions.authenticatorAssertionExtensionOutputs()), + rpIdHashGen = Gen.const(sha256(Defaults.rpId.getId)), + backupFlagsGen = Gen.const((false, false)), + ), + arbitrary[Boolean], + ) { + case (authData, storedBs) => + val step: FinishAssertionSteps#PendingStep16 = finishAssertion( + authenticatorData = authData, + credentialRepository = Some( + Helpers.CredentialRepository.withUser( + Defaults.user, + RegisteredCredential + .builder() + .credentialId(Defaults.credentialId) + .userHandle(Defaults.userHandle) + .publicKeyCose( + getPublicKeyBytes(Defaults.credentialKey) + ) + .backupEligible(true) + .backupState(storedBs) + .build(), + ) + ), + ).begin.next.next.next.next.next.next.next.next.next.next.next.next + + step.validations shouldBe a[Failure[_]] + step.validations.failed.get shouldBe an[ + IllegalArgumentException + ] + step.tryNext shouldBe a[Failure[_]] + } + } + } + describe("18. Verify that the values of the client extension outputs in clientExtensionResults and the authenticator extension outputs in the extensions in authData are as expected, considering the client extension input values that were given in options.extensions and any specific policy of the Relying Party regarding unsolicited extensions, i.e., those that were not specified as part of options.extensions. In the general case, the meaning of \"are as expected\" is specific to the Relying Party and which extensions are in use.") { it("Succeeds if clientExtensionResults is not a subset of the extensions requested by the Relying Party.") { forAll(Extensions.unrequestedClientAssertionExtensions) { @@ -1507,7 +1585,7 @@ class RelyingPartyAssertionSpec clientExtensionResults = clientExtensionOutputs, ) val step: FinishAssertionSteps#Step18 = - steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] step.tryNext shouldBe a[Success[_]] @@ -1522,7 +1600,7 @@ class RelyingPartyAssertionSpec clientExtensionResults = clientExtensionOutputs, ) val step: FinishAssertionSteps#Step18 = - steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] step.tryNext shouldBe a[Success[_]] @@ -1547,7 +1625,7 @@ class RelyingPartyAssertionSpec ), ) val step: FinishAssertionSteps#Step18 = - steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] step.tryNext shouldBe a[Success[_]] @@ -1572,7 +1650,7 @@ class RelyingPartyAssertionSpec ), ) val step: FinishAssertionSteps#Step18 = - steps.begin.next.next.next.next.next.next.next.next.next.next.next.next + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] step.tryNext shouldBe a[Success[_]] @@ -1583,7 +1661,7 @@ class RelyingPartyAssertionSpec it("19. Let hash be the result of computing a hash over the cData using SHA-256.") { val steps = finishAssertion() val step: FinishAssertionSteps#Step19 = - steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] step.tryNext shouldBe a[Success[_]] @@ -1600,7 +1678,7 @@ class RelyingPartyAssertionSpec it("The default test case succeeds.") { val steps = finishAssertion() val step: FinishAssertionSteps#Step20 = - steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] step.tryNext shouldBe a[Success[_]] @@ -1617,7 +1695,7 @@ class RelyingPartyAssertionSpec ) ) val step: FinishAssertionSteps#Step20 = - steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] step.validations.failed.get shouldBe an[IllegalArgumentException] @@ -1636,7 +1714,7 @@ class RelyingPartyAssertionSpec origins = Some(Set("https://localhost")), ) val step: FinishAssertionSteps#Step20 = - steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] step.validations.failed.get shouldBe an[IllegalArgumentException] @@ -1656,7 +1734,7 @@ class RelyingPartyAssertionSpec ) ) val step: FinishAssertionSteps#Step20 = - steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] step.validations.failed.get shouldBe an[IllegalArgumentException] @@ -1672,7 +1750,7 @@ class RelyingPartyAssertionSpec ) ) val step: FinishAssertionSteps#Step20 = - steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] step.validations.failed.get shouldBe an[IllegalArgumentException] @@ -1719,7 +1797,7 @@ class RelyingPartyAssertionSpec validateSignatureCounter = true, ) val step: FinishAssertionSteps#Step21 = - steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] step.tryNext shouldBe a[Success[_]] @@ -1736,7 +1814,7 @@ class RelyingPartyAssertionSpec validateSignatureCounter = true, ) val step: FinishAssertionSteps#Step21 = - steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Failure[_]] step.tryNext shouldBe a[Failure[_]] @@ -1758,7 +1836,7 @@ class RelyingPartyAssertionSpec validateSignatureCounter = true, ) val step: FinishAssertionSteps#Step21 = - steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] step.tryNext shouldBe a[Success[_]] @@ -1778,7 +1856,7 @@ class RelyingPartyAssertionSpec validateSignatureCounter = false, ) val step: FinishAssertionSteps#Step21 = - steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] step.tryNext shouldBe a[Success[_]] @@ -1792,7 +1870,7 @@ class RelyingPartyAssertionSpec validateSignatureCounter = true, ) val step: FinishAssertionSteps#Step21 = - steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next val result = Try(step.run()) step.validations shouldBe a[Failure[_]] @@ -1821,7 +1899,7 @@ class RelyingPartyAssertionSpec it("22. If all the above steps are successful, continue with the authentication ceremony as appropriate. Otherwise, fail the authentication ceremony.") { val steps = finishAssertion() val step: FinishAssertionSteps#Finished = - steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next + steps.begin.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next.next step.validations shouldBe a[Success[_]] Try(steps.run) shouldBe a[Success[_]] @@ -2377,6 +2455,145 @@ class RelyingPartyAssertionSpec ) } } + + describe("returns AssertionResponse which") { + { + val user = UserIdentity.builder + .name("foo") + .displayName("Foo User") + .id(new ByteArray(Array(0, 1, 2, 3))) + .build() + val (credential, credentialKeypair, _) = + TestAuthenticator.createUnattestedCredential() + val rp = RelyingParty + .builder() + .identity( + RelyingPartyIdentity + .builder() + .id("localhost") + .name("Example RP") + .build() + ) + .credentialRepository( + Helpers.CredentialRepository.withUser( + user, + RegisteredCredential + .builder() + .credentialId(credential.getId) + .userHandle(user.getId) + .publicKeyCose( + credential.getResponse.getParsedAuthenticatorData.getAttestedCredentialData.get.getCredentialPublicKey + ) + .build(), + ) + ) + .build() + + val request = AssertionRequest + .builder() + .publicKeyCredentialRequestOptions( + PublicKeyCredentialRequestOptions + .builder() + .challenge(ByteArray.fromBase64Url("Y2hhbGxlbmdl")) + .rpId("localhost") + .build() + ) + .username(user.getName) + .build() + + it("exposes isBackupEligible() with the BE flag value in authenticator data.") { + val pkcWithoutBackup = + TestAuthenticator.createAssertion( + flags = Some(new AuthenticatorDataFlags(0x00.toByte)), + challenge = + request.getPublicKeyCredentialRequestOptions.getChallenge, + credentialKey = credentialKeypair, + credentialId = credential.getId, + ) + val pkcWithBackup = + TestAuthenticator.createAssertion( + flags = Some(new AuthenticatorDataFlags(0x08.toByte)), + challenge = + request.getPublicKeyCredentialRequestOptions.getChallenge, + credentialKey = credentialKeypair, + credentialId = credential.getId, + ) + + val resultWithoutBackup = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(request) + .response(pkcWithoutBackup) + .build() + ) + val resultWithBackup = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(request) + .response(pkcWithBackup) + .build() + ) + + resultWithoutBackup.isBackupEligible should be(false) + resultWithBackup.isBackupEligible should be(true) + } + + it( + "exposes isBackedUp() with the BS flag value in authenticator data." + ) { + val pkcWithoutBackup = + TestAuthenticator.createAssertion( + flags = Some(new AuthenticatorDataFlags(0x00.toByte)), + challenge = + request.getPublicKeyCredentialRequestOptions.getChallenge, + credentialKey = credentialKeypair, + credentialId = credential.getId, + ) + val pkcWithBeOnly = + TestAuthenticator.createAssertion( + flags = Some(new AuthenticatorDataFlags(0x08.toByte)), + challenge = + request.getPublicKeyCredentialRequestOptions.getChallenge, + credentialKey = credentialKeypair, + credentialId = credential.getId, + ) + val pkcWithBackup = + TestAuthenticator.createAssertion( + flags = Some(new AuthenticatorDataFlags(0x18.toByte)), + challenge = + request.getPublicKeyCredentialRequestOptions.getChallenge, + credentialKey = credentialKeypair, + credentialId = credential.getId, + ) + + val resultWithBackup = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(request) + .response(pkcWithBackup) + .build() + ) + val resultWithBeOnly = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(request) + .response(pkcWithBeOnly) + .build() + ) + val resultWithoutBackup = rp.finishAssertion( + FinishAssertionOptions + .builder() + .request(request) + .response(pkcWithoutBackup) + .build() + ) + + resultWithoutBackup.isBackedUp should be(false) + resultWithBeOnly.isBackedUp should be(false) + resultWithBackup.isBackedUp should be(true) + } + } + } } } diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyRegistrationSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyRegistrationSpec.scala index 337e4aaeb..983cd281d 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyRegistrationSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyRegistrationSpec.scala @@ -44,6 +44,7 @@ import com.yubico.webauthn.data.AttestationObject import com.yubico.webauthn.data.AttestationType import com.yubico.webauthn.data.AuthenticatorAttestationResponse import com.yubico.webauthn.data.AuthenticatorData +import com.yubico.webauthn.data.AuthenticatorDataFlags import com.yubico.webauthn.data.AuthenticatorSelectionCriteria import com.yubico.webauthn.data.AuthenticatorTransport import com.yubico.webauthn.data.ByteArray @@ -4515,6 +4516,110 @@ class RelyingPartyRegistrationSpec testData.response.getResponse.getAttestation.getAuthenticatorData.getAttestedCredentialData.get.getAaguid ) } + + { + val rp = RelyingParty + .builder() + .identity( + RelyingPartyIdentity + .builder() + .id("localhost") + .name("Example RP") + .build() + ) + .credentialRepository(Helpers.CredentialRepository.empty) + .build() + val user = UserIdentity.builder + .name("foo") + .displayName("Foo User") + .id(new ByteArray(Array(0, 1, 2, 3))) + .build() + + val request = PublicKeyCredentialCreationOptions + .builder() + .rp(rp.getIdentity) + .user(user) + .challenge(ByteArray.fromBase64Url("Y2hhbGxlbmdl")) + .pubKeyCredParams(List(PublicKeyCredentialParameters.ES256).asJava) + .build() + + it("exposes isBackupEligible() with the BE flag value in authenticator data.") { + val (pkcWithoutBackup, _, _) = + TestAuthenticator.createUnattestedCredential( + flags = Some(new AuthenticatorDataFlags(0x00.toByte)), + challenge = request.getChallenge, + ) + val (pkcWithBackup, _, _) = + TestAuthenticator.createUnattestedCredential( + flags = Some(new AuthenticatorDataFlags(0x08.toByte)), + challenge = request.getChallenge, + ) + + val resultWithoutBackup = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(request) + .response(pkcWithoutBackup) + .build() + ) + val resultWithBackup = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(request) + .response(pkcWithBackup) + .build() + ) + + resultWithoutBackup.isBackupEligible should be(false) + resultWithBackup.isBackupEligible should be(true) + } + + it( + "exposes isBackedUp() with the BS flag value in authenticator data." + ) { + val (pkcWithoutBackup, _, _) = + TestAuthenticator.createUnattestedCredential( + flags = Some(new AuthenticatorDataFlags(0x00.toByte)), + challenge = request.getChallenge, + ) + val (pkcWithBeOnly, _, _) = + TestAuthenticator.createUnattestedCredential( + flags = Some(new AuthenticatorDataFlags(0x08.toByte)), + challenge = request.getChallenge, + ) + val (pkcWithBackup, _, _) = + TestAuthenticator.createUnattestedCredential( + flags = Some(new AuthenticatorDataFlags(0x18.toByte)), + challenge = request.getChallenge, + ) + + val resultWithBackup = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(request) + .response(pkcWithBackup) + .build() + ) + val resultWithBeOnly = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(request) + .response(pkcWithBeOnly) + .build() + ) + val resultWithoutBackup = rp.finishRegistration( + FinishRegistrationOptions + .builder() + .request(request) + .response(pkcWithoutBackup) + .build() + ) + + resultWithoutBackup.isBackedUp should be(false) + resultWithBeOnly.isBackedUp should be(false) + resultWithBackup.isBackedUp should be(true) + } + } } } diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/TestAuthenticator.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/TestAuthenticator.scala index 1fd23b828..3d70dfbc5 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/TestAuthenticator.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/TestAuthenticator.scala @@ -38,6 +38,7 @@ import com.yubico.webauthn.TpmAttestationStatementVerifier.TpmRsaScheme import com.yubico.webauthn.data.AuthenticatorAssertionResponse import com.yubico.webauthn.data.AuthenticatorAttestationResponse import com.yubico.webauthn.data.AuthenticatorData +import com.yubico.webauthn.data.AuthenticatorDataFlags import com.yubico.webauthn.data.ByteArray import com.yubico.webauthn.data.COSEAlgorithmIdentifier import com.yubico.webauthn.data.ClientAssertionExtensionOutputs @@ -378,6 +379,7 @@ object TestAuthenticator { authenticatorExtensions: Option[JsonNode] = None, credentialKeypair: Option[KeyPair] = None, keyAlgorithm: COSEAlgorithmIdentifier = Defaults.keyAlgorithm, + flags: Option[AuthenticatorDataFlags] = None, ): ( ByteArray, KeyPair, @@ -393,6 +395,7 @@ object TestAuthenticator { val authDataBytes: ByteArray = makeAuthDataBytes( rpId = Defaults.rpId, + flags = flags, attestedCredentialDataBytes = Some( makeAttestedCredentialDataBytes( aaguid = aaguid, @@ -538,6 +541,7 @@ object TestAuthenticator { def createUnattestedCredential( authenticatorExtensions: Option[JsonNode] = None, challenge: ByteArray = Defaults.challenge, + flags: Option[AuthenticatorDataFlags] = None, ): ( PublicKeyCredential[ AuthenticatorAttestationResponse, @@ -547,7 +551,8 @@ object TestAuthenticator { List[(X509Certificate, PrivateKey)], ) = { val (authData, keypair) = createAuthenticatorData( - authenticatorExtensions = authenticatorExtensions + authenticatorExtensions = authenticatorExtensions, + flags = flags, ) createCredential( authDataBytes = authData, @@ -593,6 +598,7 @@ object TestAuthenticator { ClientAssertionExtensionOutputs.builder().build(), credentialId: ByteArray = Defaults.credentialId, credentialKey: KeyPair = Defaults.credentialKey, + flags: Option[AuthenticatorDataFlags] = None, origin: String = Defaults.origin, signatureCount: Option[Int] = None, tokenBindingStatus: String = Defaults.TokenBinding.status, @@ -632,6 +638,7 @@ object TestAuthenticator { val authDataBytes: ByteArray = makeAuthDataBytes( + flags = flags, signatureCount = signatureCount, rpId = Defaults.rpId, extensionsCborBytes = authenticatorExtensions map (ext => @@ -1046,17 +1053,20 @@ object TestAuthenticator { def makeAuthDataBytes( rpId: String = Defaults.rpId, + flags: Option[AuthenticatorDataFlags] = None, signatureCount: Option[Int] = None, attestedCredentialDataBytes: Option[ByteArray] = None, extensionsCborBytes: Option[ByteArray] = None, - ): ByteArray = + ): ByteArray = { + val atFlag = if (attestedCredentialDataBytes.isDefined) 0x40 else 0x00 + val edFlag = if (extensionsCborBytes.isDefined) 0x80 else 0x00 new ByteArray( (Vector[Byte]() ++ sha256(rpId).getBytes.toVector ++ Some[Byte]( - (0x01 | (if (attestedCredentialDataBytes.isDefined) 0x40 - else 0x00) | (if (extensionsCborBytes.isDefined) 0x80 - else 0x00)).toByte + (flags + .map(_.value) + .getOrElse(0x00.toByte) | 0x01 | atFlag | edFlag).toByte ) ++ BinaryUtil .encodeUint32(signatureCount.getOrElse(1337).toLong) @@ -1068,6 +1078,7 @@ object TestAuthenticator { _.getBytes.toVector } getOrElse Nil)).toArray ) + } def makeAttestedCredentialDataBytes( publicKeyCose: ByteArray, diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AuthenticatorDataFlagsSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AuthenticatorDataFlagsSpec.scala index 33e7b16d6..9b67d11c7 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AuthenticatorDataFlagsSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AuthenticatorDataFlagsSpec.scala @@ -43,6 +43,8 @@ class AuthenticatorDataFlagsSpec extends AnyFunSpec with Matchers { val flags = decode("01") flags.UP should be(true) flags.UV should be(false) + flags.BE should be(false) + flags.BS should be(false) flags.AT should be(false) flags.ED should be(false) } @@ -51,6 +53,28 @@ class AuthenticatorDataFlagsSpec extends AnyFunSpec with Matchers { val flags = decode("04") flags.UP should be(false) flags.UV should be(true) + flags.BE should be(false) + flags.BS should be(false) + flags.AT should be(false) + flags.ED should be(false) + } + + it("0x08 to BE.") { + val flags = decode("08") + flags.UP should be(false) + flags.UV should be(false) + flags.BE should be(true) + flags.BS should be(false) + flags.AT should be(false) + flags.ED should be(false) + } + + it("0x10 to BS.") { + val flags = decode("18") + flags.UP should be(false) + flags.UV should be(false) + flags.BE should be(true) + flags.BS should be(true) flags.AT should be(false) flags.ED should be(false) } @@ -59,6 +83,8 @@ class AuthenticatorDataFlagsSpec extends AnyFunSpec with Matchers { val flags = decode("40") flags.UP should be(false) flags.UV should be(false) + flags.BE should be(false) + flags.BS should be(false) flags.AT should be(true) flags.ED should be(false) } @@ -67,6 +93,8 @@ class AuthenticatorDataFlagsSpec extends AnyFunSpec with Matchers { val flags = decode("80") flags.UP should be(false) flags.UV should be(false) + flags.BE should be(false) + flags.BS should be(false) flags.AT should be(false) flags.ED should be(true) } diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AuthenticatorDataSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AuthenticatorDataSpec.scala index d3f5186bd..a405765f6 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AuthenticatorDataSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/AuthenticatorDataSpec.scala @@ -27,6 +27,8 @@ package com.yubico.webauthn.data import com.upokecenter.cbor.CBORObject import com.yubico.internal.util.BinaryUtil import com.yubico.webauthn.WebAuthnTestCodecs +import com.yubico.webauthn.data.Generators.authenticatorDataBytes +import com.yubico.webauthn.data.Generators.authenticatorDataFlagsByte import com.yubico.webauthn.data.Generators.byteArray import org.junit.runner.RunWith import org.scalacheck.Arbitrary.arbitrary @@ -61,10 +63,11 @@ class AuthenticatorDataSpec } it("with attested credential data must be at least 55 bytes.") { - forAll(byteArray(37, 54)) { bytes => - val authData = new ByteArray( - bytes.getBytes.updated(32, (bytes.getBytes.apply(32) | 0x40).toByte) - ) + forAll( + byteArray(37, 54), + authenticatorDataFlagsByte.map(flags => (flags | 0x40).toByte), + ) { (bytes, flags) => + val authData = new ByteArray(bytes.getBytes.updated(32, flags)) val result = Try(new AuthenticatorData(authData)) result shouldBe a[Failure[_]] result.failed.get.getMessage should include( @@ -78,11 +81,12 @@ class AuthenticatorDataSpec prefix <- Gen.infiniteLazyList(arbitrary[Byte]).map(_.take(53).toArray) credIdLen <- Gen.chooseNum(1, 2048) credId <- Gen.listOfN(credIdLen - 1, arbitrary[Byte]) - } yield (prefix, credIdLen, credId)) { + flags <- authenticatorDataFlagsByte.map(flags => (flags | 0x40).toByte) + } yield (prefix.updated(32, flags), credIdLen, credId)) { case (prefix, credIdLen, credId) => - val bytes = prefix ++ BinaryUtil.encodeUint16(credIdLen) ++ credId - val authData = - new ByteArray(bytes.updated(32, (bytes(32) | 0x40).toByte)) + val authData = new ByteArray( + prefix ++ BinaryUtil.encodeUint16(credIdLen) ++ credId + ) val result = Try(new AuthenticatorData(authData)) result shouldBe a[Failure[_]] result.failed.get.getMessage should include( @@ -93,6 +97,8 @@ class AuthenticatorDataSpec def generateTests( authDataHex: String, + backupEligible: Boolean = false, + backupState: Boolean = false, hasAttestation: Boolean = false, hasExtensions: Boolean = false, ): Unit = { @@ -108,6 +114,8 @@ class AuthenticatorDataSpec it("gets the correct flags from the raw bytes.") { authData.getFlags.UP should be(true) authData.getFlags.UV should be(false) + authData.getFlags.BE should be(backupEligible) + authData.getFlags.BS should be(backupState) authData.getFlags.AT should equal(hasAttestation) authData.getFlags.ED should equal(hasExtensions) } @@ -166,6 +174,63 @@ class AuthenticatorDataSpec ) } + describe("with BE=1") { + generateTests( + "49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d9763" // RP ID hash + + "09" // Flags + + "00000539", // Signature count + backupEligible = true, + backupState = false, + ) + } + + describe("with BE=1 and BS=1") { + generateTests( + "49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d9763" // RP ID hash + + "19" // Flags + + "00000539", // Signature count + backupEligible = true, + backupState = true, + ) + } + + it("with BE=0 and BS=1 is invalid.") { + forAll( + authenticatorDataBytes( + Gen.option(Generators.Extensions.anyAssertionExtensions.map({ + case (_, _, ext) => ext + })), + backupFlagsGen = Gen.const((false, true)), + ) + ) { authDataBytes => + val neitherFlag = new AuthenticatorData( + new ByteArray( + authDataBytes.getBytes + .updated(32, (authDataBytes.getBytes()(32) & ~0x10).toByte) + ) + ) + neitherFlag should not be null + neitherFlag.getFlags.BE should be(false) + neitherFlag.getFlags.BS should be(false) + + val onlyBe = new AuthenticatorData( + new ByteArray( + authDataBytes.getBytes.updated( + 32, + ((authDataBytes.getBytes()(32) | 0x08) & ~0x10).toByte, + ) + ) + ) + onlyBe should not be null + onlyBe.getFlags.BE should be(true) + onlyBe.getFlags.BS should be(false) + + an[IllegalArgumentException] should be thrownBy { + new AuthenticatorData(authDataBytes) + } + } + } + describe("with only attestation data") { generateTests( "49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d9763" // RP ID hash diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/BuildersSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/BuildersSpec.scala index d0819af58..290c14ac8 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/BuildersSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/BuildersSpec.scala @@ -26,9 +26,6 @@ package com.yubico.webauthn.data import com.fasterxml.jackson.core.`type`.TypeReference import com.yubico.webauthn.AssertionRequest -import com.yubico.webauthn.AssertionResult -import com.yubico.webauthn.Generators._ -import com.yubico.webauthn.RegistrationResult import com.yubico.webauthn.data.Generators._ import org.junit.runner.RunWith import org.scalacheck.Arbitrary @@ -65,7 +62,6 @@ class BuildersSpec test(new TypeReference[AssertionExtensionInputs]() {}) test(new TypeReference[AssertionRequest]() {}) - test(new TypeReference[AssertionResult]() {}) test(new TypeReference[AttestedCredentialData]() {}) test(new TypeReference[AuthenticatorAssertionResponse]() {}) test(new TypeReference[AuthenticatorAttestationResponse]() {}) @@ -89,7 +85,6 @@ class BuildersSpec test(new TypeReference[PublicKeyCredentialParameters]() {}) test(new TypeReference[PublicKeyCredentialRequestOptions]() {}) test(new TypeReference[RegistrationExtensionInputs]() {}) - test(new TypeReference[RegistrationResult]() {}) test(new TypeReference[RelyingPartyIdentity]() {}) test(new TypeReference[UserIdentity]() {}) } diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/Generators.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/Generators.scala index 493ef1a95..6a0ba9742 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/Generators.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/Generators.scala @@ -63,7 +63,7 @@ object Generators { private def jsonFactory: JsonNodeFactory = JsonNodeFactory.instance - private def setFlag(flags: Byte, mask: Byte, value: Boolean): Byte = + private def setFlag(mask: Byte, value: Boolean)(flags: Byte): Byte = if (value) (flags | (mask & (-0x01).toByte)).toByte else @@ -207,10 +207,15 @@ object Generators { ) } yield new ByteArray(JacksonCodecs.cbor().writeValueAsBytes(attObj)) - implicit val arbitraryAuthenticatorDataFlags - : Arbitrary[AuthenticatorDataFlags] = Arbitrary(for { + val authenticatorDataFlagsByte: Gen[Byte] = for { value <- arbitrary[Byte] - } yield new AuthenticatorDataFlags(value)) + bsMask = (((value & 0x08) << 1) & 0xef).toByte // Bit 0x10 cannot be set unless 0x08 is + } yield (value & bsMask).toByte + + implicit val arbitraryAuthenticatorDataFlags + : Arbitrary[AuthenticatorDataFlags] = Arbitrary( + authenticatorDataFlagsByte.map(new AuthenticatorDataFlags(_)) + ) implicit val arbitraryAuthenticatorAssertionResponse : Arbitrary[AuthenticatorAssertionResponse] = Arbitrary( @@ -258,29 +263,47 @@ object Generators { ) def authenticatorDataBytes( - extensionsGen: Gen[Option[CBORObject]] + extensionsGen: Gen[Option[CBORObject]], + rpIdHashGen: Gen[ByteArray] = byteArray(32), + upFlagGen: Gen[Boolean] = Gen.const(true), + uvFlagGen: Gen[Boolean] = arbitrary[Boolean], + backupFlagsGen: Gen[(Boolean, Boolean)] = + arbitrary[(Boolean, Boolean)].map({ case (be, bs) => (be, be && bs) }), + signatureCountGen: Gen[ByteArray] = byteArray(4), ): Gen[ByteArray] = for { - fixedBytes <- byteArray(37) + rpIdHash <- rpIdHashGen + signatureCount <- signatureCountGen attestedCredentialDataBytes <- Gen.option(attestedCredentialDataBytes) - extensions <- extensionsGen + extensions <- extensionsGen extensionsBytes = extensions map { exts => new ByteArray( exts.EncodeToBytes(CBOREncodeOptions.DefaultCtap2Canonical) ) } + + flagsBase <- arbitrary[Byte] + upFlag <- upFlagGen + uvFlag <- uvFlagGen + (beFlag, bsFlag) <- backupFlagsGen atFlag = attestedCredentialDataBytes.isDefined edFlag = extensionsBytes.isDefined - flagsByte: Byte = setFlag( - setFlag(fixedBytes.getBytes()(32), 0x40, atFlag), - BinaryUtil.singleFromHex("80"), - edFlag, + flagsByte: Byte = setFlag(0x01, upFlag)( + setFlag(0x03, uvFlag)( + setFlag(0x40, atFlag)( + setFlag(BinaryUtil.singleFromHex("80"), edFlag)( + setFlag(0x08, beFlag)(setFlag(0x10, bsFlag)(flagsBase)) + ) + ) + ) ) } yield new ByteArray( - fixedBytes.getBytes.updated(32, flagsByte) - ++ attestedCredentialDataBytes.map(_.getBytes).getOrElse(Array.empty) - ++ extensionsBytes.map(_.getBytes).getOrElse(Array.empty) + rpIdHash.getBytes + :+ flagsByte + :++ signatureCount.getBytes + ++ attestedCredentialDataBytes.map(_.getBytes).getOrElse(Array.empty) + ++ extensionsBytes.map(_.getBytes).getOrElse(Array.empty) ) implicit val arbitraryAuthenticatorSelectionCriteria