diff --git a/NEWS b/NEWS
index 0652b7898..95f2e0c18 100644
--- a/NEWS
+++ b/NEWS
@@ -32,6 +32,11 @@
* (Experimental) Added property `RegisteredCredential.transports`.
** NOTE: Experimental features may receive breaking changes without a major
version increase.
+* (Experimental) Added option `FinishRegistrationOptions.isConditionalCreate` to
+ allow UP=0 in registration response for registration ceremonies with
+ `mediation: "conditional"`.
+ ** NOTE: Experimental features may receive breaking changes without a major
+ version increase.
== Version 2.6.0 ==
diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishRegistrationOptions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishRegistrationOptions.java
index 8dbc3cff2..6c734008a 100644
--- a/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishRegistrationOptions.java
+++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/FinishRegistrationOptions.java
@@ -61,6 +61,25 @@ public class FinishRegistrationOptions {
*/
private final ByteArray callerTokenBindingId;
+ /**
+ * If true, then user presence (UP) will not be required during registration
+ * verification. This is needed for conditional
+ * create operations.
+ *
+ *
The default is false (UP is required).
+ *
+ * @deprecated EXPERIMENTAL: This feature is from a not yet mature standard; it could change as
+ * the standard matures.
+ * @since 2.7.0
+ * @see
+ * conditionalCreate client capability.
+ * @see UP
+ * flag in authenticator data.
+ */
+ @Deprecated @Builder.Default private final boolean isConditionalCreate = false;
+
/**
* The token binding ID of the
* connection to the client, if any.
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 a7ec85725..512274465 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
@@ -89,6 +89,7 @@ final class FinishRegistrationSteps {
private final Clock clock;
private final boolean allowOriginPort;
private final boolean allowOriginSubdomain;
+ private final boolean isConditionalCreate;
static FinishRegistrationSteps fromV1(RelyingParty rp, FinishRegistrationOptions options) {
return new FinishRegistrationSteps(
@@ -102,7 +103,8 @@ static FinishRegistrationSteps fromV1(RelyingParty rp, FinishRegistrationOptions
new CredentialRepositoryV1ToV2Adapter(rp.getCredentialRepository()),
rp.getClock(),
rp.isAllowOriginPort(),
- rp.isAllowOriginSubdomain());
+ rp.isAllowOriginSubdomain(),
+ options.isConditionalCreate());
}
FinishRegistrationSteps(RelyingPartyV2> rp, FinishRegistrationOptions options) {
@@ -117,7 +119,8 @@ static FinishRegistrationSteps fromV1(RelyingParty rp, FinishRegistrationOptions
rp.getCredentialRepository(),
rp.getClock(),
rp.isAllowOriginPort(),
- rp.isAllowOriginSubdomain());
+ rp.isAllowOriginSubdomain(),
+ options.isConditionalCreate());
}
public Step6 begin() {
@@ -303,8 +306,8 @@ class Step14 implements Step {
@Override
public void validate() {
assertTrue(
- response.getResponse().getParsedAuthenticatorData().getFlags().UP,
- "User Presence is required.");
+ isConditionalCreate || response.getResponse().getParsedAuthenticatorData().getFlags().UP,
+ "User Presence is required unless isConditionalCreate is true.");
}
@Override
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 68c03ec9b..17e6f34b1 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
@@ -164,6 +164,7 @@ class RelyingPartyRegistrationSpec
pubkeyCredParams: Option[List[PublicKeyCredentialParameters]] = None,
testData: RegistrationTestData,
clock: Clock = Clock.systemUTC(),
+ isConditionalCreate: Boolean = false,
): FinishRegistrationSteps = {
var builder = RelyingParty
.builder()
@@ -191,6 +192,7 @@ class RelyingPartyRegistrationSpec
)
.response(testData.response)
.callerTokenBindingId(callerTokenBindingId.toJava)
+ .isConditionalCreate(isConditionalCreate)
.build()
builder
@@ -873,6 +875,7 @@ class RelyingPartyRegistrationSpec
)(chk: Step => B)(
uvr: UserVerificationRequirement,
authDataEdit: ByteArray => ByteArray,
+ isConditionalCreate: Boolean,
): B = {
val steps = finishRegistration(
testData = testData
@@ -884,14 +887,19 @@ class RelyingPartyRegistrationSpec
.build()
)
)
- .editAuthenticatorData(authDataEdit)
+ .editAuthenticatorData(authDataEdit),
+ isConditionalCreate = isConditionalCreate,
)
chk(stepsToStep(steps))
}
def checkFailsWith(
stepsToStep: FinishRegistrationSteps => Step
- ): (UserVerificationRequirement, ByteArray => ByteArray) => Unit =
+ ): (
+ UserVerificationRequirement,
+ ByteArray => ByteArray,
+ Boolean,
+ ) => Unit =
check(stepsToStep) { step =>
step.validations shouldBe a[Failure[_]]
step.validations.failed.get shouldBe an[
@@ -902,7 +910,11 @@ class RelyingPartyRegistrationSpec
def checkSucceedsWith(
stepsToStep: FinishRegistrationSteps => Step
- ): (UserVerificationRequirement, ByteArray => ByteArray) => Unit =
+ ): (
+ UserVerificationRequirement,
+ ByteArray => ByteArray,
+ Boolean,
+ ) => Unit =
check(stepsToStep) { step =>
step.validations shouldBe a[Success[_]]
step.tryNext shouldBe a[Success[_]]
@@ -912,15 +924,41 @@ class RelyingPartyRegistrationSpec
}
describe("14. Verify that the User Present bit of the flags in authData is set.") {
- val (checkFails, checkSucceeds) = checks[
+ val (chkf, chks) = checks[
FinishRegistrationSteps#Step15,
FinishRegistrationSteps#Step14,
](_.begin.next.next.next.next.next.next.next.next)
+ def checkFails(
+ uvr: UserVerificationRequirement,
+ authDataEdit: ByteArray => ByteArray,
+ isConditionalCreate: Boolean = false,
+ ): Unit = chkf(uvr, authDataEdit, isConditionalCreate)
+ def checkSucceeds(
+ uvr: UserVerificationRequirement,
+ authDataEdit: ByteArray => ByteArray,
+ isConditionalCreate: Boolean = false,
+ ): Unit = chks(uvr, authDataEdit, isConditionalCreate)
it("Fails if UV is discouraged and flag is not set.") {
checkFails(UserVerificationRequirement.DISCOURAGED, upOff)
}
+ it("Fails if UV is discouraged, isConditionalCreate is false and flag is not set.") {
+ checkFails(
+ UserVerificationRequirement.DISCOURAGED,
+ upOff,
+ isConditionalCreate = false,
+ )
+ }
+
+ it("Succeeds if UV is discouraged, isConditionalCreate is true and flag is not set.") {
+ checkSucceeds(
+ UserVerificationRequirement.DISCOURAGED,
+ upOff,
+ isConditionalCreate = true,
+ )
+ }
+
it("Succeeds if UV is discouraged and flag is set.") {
checkSucceeds(UserVerificationRequirement.DISCOURAGED, upOn)
}
@@ -949,10 +987,20 @@ class RelyingPartyRegistrationSpec
}
describe("15. If user verification is required for this registration, verify that the User Verified bit of the flags in authData is set.") {
- val (checkFails, checkSucceeds) = checks[
+ val (chkf, chks) = checks[
FinishRegistrationSteps#Step16,
FinishRegistrationSteps#Step15,
](_.begin.next.next.next.next.next.next.next.next.next)
+ def checkFails(
+ uvr: UserVerificationRequirement,
+ authDataEdit: ByteArray => ByteArray,
+ isConditionalCreate: Boolean = false,
+ ): Unit = chkf(uvr, authDataEdit, isConditionalCreate)
+ def checkSucceeds(
+ uvr: UserVerificationRequirement,
+ authDataEdit: ByteArray => ByteArray,
+ isConditionalCreate: Boolean = false,
+ ): Unit = chks(uvr, authDataEdit, isConditionalCreate)
it("Succeeds if UV is discouraged and flag is not set.") {
checkSucceeds(UserVerificationRequirement.DISCOURAGED, uvOff)