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..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. @@ -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..4f30e18e --- /dev/null +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/BioEnrollment.java @@ -0,0 +1,78 @@ +/* + * 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 java.io.IOException; +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 MODALITY_FINGERPRINT = 0x01; + + protected final Ctap2Session ctap; + protected final int modality; + + 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(ctap); + + 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"); + } + + /** + * 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, + null, + null, + null, + 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 8b136b6d..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. @@ -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,45 @@ 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 + * @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. + * @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, + @Nullable CommandState state + ) throws IOException, CommandException { + if (bioEnrollmentCommand == null) { + throw new IllegalStateException("Bio enrollment not supported"); + } + return sendCbor( + bioEnrollmentCommand, args( + modality, + subCommand, + subCommandParams, + pinUvAuthProtocol, + pinUvAuthParam, + getModality + ), state); + } + /** * 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..9e20339f --- /dev/null +++ b/fido/src/main/java/com/yubico/yubikit/fido/ctap/FingerprintBioEnrollment.java @@ -0,0 +1,487 @@ +/* + * 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.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; + +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; + +/** + * 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; + 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; + + 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; + + 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 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; + + public CaptureError(int code) { + super("Fingerprint capture error: " + code); + this.code = code; + } + + public int getCode() { + return code; + } + } + + public static class CaptureStatus { + private final int sampleStatus; + private final int remaining; + + public CaptureStatus(int sampleStatus, int remaining) { + this.sampleStatus = sampleStatus; + this.remaining = remaining; + } + + public int getSampleStatus() { + return sampleStatus; + } + + public int getRemaining() { + return remaining; + } + } + + public static class EnrollBeginStatus extends CaptureStatus { + private final byte[] templateId; + + public EnrollBeginStatus(byte[] templateId, int sampleStatus, int remaining) { + super(sampleStatus, remaining); + this.templateId = templateId; + } + + public byte[] getTemplateId() { + return templateId; + } + } + + /** + * Convenience class for handling one fingerprint enrollment + */ + public static class Context { + private final FingerprintBioEnrollment bioEnrollment; + @Nullable + private final Integer timeout; + @Nullable + private byte[] templateId; + @Nullable + private Integer remaining; + + public Context( + 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. + * + * @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. + * @throws CommandException A communication error in the protocol layer. + * @throws CaptureError An error during fingerprint capture. + */ + @Nullable + public byte[] capture(@Nullable CommandState state) + throws IOException, CommandException, CaptureError { + int sampleStatus; + 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() throws IOException, CommandException { + bioEnrollment.enrollCancel(); + templateId = null; + } + + /** + * @return number of remaining captures for successful enrollment + */ + @Nullable + public Integer getRemaining() { + return remaining; + } + } + + 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, + @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) { + 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, + state); + } + + /** + * 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 error in the protocol layer. + * @see Get fingerprint sensor info + */ + public SensorInfo getSensorInfo() throws IOException, CommandException { + + final Map result = ctap.bioEnrollment( + MODALITY_FINGERPRINT, + CMD_GET_SENSOR_INFO, + null, + null, + null, + null, + null); + + 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)) + ); + } + + /** + * 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. + * @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, @Nullable CommandState state) + throws IOException, CommandException { + 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(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)), + 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, 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. + * @throws CommandException A communication error in the protocol layer. + * @see Enrolling fingerprint + */ + public CaptureStatus enrollCaptureNext( + byte[] templateId, + @Nullable Integer timeout, + @Nullable CommandState state) throws IOException, CommandException { + 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(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))); + } + + /** + * 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(logger, "Cancelling fingerprint enrollment."); + call(CMD_ENROLL_CANCEL, null, null, false); + } + + /** + * Convenience wrapper for doing fingerprint enrollment. + * + * @param timeout Optional timeout in milliseconds. + * @return An initialized FingerprintEnrollment.Context. + * @see FingerprintBioEnrollment.Context + */ + public Context enroll(@Nullable Integer timeout) { + return new Context(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. + * @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 { + final Map result = call(CMD_ENUMERATE_ENROLLMENTS, null, null); + + @SuppressWarnings("unchecked") + final List> infos = (List>) result.get(RESULT_TEMPLATE_INFOS); + final Map enrollments = new HashMap<>(); + for (Map info : infos) { + final byte[] id = Objects.requireNonNull((byte[]) info.get(TEMPLATE_INFO_ID)); + @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); + } + + return enrollments; + } 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. + * @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(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(logger, "Fingerprint template renamed"); + } + + /** + * 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(logger, "Deleting template: {}", Base64.toUrlSafeString(templateId)); + + Map parameters = new HashMap<>(); + parameters.put(PARAM_TEMPLATE_ID, templateId); + + call(CMD_REMOVE_ENROLLMENT, parameters, null); + Logger.info(logger, "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..201bbff5 --- /dev/null +++ b/testing/src/main/java/com/yubico/yubikit/testing/fido/Ctap2BioEnrollmentTests.java @@ -0,0 +1,185 @@ +/* + * 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 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 = fingerprintBioEnrollment + .getSensorInfo() + .getMaxTemplateFriendlyName(); + + renameFingerprint(fingerprintBioEnrollment, templateId, maxNameLen); + try { + renameFingerprint(fingerprintBioEnrollment, templateId, maxNameLen + 1); + fail("Expected exception after rename with long name"); + } catch (CtapException e) { + assertEquals(CtapException.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.Context context = bioEnrollment.enroll(null); + + byte[] templateId = null; + while (templateId == null) { + logger.debug("Touch the fingerprint"); + try { + templateId = context.capture(null); + } 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 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; + } +}