diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..0dbe3a2
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,39 @@
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+name: CI check
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ name: Build and deploy
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v3.0.2
+ with:
+ submodules: true
+ - name: Set SSH key
+ uses: webfactory/ssh-agent@v0.5.4
+ with:
+ ssh-private-key: ${{ secrets.SSH_KEY }}
+ - name: Add known host key
+ run: ssh-keyscan javacard.pro >> ~/.ssh/known_hosts
+ - name: Cache local Maven repository
+ uses: actions/cache@v3.0.8
+ with:
+ path: ~/.m2/repository
+ key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
+ restore-keys: |
+ ${{ runner.os }}-maven-
+ - name: Setup java
+ uses: actions/setup-java@v3.5.0
+ with:
+ java-version: 11
+ distribution: temurin
+ - name: Compile and verify
+ run: ./mvnw -U -B -T1C verify
+ - name: Deploy snapshot
+ run: ./mvnw -B deploy
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..fef6ec8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+**/target
+*.cap
+*.iml
+*~
+/.idea
+/.mvn/wrapper/maven-wrapper.jar
+/applet/*.cap
+/applet/ant-javacard.jar
+pom.xml.versionsBackup
+target
diff --git a/.mvn/wrapper/MavenWrapperDownloader.java b/.mvn/wrapper/MavenWrapperDownloader.java
new file mode 100644
index 0000000..b901097
--- /dev/null
+++ b/.mvn/wrapper/MavenWrapperDownloader.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2007-present the original author or authors.
+ *
+ * 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.
+ */
+import java.net.*;
+import java.io.*;
+import java.nio.channels.*;
+import java.util.Properties;
+
+public class MavenWrapperDownloader {
+
+ private static final String WRAPPER_VERSION = "0.5.6";
+ /**
+ * Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided.
+ */
+ private static final String DEFAULT_DOWNLOAD_URL = "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/"
+ + WRAPPER_VERSION + "/maven-wrapper-" + WRAPPER_VERSION + ".jar";
+
+ /**
+ * Path to the maven-wrapper.properties file, which might contain a downloadUrl property to
+ * use instead of the default one.
+ */
+ private static final String MAVEN_WRAPPER_PROPERTIES_PATH =
+ ".mvn/wrapper/maven-wrapper.properties";
+
+ /**
+ * Path where the maven-wrapper.jar will be saved to.
+ */
+ private static final String MAVEN_WRAPPER_JAR_PATH =
+ ".mvn/wrapper/maven-wrapper.jar";
+
+ /**
+ * Name of the property which should be used to override the default download url for the wrapper.
+ */
+ private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl";
+
+ public static void main(String args[]) {
+ System.out.println("- Downloader started");
+ File baseDirectory = new File(args[0]);
+ System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath());
+
+ // If the maven-wrapper.properties exists, read it and check if it contains a custom
+ // wrapperUrl parameter.
+ File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH);
+ String url = DEFAULT_DOWNLOAD_URL;
+ if(mavenWrapperPropertyFile.exists()) {
+ FileInputStream mavenWrapperPropertyFileInputStream = null;
+ try {
+ mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile);
+ Properties mavenWrapperProperties = new Properties();
+ mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream);
+ url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url);
+ } catch (IOException e) {
+ System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'");
+ } finally {
+ try {
+ if(mavenWrapperPropertyFileInputStream != null) {
+ mavenWrapperPropertyFileInputStream.close();
+ }
+ } catch (IOException e) {
+ // Ignore ...
+ }
+ }
+ }
+ System.out.println("- Downloading from: " + url);
+
+ File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH);
+ if(!outputFile.getParentFile().exists()) {
+ if(!outputFile.getParentFile().mkdirs()) {
+ System.out.println(
+ "- ERROR creating output directory '" + outputFile.getParentFile().getAbsolutePath() + "'");
+ }
+ }
+ System.out.println("- Downloading to: " + outputFile.getAbsolutePath());
+ try {
+ downloadFileFromURL(url, outputFile);
+ System.out.println("Done");
+ System.exit(0);
+ } catch (Throwable e) {
+ System.out.println("- Error downloading");
+ e.printStackTrace();
+ System.exit(1);
+ }
+ }
+
+ private static void downloadFileFromURL(String urlString, File destination) throws Exception {
+ if (System.getenv("MVNW_USERNAME") != null && System.getenv("MVNW_PASSWORD") != null) {
+ String username = System.getenv("MVNW_USERNAME");
+ char[] password = System.getenv("MVNW_PASSWORD").toCharArray();
+ Authenticator.setDefault(new Authenticator() {
+ @Override
+ protected PasswordAuthentication getPasswordAuthentication() {
+ return new PasswordAuthentication(username, password);
+ }
+ });
+ }
+ URL website = new URL(urlString);
+ ReadableByteChannel rbc;
+ rbc = Channels.newChannel(website.openStream());
+ FileOutputStream fos = new FileOutputStream(destination);
+ fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE);
+ fos.close();
+ rbc.close();
+ }
+
+}
diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000..57bb584
--- /dev/null
+++ b/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,18 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.6/apache-maven-3.8.6-bin.zip
+wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar
diff --git a/README.md b/README.md
index 61f28a8..81bad3e 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,6 @@
# FIDO2
-FIDO2 JavaCard applet; Java utility and library
-Head to [Wiki](https://github.com/martinpaljak/FIDO2/wiki) for more information or [start a discussion](https://github.com/martinpaljak/FIDO2/discussions)!
+FIDO2 Java toolbox: library, command line tool, JavaCard applet (published later)
+
+Head to [Wiki](https://github.com/martinpaljak/FIDO2/wiki) for more information
+or [start a discussion](https://github.com/martinpaljak/FIDO2/discussions)!
diff --git a/common/pom.xml b/common/pom.xml
new file mode 100644
index 0000000..82e3e52
--- /dev/null
+++ b/common/pom.xml
@@ -0,0 +1,29 @@
+
+
+ 4.0.0
+
+ com.github.martinpaljak
+ fido2-toolbox
+ 22.01.05-SNAPSHOT
+
+ ctap2-common
+ FIDO2/U2F/CTAP2 common
+ FIDO2/U2F/CTAP2 common
+
+
+ com.github.martinpaljak
+ apdu4j-core
+ 2020r3
+
+
+ com.fasterxml.jackson.dataformat
+ jackson-dataformat-cbor
+ 2.13.4
+
+
+ org.bouncycastle
+ bcpkix-jdk18on
+
+
+
diff --git a/common/src/main/java/pro/javacard/fido2/common/AssertionVerifier.java b/common/src/main/java/pro/javacard/fido2/common/AssertionVerifier.java
new file mode 100644
index 0000000..8908f91
--- /dev/null
+++ b/common/src/main/java/pro/javacard/fido2/common/AssertionVerifier.java
@@ -0,0 +1,32 @@
+package pro.javacard.fido2.common;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.security.GeneralSecurityException;
+import java.security.Signature;
+import java.security.interfaces.ECPublicKey;
+
+public class AssertionVerifier {
+ private static final Logger logger = LoggerFactory.getLogger(AssertionVerifier.class);
+
+ public static boolean verify(AuthenticatorData authenticatorData, byte[] clientDataHash, byte[] signature, ECPublicKey publicKey) {
+ try {
+ // Verify assertion, if pubkey given
+ Signature ecdsa = Signature.getInstance("SHA256withECDSA");
+ ecdsa.initVerify(publicKey);
+ ecdsa.update(authenticatorData.getBytes());
+ ecdsa.update(clientDataHash);
+ if (ecdsa.verify(signature)) {
+ logger.info("Verified OK.");
+ return true;
+ } else {
+ logger.warn("Not verified!");
+ return false;
+ }
+ } catch (GeneralSecurityException e) {
+ logger.error("Failed to verify assertion: " + e.getMessage(), e);
+ return false;
+ }
+ }
+}
diff --git a/common/src/main/java/pro/javacard/fido2/common/AttestationCA.java b/common/src/main/java/pro/javacard/fido2/common/AttestationCA.java
new file mode 100644
index 0000000..4aad27c
--- /dev/null
+++ b/common/src/main/java/pro/javacard/fido2/common/AttestationCA.java
@@ -0,0 +1,124 @@
+package pro.javacard.fido2.common;
+
+import org.bouncycastle.asn1.ASN1ObjectIdentifier;
+import org.bouncycastle.asn1.DERBitString;
+import org.bouncycastle.asn1.DEROctetString;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.asn1.x500.X500NameBuilder;
+import org.bouncycastle.asn1.x500.style.BCStyle;
+import org.bouncycastle.asn1.x509.BasicConstraints;
+import org.bouncycastle.asn1.x509.Extension;
+import org.bouncycastle.asn1.x509.KeyUsage;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.operator.ContentSigner;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+import org.bouncycastle.util.encoders.Hex;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.math.BigInteger;
+import java.security.KeyPair;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.ECPublicKey;
+import java.time.LocalDate;
+import java.time.ZoneOffset;
+import java.util.Date;
+
+// This is a sample Attestation certificate CA
+public class AttestationCA {
+ private static final Logger logger = LoggerFactory.getLogger(AttestationCA.class);
+
+ static final byte[] rootPrivate = Hex.decode("314daf146b500e808360c0c904826c7afd259f401776a0dc7e6be14ce306ba49");
+ static final byte[] rootPublic = Hex.decode("043e7cf2b8b3685363d6d44ad417071398140547f9fc9cf058e6b834c30c36c02a0fc51e8c438e339dede6e69013eab8f851d2edee8786653fdbc6ac9b60a00fca");
+
+ public static final byte[] rootCertificate = Hex.decode("308201793082011fa003020102020401346607300a06082a8648ce3d04030230323130302e06035504030c276a617661636172642e70726f206174746573746174696f6e20726f6f7420233230323131323037301e170d3231303130313030303030305a170d3435303130313030303030305a30323130302e06035504030c276a617661636172642e70726f206174746573746174696f6e20726f6f74202332303231313230373059301306072a8648ce3d020106082a8648ce3d030107034200043e7cf2b8b3685363d6d44ad417071398140547f9fc9cf058e6b834c30c36c02a0fc51e8c438e339dede6e69013eab8f851d2edee8786653fdbc6ac9b60a00fcaa323302130120603551d130101ff040830060101ff020100300b0603551d0f040403020284300a06082a8648ce3d04030203480030450220509d5b1b0d66b3efd632004580965283b800a3b8e1d6ea25ad5ff94ea1d73089022100e941ecb919316ce1b03ecfa81ad293a8f053cd2446b00d1ba146c4e082e7e3ef");
+
+ public static final byte[] attestationPrivate = Hex.decode("fa18ca8b592245111416bf023ab28e06d1ea829d276365e2d0e05509d0b04674");
+ public static final byte[] attestationCertificate = Hex.decode("308201b230820159a003020102020101300a06082a8648ce3d04030230323130302e06035504030c276a617661636172642e70726f206174746573746174696f6e20726f6f7420233230323131323037301e170d3231303130313030303030305a170d3435303130313030303030305a304a310b300906035504061302454531173015060355040a0c0e4fc39c204bc3bc62657270756e6b31223020060355040b0c1941757468656e74696361746f72204174746573746174696f6e3059301306072a8648ce3d020106082a8648ce3d030107034200044c129336dd4989ef135450fec63e20136530e508744500a474dc3a72d6487b01e7e0f96219b675217e7b34c58b522844de3bacac8effcb9b1291a2a740887cc1a3483046300c0603551d130101ff040230003013060b2b0601040182e51c0201010404030204103021060b2b0601040182e51c0101040412041000000000000000000000000000000000300a06082a8648ce3d040302034700304402202b5bed230f194148ed0d439fffeb1a8b2fe95cfbfd032e1af384d7cd0dcc40c602206ea8e226fe7ff0d9a6877240af31c5050b032fc25277cc1cc81162fa4b8cae0a");
+
+ // See https://www.w3.org/TR/webauthn/#sctn-packed-attestation-cert-requirements
+ // And https://fidoalliance.org/specs/fido-v2.0-ps-20150904/fido-key-attestation-v2.0-ps-20150904.html#attestation-statement-certificate-requirements
+ BigInteger rootSerial = BigInteger.valueOf(20220927);
+
+ final X500Name rootSubject = new X500NameBuilder(BCStyle.INSTANCE)
+ .addRDN(BCStyle.CN, "javacard.pro test attestation root #" + rootSerial)
+ .build();
+
+ final X500Name attestationSubject = new X500NameBuilder(BCStyle.INSTANCE)
+ .addRDN(BCStyle.C, "EE")
+ .addRDN(BCStyle.O, "OÜ Küberpunk")
+ .addRDN(BCStyle.OU, "Authenticator Attestation") // NB! This is important
+ .build();
+
+
+ X509Certificate makeRootCertificate() throws Exception {
+ ECPrivateKey privateKey = CryptoUtils.private2privkey(rootPrivate);
+ ECPublicKey publicKey = CryptoUtils.uncompressed2pubkey(rootPublic);
+
+ // start data
+ Date startDate = Date.from(LocalDate.of(2022, 1, 1).atStartOfDay(ZoneOffset.UTC).toInstant());
+ Date endDate = Date.from(LocalDate.of(2045, 1, 1).atStartOfDay(ZoneOffset.UTC).toInstant());
+
+ // Basic certificate
+ JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(rootSubject, rootSerial, startDate, endDate, rootSubject, publicKey);
+
+ // Extensions. CA true, len = 0
+ BasicConstraints basicConstraints = new BasicConstraints(0); // True, length == 0
+ certBuilder.addExtension(new ASN1ObjectIdentifier("2.5.29.19"), true, basicConstraints);// Critical
+
+ // Extension: usage cert signing
+ KeyUsage usage = new KeyUsage(KeyUsage.keyCertSign | KeyUsage.digitalSignature);
+ certBuilder.addExtension(Extension.keyUsage, false, usage.getEncoded());
+
+ ContentSigner contentSigner = new JcaContentSignerBuilder("SHA256withECDSA").build(privateKey);
+ return new JcaX509CertificateConverter().setProvider("BC").getCertificate(certBuilder.build(contentSigner));
+ }
+
+
+ X509Certificate makeAttestationCertificate(ECPublicKey attestationPublicKey, byte[] aaguid) throws Exception {
+ ECPrivateKey privateKey = CryptoUtils.private2privkey(rootPrivate);
+
+ // Serial == 1
+ BigInteger certSerialNumber = BigInteger.ONE;
+
+ Date startDate = Date.from(LocalDate.of(2022, 1, 1).atStartOfDay(ZoneOffset.UTC).toInstant());
+ Date endDate = Date.from(LocalDate.of(2045, 1, 1).atStartOfDay(ZoneOffset.UTC).toInstant());
+
+ // Basic certificate
+ JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(rootSubject, certSerialNumber, startDate, endDate, attestationSubject, attestationPublicKey);
+
+ // Extensions - MUST CA FALSE
+ BasicConstraints basicConstraints = new BasicConstraints(false);
+ certBuilder.addExtension(new ASN1ObjectIdentifier("2.5.29.19"), true, basicConstraints);
+
+ // Extension: transports 1.3.6.1.4.1.45724.2.1.1 NFC = 3 MUST be wrapped in octet string (ref: ?)
+ certBuilder.addExtension(new ASN1ObjectIdentifier("1.3.6.1.4.1.45724.2.1.1"), false, new DEROctetString(new DERBitString(0x10)));
+
+ // Extension: AAGUID
+ certBuilder.addExtension(new ASN1ObjectIdentifier("1.3.6.1.4.1.45724.1.1.4"), false, new DEROctetString(aaguid));
+
+ ContentSigner contentSigner = new JcaContentSignerBuilder("SHA256withECDSA").build(privateKey);
+ return new JcaX509CertificateConverter().setProvider("BC").getCertificate(certBuilder.build(contentSigner));
+ }
+
+ //@Test
+ public void makeKey() {
+ KeyPair keyPair = CryptoUtils.ephemeral();
+
+ System.out.println(Hex.toHexString(((ECPrivateKey) keyPair.getPrivate()).getS().toByteArray()));
+ System.out.println(Hex.toHexString(((ECPublicKey) keyPair.getPublic()).getW().getAffineX().toByteArray()));
+ System.out.println(Hex.toHexString(((ECPublicKey) keyPair.getPublic()).getW().getAffineY().toByteArray()));
+ }
+
+ //@Test
+ public void makeCert() throws Exception {
+ X509Certificate rootCert = makeRootCertificate();
+ System.out.println(Hex.toHexString(rootCert.getEncoded()));
+
+ KeyPair keyPair = CryptoUtils.ephemeral();
+ System.out.println("Attestation key: " + Hex.toHexString(((ECPrivateKey) keyPair.getPrivate()).getS().toByteArray()));
+ System.out.println("Attestation cert: " + Hex.toHexString(makeAttestationCertificate((ECPublicKey) keyPair.getPublic(), new byte[16]).getEncoded()));
+ }
+}
diff --git a/common/src/main/java/pro/javacard/fido2/common/AttestationData.java b/common/src/main/java/pro/javacard/fido2/common/AttestationData.java
new file mode 100644
index 0000000..62bef88
--- /dev/null
+++ b/common/src/main/java/pro/javacard/fido2/common/AttestationData.java
@@ -0,0 +1,83 @@
+package pro.javacard.fido2.common;
+
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.bouncycastle.util.encoders.Hex;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.security.interfaces.ECPublicKey;
+import java.util.UUID;
+
+public class AttestationData {
+ private static final Logger logger = LoggerFactory.getLogger(AttestationData.class);
+
+ byte[] aaguid;
+ byte[] credentialID;
+ ECPublicKey publicKey;
+
+ int length;
+
+ private AttestationData(byte[] aaguid, byte[] credentialID, ECPublicKey publicKey) {
+ this.aaguid = aaguid.clone();
+ this.credentialID = credentialID.clone();
+ this.publicKey = publicKey;
+ this.length = 16 + 2 + credentialID.length + 77; // FIXME: fixed to secp256r1
+ }
+
+ static AttestationData fromBytes(byte[] bytes) throws IOException {
+ ByteBuffer b = ByteBuffer.wrap(bytes);
+ byte[] aaguid = new byte[16];
+ b.get(aaguid);
+ short credLen = b.getShort();
+ byte[] cred = new byte[credLen];
+ b.get(cred);
+ byte[] coseKey = new byte[77];
+ b.get(coseKey);
+
+ ECPublicKey key = COSE.extractKeyAgreementKey(coseKey);
+ return new AttestationData(aaguid, cred, key);
+ }
+
+ public UUID getAAGUID() {
+ return CryptoUtils.bytes2uuid(aaguid);
+ }
+
+ public ECPublicKey getPublicKey() {
+ return publicKey;
+ }
+
+ public byte[] getCredentialID() {
+ return credentialID.clone();
+ }
+
+ @Override
+ public String toString() {
+ return "AttestationData{" +
+ "aaguid=" + CryptoUtils.bytes2uuid(aaguid) +
+ ", credentialID=" + Hex.toHexString(credentialID) +
+ ", publicKey=" + Hex.toHexString(CryptoUtils.pubkey2uncompressed(publicKey)) +
+ '}';
+ }
+
+ // Given the input of attestation data, return the length of it
+ public int getLength() {
+ return length;
+ }
+
+ // XXX: this is purely "visual"
+ public ObjectNode toJSON() {
+ ObjectNode result = JsonNodeFactory.instance.objectNode();
+ result.put("aaguid", getAAGUID().toString());
+ result.put("credentialID", Hex.toHexString(credentialID));
+ ObjectNode pubkey = JsonNodeFactory.instance.objectNode();
+ pubkey.put("crv", "P-256");
+ pubkey.put("kty", "EC");
+ pubkey.put("x", Hex.toHexString(CryptoUtils.positive(publicKey.getW().getAffineX().toByteArray())));
+ pubkey.put("y", Hex.toHexString(CryptoUtils.positive(publicKey.getW().getAffineY().toByteArray())));
+ result.set("publicKey", pubkey);
+ return result;
+ }
+}
diff --git a/common/src/main/java/pro/javacard/fido2/common/AttestationVerifier.java b/common/src/main/java/pro/javacard/fido2/common/AttestationVerifier.java
new file mode 100644
index 0000000..c17812b
--- /dev/null
+++ b/common/src/main/java/pro/javacard/fido2/common/AttestationVerifier.java
@@ -0,0 +1,96 @@
+package pro.javacard.fido2.common;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.bouncycastle.util.encoders.Hex;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.security.GeneralSecurityException;
+import java.security.PublicKey;
+import java.security.Signature;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+
+public class AttestationVerifier {
+ private static final Logger logger = LoggerFactory.getLogger(AttestationVerifier.class);
+
+ static boolean valid(byte[] signature, byte[] dtbs, X509Certificate signer) throws GeneralSecurityException {
+ return valid(signature, dtbs, signer.getPublicKey());
+ }
+
+ static boolean valid(byte[] signature, byte[] dtbs, PublicKey signer) throws GeneralSecurityException {
+ logger.debug("Signature: {}", Hex.toHexString(signature));
+ Signature ecdsa_p256 = Signature.getInstance("SHA256withECDSA");
+ ecdsa_p256.initVerify(signer);
+ ecdsa_p256.update(dtbs);
+ if (!ecdsa_p256.verify(signature)) {
+ throw new GeneralSecurityException("Attestation verification failed");
+ }
+ logger.info("Attestation signature verified");
+ return true;
+ }
+
+ public static void dumpAttestation(MakeCredentialCommand command, ObjectNode registration) {
+ try {
+ if (registration.get("fmt").asText().equals("fido-u2f")) {
+ byte[] x509 = registration.get("attStmt").get("x5c").get(0).binaryValue();
+ CertificateFactory cf = CertificateFactory.getInstance("X509");
+ X509Certificate cert = (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(x509));
+ System.err.println("Attestation: " + cert.getSubjectX500Principal() + " by " + cert.getIssuerX500Principal());
+ byte[] signature = registration.get("attStmt").get("sig").binaryValue();
+ byte[] dtbs = attestation_dtbs(command, registration);
+ valid(signature, dtbs, cert);
+ } else if (registration.get("fmt").asText().equals("packed")) {
+ if (registration.get("attStmt").has("x5c")) {
+ byte[] x509 = registration.get("attStmt").get("x5c").get(0).binaryValue();
+ CertificateFactory cf = CertificateFactory.getInstance("X509");
+ X509Certificate cert = (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(x509));
+ System.err.println("Attestation: " + cert.getSubjectX500Principal() + " by " + cert.getIssuerX500Principal());
+ byte[] signature = registration.get("attStmt").get("sig").binaryValue();
+ byte[] dtbs = attestation_dtbs(command, registration);
+ valid(signature, dtbs, cert);
+ } else {
+ logger.info("self-attestation");
+ byte[] signature = registration.get("attStmt").get("sig").binaryValue();
+ byte[] dtbs = attestation_dtbs(command, registration);
+ AuthenticatorData authenticatorData = AuthenticatorData.fromBytes(registration.get("authData").binaryValue());
+ valid(signature, dtbs, authenticatorData.getAttestation().getPublicKey());
+ }
+ }
+ } catch (IOException | GeneralSecurityException e) {
+ e.printStackTrace();
+ }
+
+ // Verify.
+
+ }
+
+
+ static byte[] attestation_dtbs(MakeCredentialCommand command, ObjectNode registration) {
+ try {
+ if (registration.get("fmt").asText().equals("fido-u2f")) {
+ AuthenticatorData authenticatorData = AuthenticatorData.fromBytes(registration.get("authData").binaryValue());
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ bos.write(0x00); // fixed
+ bos.write(authenticatorData.rpIdHash);
+ bos.write(command.clientDataHash);
+ bos.write(authenticatorData.getAttestation().getCredentialID());
+ bos.write(CryptoUtils.pubkey2uncompressed(authenticatorData.getAttestation().getPublicKey()));
+ return bos.toByteArray();
+ } else if (registration.get("fmt").asText().equals("packed")) {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ bos.write(registration.get("authData").binaryValue());
+ bos.write(command.clientDataHash);
+ return bos.toByteArray();
+ } else {
+ throw new IllegalStateException("Unsupported attestation format: " + registration.get("fmt").asText());
+ }
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+}
diff --git a/common/src/main/java/pro/javacard/fido2/common/AuthenticatorData.java b/common/src/main/java/pro/javacard/fido2/common/AuthenticatorData.java
new file mode 100644
index 0000000..c39e5d5
--- /dev/null
+++ b/common/src/main/java/pro/javacard/fido2/common/AuthenticatorData.java
@@ -0,0 +1,131 @@
+package pro.javacard.fido2.common;
+
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.databind.node.TextNode;
+import org.bouncycastle.util.encoders.Hex;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.EnumSet;
+import java.util.stream.Collectors;
+
+public class AuthenticatorData {
+ public enum Flag {
+ UP(0x01), UV(0x02), AT(0x40), ED(0x80);
+ final byte mask;
+
+ Flag(int mask) {
+ this.mask = (byte) (mask & 0xFF);
+ }
+
+ static EnumSet fromByte(byte b) {
+ EnumSet r = EnumSet.noneOf(Flag.class);
+ for (Flag f : values()) {
+ if ((b & f.mask) == f.mask)
+ r.add(f);
+ }
+ return r;
+ }
+ }
+
+ final byte[] authData;
+ final byte[] rpIdHash;
+ final EnumSet flags;
+ final long counter;
+ final AttestationData attestation;
+ final ObjectNode extensions;
+
+
+ private AuthenticatorData(byte[] authData, byte[] rpIdHash, EnumSet flags, long counter, AttestationData attestation, ObjectNode extensions) {
+ this.authData = authData.clone();
+ this.rpIdHash = rpIdHash.clone();
+ this.flags = EnumSet.copyOf(flags);
+ this.counter = counter;
+ if (flags.contains(Flag.AT) && attestation == null) {
+ throw new IllegalArgumentException("Attestation flag set but no attestation data!");
+ } else if (!flags.contains(Flag.AT) && attestation != null) {
+ throw new IllegalArgumentException("Attestation flag no set but attestation data set!");
+ } else
+ this.attestation = attestation;
+ if (flags.contains(Flag.ED) && extensions == null) {
+ throw new IllegalArgumentException("Extensions flag set but extensions data!");
+ } else if (!flags.contains(Flag.ED) && extensions != null) {
+ throw new IllegalArgumentException("No extensions flag but extensions data set!");
+ } else
+ this.extensions = extensions;
+ }
+
+ public static AuthenticatorData fromBytes(byte[] bytes) throws IOException {
+ ByteBuffer b = ByteBuffer.wrap(bytes);
+ byte[] rpIdHash = new byte[32];
+ b.get(rpIdHash);
+ byte _flags = b.get();
+ EnumSet flags = Flag.fromByte(_flags);
+ long counter = Integer.toUnsignedLong(b.getInt());
+ int optionalPosition = 32 + 1 + 4;
+ AttestationData attestationData = null;
+ ObjectNode extensions = null;
+ // if AT, read attestation
+ if (b.hasRemaining() && flags.contains(Flag.AT)) {
+ byte[] attestationAndExtensions = new byte[b.remaining()];
+ b.get(attestationAndExtensions);
+ attestationData = AttestationData.fromBytes(attestationAndExtensions);
+ }
+ // Rewind to optional position
+ b.position(optionalPosition);
+ // if ED, read extensions
+ if (b.hasRemaining() && flags.contains(Flag.ED)) {
+ // Skip AT if present
+ if (flags.contains(Flag.AT) && attestationData != null)
+ b.position(b.position() + attestationData.getLength());
+ byte[] exts = new byte[b.remaining()];
+ b.get(exts);
+ //System.out.println("Extensions data: " + Hex.toHexString(exts));
+ extensions = (ObjectNode) CTAP2ProtocolHelpers.cborMapper.readTree(exts);
+ }
+ return new AuthenticatorData(bytes, rpIdHash, flags, counter, attestationData, extensions);
+ }
+
+ public byte[] getRpIdHash() {
+ return rpIdHash.clone();
+ }
+
+ public long getCounter() {
+ return counter;
+ }
+
+ public AttestationData getAttestation() {
+ return attestation;
+ }
+
+ @Override
+ public String toString() {
+ return "AuthenticatorData{" +
+ "rpIdHash=" + Hex.toHexString(getRpIdHash()) +
+ ", flags=" + flags +
+ ", counter=" + counter +
+ ", attestation=" + attestation +
+ ", extensions=" + extensions +
+ '}';
+ }
+
+ public ObjectNode toJSON() {
+ ObjectNode response = JsonNodeFactory.instance.objectNode();
+ response.put("rpIdHash", Hex.toHexString(rpIdHash));
+ ArrayNode fs = JsonNodeFactory.instance.arrayNode();
+ fs.addAll(flags.stream().map(Flag::name).map(TextNode::new).collect(Collectors.toList()));
+ response.set("flags", fs);
+ response.put("counter", counter);
+ if (flags.contains(Flag.AT))
+ response.set("attestation", attestation.toJSON());
+ if (flags.contains(Flag.ED))
+ response.set("extensions", extensions);
+ return response;
+ }
+
+ public byte[] getBytes() {
+ return authData.clone();
+ }
+}
diff --git a/common/src/main/java/pro/javacard/fido2/common/COSE.java b/common/src/main/java/pro/javacard/fido2/common/COSE.java
new file mode 100644
index 0000000..546fa47
--- /dev/null
+++ b/common/src/main/java/pro/javacard/fido2/common/COSE.java
@@ -0,0 +1,78 @@
+package pro.javacard.fido2.common;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.dataformat.cbor.CBORGenerator;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.security.interfaces.ECPublicKey;
+
+// COSE pubkey handling
+public class COSE {
+ public static final byte P256 = -7;
+ public static final byte Ed25519 = -8;
+
+ public static ECPublicKey extractKeyAgreementKey(byte[] response) throws IOException {
+ // Extract public key
+ ObjectNode kak = (ObjectNode) CTAP2ProtocolHelpers.cborMapper.readTree(response);
+ //System.out.println(CTAP2ProtocolHelpers.pretty.writeValueAsString(CTAP2ProtocolHelpers.hexify(kak)));
+
+ // This here is hacky and depends on jackson (we ask for string keys)
+ byte[] x = kak.get("-2").binaryValue();
+ byte[] y = kak.get("-3").binaryValue();
+ ECPublicKey cardKey = CryptoUtils.xy2pub(x, y);
+ return cardKey;
+ }
+
+ public static ECPublicKey extractKeyAgreementKey(JsonNode keyNode) throws IOException {
+ // Extract public key
+ // This here is hacky and depends on jackson (we ask for string keys)
+ byte[] x = keyNode.get("-2").binaryValue();
+ byte[] y = keyNode.get("-3").binaryValue();
+ ECPublicKey cardKey = CryptoUtils.xy2pub(x, y);
+ return cardKey;
+ }
+
+
+ // TODO: This does by default key exhange purpose! (-25). Signature key requires explicit -7
+ static void pubkey2cbor(ECPublicKey pub, CBORGenerator container) {
+ pubkey2cbor(pub, container, (byte) -25);
+ }
+
+ static void pubkey2cbor(ECPublicKey pub, CBORGenerator container, byte type) {
+ try {
+ container.writeStartObject(5);
+
+ container.writeFieldId(1);
+ container.writeNumber(2);
+
+ container.writeFieldId(3);
+ container.writeNumber(type);
+
+ container.writeFieldId(-1);
+ container.writeNumber(1); // P256
+
+ container.writeFieldId(-2);
+ container.writeBinary(CryptoUtils.positive(pub.getW().getAffineX().toByteArray()));
+
+ container.writeFieldId(-3);
+ container.writeBinary(CryptoUtils.positive(pub.getW().getAffineY().toByteArray()));
+
+ container.writeEndObject();
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ public static byte[] pubkey2cbor(ECPublicKey pub) {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ try (CBORGenerator container = CryptoUtils.cbor.createGenerator(bos)) {
+ pubkey2cbor(pub, container, (byte) -7); // FIXME type implicit
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ return bos.toByteArray();
+ }
+}
diff --git a/common/src/main/java/pro/javacard/fido2/common/CTAP1Transport.java b/common/src/main/java/pro/javacard/fido2/common/CTAP1Transport.java
new file mode 100644
index 0000000..347910f
--- /dev/null
+++ b/common/src/main/java/pro/javacard/fido2/common/CTAP1Transport.java
@@ -0,0 +1,13 @@
+package pro.javacard.fido2.common;
+
+import java.io.IOException;
+
+public interface CTAP1Transport {
+ byte[] transmitCTAP1(byte[] cmd) throws IOException;
+
+ default void wink() throws IOException, UnsupportedOperationException {
+ throw new UnsupportedOperationException("Wink operation not supported");
+ }
+
+ TransportMetadata getMetadata();
+}
diff --git a/common/src/main/java/pro/javacard/fido2/common/CTAP2Commands.java b/common/src/main/java/pro/javacard/fido2/common/CTAP2Commands.java
new file mode 100644
index 0000000..723f570
--- /dev/null
+++ b/common/src/main/java/pro/javacard/fido2/common/CTAP2Commands.java
@@ -0,0 +1,80 @@
+package pro.javacard.fido2.common;
+
+import apdu4j.core.CommandAPDU;
+
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.interfaces.ECPublicKey;
+
+import static pro.javacard.fido2.common.CryptoUtils.concatenate;
+import static pro.javacard.fido2.common.PINProtocols.*;
+
+// Construction of CTAP2 commands via functions.
+public final class CTAP2Commands {
+
+ public static byte[] select() {
+ return new CommandAPDU(0x00, 0xA4, 0x04, 0x00, CTAP2ProtocolHelpers.FIDO_AID, 256).getBytes();
+ }
+
+ public static byte[] make_setPIN(String pin, ECPublicKey cardKey, KeyPair hostEphemeral) {
+ // Get shared secret
+ byte[] sharedSecret = shared_secret(cardKey, hostEphemeral);
+
+ // Pad with 0x00 to 64 bytes
+ byte[] pinValue = pad00(pin.getBytes(StandardCharsets.UTF_8), 64);
+
+ // AES256-CBC(sharedSecret, IV=0, newPin)
+ byte[] newPinEnc = aes256_encrypt(sharedSecret, pinValue);
+
+ // LEFT(HMAC-SHA-256(sharedSecret, newPinEnc), 16).
+ byte[] pinAuth = left16(hmac_sha256(sharedSecret, newPinEnc));
+
+ return new ClientPINCommand()
+ .withProtocol(1)
+ .withSubCommand(CTAP2Enums.ClientPINCommandSubCommand.setPIN.value())
+ .withHostKey((ECPublicKey) hostEphemeral.getPublic())
+ .withNewPinEnc(newPinEnc)
+ .withPinAuth(pinAuth)
+ .build();
+ }
+
+
+ public static byte[] make_changePIN(String curPin, String newPin, ECPublicKey cardKey, KeyPair hostEphemeral) {
+ // Get shared secret
+ byte[] sharedSecret = shared_secret(cardKey, hostEphemeral);
+
+ byte[] pinHashEnc = aes256_encrypt(sharedSecret, left16(sha256(curPin.getBytes(StandardCharsets.UTF_8))));
+
+ // Pad with 0x00 to 64 bytes
+ byte[] newPinValue = pad00(newPin.getBytes(StandardCharsets.UTF_8), 64);
+
+ // AES256-CBC(sharedSecret, IV=0, newPin)
+ byte[] newPinEnc = aes256_encrypt(sharedSecret, newPinValue);
+
+ // LEFT(HMAC-SHA-256(sharedSecret, newPinEnc), 16).
+ byte[] pinAuth = left16(hmac_sha256(sharedSecret, concatenate(newPinEnc, pinHashEnc)));
+
+ return new ClientPINCommand()
+ .withProtocol(1)
+ .withSubCommand(CTAP2Enums.ClientPINCommandSubCommand.changePIN.value())
+ .withHostKey((ECPublicKey) hostEphemeral.getPublic())
+ .withNewPinEnc(newPinEnc)
+ .withPinHashEnc(pinHashEnc)
+ .withPinAuth(pinAuth)
+ .build();
+ }
+
+
+ public static byte[] make_getPinToken(String pin, ECPublicKey cardKey, KeyPair hostEphemeral) {
+ byte[] sharedSecret = shared_secret(cardKey, hostEphemeral);
+ byte[] pinHash = left16(sha256(pin.getBytes(StandardCharsets.UTF_8)));
+ byte[] pinHashEnc = aes256_encrypt(sharedSecret, pinHash);
+
+ return new ClientPINCommand()
+ .withProtocol(1)
+ .withSubCommand(CTAP2Enums.ClientPINCommandSubCommand.getPINToken.value())
+ .withHostKey((ECPublicKey) hostEphemeral.getPublic())
+ .withPinHashEnc(pinHashEnc)
+ .build();
+ }
+}
diff --git a/common/src/main/java/pro/javacard/fido2/common/CTAP2Enums.java b/common/src/main/java/pro/javacard/fido2/common/CTAP2Enums.java
new file mode 100644
index 0000000..b737f66
--- /dev/null
+++ b/common/src/main/java/pro/javacard/fido2/common/CTAP2Enums.java
@@ -0,0 +1,339 @@
+package pro.javacard.fido2.common;
+
+import java.util.Arrays;
+import java.util.Optional;
+
+// Names for CTAP2 numerics
+public class CTAP2Enums {
+
+ public enum Command {
+ authenticatorMakeCredential(0x01),
+ authenticatorGetAssertion(0x02),
+ authenticatorGetInfo(0x04),
+ authenticatorClientPIN(0x06),
+ authenticatorReset(0x07),
+ authenticatorGetNextAssertion(0x08),
+ authenticatorBioEnrollment(0x09), // 2.1
+ authenticatorCredentialManagement(0x0A), // 2.1
+ authenticatorSelection(0x0B), // 2.1
+ authenticatorLargeBlobs(0x0C), // 2.1
+ authenticatorConfig(0x0D), // 2.1
+ authenticatorBioEnrollmentPre(0x40), // 2.1 pre
+ authenticatorCredentialManagementPre(0x41), // 2.1 pre
+
+ // Proprietary commands
+ vendorCBOR(0x50),
+ vendorXCBOR(0x51);
+
+ public final byte cmd;
+
+ Command(int b) {
+ this.cmd = (byte) (b & 0xFF);
+ }
+
+ public static Optional valueOf(byte v) {
+ return Arrays.stream(values()).filter(command -> command.cmd == v).findFirst();
+ }
+ }
+
+ public enum MakeCredentialCommandParameter {
+ clientDataHash(1),
+ rp(2),
+ user(3),
+ pubKeyCredParams(4),
+ excludeList(5),
+ extensions(6),
+ options(7),
+ pinAuth(8),
+ pinProtocol(9),
+
+ enterpriseAttestation(0x0A); // 2.1
+ private final byte v;
+
+ MakeCredentialCommandParameter(int v) {
+ this.v = (byte) v;
+ }
+
+ public static Optional valueOf(byte v) {
+ return Arrays.stream(values()).filter(e -> e.value() == v).findFirst();
+ }
+
+ public byte value() {
+ return v;
+ }
+ }
+
+
+ public enum MakeCredentialResponseParameter {
+ fmt(1),
+ authData(2),
+ attStmt(3),
+ epAtt(4), // 2.1
+ largeBlobKey(5); // 2.1
+ private final byte v;
+
+ MakeCredentialResponseParameter(int v) {
+ this.v = (byte) v;
+ }
+
+ public static Optional valueOf(byte v) {
+ return Arrays.stream(values()).filter(e -> e.value() == v).findFirst();
+ }
+
+ public byte value() {
+ return v;
+ }
+ }
+
+
+ public enum GetInfoResponseParameter {
+ versions(1),
+ extensions(2),
+ aaguid(3),
+ options(4),
+ maxMsgSize(5),
+ pinUvAuthProtocols(6),
+ // CTAP2.1
+ maxCredentialCountInList(7),
+ maxCredentialIdLength(8),
+ transports(9),
+ algorithms(0xA),
+ maxSerializedLargeBlobArray(0xB),
+ forcePINChange(0xC),
+ minPINLength(0xD),
+ firmwareVersion(0xE),
+ maxCredBlobLength(0xF),
+ maxRPIDsForSetMinPINLength(0x10),
+ preferredPlatformUvAttempts(0x11),
+ uvModality(0x12),
+ certifications(0x13),
+ remainingDiscoverableCredentials(0x14),
+ vendorPrototypeConfigCommands(0x15);
+ private final byte v;
+
+ GetInfoResponseParameter(int v) {
+ this.v = (byte) v;
+ }
+
+ public static Optional valueOf(byte v) {
+ return Arrays.stream(values()).filter(e -> e.value() == v).findFirst();
+ }
+
+ public byte value() {
+ return v;
+ }
+ }
+
+
+ public enum GetAssertionCommandParameter {
+ rpId(1),
+ clientDataHash(2),
+ allowList(3),
+ extensions(4),
+ options(5),
+ pinAuth(6),
+ pinProtocol(7);
+ private final byte v;
+
+ GetAssertionCommandParameter(int v) {
+ this.v = (byte) v;
+ }
+
+ public static Optional valueOf(byte v) {
+ return Arrays.stream(values()).filter(e -> e.value() == v).findFirst();
+ }
+
+ public byte value() {
+ return v;
+ }
+ }
+
+ public enum GetAssertionResponseParameter {
+ credential(1),
+ authData(2),
+ signature(3),
+ publicKeyCredentialUserEntity(4),
+ numberOfCredentials(5);
+ private final byte v;
+
+ GetAssertionResponseParameter(int v) {
+ this.v = (byte) v;
+ }
+
+ public static Optional valueOf(byte v) {
+ return Arrays.stream(values()).filter(e -> e.value() == v).findFirst();
+ }
+
+ public byte value() {
+ return v;
+ }
+ }
+
+ public enum ClientPINCommandParameter {
+ pinProtocol(1),
+ subCommand(2),
+ keyAgreement(3),
+ pinAuth(4),
+ newPinEnc(5),
+ pinHashEnc(6),
+ permissions(7), // 2.1
+ rpId(8); // 2.1
+ private final byte v;
+
+ ClientPINCommandParameter(int v) {
+ this.v = (byte) v;
+ }
+
+ public static Optional valueOf(byte v) {
+ return Arrays.stream(values()).filter(e -> e.value() == v).findFirst();
+ }
+
+ public byte value() {
+ return v;
+ }
+ }
+
+ public enum ClientPINCommandSubCommand {
+ getReries(1),
+ getKeyAgreement(2),
+ setPIN(3),
+ changePIN(4),
+ getPINToken(5),
+ getPinUvAuthTokenUsingUvWithPermissions(6), // 2.1
+ getUVRetries(7), // 2.1
+ getPinUvAuthTokenUsingPinWithPermissions(8); // 2.1
+
+ private final byte v;
+
+ ClientPINCommandSubCommand(int v) {
+ this.v = (byte) v;
+ }
+
+ public byte value() {
+ return v;
+ }
+ }
+
+
+ public enum ClientPINResponseParameter {
+ keyAgreement(1),
+ pinToken(2),
+ retries(3),
+ powerCycleState(4), // 2.1
+ uvRetries(5); // 2.1
+
+ public final byte v;
+
+ ClientPINResponseParameter(int v) {
+ this.v = (byte) v;
+ }
+
+ public static Optional valueOf(byte v) {
+ return Arrays.stream(values()).filter(e -> e.v == v).findFirst();
+ }
+ }
+
+
+ public enum CredentialManagementPreCommandParameter {
+ subCommand(1),
+ subCommandParams(2),
+ pinProtocol(3),
+ pinAuth(4);
+ public final byte v;
+
+ CredentialManagementPreCommandParameter(int v) {
+ this.v = (byte) v;
+ }
+
+ public static Optional valueOf(byte v) {
+ return Arrays.stream(values()).filter(e -> e.v == v).findFirst();
+ }
+ }
+
+ public enum CredentialManagementPreResponseParameter {
+ existingResidentCredentialsCount(1),
+ maxPossibleRemainingResidentCredentialsCount(2),
+ rp(3),
+ rpIDHash(4),
+ totalRPs(5),
+ user(6),
+ credentialID(7),
+ publicKey(8),
+ totalCredentials(9),
+ credProtect(0x0A),
+
+ largeBlobKey(0x0B);
+
+ public final byte v;
+
+ CredentialManagementPreResponseParameter(int v) {
+ this.v = (byte) v;
+ }
+
+ public static Optional valueOf(byte v) {
+ return Arrays.stream(values()).filter(e -> e.v == v).findFirst();
+ }
+ }
+
+
+ public enum Error {
+ CTAP1_ERR_SUCCESS((byte) 0x00),
+ CTAP1_ERR_INVALID_COMMAND((byte) 0x01),
+ CTAP1_ERR_INVALID_PARAMETER((byte) 0x02),
+ CTAP1_ERR_INVALID_LENGTH((byte) 0x03),
+ CTAP1_ERR_INVALID_SEQ((byte) 0x04),
+ CTAP1_ERR_TIMEOUT((byte) 0x05),
+ CTAP1_ERR_CHANNEL_BUSY((byte) 0x06),
+ CTAP1_ERR_LOCK_REQUIRED((byte) 0x0A),
+ CTAP1_ERR_INVALID_CHANNEL((byte) 0x0B),
+ CTAP2_ERR_CBOR_UNEXPECTED_TYPE((byte) 0x11),
+ CTAP2_ERR_INVALID_CBOR((byte) 0x12),
+ CTAP2_ERR_MISSING_PARAMETER((byte) 0x14),
+ CTAP2_ERR_LIMIT_EXCEEDED((byte) 0x15),
+ CTAP2_ERR_UNSUPPORTED_EXTENSION((byte) 0x16),
+ CTAP2_ERR_CREDENTIAL_EXCLUDED((byte) 0x19),
+ CTAP2_ERR_PROCESSING((byte) 0x21),
+ CTAP2_ERR_INVALID_CREDENTIAL((byte) 0x22),
+ CTAP2_ERR_USER_ACTION_PENDING((byte) 0x23),
+ CTAP2_ERR_OPERATION_PENDING((byte) 0x24),
+ CTAP2_ERR_NO_OPERATIONS((byte) 0x25),
+ CTAP2_ERR_UNSUPPORTED_ALGORITHM((byte) 0x26),
+ CTAP2_ERR_OPERATION_DENIED((byte) 0x27),
+ CTAP2_ERR_KEY_STORE_FULL((byte) 0x28),
+ CTAP2_ERR_NOT_BUSY((byte) 0x29),
+ CTAP2_ERR_NO_OPERATION_PENDING((byte) 0x2A),
+ CTAP2_ERR_UNSUPPORTED_OPTION((byte) 0x2B),
+ CTAP2_ERR_INVALID_OPTION((byte) 0x2C),
+ CTAP2_ERR_KEEPALIVE_CANCEL((byte) 0x2D),
+ CTAP2_ERR_NO_CREDENTIALS((byte) 0x2E),
+ CTAP2_ERR_USER_ACTION_TIMEOUT((byte) 0x2F),
+ CTAP2_ERR_NOT_ALLOWED((byte) 0x30),
+ CTAP2_ERR_PIN_INVALID((byte) 0x31),
+ CTAP2_ERR_PIN_BLOCKED((byte) 0x32),
+ CTAP2_ERR_PIN_AUTH_INVALID((byte) 0x33),
+ CTAP2_ERR_PIN_AUTH_BLOCKED((byte) 0x34),
+ CTAP2_ERR_PIN_NOT_SET((byte) 0x35),
+ CTAP2_ERR_PIN_REQUIRED((byte) 0x36),
+ CTAP2_ERR_PIN_POLICY_VIOLATION((byte) 0x37),
+ CTAP2_ERR_PIN_TOKEN_EXPIRED((byte) 0x38),
+ CTAP2_ERR_REQUEST_TOO_LARGE((byte) 0x39),
+ CTAP2_ERR_ACTION_TIMEOUT((byte) 0x3A),
+ CTAP2_ERR_UP_REQUIRED((byte) 0x3B),
+ CTAP1_ERR_OTHER((byte) 0x7F),
+ CTAP2_ERR_SPEC_LAST((byte) 0xDF),
+ CTAP2_ERR_EXTENSION_FIRST((byte) 0xE0),
+ CTAP2_ERR_EXTENSION_LAST((byte) 0xEF),
+ CTAP2_ERR_VENDOR_FIRST((byte) 0xF0),
+ CTAP2_ERR_VENDOR_LAST((byte) 0xFF);
+
+ public final byte v;
+
+ Error(byte v) {
+ this.v = v;
+ }
+
+ public static Optional valueOf(byte v) {
+ return Arrays.stream(values()).filter(e -> e.v == v).findFirst();
+ }
+ }
+}
diff --git a/common/src/main/java/pro/javacard/fido2/common/CTAP2Extension.java b/common/src/main/java/pro/javacard/fido2/common/CTAP2Extension.java
new file mode 100644
index 0000000..d86ded8
--- /dev/null
+++ b/common/src/main/java/pro/javacard/fido2/common/CTAP2Extension.java
@@ -0,0 +1,82 @@
+package pro.javacard.fido2.common;
+
+import com.fasterxml.jackson.dataformat.cbor.CBORGenerator;
+
+import java.io.IOException;
+import java.security.interfaces.ECPublicKey;
+
+public abstract class CTAP2Extension {
+
+ abstract void serializeGetAssertionCBOR(CBORGenerator generator) throws IOException;
+
+ abstract void serializeMakeCredentialCBOR(CBORGenerator generator) throws IOException;
+
+ abstract String getExtensionName();
+
+ public static class HMACSecret extends CTAP2Extension {
+ private final ECPublicKey hostPublic;
+ private final byte[] saltEnc;
+ private final byte[] saltAuth;
+
+ public HMACSecret() {
+ hostPublic = null;
+ saltAuth = null;
+ saltEnc = null;
+ }
+
+ public HMACSecret(ECPublicKey hostPublic, byte[] saltEnc, byte[] saltAuth) {
+ this.hostPublic = hostPublic;
+ this.saltEnc = saltEnc.clone();
+ this.saltAuth = saltAuth.clone();
+ }
+
+ @Override
+ void serializeGetAssertionCBOR(CBORGenerator generator) throws IOException {
+ generator.writeStartObject(3);
+ generator.writeFieldId(1);
+ COSE.pubkey2cbor(hostPublic, generator);
+ generator.writeFieldId(2);
+ generator.writeBinary(saltEnc);
+ generator.writeFieldId(3);
+ generator.writeBinary(saltAuth);
+ generator.writeEndObject();
+ }
+
+ @Override
+ void serializeMakeCredentialCBOR(CBORGenerator generator) throws IOException {
+ generator.writeFieldName(getExtensionName());
+ generator.writeBoolean(true);
+ }
+
+ @Override
+ String getExtensionName() {
+ return "hmac-secret";
+ }
+ }
+
+ public static class CredProtect extends CTAP2Extension {
+ public enum Protection {OPTIONAL, UNLESSKNOWN, REQUIRED}
+
+ private final byte protection;
+
+ public CredProtect(byte protection) {
+ this.protection = protection;
+ }
+
+ @Override
+ void serializeGetAssertionCBOR(CBORGenerator generator) throws IOException {
+ // Nothing
+ }
+
+ @Override
+ void serializeMakeCredentialCBOR(CBORGenerator generator) throws IOException {
+ generator.writeFieldName(getExtensionName());
+ generator.writeNumber(protection);
+ }
+
+ @Override
+ String getExtensionName() {
+ return "credProtect";
+ }
+ }
+}
diff --git a/common/src/main/java/pro/javacard/fido2/common/CTAP2ProtocolHelpers.java b/common/src/main/java/pro/javacard/fido2/common/CTAP2ProtocolHelpers.java
new file mode 100644
index 0000000..79921a2
--- /dev/null
+++ b/common/src/main/java/pro/javacard/fido2/common/CTAP2ProtocolHelpers.java
@@ -0,0 +1,276 @@
+package pro.javacard.fido2.common;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.ObjectWriter;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.databind.node.TextNode;
+import com.fasterxml.jackson.dataformat.cbor.databind.CBORMapper;
+import org.bouncycastle.util.encoders.Base64;
+import org.bouncycastle.util.encoders.Hex;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.PrintStream;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.security.interfaces.ECPublicKey;
+import java.util.*;
+import java.util.function.Function;
+
+import static pro.javacard.fido2.common.CTAP2Enums.Error.CTAP1_ERR_SUCCESS;
+import static pro.javacard.fido2.common.CTAP2Enums.Error.valueOf;
+import static pro.javacard.fido2.common.CryptoUtils.concatenate;
+
+@SuppressWarnings("deprecation")
+public class CTAP2ProtocolHelpers {
+ private static final Logger logger = LoggerFactory.getLogger(CTAP2ProtocolHelpers.class);
+
+ public static final ObjectMapper cborMapper = new CBORMapper();
+ public static final ObjectMapper mapper = new ObjectMapper();
+
+ private static PrintStream protocolDebug = null;
+
+ public static void setProtocolDebug(OutputStream debug) {
+ protocolDebug = new PrintStream(debug, true, StandardCharsets.UTF_8);
+ }
+
+ static {
+ mapper.configure(JsonGenerator.Feature.QUOTE_FIELD_NAMES, false); // We have numerics in visual
+ }
+
+ public static final ObjectWriter pretty = mapper.writerWithDefaultPrettyPrinter();
+
+ public static final byte[] FIDO_AID = Hex.decode("A0000006472F0001");
+
+ // Recursive and makes a copy of the node
+ public static JsonNode hexify(JsonNode node) {
+ return hexify_(node.deepCopy());
+ }
+
+ static JsonNode hexify_(JsonNode node) {
+ if (node.isArray()) {
+ ArrayNode hexified = mapper.createArrayNode();
+ node.forEach(e -> hexified.add(hexify_(e)));
+ return hexified;
+ } else if (node.isObject()) {
+ ObjectNode obj = (ObjectNode) node;
+ obj.fieldNames().forEachRemaining(fn -> obj.set(fn, hexify_(obj.get(fn))));
+ return obj;
+ } else if (node.isBinary()) {
+ byte[] bytes = Base64.decode(node.asText());
+ return new TextNode(bytes.length + " " + Hex.toHexString(bytes));
+ }
+ return node;
+ }
+
+ // Response parsing helpers
+ public static CTAP2Enums.Error status(byte[] response) {
+ return CTAP2Enums.Error.valueOf(response[0]).orElseThrow(() -> new RuntimeException("Unknown status: " + response[0]));
+ }
+
+ public static ObjectNode payload(byte[] response) {
+ CTAP2Enums.Error status = status(response);
+ if (status != CTAP1_ERR_SUCCESS)
+ throw new RuntimeException("Response is not success: " + status);
+ try {
+ return (ObjectNode) cborMapper.readTree(Arrays.copyOfRange(response, 1, response.length));
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ public static byte[] ctap2command(CTAP2Enums.Command command, byte[] payload) {
+ return concatenate(new byte[]{command.cmd}, payload);
+ }
+
+ public static byte[] ctap2command(CTAP2Enums.Command command, Map