Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions vertx-auth-webauthn4j/src/main/asciidoc/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,16 @@ The process takes 2 steps:

If the solution is correct, the new authenticator should be added to the storage and be usable for login purposes.

The `user` object given to `createCredentialsOptions` may contain an `id`: the https://www.w3.org/TR/webauthn/#user-handle[user handle],
a stable, non user identifiable, identifier of the account (for example the primary key of the user record) encoded as
`base64url` (at most 64 bytes). Browsers and authenticators use it to recognise that a credential belongs to an existing
account, so it should be the same across registrations of the same user and different across users. When no `id` is given a
random one is generated. Pass the same value in {@link io.vertx.ext.auth.webauthn4j.WebAuthn4JCredentials#setUserId(String)}
when calling `authenticate` with the solution: it is stored in the
{@link io.vertx.ext.auth.webauthn4j.Authenticator#setUserId(String)} property, returned in the principal of the authenticated
user and, at login time, checked against the `userHandle` returned by the authenticator so that a credential can only be used
by the account it was created for.

== Login

Like the registration, login is a 2 step process:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ static void fromJson(Iterable<java.util.Map.Entry<String, Object>> json, Authent
obj.setUsername((String)member.getValue());
}
break;
case "userId":
if (member.getValue() instanceof String) {
obj.setUserId((String)member.getValue());
}
break;
case "type":
if (member.getValue() instanceof String) {
obj.setType((String)member.getValue());
Expand Down Expand Up @@ -69,6 +74,9 @@ static void toJson(Authenticator obj, java.util.Map<String, Object> json) {
if (obj.getUsername() != null) {
json.put("username", obj.getUsername());
}
if (obj.getUserId() != null) {
json.put("userId", obj.getUserId());
}
if (obj.getType() != null) {
json.put("type", obj.getType());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ static void fromJson(Iterable<java.util.Map.Entry<String, Object>> json, WebAuth
obj.setUsername((String)member.getValue());
}
break;
case "userId":
if (member.getValue() instanceof String) {
obj.setUserId((String)member.getValue());
}
break;
case "origin":
if (member.getValue() instanceof String) {
obj.setOrigin((String)member.getValue());
Expand Down Expand Up @@ -55,6 +60,9 @@ static void toJson(WebAuthn4JCredentials obj, java.util.Map<String, Object> json
if (obj.getUsername() != null) {
json.put("username", obj.getUsername());
}
if (obj.getUserId() != null) {
json.put("userId", obj.getUserId());
}
if (obj.getOrigin() != null) {
json.put("origin", obj.getOrigin());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ public Future<Void> updateCounter(Authenticator authenticator) {

// some user
JsonObject user = new JsonObject()
// id is expected to be a base64url string
// the user handle: a stable, non user identifiable, id of the account
// (base64url encoded, at most 64 bytes); generated when omitted
.put("id", "000000000000000000000000")
.put("rawId", "000000000000000000000000")
.put("name", "john.doe@email.com")
// optionally
.put("displayName", "John Doe")
Expand Down Expand Up @@ -112,6 +112,8 @@ public Future<Void> updateCounter(Authenticator authenticator) {
new WebAuthn4JCredentials()
// the username you want to link to
.setUsername("paulo")
// the user handle (user.id) sent on the previous step, if any
.setUserId("000000000000000000000000")
// the server origin
.setOrigin("https://192.168.178.206.xip.io:8443")
// the server domain
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ public class Authenticator {
*/
private String username;

/**
* The user handle linked to this authenticator, as a base64url encoded string.
* <p>
* This is the {@code user.id} sent in the {@code PublicKeyCredentialCreationOptions} at registration time
* and the {@code userHandle} returned by the authenticator in the assertion response. It is a stable,
* non user identifiable, identifier of the account (see
* <a href="https://www.w3.org/TR/webauthn/#user-handle">https://www.w3.org/TR/webauthn/#user-handle</a>) and
* may be {@code null} for authenticators registered before this property was introduced.
*/
private String userId;

/**
* The type of key (must be "public-key")
*/
Expand Down Expand Up @@ -96,6 +107,15 @@ public Authenticator setUsername(String username) {
return this;
}

public String getUserId() {
return userId;
}

public Authenticator setUserId(String userId) {
this.userId = userId;
return this;
}

public String getType() {
return type;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ static WebAuthn4J create(Vertx vertx, WebAuthn4JOptions options) {
* <p>
* The object being returned is described here <a href="https://w3c.github.io/webauthn/#dictdef-publickeycredentialcreationoptions">https://w3c.github.io/webauthn/#dictdef-publickeycredentialcreationoptions</a>
*
* @param user - the user object with name and optionally displayName and icon
* @param user - the user object with name and optionally id (the base64url encoded user handle, generated when
* absent), displayName and icon
* @return a future notified with the encoded make credentials request
*/
Future<JsonObject> createCredentialsOptions(JsonObject user);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public class WebAuthn4JCredentials implements Credentials {
private String challenge;
private JsonObject webauthn;
private String username;
private String userId;
private String origin;
private String domain;

Expand Down Expand Up @@ -65,6 +66,23 @@ public WebAuthn4JCredentials setUsername(String username) {
return this;
}

/**
* The user handle (base64url encoded) of the user performing the ceremony, if known.
* <p>
* At registration ({@code webauthn.create}) it is the {@code user.id} that was sent to the browser in the
* creation options and it is stored with the new authenticator. At authentication ({@code webauthn.get}) it
* is optional; when the relying party has identified the user before the ceremony, setting it ensures that
* the credential used belongs to that user.
*/
public String getUserId() {
return userId;
}

public WebAuthn4JCredentials setUserId(String userId) {
this.userId = userId;
return this;
}

public String getOrigin() {
return origin;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,43 @@ private static String uUIDtoBase64Url(UUID uuid) {
return base64UrlEncode(buffer.getBytes());
}

/**
* A user handle is an opaque byte sequence of at most 64 bytes, and must not be empty
* (https://www.w3.org/TR/webauthn/#user-handle). In JSON it is carried as a base64url string.
*
* @throws IllegalArgumentException when the given value is not a valid user handle
*/
private static void validateUserHandle(String userHandle) {
if (userHandle == null || userHandle.isEmpty()) {
throw new IllegalArgumentException("user handle cannot be empty");
}
final byte[] bytes;
try {
bytes = base64UrlDecode(userHandle);
} catch (RuntimeException e) {
throw new IllegalArgumentException("user handle must be base64url encoded", e);
}
if (bytes.length == 0) {
throw new IllegalArgumentException("user handle cannot be empty");
}
if (bytes.length > 64) {
throw new IllegalArgumentException("user handle cannot exceed 64 bytes");
}
}

/**
* Compares two base64url encoded user handles by value, so that different (padded/unpadded) encodings of the
* same bytes are considered equal.
*/
private static boolean sameUserHandle(String a, String b) {
try {
return Arrays.equals(base64UrlDecode(a), base64UrlDecode(b));
} catch (RuntimeException e) {
// not base64url, fallback to plain comparison
return a.equals(b);
}
}

@Override
public WebAuthn4J credentialStorage(CredentialStorage credentialStorage) {
if (credentialStorage == null) {
Expand All @@ -237,6 +274,19 @@ public WebAuthn4J credentialStorage(CredentialStorage credentialStorage) {
@Override
public Future<JsonObject> createCredentialsOptions(JsonObject user) {

// the user handle: given by the relying party (base64url encoded) or generated
final String userId;
if (user.containsKey("id")) {
userId = user.getString("id");
try {
validateUserHandle(userId);
} catch (IllegalArgumentException e) {
return Future.failedFuture(new WebAuthn4JException("Invalid user.id: " + e.getMessage(), e));
}
} else {
userId = uUIDtoBase64Url(UUID.randomUUID());
}

return credentialStorage.find(user.getString("name"), null)
.map(authenticators -> {
// empty structure with all required fields
Expand All @@ -252,7 +302,7 @@ public Future<JsonObject> createCredentialsOptions(JsonObject user) {
putOpt(json.getJsonObject("rp"), "name", options.getRelyingParty().getName());

// put non null values for User
putOpt(json.getJsonObject("user"), "id", uUIDtoBase64Url(UUID.randomUUID()));
putOpt(json.getJsonObject("user"), "id", userId);
putOpt(json.getJsonObject("user"), "name", user.getString("name"));
putOpt(json.getJsonObject("user"), "displayName", user.getString("displayName"));
putOpt(json.getJsonObject("user"), "icon", user.getString("icon"));
Expand Down Expand Up @@ -438,6 +488,8 @@ public Future<User> authenticate(Credentials credentials) {
// by default the store can upsert if a credential is missing, the user has been verified so it is valid
// the store however might disallow this operation
authrInfo.setUsername(username);
// the user handle sent to the browser in the creation options, if the relying party kept it
authrInfo.setUserId(authInfo.getUserId());

// the create challenge is complete we can finally save this
// new authenticator to the storage
Expand Down Expand Up @@ -473,6 +525,18 @@ public Future<User> authenticate(Credentials credentials) {
return Future.failedFuture("Cannot find authenticator with id: " + webauthn.getString("id"));
} else if (authenticators.size() == 1) {
Authenticator authenticator = authenticators.get(0);
// https://www.w3.org/TR/webauthn/#sctn-verifying-assertion
// the user identified by the response userHandle (or by the relying party before the
// ceremony) must be the owner of the credential
if (authenticator.getUserId() != null) {
final String userHandle = response.getString("userHandle");
if (userHandle != null && !userHandle.isEmpty() && !sameUserHandle(authenticator.getUserId(), userHandle)) {
return Future.failedFuture("User handle does not match the owner of credential id: " + credentialId);
}
if (authInfo.getUserId() != null && !sameUserHandle(authenticator.getUserId(), authInfo.getUserId())) {
return Future.failedFuture("Credential id: " + credentialId + " does not belong to the expected user");
}
}
return verifyWebAuthNGet(response, authInfo, clientDataJSON, authenticator)
.compose(counter -> {
// update the counter on the authenticator
Expand Down
Loading