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
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,20 @@
*
* <p>The _ra-badge TXT record format is:</p>
* <pre>
* 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}
* </pre>
*
* <p>Where:</p>
* <ul>
* <li>{@code v=ra-badge1} - the badge format version</li>
* <li>{@code version=1.0.0} - the agent's semantic version (optional for backwards compatibility)</li>
* <li>{@code version=v1.0.0} - the agent's semantic version in canonical form (optional for
* backwards compatibility); a bare form without the leading {@code v} (e.g., {@code 1.0.0})
* is also accepted</li>
* <li>{@code url=...} - the transparency log URL for this agent</li>
* </ul>
*
* @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
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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"),
Expand All @@ -24,11 +26,17 @@ public String getValue() {
}

/**
* Parses a certificate type from string.
* Parses a certificate type from its string value, case-insensitively.
*
* <p>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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<RaBadgeRecord> 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);
Expand Down Expand Up @@ -644,24 +653,58 @@ private List<RaBadgeRecord> filterValidBadgeUrls(List<RaBadgeRecord> 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).
*
* <p>This optimization reduces transparency log API calls during version rotation
* by only fetching registrations for badges that match the certificate version.</p>
* <p>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.</p>
*
* @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)
* <p>Pass 2: badges that carry no {@code version=} field ({@code agentVersion == null}).
* These are legacy/versionless records accepted as a fallback for backwards
* compatibility.</p>
*
* <p>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.</p>
*
* @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<RaBadgeRecord> filterBadgesByVersion(List<RaBadgeRecord> 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<RaBadgeRecord> 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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 """
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand All @@ -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);
}
}
Loading
Loading