From 5ebf587015d6a674acc3db72f9efe1655c1799ed Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Wed, 14 Feb 2024 17:50:59 +0100 Subject: [PATCH 01/15] add support for bio enrollment --- .../yubikit/core/fido/CtapException.java | 1 + .../yubikit/fido/ctap/BioEnrollment.java | 80 ++++ .../yubikit/fido/ctap/Ctap2Session.java | 47 +++ .../fido/ctap/FingerprintBioEnrollment.java | 376 ++++++++++++++++++ .../Ctap2BioEnrollmentInstrumentedTests.java | 72 ++++ .../testing/fido/Ctap2BioEnrollmentTests.java | 191 +++++++++ 6 files changed, 767 insertions(+) create mode 100644 fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java create mode 100644 fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java create mode 100644 testing-android/src/androidTest/java/com/yubico/yubikit/testing/fido/Ctap2BioEnrollmentInstrumentedTests.java create mode 100755 testing/src/main/java/com/yubico/yubikit/testing/fido/Ctap2BioEnrollmentTests.java diff --git a/core/src/main/java/com/yubico/yubikit/core/fido/CtapException.java b/core/src/main/java/com/yubico/yubikit/core/fido/CtapException.java index 5ce36061..5bf0ad81 100755 --- a/core/src/main/java/com/yubico/yubikit/core/fido/CtapException.java +++ b/core/src/main/java/com/yubico/yubikit/core/fido/CtapException.java @@ -40,6 +40,7 @@ public class CtapException extends CommandException { public static final byte ERR_MISSING_PARAMETER = 0x14; public static final byte ERR_LIMIT_EXCEEDED = 0x15; public static final byte ERR_UNSUPPORTED_EXTENSION = 0x16; + public static final byte ERR_FP_DATABASE_FULL = 0x17; public static final byte ERR_CREDENTIAL_EXCLUDED = 0x19; public static final byte ERR_PROCESSING = 0x21; public static final byte ERR_INVALID_CREDENTIAL = 0x22; diff --git a/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java b/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java new file mode 100644 index 00000000..30b24fbb --- /dev/null +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2024 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yubico.yubikit.fido.ctap; + +import com.yubico.yubikit.core.application.CommandException; + +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.Map; +import java.util.Objects; + +@SuppressWarnings("unused") +public class BioEnrollment { + private static final int RESULT_MODALITY = 0x01; + private static final int RESULT_FINGERPRINT_KIND = 0x02; + private static final int RESULT_MAX_SAMPLES_REQUIRED = 0x03; + public static final int RESULT_TEMPLATE_ID = 0x04; + public static final int RESULT_LAST_SAMPLE_STATUS = 0x05; + public static final int RESULT_REMAINING_SAMPLES = 0x06; + public static final int RESULT_TEMPLATE_INFOS = 0x07; + public static final int RESULT_MAX_TEMPLATE_FRIENDLY_NAME = 0x08; + + protected static final int TEMPLATE_INFO_ID = 0x01; + protected static final int TEMPLATE_INFO_NAME = 0x02; + + static final int MODALITY_FINGERPRINT = 0x01; + + protected final Ctap2Session ctap; + protected final int modality; + + private static final org.slf4j.Logger logger = LoggerFactory.getLogger(BioEnrollment.class); + + public BioEnrollment(Ctap2Session ctap, int modality) throws IOException, CommandException { + if (!isSupported(ctap.getCachedInfo())) { + throw new IllegalStateException("Bio enrollment not supported"); + } + + this.ctap = ctap; + this.modality = getModality(); + + if (this.modality != modality) { + throw new IllegalStateException("Device does not support modality " + modality); + } + } + + public static boolean isSupported(Ctap2Session.InfoData info) { + final Map options = info.getOptions(); + if (options.containsKey("bioEnroll")) { + return true; + } else return info.getVersions().contains("FIDO_2_1_PRE") && + options.containsKey("userVerificationMgmtPreview"); + } + + public int getModality() throws IOException, CommandException { + final Map result = ctap.bioEnrollment( + null, + null, + null, + null, + null, + Boolean.TRUE); + return Objects.requireNonNull((Integer) result.get(RESULT_MODALITY)); + } + +} diff --git a/fido/src/main/java/com/yubico/yubikit/fido/ctap/Ctap2Session.java b/fido/src/main/java/com/yubico/yubikit/fido/ctap/Ctap2Session.java index 8b136b6d..80d56b6d 100644 --- a/fido/src/main/java/com/yubico/yubikit/fido/ctap/Ctap2Session.java +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/Ctap2Session.java @@ -82,6 +82,8 @@ public class Ctap2Session extends ApplicationSession { private final InfoData info; @Nullable private final Byte credentialManagerCommand; + @Nullable + private final Byte bioEnrollmentCommand; private static final org.slf4j.Logger logger = LoggerFactory.getLogger(Ctap2Session.class); @@ -136,6 +138,15 @@ private Ctap2Session(Version version, Backend backend) } else { this.credentialManagerCommand = null; } + + if (options.containsKey("bioEnroll")) { + this.bioEnrollmentCommand = CMD_BIO_ENROLLMENT; + } else if (info.getVersions().contains("FIDO_2_1_PRE") && + options.containsKey("userVerificationMgmtPreview")) { + this.bioEnrollmentCommand = CMD_BIO_ENROLLMENT_PRE; + } else { + this.bioEnrollmentCommand = null; + } } private static Backend getSmartCardBackend(SmartCardConnection connection) @@ -394,6 +405,42 @@ public void reset(@Nullable CommandState state) throws IOException, CommandExcep sendCbor(CMD_RESET, null, state); } + /** + * This command is used by the platform to provision/enumerate/delete bio enrollments in the + * authenticator. + * + * @param modality the user verification modality being requested + * @param subCommand the user verification sub command currently being requested + * @param subCommandParams a map of subCommands parameters + * @param pinUvAuthProtocol PIN/UV protocol version chosen by the platform + * @param pinUvAuthParam first 16 bytes of HMAC-SHA-256 of contents using pinUvAuthToken + * @throws IOException A communication error in the transport layer. + * @throws CommandException A communication in the protocol layer. + * @see authenticatorBioEnrollment + */ + Map bioEnrollment( + @Nullable Integer modality, + @Nullable Integer subCommand, + @Nullable Map subCommandParams, + @Nullable Integer pinUvAuthProtocol, + @Nullable byte[] pinUvAuthParam, + @Nullable Boolean getModality + ) throws IOException, CommandException { + if (bioEnrollmentCommand == null) { + throw new IllegalStateException("Bio enrollment not supported"); + } + return sendCbor( + bioEnrollmentCommand, + args( + modality, + subCommand, + subCommandParams, + pinUvAuthProtocol, + pinUvAuthParam, + getModality), + null); + } + /** * This command is used by the platform to manage discoverable credentials on the * authenticator. diff --git a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java new file mode 100644 index 00000000..b207dd3b --- /dev/null +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java @@ -0,0 +1,376 @@ +/* + * Copyright (C) 2024 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yubico.yubikit.fido.ctap; + +import com.yubico.yubikit.core.application.CommandException; +import com.yubico.yubikit.core.fido.CtapException; +import com.yubico.yubikit.core.internal.codec.Base64; +import com.yubico.yubikit.fido.Cbor; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import javax.annotation.Nullable; + +@SuppressWarnings("unused") +public class FingerprintBioEnrollment extends BioEnrollment { + + /* commands */ + private static final int CMD_ENROLL_BEGIN = 0x01; + private static final int CMD_ENROLL_CAPTURE_NEXT = 0x02; + private static final int CMD_ENROLL_CANCEL = 0x03; + private static final int CMD_ENUMERATE_ENROLLMENTS = 0x04; + private static final int CMD_SET_NAME = 0x05; + private static final int CMD_REMOVE_ENROLLMENT = 0x06; + private static final int CMD_GET_SENSOR_INFO = 0x07; + + /* parameters */ + private static final int PARAM_TEMPLATE_ID = 0x01; + private static final int PARAM_TEMPLATE_FRIENDLY_NAME = 0x02; + private static final int PARAM_TIMEOUT_MS = 0x03; + + /* feedback */ + public static final int FEEDBACK_FP_GOOD = 0x00; + public static final int FEEDBACK_FP_TOO_HIGH = 0x01; + public static final int FEEDBACK_FP_TOO_LOW = 0x02; + public static final int FEEDBACK_FP_TOO_LEFT = 0x03; + public static final int FEEDBACK_FP_TOO_RIGHT = 0x04; + public static final int FEEDBACK_FP_TOO_FAST = 0x05; + public static final int FEEDBACK_FP_TOO_SLOW = 0x06; + public static final int FEEDBACK_FP_POOR_QUALITY = 0x07; + public static final int FEEDBACK_FP_TOO_SKEWED = 0x08; + public static final int FEEDBACK_FP_TOO_SHORT = 0x09; + public static final int FEEDBACK_FP_MERGE_FAILURE = 0x0A; + public static final int FEEDBACK_FP_EXISTS = 0x0B; + // 0x0C not used + public static final int FEEDBACK_NO_USER_ACTIVITY = 0x0D; + public static final int FEEDBACK_NO_UP_TRANSITION = 0x0E; + + private final PinUvAuthProtocol pinUvAuth; + private final byte[] pinUvToken; + private final org.slf4j.Logger logger = LoggerFactory.getLogger(FingerprintBioEnrollment.class); + + public static class CaptureError extends Exception { + private final int code; + + public CaptureError(int code) { + super("Fingerprint capture error: " + code); + this.code = code; + } + + public int getCode() { + return code; + } + } + + static class FingerprintCapture { + private final int sampleStatus; + private final int remaining; + + public FingerprintCapture(int sampleStatus, int remaining) { + this.sampleStatus = sampleStatus; + this.remaining = remaining; + } + + public int getSampleStatus() { + return sampleStatus; + } + + public int getRemaining() { + return remaining; + } + } + + static class FingerprintInitialCapture extends FingerprintCapture { + private final byte[] templateId; + + public FingerprintInitialCapture(byte[] templateId, int sampleStatus, int remaining) { + super(sampleStatus, remaining); + this.templateId = templateId; + } + + public byte[] getTemplateId() { + return templateId; + } + } + + public static class FingerprintEnrollmentContext { + private final FingerprintBioEnrollment bioEnrollment; + @Nullable + private final Integer timeout; + @Nullable + private byte[] templateId; + @Nullable + private Integer remaining; + + public FingerprintEnrollmentContext( + FingerprintBioEnrollment bioEnrollment, + @Nullable Integer timeout, + @Nullable byte[] templateId, + @Nullable Integer remaining) { + this.bioEnrollment = bioEnrollment; + this.timeout = timeout; + this.templateId = templateId; + this.remaining = remaining; + } + + /** + * Capture a fingerprint sample. + *

