diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/dns/RaBadgeRecord.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/dns/RaBadgeRecord.java index 00720d7..2b7a87a 100644 --- a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/dns/RaBadgeRecord.java +++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/dns/RaBadgeRecord.java @@ -8,18 +8,20 @@ * *

The _ra-badge TXT record format is:

*
- * v=ra-badge1; version=1.0.0; url=https://transparency.ans.godaddy.com/v1/agents/{uuid}
+ * v=ra-badge1; version=v1.0.0; url=https://transparency.ans.godaddy.com/v1/agents/{uuid}
  * 
* *

Where:

* * * @param badgeVersion the badge format version (e.g., "ra-badge1") - * @param agentVersion the agent's semantic version (e.g., "1.0.0"), may be null + * @param agentVersion the agent's semantic version (e.g., "v1.0.0"), stored verbatim, may be null * @param url the full transparency log URL * @param agentId the extracted agent ID from the URL */ diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/CertType.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/CertType.java index 9311c98..4d4cb03 100644 --- a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/CertType.java +++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/CertType.java @@ -1,11 +1,13 @@ package com.godaddy.ans.sdk.transparency.model; +import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonValue; /** * Certificate types used in attestations. */ public enum CertType { + X509_DV_CLIENT("X509-DV-CLIENT"), X509_DV_SERVER("X509-DV-SERVER"), X509_EV_CLIENT("X509-EV-CLIENT"), X509_EV_SERVER("X509-EV-SERVER"), @@ -24,11 +26,17 @@ public String getValue() { } /** - * Parses a certificate type from string. + * Parses a certificate type from its string value, case-insensitively. + * + *

This is also the Jackson deserialization entry point. Unknown certificate type strings + * are tolerated and return {@code null} so that a new certificate type introduced upstream + * does not cause deserialization to fail and lose the entire parsed payload. Callers must + * treat a {@code null} type as "unrecognized". * * @param value the string value - * @return the certificate type, or null if unknown + * @return the matching {@code CertType}, or {@code null} for any unrecognized value */ + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) public static CertType fromString(String value) { if (value == null) { return null; diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/BadgeVerificationService.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/BadgeVerificationService.java index 19d02a1..d5dc475 100644 --- a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/BadgeVerificationService.java +++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/BadgeVerificationService.java @@ -231,12 +231,21 @@ public ClientVerificationResult verifyClient(X509Certificate clientCert) { .build(); } - // Step 8: Filter badges by version to reduce TL API calls during version rotation + // Step 8: Select badges for the certificate's version (ANS-6 §6.2); also avoids unnecessary TL calls List filteredBadges = filterBadgesByVersion(validBadges, certVersion); - if (filteredBadges.isEmpty()) { - // Fall back to all valid badges if no version match (backwards compatibility) - LOG.debug("No badges match version {}, checking all {} valid badges", certVersion, validBadges.size()); + if (certVersion == null) { + // No version in cert — check all valid badges (legacy / no-URI-SAN path) + LOG.debug("No version in cert, checking all {} valid badges", validBadges.size()); filteredBadges = validBadges; + } else if (filteredBadges.isEmpty()) { + // Version present in cert but no badge matches — per ANS-6 §6.2/§8.1, reject + LOG.debug("No badge record for version {}, found {} badge(s) for other versions", + certVersion, validBadges.size()); + return ClientVerificationResult.builder() + .status(VerificationStatus.NOT_ANS_AGENT) + .warningMessage("No badge record published for version " + certVersion + + "; " + validBadges.size() + " badge record(s) found for other versions") + .build(); } else { LOG.debug("Filtered {} valid badges to {} matching version {}", validBadges.size(), filteredBadges.size(), certVersion); @@ -644,24 +653,58 @@ private List filterValidBadgeUrls(List badges) { } /** - * Filters badges to only those matching the specified version. + * Filters badges to only those matching the specified version, using two-pass semantics + * that mirror the Go SDK (ANS-6 §6.2). * - *

This optimization reduces transparency log API calls during version rotation - * by only fetching registrations for badges that match the certificate version.

+ *

Pass 1: badges whose {@code agentVersion}, after stripping one optional leading + * {@code v} or {@code V}, equals the normalized {@code version}. If any match, they + * are returned immediately.

* - * @param badges the list of badges to filter - * @param version the version to match (may be null) - * @return filtered list, or empty if no matches (caller should fall back to all badges) + *

Pass 2: badges that carry no {@code version=} field ({@code agentVersion == null}). + * These are legacy/versionless records accepted as a fallback for backwards + * compatibility.

+ * + *

When {@code version} or {@code badges} is {@code null} an empty list is returned; + * the caller decides whether to proceed with all badges or reject.

+ * + * @param badges the list of badges to filter + * @param version the version to match (may be null; null causes empty return) + * @return exact-version matches if any, otherwise versionless records, otherwise empty */ private List filterBadgesByVersion(List badges, String version) { if (version == null || badges == null) { return List.of(); } + String normalizedVersion = normalizeVersion(version); + // Pass 1: exact version match (lenient v/V prefix on both sides) + List exactMatches = badges.stream() + .filter(badge -> normalizedVersion.equals(normalizeVersion(badge.agentVersion()))) + .collect(Collectors.toList()); + if (!exactMatches.isEmpty()) { + return exactMatches; + } + // Pass 2: versionless records as fallback return badges.stream() - .filter(badge -> version.equals(badge.agentVersion())) + .filter(badge -> badge.agentVersion() == null) .collect(Collectors.toList()); } + /** + * Normalizes a version string by stripping exactly one optional leading {@code v} or {@code V}. + * + * @param version the version string to normalize (may be null) + * @return the normalized version, or {@code null} if the input is {@code null} + */ + private static String normalizeVersion(String version) { + if (version == null) { + return null; + } + if (version.length() > 1 && (version.charAt(0) == 'v' || version.charAt(0) == 'V')) { + return version.substring(1); + } + return version; + } + /** * Creates a new builder. * diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/ClientVerificationResult.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/ClientVerificationResult.java index 8cad350..40964b1 100644 --- a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/ClientVerificationResult.java +++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/ClientVerificationResult.java @@ -105,7 +105,8 @@ public boolean isSuccess() { } /** - * Returns true if the client is not an ANS agent (no ra-badge record). + * Returns true if the client is not an ANS agent: no ra-badge record exists for the host, + * or none matches the version in the certificate's ANS name. * * @return true if not an ANS agent */ diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/VerificationStatus.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/VerificationStatus.java index 074a1a7..70e6c4b 100644 --- a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/VerificationStatus.java +++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/verification/VerificationStatus.java @@ -17,8 +17,9 @@ public enum VerificationStatus { DEPRECATED_OK, /** - * The host does not have an ra-badge DNS record, indicating - * it is not an ANS-registered agent. + * The host does not have an ra-badge DNS record, indicating it is not an ANS-registered agent. + * Also returned when the host publishes badge records but none matches the version presented + * in the certificate's ANS name (and no versionless record exists). */ NOT_ANS_AGENT, diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/TransparencyClientTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/TransparencyClientTest.java index 83551db..f32ff3e 100644 --- a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/TransparencyClientTest.java +++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/TransparencyClientTest.java @@ -9,6 +9,7 @@ import com.godaddy.ans.sdk.transparency.model.AgentAuditParams; import com.godaddy.ans.sdk.transparency.model.AgentIdentitiesResponse; import com.godaddy.ans.sdk.transparency.model.CheckpointResponse; +import com.godaddy.ans.sdk.transparency.model.CertType; import com.godaddy.ans.sdk.transparency.model.EventTypeV1; import com.godaddy.ans.sdk.transparency.model.IdentityLinkedAgentsResponse; import com.godaddy.ans.sdk.transparency.model.LinkedAgentView; @@ -93,6 +94,55 @@ void shouldRetrieveAgentTransparencyLogV1(WireMockRuntimeInfo wmRuntimeInfo) { assertThat(result.getAgentHost()).isEqualTo("agent.example.com"); } + @Test + @DisplayName("Should parse V1 log with X509-DV-CLIENT identity cert type") + void shouldParseV1LogWithDvClientCertType(WireMockRuntimeInfo wmRuntimeInfo) { + String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); + + stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withHeader("X-Schema-Version", "V1") + .withBody(v1TransparencyLogResponseWithDvClientCert()))); + + TransparencyClient client = TransparencyClient.builder() + .baseUrl(baseUrl) + .build(); + + TransparencyLog result = client.getAgentTransparencyLog(TEST_AGENT_ID); + + assertThat(result.getV1Payload()).isNotNull(); + assertThat(result.getIdentityCertFingerprint()).isEqualTo("SHA256:d1c2b3a4"); + TransparencyLogV1 v1 = result.getV1Payload(); + assertThat(v1.getProducer().getEvent().getAttestations().getIdentityCert().getType()) + .isEqualTo(CertType.X509_DV_CLIENT); + } + + @Test + @DisplayName("Unknown cert type in V1 log: payload non-null, fingerprint resolvable, type null") + void shouldParseV1LogWithUnknownCertType(WireMockRuntimeInfo wmRuntimeInfo) { + String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); + + stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withHeader("X-Schema-Version", "V1") + .withBody(v1TransparencyLogResponseWithUnknownCertType()))); + + TransparencyClient client = TransparencyClient.builder() + .baseUrl(baseUrl) + .build(); + + TransparencyLog result = client.getAgentTransparencyLog(TEST_AGENT_ID); + + assertThat(result.getV1Payload()).isNotNull(); + assertThat(result.getIdentityCertFingerprint()).isEqualTo("SHA256:e5f6g7h8"); + TransparencyLogV1 v1 = result.getV1Payload(); + assertThat(v1.getProducer().getEvent().getAttestations().getIdentityCert().getType()).isNull(); + } + @Test @DisplayName("Agent badge exposes the computed identities[] join with overflow total") void getAgentBadgeExposesLinkedIdentities(WireMockRuntimeInfo wmRuntimeInfo) { @@ -1486,6 +1536,90 @@ private String agentIdentitiesResponse() { // ==================== Test Data ==================== + private String v1TransparencyLogResponseWithDvClientCert() { + return """ + { + "status": "ACTIVE", + "schemaVersion": "V1", + "payload": { + "logId": "log-456", + "producer": { + "event": { + "ansId": "7cf3c8b0-2494-5f44-b056-956f45bf8637", + "ansName": "ans://v1.0.0.dv-agent.example.com", + "eventType": "AGENT_REGISTERED", + "agent": { + "host": "dv-agent.example.com", + "name": "DV Agent", + "version": "v1.0.0" + }, + "attestations": { + "domainValidation": "ACME-DNS-01", + "serverCert": { + "fingerprint": "SHA256:a1b2c3d4", + "type": "X509-DV-SERVER" + }, + "identityCert": { + "fingerprint": "SHA256:d1c2b3a4", + "type": "X509-DV-CLIENT" + } + }, + "issuedAt": "2025-09-24T21:03:47.055Z", + "expiresAt": "2026-09-24T21:03:47.055Z", + "raId": "ra.example.com", + "timestamp": "2025-09-24T21:03:47.055Z" + }, + "keyId": "key-2", + "signature": "sig456" + } + }, + "signature": "eyJhbGci..." + } + """; + } + + private String v1TransparencyLogResponseWithUnknownCertType() { + return """ + { + "status": "ACTIVE", + "schemaVersion": "V1", + "payload": { + "logId": "log-789", + "producer": { + "event": { + "ansId": "6bf2b7a9-1383-4e33-a945-845f34af7526", + "ansName": "ans://v1.0.0.agent.example.com", + "eventType": "AGENT_REGISTERED", + "agent": { + "host": "agent.example.com", + "name": "Example Agent", + "version": "v1.0.0" + }, + "attestations": { + "domainValidation": "ACME-DNS-01", + "serverCert": { + "fingerprint": "SHA256:a1b2c3d4", + "type": "X509-DV-SERVER" + }, + "identityCert": { + "fingerprint": "SHA256:e5f6g7h8", + "type": "X509-FUTURE-TYPE" + } + }, + "issuedAt": "2025-09-24T21:03:47.055Z", + "expiresAt": "2026-09-24T21:03:47.055Z", + "raId": "ra.example.com", + "timestamp": "2025-09-24T21:03:47.055Z" + }, + "keyId": "key-1", + "signature": "sig123" + } + }, + "signature": "eyJhbGci..." + } + """; + } + private String v1TransparencyLogResponse() { return """ { diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/dns/RaBadgeRecordTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/dns/RaBadgeRecordTest.java index b4659d1..02d35ed 100644 --- a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/dns/RaBadgeRecordTest.java +++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/dns/RaBadgeRecordTest.java @@ -2,6 +2,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import static org.assertj.core.api.Assertions.assertThat; @@ -214,17 +216,16 @@ void shouldParseLegacyRaBadgeWithoutVersionField() { @Test @DisplayName("Should accept bare semver without v prefix") void shouldAcceptBareSemverWithoutVPrefix() { - // Legacy records may have bare semver (1.0.0) instead of v-prefixed (v1.0.0) - // Note: The RaBadgeRecord stores the version as-is. Normalization to v-prefixed - // format is expected to happen at the comparison level (BadgeVerificationService). + // Records store the version field verbatim — "1.0.0" is stored as "1.0.0". + // BadgeVerificationService.filterBadgesByVersion tolerates an optional leading v/V + // on both the badge record and the certificate version when comparing. String txtValue = "v=ra-badge1; version=1.0.0; url=https://transparency.ans.godaddy.com/v1/agents/bare-semver-agent"; RaBadgeRecord record = RaBadgeRecord.parse(txtValue); assertThat(record).isNotNull(); assertThat(record.agentVersion()).isEqualTo("1.0.0"); // Stored as-is - // The comparison logic in BadgeVerificationService.extractVersionFromAnsName() - // handles the v prefix when matching versions + // "v1.0.0" and "1.0.0" are treated as equivalent during version comparison } @Test @@ -251,4 +252,21 @@ void shouldSupportAnsBadgeFormatVersion() { assertThat(record.badgeVersion()).isEqualTo("ans-badge1"); assertThat(record.agentVersion()).isEqualTo("1.0.0"); } + + @ParameterizedTest(name = "version={0} stored verbatim") + @ValueSource(strings = {"1.0.0", "v1.0.0", "V1.0.0"}) + @DisplayName("Should store version= field verbatim — normalization happens at compare site") + void shouldStoreVersionVerbatim(String versionValue) { + // Documents the "normalize at compare site" contract: RaBadgeRecord stores whatever appears + // after version= in the TXT record without stripping the v/V prefix. Callers that need to + // compare versions (e.g. BadgeVerificationService.filterBadgesByVersion) are responsible + // for normalizing both sides before comparing. + String txtValue = "v=ra-badge1; version=" + versionValue + + "; url=https://transparency.ans.godaddy.com/v1/agents/verbatim-test-agent"; + + RaBadgeRecord record = RaBadgeRecord.parse(txtValue); + + assertThat(record).isNotNull(); + assertThat(record.agentVersion()).isEqualTo(versionValue); + } } \ No newline at end of file diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/model/ModelClassesTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/model/ModelClassesTest.java index 739c91f..c2b4520 100644 --- a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/model/ModelClassesTest.java +++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/model/ModelClassesTest.java @@ -4,6 +4,8 @@ import com.godaddy.ans.sdk.model.LinkedIdentity; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import java.time.OffsetDateTime; import java.util.HashMap; @@ -463,6 +465,84 @@ void certTypeFromStringWorks() { assertThat(CertType.fromString("UNKNOWN")).isNull(); } + @Test + @DisplayName("CertType X509_DV_CLIENT has correct wire value and fromString works case-insensitively") + void certTypeDvClientValueAndFromString() { + assertThat(CertType.X509_DV_CLIENT.getValue()).isEqualTo("X509-DV-CLIENT"); + assertThat(CertType.fromString("X509-DV-CLIENT")).isEqualTo(CertType.X509_DV_CLIENT); + assertThat(CertType.fromString("x509-dv-client")).isEqualTo(CertType.X509_DV_CLIENT); + } + + @Test + @DisplayName("CertType X509_DV_CLIENT round-trips through Jackson CertificateInfo") + void certTypeDvClientJacksonRoundTrip() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + CertificateInfo info = mapper.readValue( + "{\"fingerprint\":\"fp-dv\",\"type\":\"X509-DV-CLIENT\"}", + CertificateInfo.class); + assertThat(info.getType()).isEqualTo(CertType.X509_DV_CLIENT); + assertThat(info.getFingerprint()).isEqualTo("fp-dv"); + String json = mapper.writeValueAsString(info); + assertThat(json).contains("X509-DV-CLIENT"); + } + + @Test + @DisplayName("Unknown CertType in JSON deserializes to null without throwing") + void unknownCertTypeDeserializesToNull() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + CertificateInfo info = mapper.readValue( + "{\"type\":\"X509-FUTURE-TYPE\"}", + CertificateInfo.class); + assertThat(info.getType()).isNull(); + } + + @Test + @DisplayName("Lowercase CertType in JSON resolves to the constant") + void lowercaseCertTypeDeserializesToConstant() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + CertificateInfo info = mapper.readValue( + "{\"type\":\"x509-dv-client\"}", + CertificateInfo.class); + assertThat(info.getType()).isEqualTo(CertType.X509_DV_CLIENT); + } + + @ParameterizedTest(name = "{0} round-trips through CertificateInfo") + @EnumSource(CertType.class) + @DisplayName("Each CertType value round-trips through Jackson CertificateInfo (parameterized)") + void eachCertTypeRoundTripsThroughCertificateInfo(CertType expected) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + CertificateInfo info = mapper.readValue( + "{\"type\":\"" + expected.getValue() + "\"}", + CertificateInfo.class); + assertThat(info.getType()).isEqualTo(expected); + String serialized = mapper.writeValueAsString(info); + assertThat(serialized).contains(expected.getValue()); + } + + @Test + @DisplayName("CertificateInfoV2 round-trips X509_DV_CLIENT through Jackson") + void certificateInfoV2DvClientRoundTrip() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + CertificateInfoV2 info = mapper.readValue( + "{\"fingerprint\":\"fp-dv-v2\",\"type\":\"X509-DV-CLIENT\"}", + CertificateInfoV2.class); + assertThat(info.getType()).isEqualTo(CertType.X509_DV_CLIENT); + assertThat(info.getFingerprint()).isEqualTo("fp-dv-v2"); + String serialized = mapper.writeValueAsString(info); + assertThat(serialized).contains("X509-DV-CLIENT"); + } + + @Test + @DisplayName("CertificateInfoV2 unknown cert type deserializes to null without throwing") + void certificateInfoV2UnknownTypeDeserializesToNull() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + CertificateInfoV2 info = mapper.readValue( + "{\"type\":\"X509-FUTURE-TYPE\",\"fingerprint\":\"fp-unknown\"}", + CertificateInfoV2.class); + assertThat(info.getType()).isNull(); + assertThat(info.getFingerprint()).isEqualTo("fp-unknown"); + } + @Test @DisplayName("EventTypeV0 enum values exist") void eventTypeV0EnumValuesExist() { diff --git a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/verification/VersionChangeHandlingTest.java b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/verification/VersionChangeHandlingTest.java index 8a1de42..89f8a1f 100644 --- a/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/verification/VersionChangeHandlingTest.java +++ b/ans-sdk-transparency/src/test/java/com/godaddy/ans/sdk/transparency/verification/VersionChangeHandlingTest.java @@ -24,6 +24,8 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.Mockito.mockStatic; @@ -44,6 +46,18 @@ class VersionChangeHandlingTest { private static final String TEST_FINGERPRINT_V2 = "SHA256:b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3"; + // v1.0.9 constants for cross-form matching tests + private static final String TEST_AGENT_ID_V9 = "9ef5e0d2-4616-7a66-d278-178a67cf0859"; + private static final String TEST_ANS_NAME_V9 = "ans://v1.0.9.agent.example.com"; + private static final String TEST_ANS_NAME_V9_UPPER = "ans://V1.0.9.agent.example.com"; + private static final String TEST_FINGERPRINT_V9 = + "SHA256:c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"; + + // versionless constants for two-pass fallback tests + private static final String TEST_AGENT_ID_VERSIONLESS = "0af6f1e3-5727-8b77-e389-289b78df1970"; + private static final String TEST_FINGERPRINT_VERSIONLESS = + "SHA256:d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5"; + @Mock private TransparencyClient transparencyClient; @@ -108,6 +122,39 @@ void shouldSelectCorrectBadgeWithTwoActiveVersions() { } } + // ==================== DV-CLIENT cert type ==================== + + @Test + @DisplayName("Should verify client with X509-DV-CLIENT identity cert type") + void shouldVerifyClientWithDvClientCertType() { + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V1)); + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_V1); + certUtils.when( + () -> CertificateUtils.fingerprintMatches(TEST_FINGERPRINT_V1, TEST_FINGERPRINT_V1)) + .thenReturn(true); + + RaBadgeRecord badge = RaBadgeRecord.parse( + "v=ra-badge1; version=1.0.0; url=https://transparency.ans.godaddy.com/v1/agents/" + + TEST_AGENT_ID_V1); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)).thenReturn(List.of(badge)); + + TransparencyLog registration = createMockRegistrationWithDvClientCert( + "ACTIVE", TEST_FINGERPRINT_V1, TEST_ANS_NAME_V1); + when(transparencyClient.getAgentTransparencyLog(TEST_AGENT_ID_V1)).thenReturn(registration); + + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + assertThat(result.getStatus()).isEqualTo(VerificationStatus.VERIFIED); + } + } + // ==================== Old version DEPRECATED ==================== @Test @@ -152,10 +199,10 @@ void shouldPassWithWarningWhenOldVersionDeprecated() { // ==================== No matching version in DNS ==================== @Test - @DisplayName("Should reject when no matching version in DNS") + @DisplayName("Should reject when no matching version in DNS (bare semver badge)") void shouldRejectWhenNoMatchingVersionInDns() { try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { - // Given - client presents v1.0.0 cert, but DNS only has v1.0.1 + // Given - client presents v1.0.0 cert, but DNS only has v1.0.1 (bare form) certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) .thenReturn(Optional.of(TEST_HOSTNAME)); certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) @@ -164,24 +211,57 @@ void shouldRejectWhenNoMatchingVersionInDns() { .thenReturn(Optional.of(TEST_ANS_NAME_V1)); // v1.0.0 certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) .thenReturn(TEST_FINGERPRINT_V1); - // Fingerprint doesn't match v1.0.1's fingerprint - certUtils.when(() -> CertificateUtils.fingerprintMatches(TEST_FINGERPRINT_V1, TEST_FINGERPRINT_V2)) - .thenReturn(false); - // Only v1.0.1 badge exists in DNS + // Only v1.0.1 badge exists in DNS (bare semver form) RaBadgeRecord badgeV2 = RaBadgeRecord.parse( "v=ra-badge1; version=1.0.1; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V2); when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)).thenReturn(List.of(badgeV2)); - // v1.0.1 registration doesn't match the cert + // Stub TL: previously reachable via the all-badges fallback and returned FINGERPRINT_MISMATCH + TransparencyLog registrationV2 = createMockRegistration("ACTIVE", TEST_FINGERPRINT_V2, TEST_ANS_NAME_V2); + when(transparencyClient.getAgentTransparencyLog(TEST_AGENT_ID_V2)).thenReturn(registrationV2); + + // When + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + // Then - no badge published for v1.0.0 → NOT_ANS_AGENT, TL never called + assertThat(result.getStatus()).isEqualTo(VerificationStatus.NOT_ANS_AGENT); + assertThat(result.getWarningMessage()).contains("1.0.0"); + verify(transparencyClient, never()).getAgentTransparencyLog(any()); + } + } + + @Test + @DisplayName("Should reject when no matching version in DNS (v-prefixed badge)") + void shouldRejectWhenNoMatchingVersionInDnsVPrefixedBadge() { + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + // Given - client presents v1.0.0 cert, but DNS only has v1.0.1 (production v-prefixed form) + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V1)); // v1.0.0 + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_V1); + // No fingerprintMatches stub needed: code returns NOT_ANS_AGENT before any fingerprint check. + + // Only v1.0.1 badge exists in DNS (production v-prefixed form: version=v1.0.1) + RaBadgeRecord badgeV2 = RaBadgeRecord.parse( + "v=ra-badge1; version=v1.0.1; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V2); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)).thenReturn(List.of(badgeV2)); + + // Stub TL: previously reachable via the all-badges fallback and returned FINGERPRINT_MISMATCH TransparencyLog registrationV2 = createMockRegistration("ACTIVE", TEST_FINGERPRINT_V2, TEST_ANS_NAME_V2); when(transparencyClient.getAgentTransparencyLog(TEST_AGENT_ID_V2)).thenReturn(registrationV2); // When ClientVerificationResult result = verificationService.verifyClient(mockCertificate); - // Then - should fail with fingerprint mismatch (no matching badge) - assertThat(result.getStatus()).isEqualTo(VerificationStatus.FINGERPRINT_MISMATCH); + // Then - no badge published for v1.0.0 → NOT_ANS_AGENT, TL never called + assertThat(result.getStatus()).isEqualTo(VerificationStatus.NOT_ANS_AGENT); + assertThat(result.getWarningMessage()).contains("1.0.0"); + verify(transparencyClient, never()).getAgentTransparencyLog(any()); } } @@ -302,6 +382,406 @@ void shouldApplyFailurePolicyWhenAllBadgeFetchesFail() { } } + // ==================== Cross-form version matching ==================== + + @Test + @DisplayName("Should match v-prefixed badge with versioned SAN (ANS-6 §6.2 leniency)") + void shouldMatchVPrefixedBadgeWithVersionedSan() { + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + // Given - cert SAN ans://v1.0.9.agent.example.com → certVersion "1.0.9" + // badge version=v1.0.9 (production form) → agentVersion "v1.0.9" + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V9)); + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_V9); + certUtils.when(() -> CertificateUtils.fingerprintMatches(TEST_FINGERPRINT_V9, TEST_FINGERPRINT_V9)) + .thenReturn(true); + + // Badge uses production v-prefixed form: version=v1.0.9 + RaBadgeRecord badgeV9 = RaBadgeRecord.parse( + "v=ra-badge1; version=v1.0.9; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V9); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)).thenReturn(List.of(badgeV9)); + + TransparencyLog registrationV9 = createMockRegistration("ACTIVE", TEST_FINGERPRINT_V9, TEST_ANS_NAME_V9); + when(transparencyClient.getAgentTransparencyLog(TEST_AGENT_ID_V9)).thenReturn(registrationV9); + + // When + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + // Then - v-prefixed badge selected; verification reaches fingerprint check → VERIFIED + assertThat(result.getStatus()).isEqualTo(VerificationStatus.VERIFIED); + assertThat(result.getExpectedAnsName()).isEqualTo(TEST_ANS_NAME_V9); + verify(transparencyClient).getAgentTransparencyLog(TEST_AGENT_ID_V9); + } + } + + @Test + @DisplayName("Should match bare-semver badge with versioned SAN (regression guard)") + void shouldMatchBareSemverBadgeWithVersionedSan() { + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + // Given - cert SAN ans://v1.0.9.agent.example.com → certVersion "1.0.9" + // badge version=1.0.9 (bare form) → agentVersion "1.0.9" + // (regression guard: this form matched before this change) + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V9)); + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_V9); + certUtils.when(() -> CertificateUtils.fingerprintMatches(TEST_FINGERPRINT_V9, TEST_FINGERPRINT_V9)) + .thenReturn(true); + + // Badge uses bare semver form: version=1.0.9 + RaBadgeRecord badgeV9 = RaBadgeRecord.parse( + "v=ra-badge1; version=1.0.9; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V9); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)).thenReturn(List.of(badgeV9)); + + TransparencyLog registrationV9 = createMockRegistration("ACTIVE", TEST_FINGERPRINT_V9, TEST_ANS_NAME_V9); + when(transparencyClient.getAgentTransparencyLog(TEST_AGENT_ID_V9)).thenReturn(registrationV9); + + // When + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + // Then - bare-semver badge selected and verified + assertThat(result.getStatus()).isEqualTo(VerificationStatus.VERIFIED); + assertThat(result.getExpectedAnsName()).isEqualTo(TEST_ANS_NAME_V9); + verify(transparencyClient).getAgentTransparencyLog(TEST_AGENT_ID_V9); + } + } + + // ==================== Two-pass badge selection ==================== + + @Test + @DisplayName("Should prefer exact-version badge over versionless badge (pass 1 wins)") + void shouldPreferExactVersionBadgeOverVersionlessBadge() { + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + // Given - cert v1.0.0; two badges: exact-version + versionless + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V1)); + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_V1); + certUtils.when(() -> CertificateUtils.fingerprintMatches(TEST_FINGERPRINT_V1, TEST_FINGERPRINT_V1)) + .thenReturn(true); + + RaBadgeRecord exactBadge = RaBadgeRecord.parse( + "v=ra-badge1; version=1.0.0; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V1); + RaBadgeRecord versionlessBadge = RaBadgeRecord.parse( + "v=ra-badge1; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_VERSIONLESS); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)) + .thenReturn(List.of(exactBadge, versionlessBadge)); + + TransparencyLog exactRegistration = createMockRegistration("ACTIVE", TEST_FINGERPRINT_V1, TEST_ANS_NAME_V1); + when(transparencyClient.getAgentTransparencyLog(TEST_AGENT_ID_V1)).thenReturn(exactRegistration); + + // When + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + // Then - pass 1 selected exact-version badge only; versionless badge's TL never fetched + assertThat(result.getStatus()).isEqualTo(VerificationStatus.VERIFIED); + verify(transparencyClient).getAgentTransparencyLog(TEST_AGENT_ID_V1); + verify(transparencyClient, never()).getAgentTransparencyLog(TEST_AGENT_ID_VERSIONLESS); + } + } + + @Test + @DisplayName("Should fall back to versionless badge when only versionless badge exists (pass 2)") + void shouldFallbackToVersionlessBadgeWhenOnlyVersionlessBadgeExists() { + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + // Given - cert v1.0.0; only a versionless badge exists → pass 2 fallback + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V1)); + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_VERSIONLESS); + String fp = TEST_FINGERPRINT_VERSIONLESS; + certUtils.when(() -> CertificateUtils.fingerprintMatches(fp, fp)) + .thenReturn(true); + + // Only a versionless badge (no version= field) + RaBadgeRecord versionlessBadge = RaBadgeRecord.parse( + "v=ra-badge1; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_VERSIONLESS); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)).thenReturn(List.of(versionlessBadge)); + + TransparencyLog versionlessRegistration = + createMockRegistrationNoAnsName("ACTIVE", TEST_FINGERPRINT_VERSIONLESS); + when(transparencyClient.getAgentTransparencyLog(TEST_AGENT_ID_VERSIONLESS)) + .thenReturn(versionlessRegistration); + + // When + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + // Then - pass 2 used the versionless badge; verification succeeded + assertThat(result.getStatus()).isEqualTo(VerificationStatus.VERIFIED); + verify(transparencyClient).getAgentTransparencyLog(TEST_AGENT_ID_VERSIONLESS); + } + } + + // ==================== Uppercase V normalization ==================== + + @Test + @DisplayName("Should normalize uppercase-V badge version against lowercase-SAN version (badge V1.0.9 + SAN v1.0.9)") + void shouldMatchUppercaseVBadgeVersion() { + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + // badge version=V1.0.9 (uppercase V); SAN ans://v1.0.9.agent.example.com (lowercase) + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V9)); // lowercase SAN + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_V9); + certUtils.when(() -> CertificateUtils.fingerprintMatches(TEST_FINGERPRINT_V9, TEST_FINGERPRINT_V9)) + .thenReturn(true); + + // Badge uses uppercase-V form: version=V1.0.9 + RaBadgeRecord badgeV9 = RaBadgeRecord.parse( + "v=ra-badge1; version=V1.0.9; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V9); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)).thenReturn(List.of(badgeV9)); + + TransparencyLog registrationV9 = createMockRegistration("ACTIVE", TEST_FINGERPRINT_V9, TEST_ANS_NAME_V9); + when(transparencyClient.getAgentTransparencyLog(TEST_AGENT_ID_V9)).thenReturn(registrationV9); + + // When + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + // Then - normalizeVersion strips uppercase V on both sides; badge is selected → VERIFIED + assertThat(result.getStatus()).isEqualTo(VerificationStatus.VERIFIED); + verify(transparencyClient).getAgentTransparencyLog(TEST_AGENT_ID_V9); + } + } + + @Test + @DisplayName("Uppercase-V SAN yields ANS_NAME_MISMATCH — case-sensitive ansName comparison is a known limitation") + void shouldReportAnsNameMismatchForUppercaseSan() { + // Note: ANS_VERSION_PATTERN is CASE_INSENSITIVE so "ans://V1.0.9.host" → certVersion "1.0.9" + // and filterBadgesByVersion selects the badge correctly. However, + // findMatchingClientRegistration compares certAnsName.equals(expectedAnsName) case-sensitively: + // "ans://V1.0.9.agent.example.com" ≠ "ans://v1.0.9.agent.example.com" → ANS_NAME_MISMATCH. + // Spec examples and RA issuance use lowercase ans:// SANs and ANS-6 §6.2 requires equality; + // this asymmetry is pre-existing and not changed here. + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + // SAN uses uppercase V + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V9_UPPER)); + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_V9); + certUtils.when(() -> CertificateUtils.fingerprintMatches(TEST_FINGERPRINT_V9, TEST_FINGERPRINT_V9)) + .thenReturn(true); + + // Badge uses lowercase v-prefixed form (production form) + RaBadgeRecord badgeV9 = RaBadgeRecord.parse( + "v=ra-badge1; version=v1.0.9; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V9); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)).thenReturn(List.of(badgeV9)); + + // TL stores the ansName as issued (lowercase) + TransparencyLog registrationV9 = createMockRegistration("ACTIVE", TEST_FINGERPRINT_V9, TEST_ANS_NAME_V9); + when(transparencyClient.getAgentTransparencyLog(TEST_AGENT_ID_V9)).thenReturn(registrationV9); + + // When + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + // Then - badge selected (version filter is case-insensitive) but certAnsName case mismatch + // with stored ansName → ANS_NAME_MISMATCH. Assert the actual observable outcome. + assertThat(result.getStatus()).isEqualTo(VerificationStatus.ANS_NAME_MISMATCH); + } + } + + // ==================== Mixed badge sets ==================== + + @Test + @DisplayName("Should select exact-version badge from set [other-version, versionless, exact] — pass 1 preferred") + void shouldSelectExactFromThreeBadgeMixedSet() { + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + // cert SAN v1.0.0; three badges: version=1.0.1 (other), no-version, version=1.0.0 (exact) + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V1)); + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_V1); + certUtils.when(() -> CertificateUtils.fingerprintMatches(TEST_FINGERPRINT_V1, TEST_FINGERPRINT_V1)) + .thenReturn(true); + + RaBadgeRecord otherBadge = RaBadgeRecord.parse( + "v=ra-badge1; version=1.0.1; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V2); + RaBadgeRecord versionlessBadge = RaBadgeRecord.parse( + "v=ra-badge1; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_VERSIONLESS); + RaBadgeRecord exactBadge = RaBadgeRecord.parse( + "v=ra-badge1; version=1.0.0; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V1); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)) + .thenReturn(List.of(otherBadge, versionlessBadge, exactBadge)); + + TransparencyLog exactRegistration = createMockRegistration("ACTIVE", TEST_FINGERPRINT_V1, TEST_ANS_NAME_V1); + when(transparencyClient.getAgentTransparencyLog(TEST_AGENT_ID_V1)).thenReturn(exactRegistration); + + // When + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + // Then - pass 1 selected only the exact-version badge; other and versionless TLs never fetched + assertThat(result.getStatus()).isEqualTo(VerificationStatus.VERIFIED); + verify(transparencyClient).getAgentTransparencyLog(TEST_AGENT_ID_V1); + verify(transparencyClient, never()).getAgentTransparencyLog(TEST_AGENT_ID_V2); + verify(transparencyClient, never()).getAgentTransparencyLog(TEST_AGENT_ID_VERSIONLESS); + } + } + + @Test + @DisplayName("Should fall back to versionless badge from set [other-version, versionless]") + void shouldFallbackToVersionlessWhenOtherVersionAndVersionlessExist() { + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + // cert SAN v1.0.0; two badges: version=1.0.1 (other), no-version (versionless fallback) + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V1)); + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_VERSIONLESS); + String fp = TEST_FINGERPRINT_VERSIONLESS; + certUtils.when(() -> CertificateUtils.fingerprintMatches(fp, fp)) + .thenReturn(true); + + RaBadgeRecord otherBadge = RaBadgeRecord.parse( + "v=ra-badge1; version=1.0.1; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V2); + RaBadgeRecord versionlessBadge = RaBadgeRecord.parse( + "v=ra-badge1; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_VERSIONLESS); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)) + .thenReturn(List.of(otherBadge, versionlessBadge)); + + TransparencyLog versionlessRegistration = + createMockRegistrationNoAnsName("ACTIVE", TEST_FINGERPRINT_VERSIONLESS); + when(transparencyClient.getAgentTransparencyLog(TEST_AGENT_ID_VERSIONLESS)) + .thenReturn(versionlessRegistration); + + // When + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + // Then - pass 1 empty (no exact match), pass 2 selects versionless badge + assertThat(result.getStatus()).isEqualTo(VerificationStatus.VERIFIED); + verify(transparencyClient).getAgentTransparencyLog(TEST_AGENT_ID_VERSIONLESS); + verify(transparencyClient, never()).getAgentTransparencyLog(TEST_AGENT_ID_V2); + } + } + + @Test + @DisplayName("Should return NOT_ANS_AGENT when only other-version badges exist — neither pass matches") + void shouldReturnNotAnsAgentWhenOnlyOtherVersionBadgesExist() { + String otherAgentIdA = "aaaaaaaa-1111-2222-3333-444444444444"; + String otherAgentIdB = "bbbbbbbb-5555-6666-7777-888888888888"; + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + // cert SAN v1.0.0; two badges both at other versions — neither pass yields a match + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V1)); + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_V1); + + RaBadgeRecord otherA = RaBadgeRecord.parse( + "v=ra-badge1; version=1.0.1; url=https://transparency.ans.godaddy.com/v1/agents/" + otherAgentIdA); + RaBadgeRecord otherB = RaBadgeRecord.parse( + "v=ra-badge1; version=2.0.0; url=https://transparency.ans.godaddy.com/v1/agents/" + otherAgentIdB); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)).thenReturn(List.of(otherA, otherB)); + + // When + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + // Then - both passes empty → NOT_ANS_AGENT; transparency client never called + assertThat(result.getStatus()).isEqualTo(VerificationStatus.NOT_ANS_AGENT); + verify(transparencyClient, never()).getAgentTransparencyLog(any()); + } + } + + // ==================== Warning message exact text ==================== + + @Test + @DisplayName("Warning message names the presented version and the count of other-version records exactly") + void shouldIncludeExactWarningMessageForVersionMismatch() { + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + // cert SAN v1.0.0; badges at v1.0.1 and v2.0.0 (2 other-version valid badges) + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V1)); // v1.0.0 + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_V1); + + RaBadgeRecord badge101 = RaBadgeRecord.parse( + "v=ra-badge1; version=v1.0.1; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V2); + RaBadgeRecord badge200 = RaBadgeRecord.parse( + "v=ra-badge1; version=v2.0.0; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V9); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)).thenReturn(List.of(badge101, badge200)); + + // When + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + // Then - exact warning message format: version is bare (1.0.0), count is total valid badges + assertThat(result.getStatus()).isEqualTo(VerificationStatus.NOT_ANS_AGENT); + assertThat(result.getWarningMessage()).isEqualTo( + "No badge record published for version 1.0.0; 2 badge record(s) found for other versions"); + } + } + + @Test + @DisplayName("Warning message counts only badges whose URL passed validation") + void shouldCountOnlyUrlValidBadgesInVersionMismatchWarning() { + try (MockedStatic certUtils = mockStatic(CertificateUtils.class)) { + // cert SAN v1.0.0; two other-version badges with trusted URLs plus one with an untrusted URL + certUtils.when(() -> CertificateUtils.extractFqdn(mockCertificate)) + .thenReturn(Optional.of(TEST_HOSTNAME)); + certUtils.when(() -> CertificateUtils.getCommonName(mockCertificate)) + .thenReturn(TEST_HOSTNAME); + certUtils.when(() -> CertificateUtils.extractAnsName(mockCertificate)) + .thenReturn(Optional.of(TEST_ANS_NAME_V1)); // v1.0.0 + certUtils.when(() -> CertificateUtils.computeSha256Fingerprint(mockCertificate)) + .thenReturn(TEST_FINGERPRINT_V1); + + RaBadgeRecord badge101 = RaBadgeRecord.parse( + "v=ra-badge1; version=v1.0.1; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V2); + RaBadgeRecord badge200 = RaBadgeRecord.parse( + "v=ra-badge1; version=v2.0.0; url=https://transparency.ans.godaddy.com/v1/agents/" + TEST_AGENT_ID_V9); + RaBadgeRecord badgeUntrusted = RaBadgeRecord.parse( + "v=ra-badge1; version=v3.0.0; url=https://untrusted.example.com/v1/agents/" + + TEST_AGENT_ID_VERSIONLESS); + when(raBadgeLookupService.lookupBadges(TEST_HOSTNAME)) + .thenReturn(List.of(badge101, badge200, badgeUntrusted)); + + // When + ClientVerificationResult result = verificationService.verifyClient(mockCertificate); + + // Then - the untrusted-URL badge is excluded before the version filter, so the count is 2 not 3 + assertThat(result.getStatus()).isEqualTo(VerificationStatus.NOT_ANS_AGENT); + assertThat(result.getWarningMessage()).isEqualTo( + "No badge record published for version 1.0.0; 2 badge record(s) found for other versions"); + verify(transparencyClient, never()).getAgentTransparencyLog(any()); + } + } + // ==================== Helper Methods ==================== private TransparencyLog createMockRegistration(String status, String fingerprint, String ansName) { @@ -320,7 +800,7 @@ private TransparencyLog createMockRegistration(String status, String fingerprint AgentV1 agent = new AgentV1(); agent.setHost(TEST_HOSTNAME); agent.setName("Test Agent"); - agent.setVersion(ansName.contains("v1.0.0") ? "v1.0.0" : "v1.0.1"); + agent.setVersion(extractAgentVersion(ansName)); EventV1 event = new EventV1(); event.setAnsName(ansName); @@ -342,6 +822,52 @@ private TransparencyLog createMockRegistration(String status, String fingerprint return log; } + private static String extractAgentVersion(String ansName) { + // Extract the version segment from an ANS name like ans://v1.0.9.agent.example.com + java.util.regex.Matcher m = java.util.regex.Pattern + .compile("ans://v?(\\d+\\.\\d+\\.\\d+)\\.", java.util.regex.Pattern.CASE_INSENSITIVE) + .matcher(ansName); + return m.find() ? "v" + m.group(1) : "v1.0.0"; + } + + private TransparencyLog createMockRegistrationWithDvClientCert(String status, String fingerprint, String ansName) { + CertificateInfo serverCert = new CertificateInfo(); + serverCert.setFingerprint(fingerprint); + serverCert.setType(CertType.X509_DV_SERVER); + + CertificateInfo identityCert = new CertificateInfo(); + identityCert.setFingerprint(fingerprint); + identityCert.setType(CertType.X509_DV_CLIENT); + + AttestationsV1 attestations = new AttestationsV1(); + attestations.setServerCert(serverCert); + attestations.setIdentityCert(identityCert); + + AgentV1 agent = new AgentV1(); + agent.setHost(TEST_HOSTNAME); + agent.setName("Test Agent"); + agent.setVersion("v1.0.0"); + + EventV1 event = new EventV1(); + event.setAnsName(ansName); + event.setAgent(agent); + event.setAttestations(attestations); + + ProducerV1 producer = new ProducerV1(); + producer.setEvent(event); + + TransparencyLogV1 v1Payload = new TransparencyLogV1(); + v1Payload.setLogId("log-dv-client"); + v1Payload.setProducer(producer); + + TransparencyLog log = new TransparencyLog(); + log.setStatus(status); + log.setSchemaVersion("V1"); + log.setParsedPayload(v1Payload); + + return log; + } + private TransparencyLog createMockRegistrationNoAnsName(String status, String fingerprint) { CertificateInfo serverCert = new CertificateInfo(); serverCert.setFingerprint(fingerprint);