The label is the one free-text, host-visible field an unauthenticated caller controls (set + * at {@code register/finish}, changed via {@code PATCH /auth/admin/credentials/{id}}) and it is + * persisted and echoed back by {@code AdminService.listCredentials}. Bounding it keeps an + * arbitrarily large blob out of storage and out of every UI that renders a credential list. It is + * a nickname ("Work laptop", "MacBook Touch ID"), so 64 is generous. + * + *
This is a length bound only — it is not an escaping mechanism. A label is untrusted + * text: renderers MUST escape it for their output context (see the demos' credential list, which + * builds DOM nodes with {@code textContent} rather than interpolating into {@code innerHTML}). + * + * @since 2.3.0 + */ + public static final int MAX_LABEL_LENGTH = 64; + public CredentialRecord { Objects.requireNonNull(credentialId, "credentialId"); Objects.requireNonNull(userHandle, "userHandle"); diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationService.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationService.java index cae68a1..13597c7 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationService.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationService.java @@ -293,6 +293,18 @@ public RegistrationResult finishRegistration( new RegistrationResult.RateLimited("ip"), start); } + // Step 0: bound the caller-supplied label before anything else. Checked ahead of the challenge + // preflight on purpose — takeOnce is single-use, so validating first means an over-long label + // doesn't burn the challenge and force a full ceremony restart. + String label = req.label(); + if (label != null && label.length() > CredentialRecord.MAX_LABEL_LENGTH) { + return outcome( + ChallengeValidator.Ceremony.REGISTRATION, + new RegistrationResult.InvalidPayload( + "label must be at most " + CredentialRecord.MAX_LABEL_LENGTH + " characters"), + start); + } + // Step 1: challenge / origin / ceremony-type preflight. ChallengeValidation validation = challengeValidator.validate( diff --git a/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationServiceRegistrationTest.java b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationServiceRegistrationTest.java index 419261e..da122e5 100644 --- a/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationServiceRegistrationTest.java +++ b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationServiceRegistrationTest.java @@ -174,6 +174,35 @@ void happyPathPersistsCredentialWithTransportsAndAaguidAndLabel() throws Excepti verify(metrics).incrementCounter("pkauth.registration.outcome", "result", "Success"); } + @Test + void overlongLabelIsInvalidPayloadAndDoesNotConsumeTheChallenge() { + String tooLong = "x".repeat(CredentialRecord.MAX_LABEL_LENGTH + 1); + + RegistrationResult result = service.finishRegistration(finishReg(cd(), tooLong)); + + assertThat(result) + .isInstanceOfSatisfying( + RegistrationResult.InvalidPayload.class, p -> assertThat(p.detail()).contains("64")); + // The label is bounded ahead of the preflight, so the single-use challenge survives a + // rejected label rather than forcing the user through a fresh ceremony. + verify(challengeStore, never()).takeOnce(any()); + verify(credentialRepository, never()).save(any()); + } + + @Test + void labelExactlyAtTheBoundIsAccepted() throws Exception { + RegistrationData regData = mockRegistrationData(AAGUID.ZERO, null, false); + when(webAuthnManager.verify( + any(com.webauthn4j.data.RegistrationRequest.class), any(RegistrationParameters.class))) + .thenReturn(regData); + String atLimit = "x".repeat(CredentialRecord.MAX_LABEL_LENGTH); + + assertThat(service.finishRegistration(finishReg(cd(), atLimit))) + .isInstanceOfSatisfying( + RegistrationResult.Success.class, + s -> assertThat(s.credential().label()).isEqualTo(atLimit)); + } + @Test void happyPathWithZeroAaguidNullTransportsAndDefaultLabel() throws Exception { // AAGUID.ZERO → stored aaguid is null; null transports → empty transport set; null label →