+ * This call will block for up to timeout milliseconds (or indefinitely, if + * timeout not specified) waiting for the user to scan their fingerprint to + * collect one sample. + * + * @return None, if more samples are needed, or the template ID if enrollment is + * completed. + * @throws IOException A communication error in the transport layer. + * @throws CommandException A communication in the protocol layer. + * @throws CaptureError An error during fingerprint capture. + */ + @Nullable + public byte[] capture() throws IOException, CommandException, CaptureError { + int sampleStatus; + if (templateId == null) { + final FingerprintInitialCapture initialCapture = bioEnrollment.enrollBegin(timeout); + templateId = initialCapture.getTemplateId(); + remaining = initialCapture.getRemaining(); + sampleStatus = initialCapture.getSampleStatus(); + } else { + final FingerprintCapture capture = + bioEnrollment.enrollCaptureNext(templateId, timeout); + remaining = capture.getRemaining(); + sampleStatus = capture.getSampleStatus(); + } + + if (sampleStatus != FEEDBACK_FP_GOOD) { + throw new CaptureError(sampleStatus); + } + + if (remaining == 0) { + return templateId; + } + + return null; + } + + /** + * Cancels ongoing enrollment. + * + * @throws IOException A communication error in the transport layer. + * @throws CommandException A communication in the protocol layer. + */ + public void cancel() throws IOException, CommandException { + bioEnrollment.enrollCancel(); + templateId = null; + } + } + + public FingerprintBioEnrollment( + Ctap2Session ctap, + PinUvAuthProtocol pinUvAuthProtocol, + byte[] pinUvToken) throws IOException, CommandException { + super(ctap, BioEnrollment.MODALITY_FINGERPRINT); + this.pinUvAuth = pinUvAuthProtocol; + this.pinUvToken = pinUvToken; + } + + private Map call( + Integer subCommand, + @Nullable Map subCommandParams) throws IOException, CommandException { + return call(subCommand, subCommandParams, true); + } + + private Map call( + Integer subCommand, + @Nullable Map subCommandParams, + boolean authenticate) throws IOException, CommandException { + byte[] pinUvAuthParam = null; + if (authenticate) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + output.write(MODALITY_FINGERPRINT); + output.write(subCommand); + if (subCommandParams != null) { + Cbor.encodeTo(output, subCommandParams); + } + pinUvAuthParam = pinUvAuth.authenticate(pinUvToken, output.toByteArray()); + } + + return ctap.bioEnrollment( + modality, + subCommand, + subCommandParams, + pinUvAuth.getVersion(), + pinUvAuthParam, + null); + } + + /** + * Get fingerprint sensor info. + * + * @return A dict containing FINGERPRINT_KIND, MAX_SAMPLES_REQUIRES and + * MAX_TEMPLATE_FRIENDLY_NAME. + * @throws IOException A communication error in the transport layer. + * @throws CommandException A communication in the protocol layer. + */ + public Map getFingerprintSensorInfo() throws IOException, CommandException { + return call(CMD_GET_SENSOR_INFO, null, false); + } + + /** + * Start fingerprint enrollment. + *

+ * Starts the process of enrolling a new fingerprint, and will wait for the user + * to scan their fingerprint once to provide an initial sample. + * + * @param timeout Optional timeout in milliseconds. + * @return A capture result object containing the new template ID, the sample status, + * and the number of samples remaining to complete the enrollment. + */ + public FingerprintInitialCapture enrollBegin(@Nullable Integer timeout) + throws IOException, CommandException { + logger.debug("Starting fingerprint enrollment"); + + Map parameters = new HashMap<>(); + if (timeout != null) parameters.put(PARAM_TIMEOUT_MS, timeout); + + final Map result = call(CMD_ENROLL_BEGIN, parameters); + logger.debug("Sample capture result: {}", result); + return new FingerprintInitialCapture( + Objects.requireNonNull((byte[]) result.get(RESULT_TEMPLATE_ID)), + Objects.requireNonNull((Integer) result.get(RESULT_LAST_SAMPLE_STATUS)), + Objects.requireNonNull((Integer) result.get(RESULT_REMAINING_SAMPLES))); + } + + /** + * Continue fingerprint enrollment. + *

+ * Continues enrolling a new fingerprint and will wait for the user to scan their + * fingerprint once to provide a new sample. + * Once the number of samples remaining is 0, the enrollment is completed. + * + * @param templateId The template ID returned by a call to {@link #enrollBegin(Integer timeout)}. + * @param timeout Optional timeout in milliseconds. + * @return A capture result containing the sample status, and the number of samples + * remaining to complete the enrollment. + */ + public FingerprintCapture enrollCaptureNext( + byte[] templateId, + @Nullable Integer timeout) throws IOException, CommandException { + logger.debug("Capturing next sample with (timeout={})", timeout); + + Map parameters = new HashMap<>(); + parameters.put(PARAM_TEMPLATE_ID, templateId); + if (timeout != null) parameters.put(PARAM_TIMEOUT_MS, timeout); + + final Map result = call(CMD_ENROLL_CAPTURE_NEXT, parameters); + logger.debug("Sample capture result: {}", result); + return new FingerprintCapture( + Objects.requireNonNull((Integer) result.get(RESULT_LAST_SAMPLE_STATUS)), + Objects.requireNonNull((Integer) result.get(RESULT_REMAINING_SAMPLES))); + } + + /** + * Cancel any ongoing fingerprint enrollment. + */ + public void enrollCancel() throws IOException, CommandException { + logger.debug("Cancelling fingerprint enrollment."); + call(CMD_ENROLL_CANCEL, null, false); + } + + /** + * Convenience wrapper for doing fingerprint enrollment. + *

+ * See FingerprintEnrollmentContext for details. + * + * @param timeout Optional timeout in milliseconds. + * @return An initialized FingerprintEnrollmentContext. + */ + public FingerprintEnrollmentContext enroll(@Nullable Integer timeout) { + return new FingerprintEnrollmentContext(this, timeout, null, null); + } + + /** + * Get a dict of enrolled fingerprint templates which maps template ID's to + * their friendly names. + * + * @return A Map of enrolled templateId -> name pairs. + */ + public Map enumerateEnrollments() throws IOException, CommandException { + try { + final Map result = call(CMD_ENUMERATE_ENROLLMENTS, null); + + @SuppressWarnings("unchecked") + final List> infos = (List>) result.get(RESULT_TEMPLATE_INFOS); + final Map retval = new HashMap<>(); + for (Map info : infos) { + final byte[] templateId = + Objects.requireNonNull((byte[]) info.get(TEMPLATE_INFO_ID)); + final String templateFriendlyName = (String) info.get(TEMPLATE_INFO_NAME); + retval.put(templateId, templateFriendlyName); + } + + logger.debug("Enumerated enrollments: {}", retval); + + return retval; + } catch (CtapException e) { + if (e.getCtapError() == CtapException.ERR_INVALID_OPTION) { + return Collections.emptyMap(); + } + throw e; + } + } + + /** + * Set/Change the friendly name of a previously enrolled fingerprint template. + * + * @param templateId The ID of the template to change. + * @param name A friendly name to give the template. + */ + public void setName(byte[] templateId, String name) throws IOException, CommandException { + logger.debug("Changing name of template: {} {}", Base64.toUrlSafeString(templateId), name); + + Map parameters = new HashMap<>(); + parameters.put(TEMPLATE_INFO_ID, templateId); + parameters.put(TEMPLATE_INFO_NAME, name); + + call(CMD_SET_NAME, parameters); + logger.info("Fingerprint template renamed"); + } + + /** + * Remove a previously enrolled fingerprint template. + * + * @param templateId The Id of the template to remove. + */ + public void removeEnrollment(byte[] templateId) throws IOException, CommandException { + logger.debug("Deleting template: {}", Base64.toUrlSafeString(templateId)); + + Map parameters = new HashMap<>(); + parameters.put(TEMPLATE_INFO_ID, templateId); + + call(CMD_REMOVE_ENROLLMENT, parameters); + logger.info("Fingerprint template deleted"); + } +} diff --git a/testing-android/src/androidTest/java/com/yubico/yubikit/testing/fido/Ctap2BioEnrollmentInstrumentedTests.java b/testing-android/src/androidTest/java/com/yubico/yubikit/testing/fido/Ctap2BioEnrollmentInstrumentedTests.java new file mode 100644 index 00000000..29069d91 --- /dev/null +++ b/testing-android/src/androidTest/java/com/yubico/yubikit/testing/fido/Ctap2BioEnrollmentInstrumentedTests.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2024 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yubico.yubikit.testing.fido; + +import androidx.test.filters.LargeTest; + +import com.yubico.yubikit.fido.ctap.BioEnrollment; +import com.yubico.yubikit.fido.ctap.Ctap2Session; +import com.yubico.yubikit.fido.ctap.PinUvAuthProtocolV2; +import com.yubico.yubikit.testing.framework.FidoInstrumentedTests; + +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +@LargeTest +public class Ctap2BioEnrollmentInstrumentedTests extends FidoInstrumentedTests { + + @Test + public void testFingerprintEnrollment() { + runTest(Ctap2BioEnrollmentTests::testFingerprintEnrollment); + } + + // helpers + private final static Logger logger = + LoggerFactory.getLogger(Ctap2BioEnrollmentInstrumentedTests.class); + + private static boolean supportsPinUvAuthProtocol( + Ctap2Session session, + int pinUvAuthProtocolVersion) { + final List pinUvAuthProtocols = + session.getCachedInfo().getPinUvAuthProtocols(); + return pinUvAuthProtocols.contains(pinUvAuthProtocolVersion); + } + + private static boolean supportsBioEnrollment(Ctap2Session session) { + return BioEnrollment.isSupported(session.getCachedInfo()); + } + + private static boolean isSupported(Ctap2Session session) { + return supportsBioEnrollment(session) && supportsPinUvAuthProtocol(session, 2); + } + + private void runTest(Callback callback) { + try { + withCtap2Session( + "Bio enrollment or pinUvProtocol Two not supported", + (device, session) -> supportsBioEnrollment(session) && supportsPinUvAuthProtocol(session, 2), + callback, + new PinUvAuthProtocolV2() + ); + } catch (Throwable throwable) { + logger.error("Caught exception: ", throwable); + } + } +} diff --git a/testing/src/main/java/com/yubico/yubikit/testing/fido/Ctap2BioEnrollmentTests.java b/testing/src/main/java/com/yubico/yubikit/testing/fido/Ctap2BioEnrollmentTests.java new file mode 100755 index 00000000..db8e18a3 --- /dev/null +++ b/testing/src/main/java/com/yubico/yubikit/testing/fido/Ctap2BioEnrollmentTests.java @@ -0,0 +1,191 @@ +/* + * Copyright (C) 2024 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yubico.yubikit.testing.fido; + +import static com.yubico.yubikit.core.fido.CtapException.ERR_INVALID_LENGTH; +import static com.yubico.yubikit.fido.ctap.BioEnrollment.RESULT_MAX_TEMPLATE_FRIENDLY_NAME; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.yubico.yubikit.core.application.CommandException; +import com.yubico.yubikit.core.fido.CtapException; +import com.yubico.yubikit.fido.ctap.ClientPin; +import com.yubico.yubikit.fido.ctap.Ctap2Session; +import com.yubico.yubikit.fido.ctap.FingerprintBioEnrollment; +import com.yubico.yubikit.fido.ctap.PinUvAuthProtocol; +import com.yubico.yubikit.testing.piv.PivCertificateTests; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Map; +import java.util.Objects; + +public class Ctap2BioEnrollmentTests { + + private static final Logger logger = LoggerFactory.getLogger(PivCertificateTests.class); + + static PinUvAuthProtocol getPinUvAuthProtocol(Object... args) { + assertThat("Missing required argument: PinUvAuthProtocol", args.length > 0); + return (PinUvAuthProtocol) args[0]; + } + + /** + * Attempts to set (or verify) the default PIN, or fails. + */ + static void ensureDefaultPinSet(Ctap2Session session, PinUvAuthProtocol pinUvAuthProtocol) + throws IOException, CommandException { + + Ctap2Session.InfoData info = session.getCachedInfo(); + + ClientPin pin = new ClientPin(session, pinUvAuthProtocol); + boolean pinSet = Objects.requireNonNull((Boolean) info.getOptions().get("clientPin")); + + if (!pinSet) { + pin.setPin(TestData.PIN); + } else { + pin.getPinToken( + TestData.PIN, + ClientPin.PIN_PERMISSION_BE, + "localhost"); + } + } + + public static void testFingerprintEnrollment(Ctap2Session session, Object... args) throws Throwable { + final FingerprintBioEnrollment fingerprintBioEnrollment = fpBioEnrollment(session, args); + + removeAllFingerprints(fingerprintBioEnrollment); + + final byte[] templateId = enrollFingerprint(fingerprintBioEnrollment); + + Map enrollments = fingerprintBioEnrollment.enumerateEnrollments(); + assertTrue(isEnrolled(templateId, enrollments)); + + final int maxNameLen = getMaxTemplateFriendlyName(fingerprintBioEnrollment); + + renameFingerprint(fingerprintBioEnrollment, templateId, maxNameLen); + try { + renameFingerprint(fingerprintBioEnrollment, templateId, maxNameLen + 1); + fail("Expected exception after rename with long name"); + } catch (CtapException e) { + assertEquals(ERR_INVALID_LENGTH, e.getCtapError()); + logger.debug("Caught ERR_INVALID_LENGTH when using long name."); + } + + fingerprintBioEnrollment.removeEnrollment(templateId); + enrollments = fingerprintBioEnrollment.enumerateEnrollments(); + assertThat("Fingerprints still exists after removal", enrollments.isEmpty()); + } + + private static byte[] enrollFingerprint(FingerprintBioEnrollment bioEnrollment) throws Throwable { + + final FingerprintBioEnrollment.FingerprintEnrollmentContext context = bioEnrollment.enroll(null); + + byte[] templateId = null; + while (templateId == null) { + logger.debug("Touch the fingerprint"); + try { + templateId = context.capture(); + } catch (FingerprintBioEnrollment.CaptureError captureError) { + // capture errors are expected + logger.debug("Received capture error: ", captureError); + } catch (CtapException ctapException) { + assertThat("Received CTAP2_ERR_FP_DATABASE_FULL exception - " + + "remove fingerprints before running this test", + ctapException.getCtapError() != CtapException.ERR_FP_DATABASE_FULL); + fail("Received unexpected CTAP2 exception " + ctapException.getCtapError()); + } catch (Throwable exception) { + fail("Received unexpected exception " + exception.getMessage()); + } + } + + logger.debug("Enrolled: {}", templateId); + + return templateId; + } + + private static FingerprintBioEnrollment fpBioEnrollment( + Ctap2Session session, + Object... args) throws Throwable { + + assertThat("Missing required argument: PinUvAuthProtocol", args.length > 0); + final PinUvAuthProtocol pinUvAuthProtocol = getPinUvAuthProtocol(args); + + ensureDefaultPinSet(session, pinUvAuthProtocol); + + final ClientPin pin = new ClientPin(session, pinUvAuthProtocol); + final byte[] pinToken = pin.getPinToken( + TestData.PIN, + ClientPin.PIN_PERMISSION_BE, + "localhost"); + + return new FingerprintBioEnrollment(session, pinUvAuthProtocol, pinToken); + } + + public static void renameFingerprint( + FingerprintBioEnrollment fingerprintBioEnrollment, + byte[] templateId, + int newNameLen) throws Throwable { + + char[] charArray = new char[newNameLen]; + Arrays.fill(charArray, 'A'); + String newName = new String(charArray); + + fingerprintBioEnrollment.setName(templateId, newName); + Map enrollments = fingerprintBioEnrollment.enumerateEnrollments(); + assertEquals(newName, getName(templateId,enrollments)); + } + + public static void removeAllFingerprints(FingerprintBioEnrollment fingerprintBioEnrollment) throws Throwable { + Map enrollments = fingerprintBioEnrollment.enumerateEnrollments(); + + for (byte[] templateId : enrollments.keySet()) { + fingerprintBioEnrollment.removeEnrollment(templateId); + } + + enrollments = fingerprintBioEnrollment.enumerateEnrollments(); + assertThat("Fingerprints still exists after removal", enrollments.isEmpty()); + } + + public static int getMaxTemplateFriendlyName(FingerprintBioEnrollment fingerprintBioEnrollment) throws Throwable { + final Map sensorInfo = fingerprintBioEnrollment.getFingerprintSensorInfo(); + assertTrue(sensorInfo.containsKey(RESULT_MAX_TEMPLATE_FRIENDLY_NAME) && sensorInfo.get(RESULT_MAX_TEMPLATE_FRIENDLY_NAME) instanceof Integer); + return (Integer) sensorInfo.get((Integer) RESULT_MAX_TEMPLATE_FRIENDLY_NAME); + } + + public static boolean isEnrolled(byte[] templateId, Map enrollments) { + for (byte[] enrolledTemplateId : enrollments.keySet()) { + if (Arrays.equals(templateId, enrolledTemplateId)) { + return true; + } + } + return false; + } + + public static String getName(byte[] templateId, Map enrollments) { + for (byte[] enrolledTemplateId : enrollments.keySet()) { + if (Arrays.equals(templateId, enrolledTemplateId)) { + return enrollments.get(enrolledTemplateId); + } + } + return null; + } +} From 9b63e7f95427ca59c26da8d1c576273a68a2c7cd Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 16 Feb 2024 14:40:06 +0100 Subject: [PATCH 02/15] change class' names --- .../yubikit/fido/ctap/BioEnrollment.java | 1 - .../fido/ctap/FingerprintBioEnrollment.java | 44 +++++++++---------- .../testing/fido/Ctap2BioEnrollmentTests.java | 2 +- 3 files changed, 22 insertions(+), 25 deletions(-) diff --git a/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java b/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java index 30b24fbb..62c46904 100644 --- a/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java @@ -76,5 +76,4 @@ public int getModality() throws IOException, CommandException { Boolean.TRUE); return Objects.requireNonNull((Integer) result.get(RESULT_MODALITY)); } - } diff --git a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java index b207dd3b..6b4aedce 100644 --- a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java @@ -21,7 +21,6 @@ import com.yubico.yubikit.core.internal.codec.Base64; import com.yubico.yubikit.fido.Cbor; -import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.ByteArrayOutputStream; @@ -85,11 +84,11 @@ public int getCode() { } } - static class FingerprintCapture { + static class CaptureStatus { private final int sampleStatus; private final int remaining; - public FingerprintCapture(int sampleStatus, int remaining) { + public CaptureStatus(int sampleStatus, int remaining) { this.sampleStatus = sampleStatus; this.remaining = remaining; } @@ -103,10 +102,10 @@ public int getRemaining() { } } - static class FingerprintInitialCapture extends FingerprintCapture { + static class EnrollBeginStatus extends CaptureStatus { private final byte[] templateId; - public FingerprintInitialCapture(byte[] templateId, int sampleStatus, int remaining) { + public EnrollBeginStatus(byte[] templateId, int sampleStatus, int remaining) { super(sampleStatus, remaining); this.templateId = templateId; } @@ -116,7 +115,7 @@ public byte[] getTemplateId() { } } - public static class FingerprintEnrollmentContext { + public static class Context { private final FingerprintBioEnrollment bioEnrollment; @Nullable private final Integer timeout; @@ -125,7 +124,7 @@ public static class FingerprintEnrollmentContext { @Nullable private Integer remaining; - public FingerprintEnrollmentContext( + public Context( FingerprintBioEnrollment bioEnrollment, @Nullable Integer timeout, @Nullable byte[] templateId, @@ -153,15 +152,14 @@ public FingerprintEnrollmentContext( public byte[] capture() throws IOException, CommandException, CaptureError { int sampleStatus; if (templateId == null) { - final FingerprintInitialCapture initialCapture = bioEnrollment.enrollBegin(timeout); - templateId = initialCapture.getTemplateId(); - remaining = initialCapture.getRemaining(); - sampleStatus = initialCapture.getSampleStatus(); + final EnrollBeginStatus status = bioEnrollment.enrollBegin(timeout); + templateId = status.getTemplateId(); + remaining = status.getRemaining(); + sampleStatus = status.getSampleStatus(); } else { - final FingerprintCapture capture = - bioEnrollment.enrollCaptureNext(templateId, timeout); - remaining = capture.getRemaining(); - sampleStatus = capture.getSampleStatus(); + final CaptureStatus status = bioEnrollment.enrollCaptureNext(templateId, timeout); + remaining = status.getRemaining(); + sampleStatus = status.getSampleStatus(); } if (sampleStatus != FEEDBACK_FP_GOOD) { @@ -245,10 +243,10 @@ public FingerprintBioEnrollment( * to scan their fingerprint once to provide an initial sample. * * @param timeout Optional timeout in milliseconds. - * @return A capture result object containing the new template ID, the sample status, + * @return A status object containing the new template ID, the sample status, * and the number of samples remaining to complete the enrollment. */ - public FingerprintInitialCapture enrollBegin(@Nullable Integer timeout) + public EnrollBeginStatus enrollBegin(@Nullable Integer timeout) throws IOException, CommandException { logger.debug("Starting fingerprint enrollment"); @@ -257,7 +255,7 @@ public FingerprintInitialCapture enrollBegin(@Nullable Integer timeout) final Map result = call(CMD_ENROLL_BEGIN, parameters); logger.debug("Sample capture result: {}", result); - return new FingerprintInitialCapture( + return new EnrollBeginStatus( Objects.requireNonNull((byte[]) result.get(RESULT_TEMPLATE_ID)), Objects.requireNonNull((Integer) result.get(RESULT_LAST_SAMPLE_STATUS)), Objects.requireNonNull((Integer) result.get(RESULT_REMAINING_SAMPLES))); @@ -272,10 +270,10 @@ public FingerprintInitialCapture enrollBegin(@Nullable Integer timeout) * * @param templateId The template ID returned by a call to {@link #enrollBegin(Integer timeout)}. * @param timeout Optional timeout in milliseconds. - * @return A capture result containing the sample status, and the number of samples + * @return A status object containing the sample status, and the number of samples * remaining to complete the enrollment. */ - public FingerprintCapture enrollCaptureNext( + public CaptureStatus enrollCaptureNext( byte[] templateId, @Nullable Integer timeout) throws IOException, CommandException { logger.debug("Capturing next sample with (timeout={})", timeout); @@ -286,7 +284,7 @@ public FingerprintCapture enrollCaptureNext( final Map result = call(CMD_ENROLL_CAPTURE_NEXT, parameters); logger.debug("Sample capture result: {}", result); - return new FingerprintCapture( + return new CaptureStatus( Objects.requireNonNull((Integer) result.get(RESULT_LAST_SAMPLE_STATUS)), Objects.requireNonNull((Integer) result.get(RESULT_REMAINING_SAMPLES))); } @@ -307,8 +305,8 @@ public void enrollCancel() throws IOException, CommandException { * @param timeout Optional timeout in milliseconds. * @return An initialized FingerprintEnrollmentContext. */ - public FingerprintEnrollmentContext enroll(@Nullable Integer timeout) { - return new FingerprintEnrollmentContext(this, timeout, null, null); + public Context enroll(@Nullable Integer timeout) { + return new Context(this, timeout, null, null); } /** diff --git a/testing/src/main/java/com/yubico/yubikit/testing/fido/Ctap2BioEnrollmentTests.java b/testing/src/main/java/com/yubico/yubikit/testing/fido/Ctap2BioEnrollmentTests.java index db8e18a3..6e14cf20 100755 --- a/testing/src/main/java/com/yubico/yubikit/testing/fido/Ctap2BioEnrollmentTests.java +++ b/testing/src/main/java/com/yubico/yubikit/testing/fido/Ctap2BioEnrollmentTests.java @@ -97,7 +97,7 @@ public static void testFingerprintEnrollment(Ctap2Session session, Object... arg private static byte[] enrollFingerprint(FingerprintBioEnrollment bioEnrollment) throws Throwable { - final FingerprintBioEnrollment.FingerprintEnrollmentContext context = bioEnrollment.enroll(null); + final FingerprintBioEnrollment.Context context = bioEnrollment.enroll(null); byte[] templateId = null; while (templateId == null) { From 4751b7ac6b34c762bbefb9699524433a525dbd71 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 16 Feb 2024 15:07:29 +0100 Subject: [PATCH 03/15] add CommandState parameter --- .../yubikit/fido/ctap/BioEnrollment.java | 3 ++- .../yubikit/fido/ctap/Ctap2Session.java | 12 ++++++---- .../fido/ctap/FingerprintBioEnrollment.java | 24 +++++++++++-------- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java b/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java index 62c46904..aaec9e22 100644 --- a/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java @@ -73,7 +73,8 @@ public int getModality() throws IOException, CommandException { null, null, null, - Boolean.TRUE); + Boolean.TRUE, + null); return Objects.requireNonNull((Integer) result.get(RESULT_MODALITY)); } } diff --git a/fido/src/main/java/com/yubico/yubikit/fido/ctap/Ctap2Session.java b/fido/src/main/java/com/yubico/yubikit/fido/ctap/Ctap2Session.java index 80d56b6d..e973ce25 100644 --- a/fido/src/main/java/com/yubico/yubikit/fido/ctap/Ctap2Session.java +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/Ctap2Session.java @@ -414,6 +414,8 @@ public void reset(@Nullable CommandState state) throws IOException, CommandExcep * @param subCommandParams a map of subCommands parameters * @param pinUvAuthProtocol PIN/UV protocol version chosen by the platform * @param pinUvAuthParam first 16 bytes of HMAC-SHA-256 of contents using pinUvAuthToken + * @param state an optional state object to cancel a request and handle + * keepalive signals * @throws IOException A communication error in the transport layer. * @throws CommandException A communication in the protocol layer. * @see authenticatorBioEnrollment @@ -424,21 +426,21 @@ public void reset(@Nullable CommandState state) throws IOException, CommandExcep @Nullable Map subCommandParams, @Nullable Integer pinUvAuthProtocol, @Nullable byte[] pinUvAuthParam, - @Nullable Boolean getModality + @Nullable Boolean getModality, + @Nullable CommandState state ) throws IOException, CommandException { if (bioEnrollmentCommand == null) { throw new IllegalStateException("Bio enrollment not supported"); } return sendCbor( - bioEnrollmentCommand, - args( + bioEnrollmentCommand, args( modality, subCommand, subCommandParams, pinUvAuthProtocol, pinUvAuthParam, - getModality), - null); + getModality + ), state); } /** diff --git a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java index 6b4aedce..8fa41e2e 100644 --- a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java @@ -17,6 +17,7 @@ package com.yubico.yubikit.fido.ctap; import com.yubico.yubikit.core.application.CommandException; +import com.yubico.yubikit.core.application.CommandState; import com.yubico.yubikit.core.fido.CtapException; import com.yubico.yubikit.core.internal.codec.Base64; import com.yubico.yubikit.fido.Cbor; @@ -196,13 +197,15 @@ public FingerprintBioEnrollment( private Map call( Integer subCommand, - @Nullable Map subCommandParams) throws IOException, CommandException { - return call(subCommand, subCommandParams, true); + @Nullable Map subCommandParams, + @Nullable CommandState state) throws IOException, CommandException { + return call(subCommand, subCommandParams, state, true); } private Map call( Integer subCommand, @Nullable Map subCommandParams, + @Nullable CommandState state, boolean authenticate) throws IOException, CommandException { byte[] pinUvAuthParam = null; if (authenticate) { @@ -221,7 +224,8 @@ public FingerprintBioEnrollment( subCommandParams, pinUvAuth.getVersion(), pinUvAuthParam, - null); + null, + state); } /** @@ -233,7 +237,7 @@ public FingerprintBioEnrollment( * @throws CommandException A communication in the protocol layer. */ public Map getFingerprintSensorInfo() throws IOException, CommandException { - return call(CMD_GET_SENSOR_INFO, null, false); + return call(CMD_GET_SENSOR_INFO, null, null, false); } /** @@ -253,7 +257,7 @@ public EnrollBeginStatus enrollBegin(@Nullable Integer timeout) Map parameters = new HashMap<>(); if (timeout != null) parameters.put(PARAM_TIMEOUT_MS, timeout); - final Map result = call(CMD_ENROLL_BEGIN, parameters); + final Map result = call(CMD_ENROLL_BEGIN, parameters, null); logger.debug("Sample capture result: {}", result); return new EnrollBeginStatus( Objects.requireNonNull((byte[]) result.get(RESULT_TEMPLATE_ID)), @@ -282,7 +286,7 @@ public CaptureStatus enrollCaptureNext( parameters.put(PARAM_TEMPLATE_ID, templateId); if (timeout != null) parameters.put(PARAM_TIMEOUT_MS, timeout); - final Map result = call(CMD_ENROLL_CAPTURE_NEXT, parameters); + final Map result = call(CMD_ENROLL_CAPTURE_NEXT, parameters, null); logger.debug("Sample capture result: {}", result); return new CaptureStatus( Objects.requireNonNull((Integer) result.get(RESULT_LAST_SAMPLE_STATUS)), @@ -294,7 +298,7 @@ public CaptureStatus enrollCaptureNext( */ public void enrollCancel() throws IOException, CommandException { logger.debug("Cancelling fingerprint enrollment."); - call(CMD_ENROLL_CANCEL, null, false); + call(CMD_ENROLL_CANCEL, null, null, false); } /** @@ -317,7 +321,7 @@ public Context enroll(@Nullable Integer timeout) { */ public Map enumerateEnrollments() throws IOException, CommandException { try { - final Map result = call(CMD_ENUMERATE_ENROLLMENTS, null); + final Map result = call(CMD_ENUMERATE_ENROLLMENTS, null, null); @SuppressWarnings("unchecked") final List> infos = (List>) result.get(RESULT_TEMPLATE_INFOS); @@ -353,7 +357,7 @@ public void setName(byte[] templateId, String name) throws IOException, CommandE parameters.put(TEMPLATE_INFO_ID, templateId); parameters.put(TEMPLATE_INFO_NAME, name); - call(CMD_SET_NAME, parameters); + call(CMD_SET_NAME, parameters, null); logger.info("Fingerprint template renamed"); } @@ -368,7 +372,7 @@ public void removeEnrollment(byte[] templateId) throws IOException, CommandExcep Map parameters = new HashMap<>(); parameters.put(TEMPLATE_INFO_ID, templateId); - call(CMD_REMOVE_ENROLLMENT, parameters); + call(CMD_REMOVE_ENROLLMENT, parameters, null); logger.info("Fingerprint template deleted"); } } From 1505a6a22a06b9887ff4516456dd912257c44792 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 16 Feb 2024 15:28:18 +0100 Subject: [PATCH 04/15] cache SensorInfo --- .../yubikit/fido/ctap/BioEnrollment.java | 19 +++--- .../fido/ctap/FingerprintBioEnrollment.java | 60 ++++++++++++++++++- .../testing/fido/Ctap2BioEnrollmentTests.java | 16 ++--- 3 files changed, 73 insertions(+), 22 deletions(-) diff --git a/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java b/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java index aaec9e22..aab68f81 100644 --- a/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java @@ -26,19 +26,20 @@ @SuppressWarnings("unused") public class BioEnrollment { - private static final int RESULT_MODALITY = 0x01; - private static final int RESULT_FINGERPRINT_KIND = 0x02; - private static final int RESULT_MAX_SAMPLES_REQUIRED = 0x03; - public static final int RESULT_TEMPLATE_ID = 0x04; - public static final int RESULT_LAST_SAMPLE_STATUS = 0x05; - public static final int RESULT_REMAINING_SAMPLES = 0x06; - public static final int RESULT_TEMPLATE_INFOS = 0x07; - public static final int RESULT_MAX_TEMPLATE_FRIENDLY_NAME = 0x08; + protected static final int RESULT_MODALITY = 0x01; + protected static final int RESULT_FINGERPRINT_KIND = 0x02; + protected static final int RESULT_MAX_SAMPLES_REQUIRED = 0x03; + + protected static final int RESULT_TEMPLATE_ID = 0x04; + protected static final int RESULT_LAST_SAMPLE_STATUS = 0x05; + protected static final int RESULT_REMAINING_SAMPLES = 0x06; + protected static final int RESULT_TEMPLATE_INFOS = 0x07; + protected static final int RESULT_MAX_TEMPLATE_FRIENDLY_NAME = 0x08; protected static final int TEMPLATE_INFO_ID = 0x01; protected static final int TEMPLATE_INFO_NAME = 0x02; - static final int MODALITY_FINGERPRINT = 0x01; + protected static final int MODALITY_FINGERPRINT = 0x01; protected final Ctap2Session ctap; protected final int modality; diff --git a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java index 8fa41e2e..4f9192aa 100644 --- a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java @@ -70,8 +70,49 @@ public class FingerprintBioEnrollment extends BioEnrollment { private final PinUvAuthProtocol pinUvAuth; private final byte[] pinUvToken; + private final SensorInfo cachedSensorInfo; + private final org.slf4j.Logger logger = LoggerFactory.getLogger(FingerprintBioEnrollment.class); + public static class SensorInfo { + public final int fingerprintKind; + public final int maxCaptureSamplesRequiredForEnroll; + public final int maxTemplateFriendlyName; + + public SensorInfo(int fingerprintKind, int maxCaptureSamplesRequiredForEnroll, int maxTemplateFriendlyName) { + this.fingerprintKind = fingerprintKind; + this.maxCaptureSamplesRequiredForEnroll = maxCaptureSamplesRequiredForEnroll; + this.maxTemplateFriendlyName = maxTemplateFriendlyName; + } + + /** + * Indicates type of fingerprint sensor. + * + * @return For touch type fingerprints returns 1, for swipe type fingerprints returns 2. + */ + public int getFingerprintKind() { + return fingerprintKind; + } + + /** + * Indicates the maximum good samples required for enrollment. + * + * @return Maximum good samples required for enrollment. + */ + public int getMaxCaptureSamplesRequiredForEnroll() { + return maxCaptureSamplesRequiredForEnroll; + } + + /** + * Indicates the maximum number of bytes the authenticator will accept as a templateFriendlyName. + * + * @return Maximum number of bytes the authenticator will accept as a templateFriendlyName. + */ + public int getMaxTemplateFriendlyName() { + return maxTemplateFriendlyName; + } + } + public static class CaptureError extends Exception { private final int code; @@ -193,6 +234,7 @@ public FingerprintBioEnrollment( super(ctap, BioEnrollment.MODALITY_FINGERPRINT); this.pinUvAuth = pinUvAuthProtocol; this.pinUvToken = pinUvToken; + this.cachedSensorInfo = readFingerprintSensorInfo(); } private Map call( @@ -236,8 +278,22 @@ public FingerprintBioEnrollment( * @throws IOException A communication error in the transport layer. * @throws CommandException A communication in the protocol layer. */ - public Map getFingerprintSensorInfo() throws IOException, CommandException { - return call(CMD_GET_SENSOR_INFO, null, null, false); + public SensorInfo readFingerprintSensorInfo() throws IOException, CommandException { + final Map result = call( + CMD_GET_SENSOR_INFO, + null, + null, + false); + + return new SensorInfo( + Objects.requireNonNull((Integer) result.get(RESULT_FINGERPRINT_KIND)), + Objects.requireNonNull((Integer) result.get(RESULT_MAX_SAMPLES_REQUIRED)), + Objects.requireNonNull((Integer) result.get(RESULT_MAX_TEMPLATE_FRIENDLY_NAME)) + ); + } + + public SensorInfo getCachedSensorInfo() { + return cachedSensorInfo; } /** diff --git a/testing/src/main/java/com/yubico/yubikit/testing/fido/Ctap2BioEnrollmentTests.java b/testing/src/main/java/com/yubico/yubikit/testing/fido/Ctap2BioEnrollmentTests.java index 6e14cf20..6d7ea782 100755 --- a/testing/src/main/java/com/yubico/yubikit/testing/fido/Ctap2BioEnrollmentTests.java +++ b/testing/src/main/java/com/yubico/yubikit/testing/fido/Ctap2BioEnrollmentTests.java @@ -16,8 +16,6 @@ package com.yubico.yubikit.testing.fido; -import static com.yubico.yubikit.core.fido.CtapException.ERR_INVALID_LENGTH; -import static com.yubico.yubikit.fido.ctap.BioEnrollment.RESULT_MAX_TEMPLATE_FRIENDLY_NAME; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -79,14 +77,16 @@ public static void testFingerprintEnrollment(Ctap2Session session, Object... arg Map enrollments = fingerprintBioEnrollment.enumerateEnrollments(); assertTrue(isEnrolled(templateId, enrollments)); - final int maxNameLen = getMaxTemplateFriendlyName(fingerprintBioEnrollment); + final int maxNameLen = fingerprintBioEnrollment + .getCachedSensorInfo() + .getMaxTemplateFriendlyName(); renameFingerprint(fingerprintBioEnrollment, templateId, maxNameLen); try { renameFingerprint(fingerprintBioEnrollment, templateId, maxNameLen + 1); fail("Expected exception after rename with long name"); } catch (CtapException e) { - assertEquals(ERR_INVALID_LENGTH, e.getCtapError()); + assertEquals(CtapException.ERR_INVALID_LENGTH, e.getCtapError()); logger.debug("Caught ERR_INVALID_LENGTH when using long name."); } @@ -151,7 +151,7 @@ public static void renameFingerprint( fingerprintBioEnrollment.setName(templateId, newName); Map enrollments = fingerprintBioEnrollment.enumerateEnrollments(); - assertEquals(newName, getName(templateId,enrollments)); + assertEquals(newName, getName(templateId, enrollments)); } public static void removeAllFingerprints(FingerprintBioEnrollment fingerprintBioEnrollment) throws Throwable { @@ -165,12 +165,6 @@ public static void removeAllFingerprints(FingerprintBioEnrollment fingerprintBio assertThat("Fingerprints still exists after removal", enrollments.isEmpty()); } - public static int getMaxTemplateFriendlyName(FingerprintBioEnrollment fingerprintBioEnrollment) throws Throwable { - final Map sensorInfo = fingerprintBioEnrollment.getFingerprintSensorInfo(); - assertTrue(sensorInfo.containsKey(RESULT_MAX_TEMPLATE_FRIENDLY_NAME) && sensorInfo.get(RESULT_MAX_TEMPLATE_FRIENDLY_NAME) instanceof Integer); - return (Integer) sensorInfo.get((Integer) RESULT_MAX_TEMPLATE_FRIENDLY_NAME); - } - public static boolean isEnrolled(byte[] templateId, Map enrollments) { for (byte[] enrolledTemplateId : enrollments.keySet()) { if (Arrays.equals(templateId, enrolledTemplateId)) { From 44899e863011b8d6db5356f87862c94e38de5022 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 16 Feb 2024 15:52:47 +0100 Subject: [PATCH 05/15] cleanup --- .../yubikit/fido/ctap/BioEnrollment.java | 7 +------ .../fido/ctap/FingerprintBioEnrollment.java | 18 ++++++------------ 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java b/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java index aab68f81..a24c3f5b 100644 --- a/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java @@ -18,13 +18,10 @@ import com.yubico.yubikit.core.application.CommandException; -import org.slf4j.LoggerFactory; - import java.io.IOException; import java.util.Map; import java.util.Objects; -@SuppressWarnings("unused") public class BioEnrollment { protected static final int RESULT_MODALITY = 0x01; protected static final int RESULT_FINGERPRINT_KIND = 0x02; @@ -37,15 +34,13 @@ public class BioEnrollment { protected static final int RESULT_MAX_TEMPLATE_FRIENDLY_NAME = 0x08; protected static final int TEMPLATE_INFO_ID = 0x01; - protected static final int TEMPLATE_INFO_NAME = 0x02; + protected static final int TEMPLATE_INFO_FRIENDLY_NAME = 0x02; protected static final int MODALITY_FINGERPRINT = 0x01; protected final Ctap2Session ctap; protected final int modality; - private static final org.slf4j.Logger logger = LoggerFactory.getLogger(BioEnrollment.class); - public BioEnrollment(Ctap2Session ctap, int modality) throws IOException, CommandException { if (!isSupported(ctap.getCachedInfo())) { throw new IllegalStateException("Bio enrollment not supported"); diff --git a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java index 4f9192aa..41c57d96 100644 --- a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java @@ -34,10 +34,7 @@ import javax.annotation.Nullable; -@SuppressWarnings("unused") public class FingerprintBioEnrollment extends BioEnrollment { - - /* commands */ private static final int CMD_ENROLL_BEGIN = 0x01; private static final int CMD_ENROLL_CAPTURE_NEXT = 0x02; private static final int CMD_ENROLL_CANCEL = 0x03; @@ -46,12 +43,10 @@ public class FingerprintBioEnrollment extends BioEnrollment { private static final int CMD_REMOVE_ENROLLMENT = 0x06; private static final int CMD_GET_SENSOR_INFO = 0x07; - /* parameters */ private static final int PARAM_TEMPLATE_ID = 0x01; private static final int PARAM_TEMPLATE_FRIENDLY_NAME = 0x02; private static final int PARAM_TIMEOUT_MS = 0x03; - /* feedback */ public static final int FEEDBACK_FP_GOOD = 0x00; public static final int FEEDBACK_FP_TOO_HIGH = 0x01; public static final int FEEDBACK_FP_TOO_LOW = 0x02; @@ -383,10 +378,9 @@ public Map enumerateEnrollments() throws IOException, CommandExc final List> infos = (List>) result.get(RESULT_TEMPLATE_INFOS); final Map retval = new HashMap<>(); for (Map info : infos) { - final byte[] templateId = - Objects.requireNonNull((byte[]) info.get(TEMPLATE_INFO_ID)); - final String templateFriendlyName = (String) info.get(TEMPLATE_INFO_NAME); - retval.put(templateId, templateFriendlyName); + final byte[] id = Objects.requireNonNull((byte[]) info.get(TEMPLATE_INFO_ID)); + final String friendlyName = (String) info.get(TEMPLATE_INFO_FRIENDLY_NAME); + retval.put(id, friendlyName); } logger.debug("Enumerated enrollments: {}", retval); @@ -410,8 +404,8 @@ public void setName(byte[] templateId, String name) throws IOException, CommandE logger.debug("Changing name of template: {} {}", Base64.toUrlSafeString(templateId), name); Map parameters = new HashMap<>(); - parameters.put(TEMPLATE_INFO_ID, templateId); - parameters.put(TEMPLATE_INFO_NAME, name); + parameters.put(PARAM_TEMPLATE_ID, templateId); + parameters.put(PARAM_TEMPLATE_FRIENDLY_NAME, name); call(CMD_SET_NAME, parameters, null); logger.info("Fingerprint template renamed"); @@ -426,7 +420,7 @@ public void removeEnrollment(byte[] templateId) throws IOException, CommandExcep logger.debug("Deleting template: {}", Base64.toUrlSafeString(templateId)); Map parameters = new HashMap<>(); - parameters.put(TEMPLATE_INFO_ID, templateId); + parameters.put(PARAM_TEMPLATE_ID, templateId); call(CMD_REMOVE_ENROLLMENT, parameters, null); logger.info("Fingerprint template deleted"); From 77f230ba121d17a96e2b834a97c5d83c16a6663c Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Fri, 16 Feb 2024 16:00:56 +0100 Subject: [PATCH 06/15] move fingerprint specific constants to its class --- .../yubico/yubikit/fido/ctap/BioEnrollment.java | 17 +++++------------ .../fido/ctap/FingerprintBioEnrollment.java | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java b/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java index a24c3f5b..0f99e712 100644 --- a/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java @@ -22,20 +22,13 @@ import java.util.Map; import java.util.Objects; +/** + * Implements Bio enrollment commands. + * + * @see authenticatorBioEnrollment + */ public class BioEnrollment { protected static final int RESULT_MODALITY = 0x01; - protected static final int RESULT_FINGERPRINT_KIND = 0x02; - protected static final int RESULT_MAX_SAMPLES_REQUIRED = 0x03; - - protected static final int RESULT_TEMPLATE_ID = 0x04; - protected static final int RESULT_LAST_SAMPLE_STATUS = 0x05; - protected static final int RESULT_REMAINING_SAMPLES = 0x06; - protected static final int RESULT_TEMPLATE_INFOS = 0x07; - protected static final int RESULT_MAX_TEMPLATE_FRIENDLY_NAME = 0x08; - - protected static final int TEMPLATE_INFO_ID = 0x01; - protected static final int TEMPLATE_INFO_FRIENDLY_NAME = 0x02; - protected static final int MODALITY_FINGERPRINT = 0x01; protected final Ctap2Session ctap; diff --git a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java index 41c57d96..e2015864 100644 --- a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java @@ -34,6 +34,11 @@ import javax.annotation.Nullable; +/** + * Implements Fingerprint Bio Enrollment commands. + * + * @see authenticatorConfig + */ public class FingerprintBioEnrollment extends BioEnrollment { private static final int CMD_ENROLL_BEGIN = 0x01; private static final int CMD_ENROLL_CAPTURE_NEXT = 0x02; @@ -43,6 +48,17 @@ public class FingerprintBioEnrollment extends BioEnrollment { private static final int CMD_REMOVE_ENROLLMENT = 0x06; private static final int CMD_GET_SENSOR_INFO = 0x07; + private static final int RESULT_FINGERPRINT_KIND = 0x02; + private static final int RESULT_MAX_SAMPLES_REQUIRED = 0x03; + private static final int RESULT_TEMPLATE_ID = 0x04; + private static final int RESULT_LAST_SAMPLE_STATUS = 0x05; + private static final int RESULT_REMAINING_SAMPLES = 0x06; + private static final int RESULT_TEMPLATE_INFOS = 0x07; + private static final int RESULT_MAX_TEMPLATE_FRIENDLY_NAME = 0x08; + + protected static final int TEMPLATE_INFO_ID = 0x01; + protected static final int TEMPLATE_INFO_FRIENDLY_NAME = 0x02; + private static final int PARAM_TEMPLATE_ID = 0x01; private static final int PARAM_TEMPLATE_FRIENDLY_NAME = 0x02; private static final int PARAM_TIMEOUT_MS = 0x03; From 7a1289a5f03b0c856102a0350386d7a5a654ded7 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Thu, 29 Feb 2024 15:21:12 +0100 Subject: [PATCH 07/15] fix issues discovered in PR review --- .../yubikit/fido/ctap/BioEnrollment.java | 13 ++++- .../yubikit/fido/ctap/Ctap2Session.java | 1 + .../fido/ctap/FingerprintBioEnrollment.java | 48 +++++++++++++++---- 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java b/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java index 0f99e712..4f30e18e 100644 --- a/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java @@ -40,7 +40,7 @@ public BioEnrollment(Ctap2Session ctap, int modality) throws IOException, Comman } this.ctap = ctap; - this.modality = getModality(); + this.modality = getModality(ctap); if (this.modality != modality) { throw new IllegalStateException("Device does not support modality " + modality); @@ -55,7 +55,16 @@ public static boolean isSupported(Ctap2Session.InfoData info) { options.containsKey("userVerificationMgmtPreview"); } - public int getModality() throws IOException, CommandException { + /** + * Get the type of modality the authenticator supports. + * + * @param ctap CTAP2 session + * @return The type of modality authenticator supports. For fingerprint, its value is 1. + * @throws IOException A communication error in the transport layer. + * @throws CommandException A communication in the protocol layer. + * @see Get bio modality + */ + public static int getModality(Ctap2Session ctap) throws IOException, CommandException { final Map result = ctap.bioEnrollment( null, null, diff --git a/fido/src/main/java/com/yubico/yubikit/fido/ctap/Ctap2Session.java b/fido/src/main/java/com/yubico/yubikit/fido/ctap/Ctap2Session.java index e973ce25..551c1e8f 100644 --- a/fido/src/main/java/com/yubico/yubikit/fido/ctap/Ctap2Session.java +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/Ctap2Session.java @@ -414,6 +414,7 @@ public void reset(@Nullable CommandState state) throws IOException, CommandExcep * @param subCommandParams a map of subCommands parameters * @param pinUvAuthProtocol PIN/UV protocol version chosen by the platform * @param pinUvAuthParam first 16 bytes of HMAC-SHA-256 of contents using pinUvAuthToken + * @param getModality get the user verification type modality * @param state an optional state object to cancel a request and handle * keepalive signals * @throws IOException A communication error in the transport layer. diff --git a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java index e2015864..d2a95bd3 100644 --- a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java @@ -168,6 +168,9 @@ public byte[] getTemplateId() { } } + /** + * Convenience class for handling one fingerprint enrollment + */ public static class Context { private final FingerprintBioEnrollment bioEnrollment; @Nullable @@ -198,7 +201,7 @@ public Context( * @return None, if more samples are needed, or the template ID if enrollment is * completed. * @throws IOException A communication error in the transport layer. - * @throws CommandException A communication in the protocol layer. + * @throws CommandException A communication error in the protocol layer. * @throws CaptureError An error during fingerprint capture. */ @Nullable @@ -230,7 +233,7 @@ public byte[] capture() throws IOException, CommandException, CaptureError { * Cancels ongoing enrollment. * * @throws IOException A communication error in the transport layer. - * @throws CommandException A communication in the protocol layer. + * @throws CommandException A communication error in the protocol layer. */ public void cancel() throws IOException, CommandException { bioEnrollment.enrollCancel(); @@ -245,7 +248,7 @@ public FingerprintBioEnrollment( super(ctap, BioEnrollment.MODALITY_FINGERPRINT); this.pinUvAuth = pinUvAuthProtocol; this.pinUvToken = pinUvToken; - this.cachedSensorInfo = readFingerprintSensorInfo(); + this.cachedSensorInfo = readFingerprintSensorInfo(ctap); } private Map call( @@ -284,17 +287,24 @@ public FingerprintBioEnrollment( /** * Get fingerprint sensor info. * + * @param ctap CTAP2 session * @return A dict containing FINGERPRINT_KIND, MAX_SAMPLES_REQUIRES and * MAX_TEMPLATE_FRIENDLY_NAME. * @throws IOException A communication error in the transport layer. - * @throws CommandException A communication in the protocol layer. + * @throws CommandException A communication error in the protocol layer. + * @see Get fingerprint sensor info */ - public SensorInfo readFingerprintSensorInfo() throws IOException, CommandException { - final Map result = call( + public static SensorInfo readFingerprintSensorInfo(Ctap2Session ctap) + throws IOException, CommandException { + + final Map result = ctap.bioEnrollment( + MODALITY_FINGERPRINT, CMD_GET_SENSOR_INFO, null, null, - false); + null, + null, + null); return new SensorInfo( Objects.requireNonNull((Integer) result.get(RESULT_FINGERPRINT_KIND)), @@ -316,6 +326,9 @@ public SensorInfo getCachedSensorInfo() { * @param timeout Optional timeout in milliseconds. * @return A status object containing the new template ID, the sample status, * and the number of samples remaining to complete the enrollment. + * @throws IOException A communication error in the transport layer. + * @throws CommandException A communication error in the protocol layer. + * @see Enrolling fingerprint */ public EnrollBeginStatus enrollBegin(@Nullable Integer timeout) throws IOException, CommandException { @@ -343,6 +356,9 @@ public EnrollBeginStatus enrollBegin(@Nullable Integer timeout) * @param timeout Optional timeout in milliseconds. * @return A status object containing the sample status, and the number of samples * remaining to complete the enrollment. + * @throws IOException A communication error in the transport layer. + * @throws CommandException A communication error in the protocol layer. + * @see Enrolling fingerprint */ public CaptureStatus enrollCaptureNext( byte[] templateId, @@ -362,6 +378,10 @@ public CaptureStatus enrollCaptureNext( /** * Cancel any ongoing fingerprint enrollment. + * + * @throws IOException A communication error in the transport layer. + * @throws CommandException A communication error in the protocol layer. + * @see Cancel current enrollment */ public void enrollCancel() throws IOException, CommandException { logger.debug("Cancelling fingerprint enrollment."); @@ -370,11 +390,10 @@ public void enrollCancel() throws IOException, CommandException { /** * Convenience wrapper for doing fingerprint enrollment. - *

- * See FingerprintEnrollmentContext for details. * * @param timeout Optional timeout in milliseconds. - * @return An initialized FingerprintEnrollmentContext. + * @return An initialized FingerprintEnrollment.Context. + * @see FingerprintBioEnrollment.Context */ public Context enroll(@Nullable Integer timeout) { return new Context(this, timeout, null, null); @@ -385,6 +404,9 @@ public Context enroll(@Nullable Integer timeout) { * their friendly names. * * @return A Map of enrolled templateId -> name pairs. + * @throws IOException A communication error in the transport layer. + * @throws CommandException A communication error in the protocol layer. + * @see Enumerate enrollments */ public Map enumerateEnrollments() throws IOException, CommandException { try { @@ -415,6 +437,9 @@ public Map enumerateEnrollments() throws IOException, CommandExc * * @param templateId The ID of the template to change. * @param name A friendly name to give the template. + * @throws IOException A communication error in the transport layer. + * @throws CommandException A communication error in the protocol layer. + * @see Rename/Set FriendlyName */ public void setName(byte[] templateId, String name) throws IOException, CommandException { logger.debug("Changing name of template: {} {}", Base64.toUrlSafeString(templateId), name); @@ -431,6 +456,9 @@ public void setName(byte[] templateId, String name) throws IOException, CommandE * Remove a previously enrolled fingerprint template. * * @param templateId The Id of the template to remove. + * @throws IOException A communication error in the transport layer. + * @throws CommandException A communication error in the protocol layer. + * @see Remove enrollment */ public void removeEnrollment(byte[] templateId) throws IOException, CommandException { logger.debug("Deleting template: {}", Base64.toUrlSafeString(templateId)); From 54c309243285bee7888b7d52a866b2acc5cf8267 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Thu, 29 Feb 2024 15:51:29 +0100 Subject: [PATCH 08/15] improve enrollment cancellation --- .../fido/ctap/FingerprintBioEnrollment.java | 71 ++++++++++++------- 1 file changed, 47 insertions(+), 24 deletions(-) diff --git a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java index d2a95bd3..60ac373a 100644 --- a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java @@ -179,6 +179,7 @@ public static class Context { private byte[] templateId; @Nullable private Integer remaining; + private final CommandState state = new CommandState(); public Context( FingerprintBioEnrollment bioEnrollment, @@ -207,23 +208,34 @@ public Context( @Nullable public byte[] capture() throws IOException, CommandException, CaptureError { int sampleStatus; - if (templateId == null) { - final EnrollBeginStatus status = bioEnrollment.enrollBegin(timeout); - templateId = status.getTemplateId(); - remaining = status.getRemaining(); - sampleStatus = status.getSampleStatus(); - } else { - final CaptureStatus status = bioEnrollment.enrollCaptureNext(templateId, timeout); - remaining = status.getRemaining(); - sampleStatus = status.getSampleStatus(); - } - - if (sampleStatus != FEEDBACK_FP_GOOD) { - throw new CaptureError(sampleStatus); - } - - if (remaining == 0) { - return templateId; + try { + if (templateId == null) { + final EnrollBeginStatus status = bioEnrollment.enrollBegin(timeout, state); + templateId = status.getTemplateId(); + remaining = status.getRemaining(); + sampleStatus = status.getSampleStatus(); + } else { + final CaptureStatus status = bioEnrollment.enrollCaptureNext( + templateId, + timeout, + state); + remaining = status.getRemaining(); + sampleStatus = status.getSampleStatus(); + } + + if (sampleStatus != FEEDBACK_FP_GOOD) { + throw new CaptureError(sampleStatus); + } + + if (remaining == 0) { + return templateId; + } + } catch (CtapException ctapException) { + if (ctapException.getCtapError() == CtapException.ERR_KEEPALIVE_CANCEL) { + bioEnrollment.enrollCancel(); + templateId = null; + } + throw ctapException; } return null; @@ -236,8 +248,15 @@ public byte[] capture() throws IOException, CommandException, CaptureError { * @throws CommandException A communication error in the protocol layer. */ public void cancel() throws IOException, CommandException { - bioEnrollment.enrollCancel(); - templateId = null; + state.cancel(); + } + + /** + * @return number of remaining captures for successful enrollment + */ + @Nullable + public Integer getRemaining() { + return remaining; } } @@ -324,20 +343,21 @@ public SensorInfo getCachedSensorInfo() { * to scan their fingerprint once to provide an initial sample. * * @param timeout Optional timeout in milliseconds. + * @param state If needed, the state to provide control over the ongoing operation. * @return A status object containing the new template ID, the sample status, * and the number of samples remaining to complete the enrollment. * @throws IOException A communication error in the transport layer. * @throws CommandException A communication error in the protocol layer. * @see Enrolling fingerprint */ - public EnrollBeginStatus enrollBegin(@Nullable Integer timeout) + public EnrollBeginStatus enrollBegin(@Nullable Integer timeout, @Nullable CommandState state) throws IOException, CommandException { logger.debug("Starting fingerprint enrollment"); Map parameters = new HashMap<>(); if (timeout != null) parameters.put(PARAM_TIMEOUT_MS, timeout); - final Map result = call(CMD_ENROLL_BEGIN, parameters, null); + final Map result = call(CMD_ENROLL_BEGIN, parameters, state); logger.debug("Sample capture result: {}", result); return new EnrollBeginStatus( Objects.requireNonNull((byte[]) result.get(RESULT_TEMPLATE_ID)), @@ -352,8 +372,10 @@ public EnrollBeginStatus enrollBegin(@Nullable Integer timeout) * fingerprint once to provide a new sample. * Once the number of samples remaining is 0, the enrollment is completed. * - * @param templateId The template ID returned by a call to {@link #enrollBegin(Integer timeout)}. + * @param templateId The template ID returned by a call to + * {@link #enrollBegin(Integer timeout, CommandState state)}. * @param timeout Optional timeout in milliseconds. + * @param state If needed, the state to provide control over the ongoing operation. * @return A status object containing the sample status, and the number of samples * remaining to complete the enrollment. * @throws IOException A communication error in the transport layer. @@ -362,14 +384,15 @@ public EnrollBeginStatus enrollBegin(@Nullable Integer timeout) */ public CaptureStatus enrollCaptureNext( byte[] templateId, - @Nullable Integer timeout) throws IOException, CommandException { + @Nullable Integer timeout, + @Nullable CommandState state) throws IOException, CommandException { logger.debug("Capturing next sample with (timeout={})", timeout); Map parameters = new HashMap<>(); parameters.put(PARAM_TEMPLATE_ID, templateId); if (timeout != null) parameters.put(PARAM_TIMEOUT_MS, timeout); - final Map result = call(CMD_ENROLL_CAPTURE_NEXT, parameters, null); + final Map result = call(CMD_ENROLL_CAPTURE_NEXT, parameters, state); logger.debug("Sample capture result: {}", result); return new CaptureStatus( Objects.requireNonNull((Integer) result.get(RESULT_LAST_SAMPLE_STATUS)), From ade10d29e4d068e3dc5527f9bd3d42143ce4f535 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Thu, 29 Feb 2024 16:02:22 +0100 Subject: [PATCH 09/15] use internal Logger for library logging --- .../fido/ctap/FingerprintBioEnrollment.java | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java index 60ac373a..bd1aa6f3 100644 --- a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java @@ -19,6 +19,7 @@ import com.yubico.yubikit.core.application.CommandException; import com.yubico.yubikit.core.application.CommandState; import com.yubico.yubikit.core.fido.CtapException; +import com.yubico.yubikit.core.internal.Logger; import com.yubico.yubikit.core.internal.codec.Base64; import com.yubico.yubikit.fido.Cbor; @@ -352,13 +353,13 @@ public SensorInfo getCachedSensorInfo() { */ public EnrollBeginStatus enrollBegin(@Nullable Integer timeout, @Nullable CommandState state) throws IOException, CommandException { - logger.debug("Starting fingerprint enrollment"); + Logger.debug(logger, "Starting fingerprint enrollment"); Map parameters = new HashMap<>(); if (timeout != null) parameters.put(PARAM_TIMEOUT_MS, timeout); final Map result = call(CMD_ENROLL_BEGIN, parameters, state); - logger.debug("Sample capture result: {}", result); + Logger.debug(logger, "Sample capture result: {}", result); return new EnrollBeginStatus( Objects.requireNonNull((byte[]) result.get(RESULT_TEMPLATE_ID)), Objects.requireNonNull((Integer) result.get(RESULT_LAST_SAMPLE_STATUS)), @@ -386,14 +387,17 @@ public CaptureStatus enrollCaptureNext( byte[] templateId, @Nullable Integer timeout, @Nullable CommandState state) throws IOException, CommandException { - logger.debug("Capturing next sample with (timeout={})", timeout); + Logger.debug(logger, "Capturing next sample with (timeout={})", + timeout != null + ? timeout + : "none specified"); Map parameters = new HashMap<>(); parameters.put(PARAM_TEMPLATE_ID, templateId); if (timeout != null) parameters.put(PARAM_TIMEOUT_MS, timeout); final Map result = call(CMD_ENROLL_CAPTURE_NEXT, parameters, state); - logger.debug("Sample capture result: {}", result); + Logger.debug(logger, "Sample capture result: {}", result); return new CaptureStatus( Objects.requireNonNull((Integer) result.get(RESULT_LAST_SAMPLE_STATUS)), Objects.requireNonNull((Integer) result.get(RESULT_REMAINING_SAMPLES))); @@ -407,7 +411,7 @@ public CaptureStatus enrollCaptureNext( * @see Cancel current enrollment */ public void enrollCancel() throws IOException, CommandException { - logger.debug("Cancelling fingerprint enrollment."); + Logger.debug(logger, "Cancelling fingerprint enrollment."); call(CMD_ENROLL_CANCEL, null, null, false); } @@ -444,7 +448,7 @@ public Map enumerateEnrollments() throws IOException, CommandExc retval.put(id, friendlyName); } - logger.debug("Enumerated enrollments: {}", retval); + Logger.debug(logger, "Enumerated enrollments: {}", retval); return retval; } catch (CtapException e) { @@ -465,14 +469,14 @@ public Map enumerateEnrollments() throws IOException, CommandExc * @see Rename/Set FriendlyName */ public void setName(byte[] templateId, String name) throws IOException, CommandException { - logger.debug("Changing name of template: {} {}", Base64.toUrlSafeString(templateId), name); + Logger.debug(logger, "Changing name of template: {} {}", Base64.toUrlSafeString(templateId), name); Map parameters = new HashMap<>(); parameters.put(PARAM_TEMPLATE_ID, templateId); parameters.put(PARAM_TEMPLATE_FRIENDLY_NAME, name); call(CMD_SET_NAME, parameters, null); - logger.info("Fingerprint template renamed"); + Logger.info(logger, "Fingerprint template renamed"); } /** @@ -484,12 +488,12 @@ public void setName(byte[] templateId, String name) throws IOException, CommandE * @see Remove enrollment */ public void removeEnrollment(byte[] templateId) throws IOException, CommandException { - logger.debug("Deleting template: {}", Base64.toUrlSafeString(templateId)); + Logger.debug(logger, "Deleting template: {}", Base64.toUrlSafeString(templateId)); Map parameters = new HashMap<>(); parameters.put(PARAM_TEMPLATE_ID, templateId); call(CMD_REMOVE_ENROLLMENT, parameters, null); - logger.info("Fingerprint template deleted"); + Logger.info(logger, "Fingerprint template deleted"); } } From 56f7bd20d4b7a0598aa834b14ee67186a6c29abd Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Thu, 29 Feb 2024 16:48:18 +0100 Subject: [PATCH 10/15] remove exceptions from cancel declaration --- .../yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java index bd1aa6f3..9ccd626f 100644 --- a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java @@ -244,11 +244,8 @@ public byte[] capture() throws IOException, CommandException, CaptureError { /** * Cancels ongoing enrollment. - * - * @throws IOException A communication error in the transport layer. - * @throws CommandException A communication error in the protocol layer. */ - public void cancel() throws IOException, CommandException { + public void cancel() { state.cancel(); } From bb9b7965b0f4ade588df7be1855b1644e2e70ba9 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Mon, 4 Mar 2024 08:24:53 +0100 Subject: [PATCH 11/15] fix class visibility --- .../yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java index 9ccd626f..f28e41f3 100644 --- a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java @@ -138,7 +138,7 @@ public int getCode() { } } - static class CaptureStatus { + public static class CaptureStatus { private final int sampleStatus; private final int remaining; @@ -156,7 +156,7 @@ public int getRemaining() { } } - static class EnrollBeginStatus extends CaptureStatus { + public static class EnrollBeginStatus extends CaptureStatus { private final byte[] templateId; public EnrollBeginStatus(byte[] templateId, int sampleStatus, int remaining) { From a448d1581aef65bf098f2b86dc1600b0106f9786 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Mon, 4 Mar 2024 08:33:32 +0100 Subject: [PATCH 12/15] don't cache sensor info --- .../yubikit/fido/ctap/FingerprintBioEnrollment.java | 10 +--------- .../yubikit/testing/fido/Ctap2BioEnrollmentTests.java | 2 +- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java index f28e41f3..93288f98 100644 --- a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java @@ -82,7 +82,6 @@ public class FingerprintBioEnrollment extends BioEnrollment { private final PinUvAuthProtocol pinUvAuth; private final byte[] pinUvToken; - private final SensorInfo cachedSensorInfo; private final org.slf4j.Logger logger = LoggerFactory.getLogger(FingerprintBioEnrollment.class); @@ -265,7 +264,6 @@ public FingerprintBioEnrollment( super(ctap, BioEnrollment.MODALITY_FINGERPRINT); this.pinUvAuth = pinUvAuthProtocol; this.pinUvToken = pinUvToken; - this.cachedSensorInfo = readFingerprintSensorInfo(ctap); } private Map call( @@ -304,15 +302,13 @@ public FingerprintBioEnrollment( /** * Get fingerprint sensor info. * - * @param ctap CTAP2 session * @return A dict containing FINGERPRINT_KIND, MAX_SAMPLES_REQUIRES and * MAX_TEMPLATE_FRIENDLY_NAME. * @throws IOException A communication error in the transport layer. * @throws CommandException A communication error in the protocol layer. * @see Get fingerprint sensor info */ - public static SensorInfo readFingerprintSensorInfo(Ctap2Session ctap) - throws IOException, CommandException { + public SensorInfo getSensorInfo() throws IOException, CommandException { final Map result = ctap.bioEnrollment( MODALITY_FINGERPRINT, @@ -330,10 +326,6 @@ public static SensorInfo readFingerprintSensorInfo(Ctap2Session ctap) ); } - public SensorInfo getCachedSensorInfo() { - return cachedSensorInfo; - } - /** * Start fingerprint enrollment. *

diff --git a/testing/src/main/java/com/yubico/yubikit/testing/fido/Ctap2BioEnrollmentTests.java b/testing/src/main/java/com/yubico/yubikit/testing/fido/Ctap2BioEnrollmentTests.java index 6d7ea782..7d2435e3 100755 --- a/testing/src/main/java/com/yubico/yubikit/testing/fido/Ctap2BioEnrollmentTests.java +++ b/testing/src/main/java/com/yubico/yubikit/testing/fido/Ctap2BioEnrollmentTests.java @@ -78,7 +78,7 @@ public static void testFingerprintEnrollment(Ctap2Session session, Object... arg assertTrue(isEnrolled(templateId, enrollments)); final int maxNameLen = fingerprintBioEnrollment - .getCachedSensorInfo() + .getSensorInfo() .getMaxTemplateFriendlyName(); renameFingerprint(fingerprintBioEnrollment, templateId, maxNameLen); From 3a98b4558ac38974dc388c72e06a6459808e7895 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Mon, 4 Mar 2024 15:21:24 +0100 Subject: [PATCH 13/15] add CommandState argument to Context.capture --- .../fido/ctap/FingerprintBioEnrollment.java | 57 ++++++++----------- .../testing/fido/Ctap2BioEnrollmentTests.java | 2 +- 2 files changed, 26 insertions(+), 33 deletions(-) diff --git a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java index 93288f98..525b52d8 100644 --- a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java @@ -179,7 +179,6 @@ public static class Context { private byte[] templateId; @Nullable private Integer remaining; - private final CommandState state = new CommandState(); public Context( FingerprintBioEnrollment bioEnrollment, @@ -199,6 +198,7 @@ public Context( * timeout not specified) waiting for the user to scan their fingerprint to * collect one sample. * + * @param state If needed, the state to provide control over the ongoing operation. * @return None, if more samples are needed, or the template ID if enrollment is * completed. * @throws IOException A communication error in the transport layer. @@ -206,46 +206,39 @@ public Context( * @throws CaptureError An error during fingerprint capture. */ @Nullable - public byte[] capture() throws IOException, CommandException, CaptureError { + public byte[] capture(@Nullable CommandState state) + throws IOException, CommandException, CaptureError { int sampleStatus; - try { - if (templateId == null) { - final EnrollBeginStatus status = bioEnrollment.enrollBegin(timeout, state); - templateId = status.getTemplateId(); - remaining = status.getRemaining(); - sampleStatus = status.getSampleStatus(); - } else { - final CaptureStatus status = bioEnrollment.enrollCaptureNext( - templateId, - timeout, - state); - remaining = status.getRemaining(); - sampleStatus = status.getSampleStatus(); - } - - if (sampleStatus != FEEDBACK_FP_GOOD) { - throw new CaptureError(sampleStatus); - } - - if (remaining == 0) { - return templateId; - } - } catch (CtapException ctapException) { - if (ctapException.getCtapError() == CtapException.ERR_KEEPALIVE_CANCEL) { - bioEnrollment.enrollCancel(); - templateId = null; - } - throw ctapException; + if (templateId == null) { + final EnrollBeginStatus status = bioEnrollment.enrollBegin(timeout, state); + templateId = status.getTemplateId(); + remaining = status.getRemaining(); + sampleStatus = status.getSampleStatus(); + } else { + final CaptureStatus status = bioEnrollment.enrollCaptureNext( + templateId, + timeout, + state); + remaining = status.getRemaining(); + sampleStatus = status.getSampleStatus(); } + if (sampleStatus != FEEDBACK_FP_GOOD) { + throw new CaptureError(sampleStatus); + } + + if (remaining == 0) { + return templateId; + } return null; } /** * Cancels ongoing enrollment. */ - public void cancel() { - state.cancel(); + public void cancel() throws IOException, CommandException { + bioEnrollment.enrollCancel(); + templateId = null; } /** diff --git a/testing/src/main/java/com/yubico/yubikit/testing/fido/Ctap2BioEnrollmentTests.java b/testing/src/main/java/com/yubico/yubikit/testing/fido/Ctap2BioEnrollmentTests.java index 7d2435e3..201bbff5 100755 --- a/testing/src/main/java/com/yubico/yubikit/testing/fido/Ctap2BioEnrollmentTests.java +++ b/testing/src/main/java/com/yubico/yubikit/testing/fido/Ctap2BioEnrollmentTests.java @@ -103,7 +103,7 @@ private static byte[] enrollFingerprint(FingerprintBioEnrollment bioEnrollment) while (templateId == null) { logger.debug("Touch the fingerprint"); try { - templateId = context.capture(); + templateId = context.capture(null); } catch (FingerprintBioEnrollment.CaptureError captureError) { // capture errors are expected logger.debug("Received capture error: ", captureError); From f031a7e67b95dbc219a1f0f9420ef851b55bfd01 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Mon, 4 Mar 2024 16:25:21 +0100 Subject: [PATCH 14/15] treat empty friendly names as null --- .../fido/ctap/FingerprintBioEnrollment.java | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java index 525b52d8..9e20339f 100644 --- a/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java @@ -423,16 +423,22 @@ public Map enumerateEnrollments() throws IOException, CommandExc @SuppressWarnings("unchecked") final List> infos = (List>) result.get(RESULT_TEMPLATE_INFOS); - final Map retval = new HashMap<>(); + final Map enrollments = new HashMap<>(); for (Map info : infos) { final byte[] id = Objects.requireNonNull((byte[]) info.get(TEMPLATE_INFO_ID)); - final String friendlyName = (String) info.get(TEMPLATE_INFO_FRIENDLY_NAME); - retval.put(id, friendlyName); + @Nullable + String friendlyName = (String) info.get(TEMPLATE_INFO_FRIENDLY_NAME); + // treat empty strings as null values + if (friendlyName != null) { + friendlyName = friendlyName.trim(); + if (friendlyName.isEmpty()) { + friendlyName = null; + } + } + enrollments.put(id, friendlyName); } - Logger.debug(logger, "Enumerated enrollments: {}", retval); - - return retval; + return enrollments; } catch (CtapException e) { if (e.getCtapError() == CtapException.ERR_INVALID_OPTION) { return Collections.emptyMap(); From 706981154cf902a6fd33c83c661d227ebaceb2dd Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Mon, 4 Mar 2024 17:12:10 +0100 Subject: [PATCH 15/15] Update license year --- .../main/java/com/yubico/yubikit/core/fido/CtapException.java | 2 +- .../main/java/com/yubico/yubikit/fido/ctap/Ctap2Session.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/com/yubico/yubikit/core/fido/CtapException.java b/core/src/main/java/com/yubico/yubikit/core/fido/CtapException.java index 5bf0ad81..b6ceea4f 100755 --- a/core/src/main/java/com/yubico/yubikit/core/fido/CtapException.java +++ b/core/src/main/java/com/yubico/yubikit/core/fido/CtapException.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2020-2023 Yubico. + * Copyright (C) 2020-2024 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/fido/src/main/java/com/yubico/yubikit/fido/ctap/Ctap2Session.java b/fido/src/main/java/com/yubico/yubikit/fido/ctap/Ctap2Session.java index 551c1e8f..3180a885 100644 --- a/fido/src/main/java/com/yubico/yubikit/fido/ctap/Ctap2Session.java +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/Ctap2Session.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2020-2023 Yubico. + * Copyright (C) 2020-2024 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License.