From 604172c76d234cc015312ceffa649c0d9f7d8c12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20=C3=89pardaud?= Date: Fri, 13 Dec 2024 12:25:20 +0100 Subject: [PATCH] Update test suite for webauthn4j changes --- pom.xml | 3 +- .../security/webauthn/api/LoginResource.java | 110 ---------- .../webauthn/model/WebAuthnCertificate.java | 17 -- .../webauthn/model/WebAuthnCredential.java | 99 +++------ .../webauthn/security/MyWebAuthnSetup.java | 72 ++----- .../src/main/resources/application.properties | 3 +- .../webauthn/AbstractWebAuthnTest.java | 69 +++++-- .../security/webauthn/MyWebAuthnHardware.java | 189 +++++++++++++----- 8 files changed, 231 insertions(+), 331 deletions(-) delete mode 100644 security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/api/LoginResource.java delete mode 100644 security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/model/WebAuthnCertificate.java diff --git a/pom.xml b/pom.xml index a898a3469..3842dd948 100644 --- a/pom.xml +++ b/pom.xml @@ -539,8 +539,7 @@ security/keycloak-oidc-client-reactive-extended security/vertx-jwt security/oidc-client-mutual-tls - - + security/webauthn diff --git a/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/api/LoginResource.java b/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/api/LoginResource.java deleted file mode 100644 index b2cceced8..000000000 --- a/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/api/LoginResource.java +++ /dev/null @@ -1,110 +0,0 @@ -package io.quarkus.ts.security.webauthn.api; - -import jakarta.inject.Inject; -import jakarta.ws.rs.BeanParam; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.core.Response; - -import org.jboss.resteasy.reactive.RestForm; - -import io.quarkus.hibernate.reactive.panache.common.WithTransaction; -import io.quarkus.security.webauthn.WebAuthnLoginResponse; -import io.quarkus.security.webauthn.WebAuthnRegisterResponse; -import io.quarkus.security.webauthn.WebAuthnSecurity; -import io.quarkus.ts.security.webauthn.model.User; -import io.quarkus.ts.security.webauthn.model.WebAuthnCredential; -import io.smallrye.mutiny.Uni; -import io.vertx.ext.auth.webauthn.Authenticator; -import io.vertx.ext.web.RoutingContext; - -@Path("") -public class LoginResource { - - @Inject - WebAuthnSecurity webAuthnSecurity; - - @Path("/login") - @POST - @WithTransaction - public Uni login(@RestForm String userName, - @BeanParam WebAuthnLoginResponse webAuthnResponse, - RoutingContext ctx) { - // Input validation - if (userName == null || userName.isEmpty() - || !webAuthnResponse.isSet() - || !webAuthnResponse.isValid()) { - return Uni.createFrom().item(Response.status(Response.Status.BAD_REQUEST).build()); - } - - Uni userUni = User.findByUserName(userName); - return userUni.flatMap(user -> { - if (user == null) { - // Invalid user - return Uni.createFrom().item(Response.status(Response.Status.BAD_REQUEST).build()); - } - Uni authenticator = this.webAuthnSecurity.login(webAuthnResponse, ctx); - - return authenticator - // bump the auth counter - .invoke(auth -> user.webAuthnCredential.counter = auth.getCounter()) - .map(auth -> { - // make a login cookie - this.webAuthnSecurity.rememberUser(auth.getUserName(), ctx); - return Response.ok().build(); - }) - // handle login failure - .onFailure().recoverWithItem(x -> { - // make a proper error response - return Response.status(Response.Status.BAD_REQUEST).build(); - }); - - }); - } - - @Path("/register") - @POST - @WithTransaction - public Uni register(@RestForm String userName, - @BeanParam WebAuthnRegisterResponse webAuthnResponse, - RoutingContext ctx) { - // Input validation - if (userName == null || userName.isEmpty() - || !webAuthnResponse.isSet() - || !webAuthnResponse.isValid()) { - return Uni.createFrom().item(Response.status(Response.Status.BAD_REQUEST).build()); - } - - Uni userUni = User.findByUserName(userName); - return userUni.flatMap(user -> { - if (user != null) { - // Duplicate user - return Uni.createFrom().item(Response.status(Response.Status.BAD_REQUEST).build()); - } - Uni authenticator = this.webAuthnSecurity.register(webAuthnResponse, ctx); - - return authenticator - // store the user - .flatMap(auth -> { - User newUser = new User(); - newUser.userName = auth.getUserName(); - WebAuthnCredential credential = new WebAuthnCredential(auth, newUser); - return credential.persist() - .flatMap(c -> newUser. persist()); - - }) - .map(newUser -> { - // make a login cookie - this.webAuthnSecurity.rememberUser(newUser.userName, ctx); - return Response.ok().build(); - }) - // handle login failure - .onFailure().recoverWithItem(x -> { - // make a proper error response - return Response.status(Response.Status.BAD_REQUEST).build(); - }); - - }); - } - -} diff --git a/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/model/WebAuthnCertificate.java b/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/model/WebAuthnCertificate.java deleted file mode 100644 index 2d5ae828c..000000000 --- a/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/model/WebAuthnCertificate.java +++ /dev/null @@ -1,17 +0,0 @@ -package io.quarkus.ts.security.webauthn.model; - -import jakarta.persistence.Entity; -import jakarta.persistence.ManyToOne; - -import io.quarkus.hibernate.reactive.panache.PanacheEntity; - -@Entity -public class WebAuthnCertificate extends PanacheEntity { - @ManyToOne - public WebAuthnCredential webAuthnCredential; - - /** - * The list of X509 certificates encoded as base64url. - */ - public String base64X509Certificate; -} diff --git a/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/model/WebAuthnCredential.java b/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/model/WebAuthnCredential.java index 28ac6369a..4e2361e2e 100644 --- a/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/model/WebAuthnCredential.java +++ b/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/model/WebAuthnCredential.java @@ -1,73 +1,38 @@ package io.quarkus.ts.security.webauthn.model; -import java.util.ArrayList; import java.util.List; +import java.util.UUID; import jakarta.persistence.Entity; -import jakarta.persistence.OneToMany; +import jakarta.persistence.Id; import jakarta.persistence.OneToOne; -import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; -import io.quarkus.hibernate.reactive.panache.PanacheEntity; +import io.quarkus.hibernate.reactive.panache.PanacheEntityBase; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord.RequiredPersistedData; import io.smallrye.mutiny.Uni; -import io.vertx.ext.auth.webauthn.Authenticator; -import io.vertx.ext.auth.webauthn.PublicKeyCredential; -@Table(uniqueConstraints = @UniqueConstraint(columnNames = { "userName", "credID" })) @Entity -public class WebAuthnCredential extends PanacheEntity { - /** - * The username linked to this authenticator - */ - public String userName; - - /** - * The type of key (must be "public-key") - */ - public String type = "public-key"; - +public class WebAuthnCredential extends PanacheEntityBase { /** * The non user identifiable id for the authenticator */ + @Id public String credID; /** * The public key associated with this authenticator */ - public String publicKey; + public byte[] publicKey; + + public long publicKeyAlgorithm; /** * The signature counter of the authenticator to prevent replay attacks */ public long counter; - public String aaguid; - - /** - * The Authenticator attestation certificates object, a JSON like: - * - *
{@code
-     *   {
-     *     "alg": "string",
-     *     "x5c": [
-     *       "base64"
-     *     ]
-     *   }
-     * }
- */ - /** - * The algorithm used for the public credential - */ - public PublicKeyCredential alg; - - /** - * The list of X509 certificates encoded as base64url. - */ - @OneToMany(mappedBy = "webAuthnCredential") - public List webAuthnx509Certificates = new ArrayList<>(); - - public String fmt; + public UUID aaguid; // owning side @OneToOne @@ -76,43 +41,29 @@ public class WebAuthnCredential extends PanacheEntity { public WebAuthnCredential() { } - public WebAuthnCredential(Authenticator authenticator, User user) { - aaguid = authenticator.getAaguid(); - if (authenticator.getAttestationCertificates() != null) - alg = authenticator.getAttestationCertificates().getAlg(); - counter = authenticator.getCounter(); - credID = authenticator.getCredID(); - fmt = authenticator.getFmt(); - publicKey = authenticator.getPublicKey(); - type = authenticator.getType(); - userName = authenticator.getUserName(); - if (authenticator.getAttestationCertificates() != null - && authenticator.getAttestationCertificates().getX5c() != null) { - for (String x509VCertificate : authenticator.getAttestationCertificates().getX5c()) { - WebAuthnCertificate cert = new WebAuthnCertificate(); - cert.base64X509Certificate = x509VCertificate; - cert.webAuthnCredential = this; - this.webAuthnx509Certificates.add(cert); - } - } + public WebAuthnCredential(WebAuthnCredentialRecord credentialRecord, User user) { + RequiredPersistedData requiredPersistedData = credentialRecord.getRequiredPersistedData(); + aaguid = requiredPersistedData.aaguid(); + counter = requiredPersistedData.counter(); + credID = requiredPersistedData.credentialId(); + publicKey = requiredPersistedData.publicKey(); + publicKeyAlgorithm = requiredPersistedData.publicKeyAlgorithm(); this.user = user; user.webAuthnCredential = this; } - public static Uni createWebAuthnCredential(Authenticator authenticator, User user) { - WebAuthnCredential credential = new WebAuthnCredential(authenticator, user); - credential.persistAndFlush(); - user.webAuthnCredential = credential; - user.persistAndFlush(); - return Uni.createFrom().item(credential); + public WebAuthnCredentialRecord toWebAuthnCredentialRecord() { + return WebAuthnCredentialRecord + .fromRequiredPersistedData( + new RequiredPersistedData(user.userName, credID, aaguid, publicKey, publicKeyAlgorithm, counter)); } public static Uni> findByUserName(String userName) { - return list("userName", userName); + return list("user.userName", userName); } - public static Uni> findByCredID(String credID) { - return list("credID", credID); + public static Uni findByCredentialId(String credID) { + return findById(credID); } public Uni fetch(T association) { diff --git a/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/security/MyWebAuthnSetup.java b/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/security/MyWebAuthnSetup.java index bb3eea4e9..d0ba6f07b 100644 --- a/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/security/MyWebAuthnSetup.java +++ b/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/security/MyWebAuthnSetup.java @@ -1,6 +1,5 @@ package io.quarkus.ts.security.webauthn.security; -import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; @@ -9,79 +8,46 @@ import jakarta.enterprise.context.ApplicationScoped; import io.quarkus.hibernate.reactive.panache.common.WithTransaction; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.security.webauthn.WebAuthnUserProvider; import io.quarkus.ts.security.webauthn.model.User; import io.quarkus.ts.security.webauthn.model.WebAuthnCredential; import io.smallrye.mutiny.Uni; -import io.vertx.ext.auth.webauthn.AttestationCertificates; -import io.vertx.ext.auth.webauthn.Authenticator; @ApplicationScoped public class MyWebAuthnSetup implements WebAuthnUserProvider { @WithTransaction @Override - public Uni> findWebAuthnCredentialsByUserName(String userName) { + public Uni> findByUserName(String userName) { return WebAuthnCredential.findByUserName(userName) - .flatMap(MyWebAuthnSetup::toAuthenticators); + .map(list -> list.stream().map(WebAuthnCredential::toWebAuthnCredentialRecord).toList()); } @WithTransaction @Override - public Uni> findWebAuthnCredentialsByCredID(String credID) { - return WebAuthnCredential.findByCredID(credID) - .flatMap(MyWebAuthnSetup::toAuthenticators); + public Uni findByCredentialId(String credentialId) { + return WebAuthnCredential.findByCredentialId(credentialId) + .onItem().ifNull().failWith(() -> new RuntimeException("No such credentials")) + .map(WebAuthnCredential::toWebAuthnCredentialRecord); } @WithTransaction @Override - public Uni updateOrStoreWebAuthnCredentials(Authenticator authenticator) { - return User.findByUserName(authenticator.getUserName()) - .flatMap(user -> { - // new user - if (user == null) { - User newUser = new User(); - newUser.userName = authenticator.getUserName(); - WebAuthnCredential credential = new WebAuthnCredential(authenticator, newUser); - return credential.persist() - .flatMap(c -> newUser.persist()) - .onItem().ignore().andContinueWithNull(); - } else { - - // existing user - user.webAuthnCredential.counter = authenticator.getCounter(); - return Uni.createFrom().nullItem(); - } - }); - } - - private static Uni> toAuthenticators(List dbs) { - // can't call combine/uni on empty list - if (dbs.isEmpty()) - return Uni.createFrom().item(Collections.emptyList()); - List> ret = new ArrayList<>(dbs.size()); - for (WebAuthnCredential db : dbs) { - ret.add(toAuthenticator(db)); - } - return Uni.combine().all().unis(ret).with(f -> (List) f); + public Uni store(WebAuthnCredentialRecord credentialRecord) { + User newUser = new User(); + newUser.userName = credentialRecord.getUserName(); + WebAuthnCredential credential = new WebAuthnCredential(credentialRecord, newUser); + return credential.persist() + .flatMap(c -> newUser.persist()) + .onItem().ignore().andContinueWithNull(); } - private static Uni toAuthenticator(WebAuthnCredential credential) { - return credential.fetch(credential.webAuthnx509Certificates) - .map(x5c -> { - Authenticator ret = new Authenticator(); - ret.setAaguid(credential.aaguid); - AttestationCertificates attestationCertificates = new AttestationCertificates(); - attestationCertificates.setAlg(credential.alg); - ret.setAttestationCertificates(attestationCertificates); - ret.setCounter(credential.counter); - ret.setCredID(credential.credID); - ret.setFmt(credential.fmt); - ret.setPublicKey(credential.publicKey); - ret.setType(credential.type); - ret.setUserName(credential.userName); - return ret; - }); + @WithTransaction + @Override + public Uni update(String credentialId, long counter) { + return WebAuthnCredential.findByCredentialId(credentialId) + .onItem().ignore().andContinueWithNull(); } @Override diff --git a/security/webauthn/src/main/resources/application.properties b/security/webauthn/src/main/resources/application.properties index b89856e15..075d2efe5 100644 --- a/security/webauthn/src/main/resources/application.properties +++ b/security/webauthn/src/main/resources/application.properties @@ -1,3 +1,2 @@ quarkus.hibernate-orm.database.generation=drop-and-create - - +quarkus.webauthn.enable-registration-endpoint=true diff --git a/security/webauthn/src/test/java/io/quarkus/ts/security/webauthn/AbstractWebAuthnTest.java b/security/webauthn/src/test/java/io/quarkus/ts/security/webauthn/AbstractWebAuthnTest.java index abf694231..ec794eeca 100644 --- a/security/webauthn/src/test/java/io/quarkus/ts/security/webauthn/AbstractWebAuthnTest.java +++ b/security/webauthn/src/test/java/io/quarkus/ts/security/webauthn/AbstractWebAuthnTest.java @@ -3,9 +3,13 @@ import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.is; +import java.net.MalformedURLException; +import java.net.URL; + import org.hamcrest.Matchers; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.MethodOrderer; import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; @@ -23,10 +27,13 @@ @TestMethodOrder(MethodOrderer.OrderAnnotation.class) public abstract class AbstractWebAuthnTest { + private URL url; + protected abstract RestService getApp(); + private static final String REGISTER_CHALLENGE_OPTIONS_URL = "/q/webauthn/register-options-challenge"; private static final String REGISTER_URL = "/q/webauthn/register"; - private static final String REGISTER_CALLBACK_URL = "/q/webauthn/callback"; + private static final String LOGIN_CHALLENGE_OPTIONS_URL = "/q/webauthn/login-options-challenge"; private static final String LOGIN_URL = "/q/webauthn/login"; private static final String LOGOUT_URL = "/q/webauthn/logout"; @@ -47,7 +54,11 @@ enum User { @BeforeAll public static void setup() { cookieFilter = new CookieFilter(); + } + @BeforeEach + public void setupUrl() throws MalformedURLException { + url = new URL(getApp().getURI(Protocol.HTTP).toString()); } @Test @@ -89,10 +100,10 @@ public void checkPublicAPI() { @Test @Order(5) public void testRegisterWebAuthnUser() { - MyWebAuthnHardware myWebAuthnHardware = new MyWebAuthnHardware(); - String challenge = getChallenge(USERNAME, cookieFilter); + MyWebAuthnHardware myWebAuthnHardware = new MyWebAuthnHardware(url); + String challenge = getRegistrationChallenge(USERNAME, cookieFilter); JsonObject registrationJson = myWebAuthnHardware.makeRegistrationJson(challenge); - invokeCallback(registrationJson, cookieFilter); + invokeRegisteration(USERNAME, registrationJson, cookieFilter); verifyLoggedIn(cookieFilter, USERNAME, User.USER); invokeUserLogout(); @@ -101,23 +112,35 @@ public void testRegisterWebAuthnUser() { @Test @Order(6) public void testRegisterSameUserName() { - MyWebAuthnHardware myWebAuthnHardware = new MyWebAuthnHardware(); - String challenge = getChallenge(USERNAME, cookieFilter); + MyWebAuthnHardware myWebAuthnHardware = new MyWebAuthnHardware(url); + String challenge = getRegistrationChallenge(USERNAME, cookieFilter); JsonObject registrationJson = myWebAuthnHardware.makeRegistrationJson(challenge); - invokeCallback(registrationJson, cookieFilter); - verifyLoggedIn(cookieFilter, USERNAME, User.USER); + ExtractableResponse response = RestAssured + .given() + .queryParam("userName", USERNAME) + .body(registrationJson.encode()) + .filter(cookieFilter) + .contentType(ContentType.JSON) + .log().ifValidationFails() + .post(REGISTER_URL) + .then() + .statusCode(400) + .log().ifValidationFails() + .cookie("_quarkus_webauthn_challenge", Matchers.is("")) + .extract(); + Assertions.assertNull(response.cookie("quarkus-credential")); + verifyLoggedOut(cookieFilter); } @Test @Order(7) public void testFailLoginWithFakeRegisterUser() { - invokeUserLogout(); String newUserName = "Kipchoge"; ExtractableResponse response = given().filter(cookieFilter) .contentType(ContentType.JSON) .body("{\"name\": \"" + newUserName + "\"}") - .post(REGISTER_URL) + .post(REGISTER_CHALLENGE_OPTIONS_URL) .then() .statusCode(is(200)).extract(); @@ -134,26 +157,33 @@ public void testFailLoginWithFakeRegisterUser() { .statusCode(404); } - public static void invokeCallback(JsonObject registration, Filter cookieFilter) { + public static void invokeRegisteration(String userName, JsonObject registration, Filter cookieFilter) { RestAssured - .given().body(registration.encode()).filter(cookieFilter).contentType(ContentType.JSON).log() - .ifValidationFails().post(REGISTER_CALLBACK_URL, new Object[0]).then().statusCode(204).log() - .ifValidationFails().cookie("_quarkus_webauthn_challenge", Matchers.is("")) - .cookie("_quarkus_webauthn_username", Matchers.is("")).cookie("quarkus-credential", Matchers.notNullValue()); + .given() + .queryParam("userName", userName) + .body(registration.encode()) + .filter(cookieFilter) + .contentType(ContentType.JSON) + .log().ifValidationFails() + .post(REGISTER_URL) + .then() + .statusCode(204) + .log().ifValidationFails() + .cookie("_quarkus_webauthn_challenge", Matchers.is("")) + .cookie("quarkus-credential", Matchers.notNullValue()); } - public static String getChallenge(String userName, Filter cookieFilter) { + public static String getRegistrationChallenge(String userName, Filter cookieFilter) { JsonObject registerJson = new JsonObject().put("name", userName); ExtractableResponse response = given() .body(registerJson.encode()) .contentType(ContentType.JSON) .filter(cookieFilter) - .post(REGISTER_URL) + .post(REGISTER_CHALLENGE_OPTIONS_URL) .then() .statusCode(200) - .cookie("_quarkus_webauthn_challenge", Matchers.notNullValue()) - .cookie("_quarkus_webauthn_username", Matchers.notNullValue()).extract(); + .cookie("_quarkus_webauthn_challenge", Matchers.notNullValue()).extract(); JsonObject responseJson = new JsonObject(response.asString()); String challenge = responseJson.getString("challenge"); Assertions.assertNotNull(challenge); @@ -233,6 +263,7 @@ public void invokeUserLogout() { .follow(false) .get(LOGOUT_URL) .then() + .log().ifValidationFails() .statusCode(302) .cookie("quarkus-credential", Matchers.is("")); } diff --git a/security/webauthn/src/test/java/io/quarkus/ts/security/webauthn/MyWebAuthnHardware.java b/security/webauthn/src/test/java/io/quarkus/ts/security/webauthn/MyWebAuthnHardware.java index bae47f47b..930e8fc47 100644 --- a/security/webauthn/src/test/java/io/quarkus/ts/security/webauthn/MyWebAuthnHardware.java +++ b/security/webauthn/src/test/java/io/quarkus/ts/security/webauthn/MyWebAuthnHardware.java @@ -2,56 +2,75 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.net.URL; import java.nio.charset.StandardCharsets; import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.security.Signature; +import java.security.SignatureException; import java.security.interfaces.ECPublicKey; import java.security.spec.ECGenParameterSpec; import java.util.Base64; +import java.util.Base64.Encoder; import java.util.Random; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.dataformat.cbor.CBORFactory; +import com.webauthn4j.data.attestation.authenticator.AuthenticatorData; -import io.quarkus.test.bootstrap.Protocol; import io.vertx.core.buffer.Buffer; import io.vertx.core.json.JsonObject; import io.vertx.ext.auth.impl.Codec; public class MyWebAuthnHardware { + private KeyPair keyPair; private String id; private byte[] credID; + private int counter = 1; + private URL origin; - public MyWebAuthnHardware() { - generateKeyPair(); - } - - private void generateKeyPair() { + public MyWebAuthnHardware(URL origin) { + KeyPairGenerator generator; try { - KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator = KeyPairGenerator.getInstance("EC"); generator.initialize(new ECGenParameterSpec("secp256r1")); - this.keyPair = generator.generateKeyPair(); } catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException e) { throw new RuntimeException(e); } - - this.credID = new byte[32]; - new Random().nextBytes(this.credID); - this.id = Base64.getUrlEncoder().withoutPadding().encodeToString(this.credID); + keyPair = generator.generateKeyPair(); + // This can be a random number, I think + Random random = new Random(); + credID = new byte[32]; + random.nextBytes(credID); + id = Base64.getUrlEncoder().withoutPadding().encodeToString(credID); + this.origin = origin; } + /** + * Creates a registration JSON object for the given challenge + * + * @param challenge the server-sent challenge + * @return a registration JSON object + */ public JsonObject makeRegistrationJson(String challenge) { - JsonObject clientData = (new JsonObject()).put("type", "webauthn.create").put("challenge", challenge) - .put("origin", MySqlWebAuthnIT.app.getURI(Protocol.HTTP).getHost()).put("crossOrigin", false); + JsonObject clientData = new JsonObject() + .put("type", "webauthn.create") + .put("challenge", challenge) + .put("origin", origin.toString()) + .put("crossOrigin", false); String clientDataEncoded = Base64.getUrlEncoder().encodeToString(clientData.encode().getBytes(StandardCharsets.UTF_8)); - byte[] authBytes = this.makeAuthBytes(); + + byte[] authBytes = makeAuthBytes(true); + /* + * {"fmt": "none", "attStmt": {}, "authData": h'DATAAAAA'} + */ CBORFactory cborFactory = new CBORFactory(); ByteArrayOutputStream byteWriter = new ByteArrayOutputStream(); - try { JsonGenerator generator = cborFactory.createGenerator(byteWriter); generator.writeStartObject(); @@ -61,62 +80,124 @@ public JsonObject makeRegistrationJson(String challenge) { generator.writeBinaryField("authData", authBytes); generator.writeEndObject(); generator.close(); - } catch (IOException var8) { - throw new RuntimeException(var8); + } catch (IOException t) { + throw new RuntimeException(t); } - String attestationObjectEncoded = Base64.getUrlEncoder().encodeToString(byteWriter.toByteArray()); - return (new JsonObject()) - .put("id", this.id).put("rawId", this.id).put("response", (new JsonObject()) - .put("attestationObject", attestationObjectEncoded).put("clientDataJSON", clientDataEncoded)) + + return new JsonObject() + .put("id", id) + .put("rawId", id) + .put("response", new JsonObject() + .put("attestationObject", attestationObjectEncoded) + .put("clientDataJSON", clientDataEncoded)) .put("type", "public-key"); } - private byte[] makeAuthBytes() { - int counter = 1; - Buffer buffer = Buffer.buffer(); - String rpDomain = MySqlWebAuthnIT.app.getURI(Protocol.HTTP).getHost(); + /** + * Creates a login JSON object for the given challenge + * + * @param challenge the server-sent challenge + * @return a login JSON object + */ + public JsonObject makeLoginJson(String challenge) { + JsonObject clientData = new JsonObject() + .put("type", "webauthn.get") + .put("challenge", challenge) + .put("origin", origin.toString()) + .put("crossOrigin", false); + byte[] clientDataBytes = clientData.encode().getBytes(StandardCharsets.UTF_8); + String clientDataEncoded = Base64.getUrlEncoder().encodeToString(clientDataBytes); + byte[] authBytes = makeAuthBytes(false); + String authenticatorData = Base64.getUrlEncoder().encodeToString(authBytes); + + // sign the authbytes + hash(client data json) MessageDigest md; try { md = MessageDigest.getInstance("SHA-256"); - } catch (NoSuchAlgorithmException var19) { - throw new RuntimeException(var19); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + byte[] clientDataHash = md.digest(clientDataBytes); + byte[] signedBytes = new byte[authBytes.length + clientDataHash.length]; + System.arraycopy(authBytes, 0, signedBytes, 0, authBytes.length); + System.arraycopy(clientDataHash, 0, signedBytes, authBytes.length, clientDataHash.length); + byte[] signatureBytes; + try { + Signature signature = Signature.getInstance("SHA256withECDSA"); + signature.initSign(this.keyPair.getPrivate()); + signature.update(signedBytes); + signatureBytes = signature.sign(); + } catch (InvalidKeyException | NoSuchAlgorithmException | SignatureException e) { + throw new RuntimeException(e); } + String signatureEncoded = Base64.getUrlEncoder().encodeToString(signatureBytes); + + return new JsonObject() + .put("id", id) + .put("rawId", id) + .put("response", new JsonObject() + .put("authenticatorData", authenticatorData) + .put("clientDataJSON", clientDataEncoded) + .put("signature", signatureEncoded)) + .put("type", "public-key"); + } + + private byte[] makeAuthBytes(boolean attest) { + Buffer buffer = Buffer.buffer(); + String rpDomain = "localhost"; + MessageDigest md; + try { + md = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } byte[] rpIdHash = md.digest(rpDomain.getBytes(StandardCharsets.UTF_8)); buffer.appendBytes(rpIdHash); - byte flags = 65; + + byte flags = AuthenticatorData.BIT_AT | AuthenticatorData.BIT_UP | AuthenticatorData.BIT_UV; buffer.appendByte(flags); + long signCounter = counter++; buffer.appendUnsignedInt(signCounter); - String aaguidString = "00000000-0000-0000-0000-000000000000"; - String aaguidStringShort = aaguidString.replace("-", ""); - byte[] aaguid = Codec.base16Decode(aaguidStringShort); - buffer.appendBytes(aaguid); - buffer.appendUnsignedShort(this.credID.length); - buffer.appendBytes(this.credID); - ECPublicKey publicKey = (ECPublicKey) this.keyPair.getPublic(); - Base64.Encoder urlEncoder = Base64.getUrlEncoder(); - String x = urlEncoder.encodeToString(publicKey.getW().getAffineX().toByteArray()); - String y = urlEncoder.encodeToString(publicKey.getW().getAffineY().toByteArray()); - CBORFactory cborFactory = new CBORFactory(); - ByteArrayOutputStream byteWriter = new ByteArrayOutputStream(); - try { - JsonGenerator generator = cborFactory.createGenerator(byteWriter); - generator.writeStartObject(); - generator.writeNumberField("1", 2); - generator.writeNumberField("3", -7); - generator.writeNumberField("-1", 1); - generator.writeStringField("-2", x); - generator.writeStringField("-3", y); - generator.writeEndObject(); - generator.close(); - } catch (IOException var18) { - throw new RuntimeException(var18); + if (attest) { + // Attested Data is present + String aaguidString = "00000000-0000-0000-0000-000000000000"; + String aaguidStringShort = aaguidString.replace("-", ""); + byte[] aaguid = Codec.base16Decode(aaguidStringShort); + buffer.appendBytes(aaguid); + + buffer.appendUnsignedShort(credID.length); + buffer.appendBytes(credID); + + ECPublicKey publicKey = (ECPublicKey) keyPair.getPublic(); + // NOTE: this used to be Base64 URL, but webauthn4j refuses it and wants Base64. I can't find in the spec where it's specified. + Encoder urlEncoder = Base64.getEncoder(); + String x = urlEncoder.encodeToString(publicKey.getW().getAffineX().toByteArray()); + String y = urlEncoder.encodeToString(publicKey.getW().getAffineY().toByteArray()); + + CBORFactory cborFactory = new CBORFactory(); + ByteArrayOutputStream byteWriter = new ByteArrayOutputStream(); + try { + JsonGenerator generator = cborFactory.createGenerator(byteWriter); + generator.writeStartObject(); + // see CWK and https://tools.ietf.org/html/rfc8152#section-7.1 + generator.writeNumberField("1", 2); // kty: "EC" + generator.writeNumberField("3", -7); // alg: "ES256" + generator.writeNumberField("-1", 1); // crv: "P-256" + // https://tools.ietf.org/html/rfc8152#section-13.1.1 + generator.writeStringField("-2", x); // x, base64url + generator.writeStringField("-3", y); // y, base64url + generator.writeEndObject(); + generator.close(); + } catch (IOException t) { + throw new RuntimeException(t); + } + buffer.appendBytes(byteWriter.toByteArray()); } - buffer.appendBytes(byteWriter.toByteArray()); return buffer.getBytes(); }