diff --git a/ans-sdk-api/src/test/java/com/godaddy/ans/sdk/model/AgentDetailsTest.java b/ans-sdk-api/src/test/java/com/godaddy/ans/sdk/model/AgentDetailsTest.java index c75f548..e668e65 100644 --- a/ans-sdk-api/src/test/java/com/godaddy/ans/sdk/model/AgentDetailsTest.java +++ b/ans-sdk-api/src/test/java/com/godaddy/ans/sdk/model/AgentDetailsTest.java @@ -82,4 +82,23 @@ void addIdentityInitialisesList() { o.addIdentitiesItem(new LinkedIdentity()); assertThat(o.getIdentities()).hasSize(1); } + + @Test + void identitiesAbsentYieldsEmptyList() throws Exception { + // Absent identities[] deserializes to an empty list, never null. + String json = """ + { + "agentId": "11111111-1111-1111-1111-111111111111", + "agentDisplayName": "x", + "version": "x", + "agentHost": "x", + "ansName": "x", + "agentStatus": "PENDING_VALIDATION", + "endpoints": [], + "links": [] + } + """; + AgentDetails back = ModelTestSupport.mapper().readValue(json, AgentDetails.class); + assertThat(back.getIdentities()).isNotNull().isEmpty(); + } } diff --git a/ans-sdk-core/src/main/java/com/godaddy/ans/sdk/util/Identifiers.java b/ans-sdk-core/src/main/java/com/godaddy/ans/sdk/util/Identifiers.java new file mode 100644 index 0000000..5e98534 --- /dev/null +++ b/ans-sdk-core/src/main/java/com/godaddy/ans/sdk/util/Identifiers.java @@ -0,0 +1,34 @@ +package com.godaddy.ans.sdk.util; + +import java.util.UUID; + +/** + * Validation helpers for ANS resource identifiers. + * + *
Agent and identity IDs are server-assigned UUIDs. Validating them before + * they are concatenated into a request path fails fast on a malformed value and + * keeps non-UUID input (a DID, a path segment, a query fragment) from silently + * routing to the wrong resource or throwing deep inside URI parsing.
+ */ +public final class Identifiers { + + private Identifiers() { + } + + /** + * Returns {@code value} when it is a UUID, otherwise throws. + * + * @param value the identifier to validate + * @param name the parameter name to use in the error message + * @return the validated value, unchanged + * @throws IllegalArgumentException if {@code value} is null or not a UUID + */ + public static String requireUuid(String value, String name) { + try { + UUID.fromString(value); + } catch (IllegalArgumentException | NullPointerException e) { + throw new IllegalArgumentException(name + " must be a UUID, got: " + value, e); + } + return value; + } +} \ No newline at end of file diff --git a/ans-sdk-crypto/build.gradle.kts b/ans-sdk-crypto/build.gradle.kts index dd238fa..352b1b0 100644 --- a/ans-sdk-crypto/build.gradle.kts +++ b/ans-sdk-crypto/build.gradle.kts @@ -1,4 +1,5 @@ val bouncyCastleVersion: String by project +val nimbusJoseVersion: String by project val slf4jVersion: String by project val junitVersion: String by project val mockitoVersion: String by project @@ -12,6 +13,9 @@ dependencies { implementation("org.bouncycastle:bcpkix-jdk18on:$bouncyCastleVersion") implementation("org.bouncycastle:bcprov-jdk18on:$bouncyCastleVersion") + // Nimbus JOSE + JWT for compact-JWS control proofs (EdDSA/ES256/RS256) + implementation("com.nimbusds:nimbus-jose-jwt:$nimbusJoseVersion") + // Logging implementation("org.slf4j:slf4j-api:$slf4jVersion") diff --git a/ans-sdk-crypto/src/main/java/com/godaddy/ans/sdk/crypto/IdentityProofSigner.java b/ans-sdk-crypto/src/main/java/com/godaddy/ans/sdk/crypto/IdentityProofSigner.java new file mode 100644 index 0000000..7e2e6ee --- /dev/null +++ b/ans-sdk-crypto/src/main/java/com/godaddy/ans/sdk/crypto/IdentityProofSigner.java @@ -0,0 +1,205 @@ +package com.godaddy.ans.sdk.crypto; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.OctetKeyPair; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.util.Base64URL; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Signature; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.security.interfaces.EdECPrivateKey; +import java.security.interfaces.EdECPublicKey; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.util.Arrays; + +/** + * Signs Verified-Identity control-proof challenges as compact JWS strings. + * + *The Registration Authority (RA) serves a {@code signingInput} — the base64url of the + * canonical proof bytes — in the {@code 202} challenge round. This signer produces one compact + * JWS per proven key, suitable for the {@code signedProofs} array of a verify-control request.
+ * + *The served {@code signingInput} becomes the JWS payload segment verbatim: the RA checks + * payload equality before it checks the signature, so the client never canonicalizes or re-encodes + * it. The protected header always carries {@code kid} and may carry the public {@code jwk}.
+ * + *This signer supports only the algorithms the verifier implements: EdDSA (Ed25519), ES256 + * (ECDSA P-256), and RS256 (RSA >= 2048). It infers the algorithm from the private key. It + * rejects key-agreement keys (X25519) and curves with no verifier (secp256k1, P-384, P-521) + * before it signs.
+ */ +public final class IdentityProofSigner { + + private static final Logger LOG = LoggerFactory.getLogger(IdentityProofSigner.class); + + private static final int MIN_RSA_KEY_BITS = 2048; + private static final int ED25519_RAW_KEY_LEN = 32; + private static final String ED25519 = "Ed25519"; + + /** + * Creates a new IdentityProofSigner. + */ + public IdentityProofSigner() { + // Default constructor + } + + /** + * Signs the served {@code signingInput} and returns a compact JWS with {@code kid} in the + * protected header. + * + * @param signingInput the base64url signing input served by the RA, used as the JWS payload verbatim + * @param privateKey the private key that proves control of the identifier + * @param kid the verification-method id claimed by this proof + * @return the compact JWS ({@code header.payload.signature}) + * @throws IllegalArgumentException if an argument is missing or the key/algorithm is unsupported + * @throws RuntimeException if signing fails + */ + public String sign(String signingInput, PrivateKey privateKey, String kid) { + return sign(signingInput, privateKey, kid, null); + } + + /** + * Signs the served {@code signingInput} and returns a compact JWS with {@code kid} and the + * public {@code jwk} in the protected header. + * + *The embedded {@code jwk} is public-only. It is required by the quickstart noop resolver and + * ignored by the web resolver, which always uses the resolved DID document.
+ * + * @param signingInput the base64url signing input served by the RA, used as the JWS payload verbatim + * @param privateKey the private key that proves control of the identifier + * @param kid the verification-method id claimed by this proof + * @param publicKey the public key to embed as {@code jwk}, or {@code null} to omit it + * @return the compact JWS ({@code header.payload.signature}) + * @throws IllegalArgumentException if an argument is missing or the key/algorithm is unsupported + * @throws RuntimeException if signing fails + */ + public String sign(String signingInput, PrivateKey privateKey, String kid, PublicKey publicKey) { + if (signingInput == null || signingInput.isBlank()) { + throw new IllegalArgumentException("signingInput cannot be null or blank"); + } + if (privateKey == null) { + throw new IllegalArgumentException("privateKey cannot be null"); + } + if (kid == null || kid.isBlank()) { + throw new IllegalArgumentException("kid cannot be null or blank"); + } + + JWSAlgorithm algorithm = resolveAlgorithm(privateKey); + LOG.debug("Signing identity proof with algorithm {} and kid {}", algorithm, kid); + + JWSHeader.Builder headerBuilder = new JWSHeader.Builder(algorithm).keyID(kid); + if (publicKey != null) { + headerBuilder.jwk(toPublicJwk(algorithm, publicKey)); + } + JWSHeader header = headerBuilder.build(); + + String headerSegment = header.toBase64URL().toString(); + byte[] signingInputBytes = (headerSegment + "." + signingInput).getBytes(StandardCharsets.US_ASCII); + + Base64URL signature = computeSignature(algorithm, privateKey, header, signingInputBytes); + return headerSegment + "." + signingInput + "." + signature; + } + + /** + * Resolves the JWS algorithm from the private key, rejecting unsupported keys before signing. + */ + private JWSAlgorithm resolveAlgorithm(PrivateKey privateKey) { + if (privateKey instanceof RSAPrivateKey rsaKey) { + int bits = rsaKey.getModulus().bitLength(); + if (bits < MIN_RSA_KEY_BITS) { + throw new IllegalArgumentException( + "RSA key must be at least " + MIN_RSA_KEY_BITS + " bits, was " + bits); + } + return JWSAlgorithm.RS256; + } + if (privateKey instanceof ECPrivateKey ecKey) { + Curve curve = Curve.forECParameterSpec(ecKey.getParams()); + if (!Curve.P_256.equals(curve)) { + throw new IllegalArgumentException( + "Unsupported EC curve for ES256 (only P-256 is supported): " + curve); + } + return JWSAlgorithm.ES256; + } + if (privateKey instanceof EdECPrivateKey edKey) { + String curveName = edKey.getParams().getName(); + if (!ED25519.equals(curveName)) { + throw new IllegalArgumentException( + "Unsupported EdDSA curve (only Ed25519 is supported): " + curveName); + } + return JWSAlgorithm.EdDSA; + } + throw new IllegalArgumentException( + "Unsupported key type for identity proof: " + privateKey.getAlgorithm()); + } + + /** + * Computes the JWS signature over the signing input bytes for the resolved algorithm. + */ + private Base64URL computeSignature(JWSAlgorithm algorithm, PrivateKey privateKey, + JWSHeader header, byte[] signingInputBytes) { + try { + if (JWSAlgorithm.EdDSA.equals(algorithm)) { + // Ed25519 JCA output is the raw 64-byte signature per RFC 8037 — no DER transcoding needed unlike + // ECDSA. + Signature signature = Signature.getInstance(ED25519); + signature.initSign(privateKey); + signature.update(signingInputBytes); + return Base64URL.encode(signature.sign()); + } + JWSSigner signer = JWSAlgorithm.RS256.equals(algorithm) + ? new RSASSASigner(privateKey) + : new ECDSASigner(privateKey, Curve.P_256); + return signer.sign(header, signingInputBytes); + } catch (GeneralSecurityException | JOSEException e) { + throw new IllegalStateException( + "Failed to sign identity proof (alg=" + algorithm + ", kid=" + header.getKeyID() + ")", e); + } + } + + /** + * Builds a public-only JWK for the given public key and resolved algorithm. + */ + private JWK toPublicJwk(JWSAlgorithm algorithm, PublicKey publicKey) { + try { + if (JWSAlgorithm.RS256.equals(algorithm)) { + return new RSAKey.Builder((RSAPublicKey) publicKey).build(); + } + if (JWSAlgorithm.ES256.equals(algorithm)) { + return new ECKey.Builder(Curve.P_256, (ECPublicKey) publicKey).build(); + } + // EdDSA: the raw 32-byte public key is the tail of the X.509 SubjectPublicKeyInfo encoding. + if (!(publicKey instanceof EdECPublicKey)) { + throw new IllegalArgumentException("publicKey does not match the private key algorithm"); + } + EdECPublicKey edPublicKey = (EdECPublicKey) publicKey; + if (!ED25519.equals(edPublicKey.getParams().getName())) { + throw new IllegalArgumentException( + "EdEC public key must use Ed25519 curve, got: " + edPublicKey.getParams().getName()); + } + byte[] encoded = edPublicKey.getEncoded(); + if (encoded == null) { + throw new IllegalArgumentException("EdEC public key encoding is not available"); + } + byte[] raw = Arrays.copyOfRange(encoded, encoded.length - ED25519_RAW_KEY_LEN, encoded.length); + return new OctetKeyPair.Builder(Curve.Ed25519, Base64URL.encode(raw)).build(); + } catch (ClassCastException e) { + throw new IllegalArgumentException("publicKey does not match the private key algorithm", e); + } + } +} diff --git a/ans-sdk-crypto/src/test/java/com/godaddy/ans/sdk/crypto/IdentityProofSignerTest.java b/ans-sdk-crypto/src/test/java/com/godaddy/ans/sdk/crypto/IdentityProofSignerTest.java new file mode 100644 index 0000000..8d7f5d3 --- /dev/null +++ b/ans-sdk-crypto/src/test/java/com/godaddy/ans/sdk/crypto/IdentityProofSignerTest.java @@ -0,0 +1,276 @@ +package com.godaddy.ans.sdk.crypto; + +import com.nimbusds.jose.JWSObject; +import com.nimbusds.jose.crypto.ECDSAVerifier; +import com.nimbusds.jose.crypto.RSASSAVerifier; +import com.nimbusds.jose.jwk.AsymmetricJWK; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.OctetKeyPair; +import com.nimbusds.jose.util.Base64URL; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PublicKey; +import java.security.Security; +import java.security.Signature; +import java.security.interfaces.ECPublicKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.security.spec.X509EncodedKeySpec; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class IdentityProofSignerTest { + + private static final String KID = "did:web:identity.acme-corp.com#key-1"; + // Base64url payload with '-' and '_' so any re-encoding would change it. + private static final String SIGNING_INPUT = "c2ln-bmlu_Zy1pbnB1dA"; + + static { + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + + private final IdentityProofSigner signer = new IdentityProofSigner(); + + // ==================== payload verbatim + roundtrip per alg ==================== + + @Test + void signVerifyRoundtripRs256() throws Exception { + assertRoundTrip(genRsa(2048)); + } + + @Test + void signVerifyRoundtripEs256() throws Exception { + assertRoundTrip(genEc("secp256r1", null)); + } + + @Test + void signVerifyRoundtripEddsa() throws Exception { + assertRoundTrip(gen("Ed25519")); + } + + @Test + void payloadSegmentEqualsSigningInputVerbatim() { + String jws = signer.sign(SIGNING_INPUT, genRsa(2048).getPrivate(), KID); + String[] parts = jws.split("\\."); + assertThat(parts).hasSize(3); + assertThat(parts[1]).isEqualTo(SIGNING_INPUT); + } + + @Test + void kidOnlyOverloadOmitsJwk() throws Exception { + KeyPair kp = genRsa(2048); + String jws = signer.sign(SIGNING_INPUT, kp.getPrivate(), KID); + JWSObject parsed = JWSObject.parse(jws); + assertThat(parsed.getHeader().getKeyID()).isEqualTo(KID); + assertThat(parsed.getHeader().getJWK()).isNull(); + assertThat(parsed.verify(new RSASSAVerifier((RSAPublicKey) kp.getPublic()))).isTrue(); + } + + @Test + void kidOnlyOverloadOmitsJwkEs256() throws Exception { + KeyPair kp = genEc("secp256r1", null); + String jws = signer.sign(SIGNING_INPUT, kp.getPrivate(), KID); + JWSObject parsed = JWSObject.parse(jws); + assertThat(parsed.getHeader().getKeyID()).isEqualTo(KID); + assertThat(parsed.getHeader().getJWK()).isNull(); + assertThat(parsed.verify(new ECDSAVerifier((ECPublicKey) kp.getPublic()))).isTrue(); + } + + @Test + void kidOnlyOverloadOmitsJwkEddsa() throws Exception { + KeyPair kp = gen("Ed25519"); + String jws = signer.sign(SIGNING_INPUT, kp.getPrivate(), KID); + String[] parts = jws.split("\\."); + JWSObject parsed = JWSObject.parse(jws); + assertThat(parsed.getHeader().getKeyID()).isEqualTo(KID); + assertThat(parsed.getHeader().getJWK()).isNull(); + assertThat(verifies(parts, kp.getPublic())).isTrue(); + } + + // ==================== unsupported key/alg → throw before signing ==================== + + @Test + void rsaBelow2048Throws() { + assertThatThrownBy(() -> signer.sign(SIGNING_INPUT, genRsa(1024).getPrivate(), KID)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void secp256k1Throws() throws Exception { + KeyPair kp = genEc("secp256k1", BouncyCastleProvider.PROVIDER_NAME); + assertThatThrownBy(() -> signer.sign(SIGNING_INPUT, kp.getPrivate(), KID)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void ecP384Throws() throws Exception { + KeyPair kp = genEc("secp384r1", null); + assertThatThrownBy(() -> signer.sign(SIGNING_INPUT, kp.getPrivate(), KID)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void ed448Throws() throws Exception { + KeyPair kp = gen("Ed448"); + assertThatThrownBy(() -> signer.sign(SIGNING_INPUT, kp.getPrivate(), KID)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void ed448PublicKeyWithEd25519PrivateThrows() throws Exception { + KeyPair ed25519 = gen("Ed25519"); + PublicKey ed448Public = gen("Ed448").getPublic(); + assertThatThrownBy(() -> signer.sign(SIGNING_INPUT, ed25519.getPrivate(), KID, ed448Public)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void x25519Throws() throws Exception { + KeyPair kp = gen("X25519"); + assertThatThrownBy(() -> signer.sign(SIGNING_INPUT, kp.getPrivate(), KID)) + .isInstanceOf(IllegalArgumentException.class); + } + + // ==================== input validation ==================== + + @Test + void nullSigningInputThrows() { + assertThatThrownBy(() -> signer.sign(null, genRsa(2048).getPrivate(), KID)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void blankSigningInputThrows() { + assertThatThrownBy(() -> signer.sign(" ", genRsa(2048).getPrivate(), KID)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void nullPrivateKeyThrows() { + assertThatThrownBy(() -> signer.sign(SIGNING_INPUT, null, KID)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void blankKidThrows() { + assertThatThrownBy(() -> signer.sign(SIGNING_INPUT, genRsa(2048).getPrivate(), " ")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void mismatchedPublicKeyThrows() { + KeyPair rsa = genRsa(2048); + PublicKey ecPublic = genEcQuietly().getPublic(); + assertThatThrownBy(() -> signer.sign(SIGNING_INPUT, rsa.getPrivate(), KID, ecPublic)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void mismatchedPublicKeyForEddsaThrows() throws Exception { + KeyPair ed = gen("Ed25519"); + PublicKey rsaPublic = genRsa(2048).getPublic(); + assertThatThrownBy(() -> signer.sign(SIGNING_INPUT, ed.getPrivate(), KID, rsaPublic)) + .isInstanceOf(IllegalArgumentException.class); + } + + // ==================== helpers ==================== + + private void assertRoundTrip(KeyPair kp) throws Exception { + String jws = signer.sign(SIGNING_INPUT, kp.getPrivate(), KID, kp.getPublic()); + String[] parts = jws.split("\\."); + assertThat(parts).hasSize(3); + assertThat(parts[1]).isEqualTo(SIGNING_INPUT); + + JWSObject parsed = JWSObject.parse(jws); + assertThat(parsed.getHeader().getKeyID()).isEqualTo(KID); + + JWK embeddedJwk = parsed.getHeader().getJWK(); + assertThat(embeddedJwk).isNotNull(); + assertThat(embeddedJwk.isPrivate()).isFalse(); + + // Verify against the key recovered from the embedded JWK, not the original key pair. + // This exercises toPublicJwk(): if it encoded the wrong key material, recovery yields a + // different key and verification fails. + PublicKey recoveredPublic = recoverPublicKey(embeddedJwk); + assertThat(verifies(parts, recoveredPublic)).isTrue(); + } + + /** + * Rebuilds a JCA public key from the embedded JWK. RSA and EC use Nimbus directly. Ed25519 is + * reconstructed from its raw x-coordinate wrapped in a SubjectPublicKeyInfo, because Nimbus's + * OctetKeyPair.toPublicKey() pulls in an optional Tink dependency that is not on the classpath. + */ + private PublicKey recoverPublicKey(JWK jwk) throws Exception { + if (jwk instanceof OctetKeyPair okp) { + byte[] raw = okp.getDecodedX(); + byte[] spki = new byte[SPKI_ED25519_PREFIX.length + raw.length]; + System.arraycopy(SPKI_ED25519_PREFIX, 0, spki, 0, SPKI_ED25519_PREFIX.length); + System.arraycopy(raw, 0, spki, SPKI_ED25519_PREFIX.length, raw.length); + return KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(spki)); + } + return ((AsymmetricJWK) jwk).toPublicKey(); + } + + // DER prefix for an Ed25519 SubjectPublicKeyInfo: SEQUENCE / AlgorithmIdentifier(1.3.101.112) + // / BIT STRING, followed by the 32-byte raw public key. + private static final byte[] SPKI_ED25519_PREFIX = { + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00 + }; + + /** + * Verifies the compact JWS signature against the public key. RSA and EC use Nimbus verifiers. + * Ed25519 uses JCA directly, because Nimbus's Ed25519Verifier pulls in an optional Tink dependency. + */ + private boolean verifies(String[] parts, PublicKey publicKey) throws Exception { + if (publicKey instanceof RSAPublicKey rsa) { + return JWSObject.parse(String.join(".", parts)).verify(new RSASSAVerifier(rsa)); + } + if (publicKey instanceof ECPublicKey ec) { + return JWSObject.parse(String.join(".", parts)).verify(new ECDSAVerifier(ec)); + } + byte[] signingInputBytes = (parts[0] + "." + parts[1]).getBytes(StandardCharsets.US_ASCII); + byte[] signatureBytes = new Base64URL(parts[2]).decode(); + Signature verifier = Signature.getInstance("Ed25519"); + verifier.initVerify(publicKey); + verifier.update(signingInputBytes); + return verifier.verify(signatureBytes); + } + + private static KeyPair genRsa(int bits) { + try { + KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); + gen.initialize(bits); + return gen.generateKeyPair(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static KeyPair genEc(String curve, String provider) throws Exception { + KeyPairGenerator gen = provider == null + ? KeyPairGenerator.getInstance("EC") + : KeyPairGenerator.getInstance("EC", provider); + gen.initialize(new ECGenParameterSpec(curve)); + return gen.generateKeyPair(); + } + + private static KeyPair genEcQuietly() { + try { + return genEc("secp256r1", null); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static KeyPair gen(String algorithm) throws Exception { + return KeyPairGenerator.getInstance(algorithm).generateKeyPair(); + } +} diff --git a/ans-sdk-discovery/src/main/java/com/godaddy/ans/sdk/discovery/ResolutionService.java b/ans-sdk-discovery/src/main/java/com/godaddy/ans/sdk/discovery/ResolutionService.java index b077bbb..ebeee3b 100644 --- a/ans-sdk-discovery/src/main/java/com/godaddy/ans/sdk/discovery/ResolutionService.java +++ b/ans-sdk-discovery/src/main/java/com/godaddy/ans/sdk/discovery/ResolutionService.java @@ -1,5 +1,7 @@ package com.godaddy.ans.sdk.discovery; +import static com.godaddy.ans.sdk.util.Identifiers.requireUuid; + import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; @@ -158,7 +160,7 @@ private AgentDetails parseAgentDetails(String responseBody) { * @throws AnsAuthenticationException if authentication fails */ AgentDetails getAgent(String agentId) { - HttpRequest request = createRequestBuilder("/v1/agents/" + agentId) + HttpRequest request = createRequestBuilder("/v1/agents/" + requireUuid(agentId, "agentId")) .GET() .build(); diff --git a/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/AgentPaths.java b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/AgentPaths.java index 2b5ede0..5c7494c 100644 --- a/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/AgentPaths.java +++ b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/AgentPaths.java @@ -1,5 +1,7 @@ package com.godaddy.ans.sdk.registration; +import static com.godaddy.ans.sdk.util.Identifiers.requireUuid; + import com.godaddy.ans.sdk.config.ApiVersion; /** @@ -50,7 +52,7 @@ static String registerPath(ApiVersion apiVersion) { static String agentPath(ApiVersion apiVersion, String agentId, String... segments) { StringBuilder path = new StringBuilder(agentsCollectionPath(apiVersion)) .append('/') - .append(agentId); + .append(requireUuid(agentId, "agentId")); for (String segment : segments) { path.append('/').append(segment); } diff --git a/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/AnsApiClient.java b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/AnsApiClient.java index c3abdfd..1061686 100644 --- a/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/AnsApiClient.java +++ b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/AnsApiClient.java @@ -41,7 +41,12 @@ class AnsApiClient { } /** - * Creates an HTTP request builder with common headers (Authorization, Content-Type, Accept). + * Creates an HTTP request builder with common headers (Authorization, Accept). + * + *Content-Type is not set here. It describes a request body, so callers that + * send one add {@code Content-Type: application/json} on the returned builder. + * Bodyless requests (GET, DELETE, bodyless POST) omit it: a JSON content type on + * an empty body is malformed and strict gateways can reject it.
* * @param path the API path (e.g., "/v1/agents/register") * @return a configured HttpRequest.Builder @@ -52,7 +57,6 @@ HttpRequest.Builder createRequestBuilder(String path) { return HttpRequest.newBuilder() .uri(URI.create(configuration.getBaseUrl() + path)) .header("Authorization", credentials.toAuthorizationHeader()) - .header("Content-Type", "application/json") .header("Accept", "application/json") .timeout(configuration.getReadTimeout()); } diff --git a/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/IdentityClient.java b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/IdentityClient.java new file mode 100644 index 0000000..433856d --- /dev/null +++ b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/IdentityClient.java @@ -0,0 +1,418 @@ +package com.godaddy.ans.sdk.registration; + +import com.godaddy.ans.sdk.auth.AnsCredentialsProvider; +import com.godaddy.ans.sdk.concurrent.AnsExecutors; +import com.godaddy.ans.sdk.config.AnsConfiguration; +import com.godaddy.ans.sdk.config.ApiVersion; +import com.godaddy.ans.sdk.config.Environment; +import com.godaddy.ans.sdk.model.IdentityChallengeResponse; +import com.godaddy.ans.sdk.model.IdentityDetails; +import com.godaddy.ans.sdk.model.IdentityLinkRequest; +import com.godaddy.ans.sdk.model.IdentityLinkResponse; +import com.godaddy.ans.sdk.model.IdentityListResponse; +import com.godaddy.ans.sdk.model.IdentityRegistrationRequest; +import com.godaddy.ans.sdk.model.VerifyControlRequest; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; + +/** + * Client for ANS Verified-Identity management operations. + * + *An identity is a first-class object with its own lifecycle, separate from + * agent registration. This client covers the eight RA management operations on + * the {@code /v2/ans/identities} surface: register, list, get details, rotate, + * verify control, revoke, link to agents, and unlink.
+ * + *Register and rotate return a {@link IdentityChallengeResponse} (a 202 async + * challenge round). The identity is not sealed on that response. The caller must + * complete the challenge and then submit a control proof to verify-control.
+ * + *Example identity flow:
+ *{@code
+ * IdentityClient client = IdentityClient.builder()
+ * .environment(Environment.OTE)
+ * .credentialsProvider(new JwtCredentialsProvider(jwtToken))
+ * .build();
+ *
+ * IdentityChallengeResponse challenge = client.registerIdentity(request);
+ * // Complete the challenge, build the proof, then:
+ * IdentityDetails identity = client.verifyControl(challenge.getIdentityId(), proofRequest);
+ * }
+ *
+ * Example link and unlink:
+ *{@code
+ * IdentityLinkResponse linked = client.linkAgents(identityId,
+ * new IdentityLinkRequest().agentIds(List.of(agentId)));
+ * client.unlinkAgent(identityId, agentId);
+ * }
+ */
+public final class IdentityClient {
+
+ private final AnsConfiguration configuration;
+ private final IdentityService identityService;
+
+ private IdentityClient(AnsConfiguration configuration, AnsApiClient ansApiClient) {
+ this.configuration = configuration;
+ this.identityService = new IdentityService(ansApiClient);
+ }
+
+ /**
+ * Creates a new builder for constructing an IdentityClient.
+ *
+ * @return a new builder instance
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ // ==================== Identity Operations (Sync) ====================
+
+ /**
+ * Registers a new identity and returns the 202 challenge round.
+ *
+ * The kind ({@code did:web}, {@code did:key}, or {@code lei}) is inferred + * from the value. The identity is not sealed by this response. Complete the + * challenge and submit a control proof to verify-control.
+ * + * @param request the registration request + * @return the challenge round to complete + * @throws com.godaddy.ans.sdk.exception.AnsValidationException if the request is invalid + * @throws com.godaddy.ans.sdk.exception.AnsAuthenticationException if authentication fails + * @throws com.godaddy.ans.sdk.exception.AnsServerException if a server error occurs + */ + public IdentityChallengeResponse registerIdentity(IdentityRegistrationRequest request) { + return identityService.register(request); + } + + /** + * Lists the caller's identities, cursor-paginated. + * + * @param limit optional page size (1..100), or {@code null} for the server default + * @param cursor optional opaque page cursor, or {@code null} for the first page + * @return the page of identities plus the next cursor + * @throws com.godaddy.ans.sdk.exception.AnsAuthenticationException if authentication fails + */ + public IdentityListResponse listIdentities(Integer limit, String cursor) { + return identityService.list(limit, cursor); + } + + /** + * Gets the full details for a single identity. + * + * @param identityId the identity ID + * @return the identity details + * @throws com.godaddy.ans.sdk.exception.AnsNotFoundException if the identity is not found + * @throws com.godaddy.ans.sdk.exception.AnsAuthenticationException if authentication fails + */ + public IdentityDetails getIdentity(String identityId) { + return identityService.getDetails(identityId); + } + + /** + * Rotates the key material for an identity and returns a fresh 202 challenge round. + * + *Rotation is same-kind only. As with registration, the response is a + * challenge to complete, not a verified state.
+ * + * @param identityId the identity ID to rotate + * @param request the rotation request + * @return the new challenge round to complete + * @throws com.godaddy.ans.sdk.exception.AnsNotFoundException if the identity is not found + * @throws com.godaddy.ans.sdk.exception.AnsAuthenticationException if authentication fails + */ + public IdentityChallengeResponse rotateIdentity(String identityId, IdentityRegistrationRequest request) { + return identityService.rotate(identityId, request); + } + + /** + * Submits a control proof for an identity. + * + *Exactly one proof family may be present: {@code signedProofs} (JWS kinds) + * or {@code cesrSignature} (lei). The SDK rejects both, or neither, before the + * request leaves.
+ * + * @param identityId the identity ID + * @param request the control-proof request + * @return the updated identity details + * @throws IllegalArgumentException if the request carries both proof families or none + * @throws com.godaddy.ans.sdk.exception.AnsAuthenticationException if authentication fails + */ + public IdentityDetails verifyControl(String identityId, VerifyControlRequest request) { + return identityService.verifyControl(identityId, request); + } + + /** + * Revokes an identity. + * + * @param identityId the identity ID to revoke + * @return the updated identity details + * @throws com.godaddy.ans.sdk.exception.AnsNotFoundException if the identity is not found + * @throws com.godaddy.ans.sdk.exception.AnsAuthenticationException if authentication fails + */ + public IdentityDetails revokeIdentity(String identityId) { + return identityService.revoke(identityId); + } + + /** + * Links an identity to one or more agents as an all-or-nothing batch. + * + * @param identityId the identity ID + * @param request the link request carrying 1..256 agent IDs + * @return the link response with the count of linked agents + * @throws IllegalArgumentException if the batch is empty or exceeds 256 agents + * @throws com.godaddy.ans.sdk.exception.AnsAuthenticationException if authentication fails + */ + public IdentityLinkResponse linkAgents(String identityId, IdentityLinkRequest request) { + return identityService.link(identityId, request); + } + + /** + * Removes the link between an identity and a single agent. + * + * @param identityId the identity ID + * @param agentId the linked agent ID to remove + * @throws com.godaddy.ans.sdk.exception.AnsNotFoundException if the link is not found + * @throws com.godaddy.ans.sdk.exception.AnsAuthenticationException if authentication fails + */ + public void unlinkAgent(String identityId, String agentId) { + identityService.unlink(identityId, agentId); + } + + // ==================== Identity Operations (Async) ==================== + + /** + * Registers a new identity asynchronously. + * + *Failures arrive through the returned future. {@code get()} wraps them in + * {@link java.util.concurrent.ExecutionException}; see {@link #registerIdentity} for the causes.
+ * + * @param request the registration request + * @return a CompletableFuture with the challenge round + */ + public CompletableFutureFailures arrive through the returned future. {@code get()} wraps them in + * {@link java.util.concurrent.ExecutionException}; see {@link #listIdentities} for the causes.
+ * + * @param limit optional page size (1..100), or {@code null} for the server default + * @param cursor optional opaque page cursor, or {@code null} for the first page + * @return a CompletableFuture with the page of identities + */ + public CompletableFutureFailures arrive through the returned future. {@code get()} wraps them in + * {@link java.util.concurrent.ExecutionException}; see {@link #getIdentity} for the causes.
+ * + * @param identityId the identity ID + * @return a CompletableFuture with the identity details + */ + public CompletableFutureFailures arrive through the returned future. {@code get()} wraps them in + * {@link java.util.concurrent.ExecutionException}; see {@link #rotateIdentity} for the causes.
+ * + * @param identityId the identity ID to rotate + * @param request the rotation request + * @return a CompletableFuture with the new challenge round + */ + public CompletableFutureFailures arrive through the returned future. {@code get()} wraps them in + * {@link java.util.concurrent.ExecutionException}; see {@link #verifyControl} for the causes.
+ * + * @param identityId the identity ID + * @param request the control-proof request + * @return a CompletableFuture with the updated identity details + */ + public CompletableFutureFailures arrive through the returned future. {@code get()} wraps them in + * {@link java.util.concurrent.ExecutionException}; see {@link #revokeIdentity} for the causes.
+ * + * @param identityId the identity ID to revoke + * @return a CompletableFuture with the updated identity details + */ + public CompletableFutureFailures arrive through the returned future. {@code get()} wraps them in + * {@link java.util.concurrent.ExecutionException}; see {@link #linkAgents} for the causes.
+ * + * @param identityId the identity ID + * @param request the link request carrying 1..256 agent IDs + * @return a CompletableFuture with the link response + */ + public CompletableFutureFailures arrive through the returned future. {@code get()} wraps them in + * {@link java.util.concurrent.ExecutionException}; see {@link #unlinkAgent} for the causes.
+ * + * @param identityId the identity ID + * @param agentId the linked agent ID to remove + * @return a CompletableFuture that completes when the link is removed + */ + public CompletableFutureWhen set, this configuration is used as-is and any values set via + * other builder methods are ignored.
+ * + * @param configuration the pre-built configuration + * @return this builder + */ + public Builder configuration(AnsConfiguration configuration) { + this.prebuiltConfiguration = configuration; + return this; + } + + /** + * Sets the environment. + * + * @param environment the environment + * @return this builder + */ + public Builder environment(Environment environment) { + configBuilder.environment(environment); + return this; + } + + /** + * Sets a custom base URL. + * + * @param baseUrl the base URL + * @return this builder + */ + public Builder baseUrl(String baseUrl) { + configBuilder.baseUrl(baseUrl); + return this; + } + + /** + * Sets the credentials provider. + * + * @param credentialsProvider the credentials provider + * @return this builder + */ + public Builder credentialsProvider(AnsCredentialsProvider credentialsProvider) { + configBuilder.credentialsProvider(credentialsProvider); + return this; + } + + /** + * Sets the connection timeout. + * + * @param timeout the connection timeout + * @return this builder + */ + public Builder connectTimeout(Duration timeout) { + configBuilder.connectTimeout(timeout); + return this; + } + + /** + * Sets the read timeout. + * + * @param timeout the read timeout + * @return this builder + */ + public Builder readTimeout(Duration timeout) { + configBuilder.readTimeout(timeout); + return this; + } + + /** + * Enables retry with the specified maximum number of attempts. + * + * @param maxRetries the maximum number of retry attempts + * @return this builder + */ + public Builder enableRetry(int maxRetries) { + configBuilder.enableRetry(maxRetries); + return this; + } + + /** + * Sets the API version lane. Defaults to {@link ApiVersion#V2}. + * + * @param apiVersion the API version + * @return this builder + */ + public Builder apiVersion(ApiVersion apiVersion) { + configBuilder.apiVersion(apiVersion); + return this; + } + + /** + * Builds the IdentityClient. + * + * @return a new IdentityClient instance + */ + public IdentityClient build() { + AnsConfiguration config = (prebuiltConfiguration != null) + ? prebuiltConfiguration + : configBuilder.build(); + return new IdentityClient(config, new AnsApiClient(config)); + } + } +} diff --git a/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/IdentityPaths.java b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/IdentityPaths.java new file mode 100644 index 0000000..58d9abd --- /dev/null +++ b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/IdentityPaths.java @@ -0,0 +1,111 @@ +package com.godaddy.ans.sdk.registration; + +import static com.godaddy.ans.sdk.util.Identifiers.requireUuid; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.StringJoiner; + +/** + * Builds ANS Verified-Identity API paths on the RA management host. + * + *All identity path branching lives here so the identity service never + * string-concatenates paths inline. Identities are a v2-only feature, so every + * path is rooted at the fixed {@code /v2/ans/identities} collection.
+ */ +final class IdentityPaths { + + private static final String COLLECTION = "/v2/ans/identities"; + + private IdentityPaths() { + } + + /** + * Returns the identities collection path. + * + * @return {@code /v2/ans/identities} + */ + static String identitiesCollectionPath() { + return COLLECTION; + } + + /** + * Builds the paginated identities collection path with optional query parameters. + * + *Mirrors the agent-list cursor convention: {@code limit} (1..100) and an + * opaque {@code cursor}. Null arguments are omitted so the server applies its + * defaults.
+ * + * @param limit optional page size, or {@code null} for the server default + * @param cursor optional opaque page cursor, or {@code null} for the first page + * @return {@code /v2/ans/identities} with an appended query string when needed + */ + static String listPath(Integer limit, String cursor) { + StringJoiner query = new StringJoiner("&"); + if (limit != null) { + query.add("limit=" + limit); + } + if (cursor != null) { + query.add("cursor=" + URLEncoder.encode(cursor, StandardCharsets.UTF_8)); + } + return query.length() == 0 ? COLLECTION : COLLECTION + "?" + query; + } + + /** + * Builds an identity-scoped path: collection + identityId + any trailing segments. + * + * @param identityId the identity ID + * @param segments optional trailing path segments (e.g. {@code "verify-control"}) + * @return the joined path, e.g. {@code /v2/ans/identities/{identityId}/verify-control} + */ + static String identityPath(String identityId, String... segments) { + StringBuilder path = new StringBuilder(COLLECTION) + .append('/') + .append(requireUuid(identityId, "identityId")); + for (String segment : segments) { + path.append('/').append(segment); + } + return path.toString(); + } + + /** + * Returns the control-proof submission path for an identity. + * + * @param identityId the identity ID + * @return {@code /v2/ans/identities/{identityId}/verify-control} + */ + static String verifyControlPath(String identityId) { + return identityPath(identityId, "verify-control"); + } + + /** + * Returns the revocation path for an identity. + * + * @param identityId the identity ID + * @return {@code /v2/ans/identities/{identityId}/revoke} + */ + static String revokePath(String identityId) { + return identityPath(identityId, "revoke"); + } + + /** + * Returns the links collection path for an identity. + * + * @param identityId the identity ID + * @return {@code /v2/ans/identities/{identityId}/links} + */ + static String linksPath(String identityId) { + return identityPath(identityId, "links"); + } + + /** + * Returns the path for a single identity-to-agent link. + * + * @param identityId the identity ID + * @param agentId the linked agent ID + * @return {@code /v2/ans/identities/{identityId}/links/{agentId}} + */ + static String linkPath(String identityId, String agentId) { + return identityPath(identityId, "links", requireUuid(agentId, "agentId")); + } +} diff --git a/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/IdentityService.java b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/IdentityService.java new file mode 100644 index 0000000..d58aa89 --- /dev/null +++ b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/IdentityService.java @@ -0,0 +1,247 @@ +package com.godaddy.ans.sdk.registration; + +import com.godaddy.ans.sdk.exception.AnsServerException; +import com.godaddy.ans.sdk.model.IdentityChallengeResponse; +import com.godaddy.ans.sdk.model.IdentityDetails; +import com.godaddy.ans.sdk.model.IdentityLinkRequest; +import com.godaddy.ans.sdk.model.IdentityLinkResponse; +import com.godaddy.ans.sdk.model.IdentityListResponse; +import com.godaddy.ans.sdk.model.IdentityRegistrationRequest; +import com.godaddy.ans.sdk.model.VerifyControlRequest; + +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; + +/** + * Internal service for ANS Verified-Identity management API calls. + * + *Covers the eight RA management operations on the {@code /v2/ans/identities} + * surface. All paths come from {@link IdentityPaths}; all HTTP work reuses + * {@link AnsApiClient}, which maps error status codes to typed exceptions.
+ */ +class IdentityService { + + /** Maximum number of agents that a single link request can carry. */ + private static final int MAX_LINK_AGENTS = 256; + + private final AnsApiClient httpClient; + + IdentityService(final AnsApiClient ansApiClient) { + this.httpClient = ansApiClient; + } + + /** + * Registers a new identity and returns the 202 challenge round. + * + *The identity is not sealed by this response. The caller must complete + * the returned challenge and then submit a control proof to verify-control.
+ * + * @param request the registration request (kind is inferred from the value) + * @return the challenge round to complete + */ + IdentityChallengeResponse register(IdentityRegistrationRequest request) { + String requestBody = httpClient.serializeToJson(request); + + HttpRequest httpRequest = httpClient.createRequestBuilder(IdentityPaths.identitiesCollectionPath()) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build(); + + HttpResponseRotation is same-kind only. As with registration, the response is a + * challenge to complete, not a verified state.
+ * + * @param identityId the identity ID to rotate + * @param request the rotation request + * @return the new challenge round to complete + */ + IdentityChallengeResponse rotate(String identityId, IdentityRegistrationRequest request) { + String requestBody = httpClient.serializeToJson(request); + + HttpRequest httpRequest = httpClient.createRequestBuilder(IdentityPaths.identityPath(identityId)) + .header("Content-Type", "application/json") + .PUT(HttpRequest.BodyPublishers.ofString(requestBody)) + .build(); + + HttpResponseExactly one proof family may be present: {@code signedProofs} (JWS kinds) + * or {@code cesrSignature} (lei). The SDK rejects both, or neither, before the + * request leaves.
+ * + * @param identityId the identity ID + * @param request the control-proof request + * @return the updated identity details + * @throws IllegalArgumentException if the request carries both proof families or none + */ + IdentityDetails verifyControl(String identityId, VerifyControlRequest request) { + validateProofFamily(request); + String requestBody = httpClient.serializeToJson(request); + + HttpRequest httpRequest = httpClient.createRequestBuilder(IdentityPaths.verifyControlPath(identityId)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build(); + + HttpResponseThe server returns 204 with no body. Nothing is parsed.
+ * + * @param identityId the identity ID + * @param agentId the linked agent ID to remove + */ + void unlink(String identityId, String agentId) { + HttpRequest httpRequest = httpClient.createRequestBuilder(IdentityPaths.linkPath(identityId, agentId)) + .DELETE() + .build(); + + httpClient.sendRequest(httpRequest); + } + + /** + * Parses a 202 challenge body and guards against missing required fields. + * + *Jackson does not enforce {@code required} on deserialization, so a + * malformed response can leave any {@code @Nonnull} field null. A response + * that omits one cannot drive the verify-control round and is a server + * fault. Every required field is checked here so callers never receive a + * partially-populated challenge and hit a deferred {@link NullPointerException} + * at a getter call site.
+ */ + private IdentityChallengeResponse parseChallenge(String body) { + IdentityChallengeResponse challenge = + httpClient.parseResponse(body, IdentityChallengeResponse.class); + + requireField(challenge.getIdentityId(), "identityId"); + requireField(challenge.getKind(), "kind"); + requireField(challenge.getValue(), "value"); + requireField(challenge.getStatus(), "status"); + requireField(challenge.getNonce(), "nonce"); + requireField(challenge.getExpiresAt(), "expiresAt"); + requireField(challenge.getChallenges(), "challenges"); + return challenge; + } + + /** + * Rejects a challenge response that omits a required field. + * + * @param value the deserialized field value + * @param name the JSON property name, used in the error message + * @throws AnsServerException if {@code value} is null + */ + private void requireField(Object value, String name) { + if (value == null) { + throw new AnsServerException("Identity challenge response missing '" + name + "'", 0, null); + } + } + + /** + * Enforces the exactly-one-proof-family rule before sending verify-control. + */ + private void validateProofFamily(VerifyControlRequest request) { + ListFailures arrive through the returned future. {@code get()} wraps them in + * {@link java.util.concurrent.ExecutionException}; see {@link #registerAgent} for the causes.
+ * * @param request the registration request * @return a CompletableFuture with the agent details */ @@ -187,6 +190,9 @@ public CompletableFutureFailures arrive through the returned future. {@code get()} wraps them in + * {@link java.util.concurrent.ExecutionException}; see {@link #verifyAcme} for the causes.
+ * * @param agentId the agent ID * @return a CompletableFuture with the updated agent status */ @@ -197,6 +203,9 @@ public CompletableFutureFailures arrive through the returned future. {@code get()} wraps them in + * {@link java.util.concurrent.ExecutionException}; see {@link #verifyDns} for the causes.
+ * * @param agentId the agent ID * @return a CompletableFuture with the updated agent status */ @@ -207,6 +216,10 @@ public CompletableFutureFailures arrive through the returned future. {@code get()} wraps them in + * {@link java.util.concurrent.ExecutionException}; see + * {@link #revokeAgent(String, AgentRevocationRequest)} for the causes.
+ * * @param agentId the agent ID to revoke * @param request the revocation request * @return a CompletableFuture with the revocation response @@ -218,6 +231,10 @@ public CompletableFutureFailures arrive through the returned future. {@code get()} wraps them in + * {@link java.util.concurrent.ExecutionException}; see + * {@link #revokeAgent(String, RevocationReason)} for the causes.
+ * * @param agentId the agent ID to revoke * @param reason the reason for revocation * @return a CompletableFuture with the revocation response diff --git a/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/RegistrationService.java b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/RegistrationService.java index 43311b5..6a600fb 100644 --- a/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/RegistrationService.java +++ b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/RegistrationService.java @@ -45,6 +45,7 @@ AgentDetails register(AgentRegistrationRequest request) { String requestBody = httpClient.serializeToJson(request); HttpRequest httpRequest = httpClient.createRequestBuilder(AgentPaths.registerPath(apiVersion)) + .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(requestBody)) .build(); @@ -155,6 +156,7 @@ AgentRevocationResponse revoke(String agentId, AgentRevocationRequest request) { HttpRequest httpRequest = httpClient.createRequestBuilder( AgentPaths.agentPath(apiVersion, agentId, "revoke")) + .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(requestBody)) .build(); diff --git a/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/CertificateServiceTest.java b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/CertificateServiceTest.java index 3c731df..532626a 100644 --- a/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/CertificateServiceTest.java +++ b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/CertificateServiceTest.java @@ -32,7 +32,7 @@ @WireMockTest class CertificateServiceTest { - private static final String TEST_AGENT_ID = "test-agent-123"; + private static final String TEST_AGENT_ID = "550e8400-e29b-41d4-a716-446655440000"; private static final String API_KEY = "test-api-key"; private static final String API_SECRET = "test-api-secret"; diff --git a/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/IdentityClientTest.java b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/IdentityClientTest.java new file mode 100644 index 0000000..c094761 --- /dev/null +++ b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/IdentityClientTest.java @@ -0,0 +1,861 @@ +package com.godaddy.ans.sdk.registration; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.deleteRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.delete; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.put; +import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.List; +import java.util.UUID; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.godaddy.ans.sdk.auth.ApiKeyCredentialsProvider; +import com.godaddy.ans.sdk.config.AnsConfiguration; +import com.godaddy.ans.sdk.config.ApiVersion; +import com.godaddy.ans.sdk.config.Environment; +import com.godaddy.ans.sdk.exception.AnsAuthenticationException; +import com.godaddy.ans.sdk.exception.AnsConflictException; +import com.godaddy.ans.sdk.exception.AnsNotFoundException; +import com.godaddy.ans.sdk.exception.AnsServerException; +import com.godaddy.ans.sdk.model.IdentityChallengeResponse; +import com.godaddy.ans.sdk.model.IdentityDetails; +import com.godaddy.ans.sdk.model.IdentityLifecycleStatus; +import com.godaddy.ans.sdk.model.IdentityLinkRequest; +import com.godaddy.ans.sdk.model.IdentityLinkResponse; +import com.godaddy.ans.sdk.model.IdentityListResponse; +import com.godaddy.ans.sdk.model.IdentityProofChallenge; +import com.godaddy.ans.sdk.model.IdentityRegistrationRequest; +import com.godaddy.ans.sdk.model.VLEIPresentation; +import com.godaddy.ans.sdk.model.VerifyControlRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@WireMockTest +class IdentityClientTest { + + private static final String TEST_IDENTITY_ID = "550e8400-e29b-41d4-a716-446655440000"; + private static final String TEST_AGENT_ID = "660e8400-e29b-41d4-a716-446655440111"; + private static final String TEST_OTHER_AGENT_ID = "770e8400-e29b-41d4-a716-446655440222"; + private static final String TEST_API_KEY = "123e4567-e89b-12d3-a456-426614174000"; + private static final String TEST_API_KEY_SECRET = "123e4567-e89b-12d3-a456-426614174000"; + private static final String TEST_NONCE = "abc123"; + private static final String DID_WEB_IDENTITY = "did:web:identity.acme-corp.com"; + + // ==================== Builder Tests ==================== + + @Test + @DisplayName("Should build client with environment") + void shouldBuildClientWithEnvironment() { + IdentityClient client = IdentityClient.builder() + .environment(Environment.OTE) + .credentialsProvider(new ApiKeyCredentialsProvider(TEST_API_KEY, TEST_API_KEY_SECRET)) + .build(); + + assertThat(client).isNotNull(); + assertThat(client.getConfiguration().getEnvironment()).isEqualTo(Environment.OTE); + assertThat(client.getConfiguration().getBaseUrl()).isEqualTo("https://api.ote-godaddy.com"); + } + + @Test + @DisplayName("Should build client with custom base URL") + void shouldBuildClientWithCustomBaseUrl(WireMockRuntimeInfo wmRuntimeInfo) { + String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); + + IdentityClient client = IdentityClient.builder() + .baseUrl(baseUrl) + .credentialsProvider(new ApiKeyCredentialsProvider(TEST_API_KEY, TEST_API_KEY_SECRET)) + .build(); + + assertThat(client).isNotNull(); + assertThat(client.getConfiguration().getBaseUrl()).isEqualTo(baseUrl); + } + + @Test + @DisplayName("Should build client with custom timeouts") + void shouldBuildClientWithCustomTimeouts() { + IdentityClient client = IdentityClient.builder() + .environment(Environment.OTE) + .credentialsProvider(new ApiKeyCredentialsProvider(TEST_API_KEY, TEST_API_KEY_SECRET)) + .connectTimeout(Duration.ofSeconds(5)) + .readTimeout(Duration.ofSeconds(15)) + .build(); + + assertThat(client.getConfiguration().getConnectTimeout()).isEqualTo(Duration.ofSeconds(5)); + assertThat(client.getConfiguration().getReadTimeout()).isEqualTo(Duration.ofSeconds(15)); + } + + @Test + @DisplayName("Should build client with retry enabled") + void shouldBuildClientWithRetryEnabled() { + IdentityClient client = IdentityClient.builder() + .environment(Environment.OTE) + .credentialsProvider(new ApiKeyCredentialsProvider(TEST_API_KEY, TEST_API_KEY_SECRET)) + .enableRetry(5) + .build(); + + assertThat(client.getConfiguration().isRetryEnabled()).isTrue(); + assertThat(client.getConfiguration().getMaxRetries()).isEqualTo(5); + } + + @Test + @DisplayName("Should use a pre-built configuration as-is") + void shouldBuildClientWithPrebuiltConfiguration() { + AnsConfiguration prebuilt = AnsConfiguration.builder() + .environment(Environment.OTE) + .credentialsProvider(new ApiKeyCredentialsProvider(TEST_API_KEY, TEST_API_KEY_SECRET)) + .apiVersion(ApiVersion.V1) + .build(); + + IdentityClient client = IdentityClient.builder() + .baseUrl("https://ignored.example.com") + .configuration(prebuilt) + .build(); + + // The pre-built configuration wins; the builder's own baseUrl is ignored. + assertThat(client.getConfiguration()).isSameAs(prebuilt); + assertThat(client.getConfiguration().getApiVersion()).isEqualTo(ApiVersion.V1); + } + + @Test + @DisplayName("Should build client with a custom API version lane") + void shouldBuildClientWithApiVersion() { + IdentityClient client = IdentityClient.builder() + .environment(Environment.OTE) + .credentialsProvider(new ApiKeyCredentialsProvider(TEST_API_KEY, TEST_API_KEY_SECRET)) + .apiVersion(ApiVersion.V1) + .build(); + + assertThat(client.getConfiguration().getApiVersion()).isEqualTo(ApiVersion.V1); + } + + @Test + @DisplayName("Should throw exception when credentials provider is null") + void shouldThrowExceptionWhenCredentialsProviderIsNull() { + assertThatThrownBy(() -> IdentityClient.builder() + .environment(Environment.OTE) + .build()) + .isInstanceOf(NullPointerException.class); + } + + // ==================== Identity Registration Tests ==================== + + @Test + @DisplayName("Should register Identity successfully") + void shouldRegisterIdentitySuccessfully(WireMockRuntimeInfo wmRuntimeInfo) { + String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); + + // Stub the initial registration POST + stubFor(post(urlEqualTo("/v2/ans/identities")) + .willReturn(aResponse() + .withStatus(202) + .withHeader("Content-Type", "application/json") + .withBody(identityRegistrationResponse()))); + + IdentityClient client = IdentityClient.builder() + .environment(Environment.OTE) + .baseUrl(baseUrl) + .credentialsProvider(new ApiKeyCredentialsProvider(TEST_API_KEY, TEST_API_KEY_SECRET)) + .build(); + + IdentityRegistrationRequest request = new IdentityRegistrationRequest() + .value(TEST_IDENTITY_ID); + + IdentityChallengeResponse result = client.registerIdentity(request); + + assertThat(result).isNotNull(); + assertThat(result.getIdentityId()).isEqualTo(TEST_IDENTITY_ID); + assertThat(result.getNonce()).isEqualTo(TEST_NONCE); + assertThat(result.getExpiresAt()).isEqualTo("2024-01-15T12:00:00Z"); + assertThat(result.getValue()).isEqualTo(DID_WEB_IDENTITY); + assertThat(result.getStatus()).isEqualTo(IdentityLifecycleStatus.PENDING_CONTROL); + IdentityProofChallenge identityProofChallenge = result.getChallenges().get(0); + assertThat(identityProofChallenge.getKid()).isEqualTo("#key-1"); + assertThat(identityProofChallenge.getSigningInput()).isEqualTo("abc123"); + + verify(postRequestedFor(urlEqualTo("/v2/ans/identities")) + .withRequestBody(containing("\"value\":\"" + TEST_IDENTITY_ID + "\"")) + .withHeader("Authorization", equalTo("sso-key " + TEST_API_KEY + ":" + TEST_API_KEY_SECRET))); + } + + @Test + @DisplayName("Should throw AnsServerException when challenge response missing identityId") + void shouldThrowWhenChallengeMissingIdentityId(WireMockRuntimeInfo wmRuntimeInfo) { + String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); + + stubFor(post(urlEqualTo("/v2/ans/identities")) + .willReturn(aResponse() + .withStatus(202) + .withHeader("Content-Type", "application/json") + .withBody("{\"nonce\":\"abc123\"}"))); + + IdentityClient client = client(baseUrl); + + assertThatThrownBy(() -> client.registerIdentity(new IdentityRegistrationRequest().value(TEST_IDENTITY_ID))) + .isInstanceOf(AnsServerException.class) + .hasMessageContaining("missing 'identityId'"); + } + + @Test + @DisplayName("Should throw AnsServerException when challenge response missing nonce") + void shouldThrowWhenChallengeMissingNonce(WireMockRuntimeInfo wmRuntimeInfo) { + String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); + + stubFor(post(urlEqualTo("/v2/ans/identities")) + .willReturn(aResponse() + .withStatus(202) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "identityId": "%s", + "kind": "did:web", + "value": "did:web:example.com", + "status": "PENDING_CONTROL", + "expiresAt": "2026-01-01T00:00:00Z", + "challenges": [] + } + """.formatted(TEST_IDENTITY_ID)))); + + IdentityClient client = client(baseUrl); + + assertThatThrownBy(() -> client.registerIdentity(new IdentityRegistrationRequest().value(TEST_IDENTITY_ID))) + .isInstanceOf(AnsServerException.class) + .hasMessageContaining("missing 'nonce'"); + } + + @Test + @DisplayName("Should register a lei identity carrying a vLEI/CESR presentation") + void shouldRegisterLeiIdentityWithVleiPresentation(WireMockRuntimeInfo wmRuntimeInfo) { + String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); + + stubFor(post(urlEqualTo("/v2/ans/identities")) + .willReturn(aResponse() + .withStatus(202) + .withHeader("Content-Type", "application/json") + .withBody(identityRegistrationResponse()))); + + IdentityClient client = client(baseUrl); + + String cesrBytes = "-VAj-AABAAA-transport-only-cesr"; + IdentityRegistrationRequest request = new IdentityRegistrationRequest() + .value("5493001KJTIIGC8Y1R12") + .vleiPresentation(new VLEIPresentation().cesr(cesrBytes)); + + IdentityChallengeResponse result = client.registerIdentity(request); + + assertThat(result).isNotNull(); + + verify(postRequestedFor(urlEqualTo("/v2/ans/identities")) + .withRequestBody(containing("\"vleiPresentation\"")) + .withRequestBody(containing("\"cesr\":\"" + cesrBytes + "\"")) + .withHeader("Authorization", equalTo("sso-key " + TEST_API_KEY + ":" + TEST_API_KEY_SECRET))); + } + + @Test + @DisplayName("Should surface AnsConflictException carrying IDENTIFIER_DUPLICATE on 409") + void shouldSurfaceConflictOnDuplicateIdentifier(WireMockRuntimeInfo wmRuntimeInfo) { + String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); + + stubFor(post(urlEqualTo("/v2/ans/identities")) + .willReturn(aResponse() + .withStatus(409) + .withHeader("Content-Type", "application/json") + .withBody("{\"status\":\"error\",\"code\":\"IDENTIFIER_DUPLICATE\"," + + "\"message\":\"identifier already registered\"}"))); + + IdentityClient client = client(baseUrl); + + assertThatThrownBy(() -> client.registerIdentity(new IdentityRegistrationRequest().value(DID_WEB_IDENTITY))) + .isInstanceOf(AnsConflictException.class) + .hasMessageContaining("IDENTIFIER_DUPLICATE"); + } + + @Test + @DisplayName("Should surface a retryable AnsServerException carrying TL_UNAVAILABLE on 503") + void shouldSurfaceServerErrorOnTransparencyLogUnavailable(WireMockRuntimeInfo wmRuntimeInfo) { + String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); + + stubFor(post(urlEqualTo("/v2/ans/identities")) + .willReturn(aResponse() + .withStatus(503) + .withHeader("Content-Type", "application/json") + .withBody("{\"status\":\"error\",\"code\":\"TL_UNAVAILABLE\"," + + "\"message\":\"transparency log unavailable\"}"))); + + IdentityClient client = client(baseUrl); + + assertThatThrownBy(() -> client.registerIdentity(new IdentityRegistrationRequest().value(DID_WEB_IDENTITY))) + .isInstanceOf(AnsServerException.class) + .hasMessageContaining("TL_UNAVAILABLE") + .satisfies(e -> assertThat(((AnsServerException) e).isRetryable()).isTrue()); + } + + // ==================== List Identities Tests ==================== + + @Test + @DisplayName("Should list identities with limit and cursor") + void shouldListIdentitiesWithLimitAndCursor(WireMockRuntimeInfo wmRuntimeInfo) { + String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); + + stubFor(get(urlEqualTo("/v2/ans/identities?limit=10&cursor=page-2")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(identityListResponse()))); + + IdentityClient client = client(baseUrl); + + IdentityListResponse result = client.listIdentities(10, "page-2"); + + assertThat(result).isNotNull(); + assertThat(result.getReturnedCount()).isEqualTo(1); + assertThat(result.getLimit()).isEqualTo(10); + assertThat(result.getNextCursor()).isEqualTo("page-3"); + assertThat(result.getHasMore()).isTrue(); + assertThat(result.getItems()).hasSize(1); + assertThat(result.getItems().get(0).getIdentityId()).isEqualTo(TEST_IDENTITY_ID); + } + + @Test + @DisplayName("Should list identities with server defaults when limit and cursor are null") + void shouldListIdentitiesWithDefaults(WireMockRuntimeInfo wmRuntimeInfo) { + String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); + + stubFor(get(urlEqualTo("/v2/ans/identities")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(identityListResponse()))); + + IdentityClient client = client(baseUrl); + + IdentityListResponse result = client.listIdentities(null, null); + + assertThat(result).isNotNull(); + assertThat(result.getItems()).hasSize(1); + } + + @Test + @DisplayName("Should throw AnsAuthenticationException on 401 for listIdentities") + void shouldThrowAuthExceptionForListIdentities(WireMockRuntimeInfo wmRuntimeInfo) { + String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); + + stubFor(get(urlEqualTo("/v2/ans/identities")) + .willReturn(aResponse() + .withStatus(401) + .withHeader("Content-Type", "application/json") + .withBody("{\"status\":\"error\",\"code\":\"UNAUTHORIZED\",\"message\":\"Invalid key\"}"))); + + IdentityClient client = client(baseUrl); + + assertThatThrownBy(() -> client.listIdentities(null, null)) + .isInstanceOf(AnsAuthenticationException.class) + .hasMessageContaining("Authentication failed"); + } + + // ==================== Get Identity Tests ==================== + + @Test + @DisplayName("Should get identity by ID successfully") + void shouldGetIdentitySuccessfully(WireMockRuntimeInfo wmRuntimeInfo) { + String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); + + stubFor(get(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(identityDetailsResponse(IdentityLifecycleStatus.VERIFIED)))); + + IdentityClient client = client(baseUrl); + + IdentityDetails result = client.getIdentity(TEST_IDENTITY_ID); + + assertThat(result).isNotNull(); + assertThat(result.getIdentityId()).isEqualTo(TEST_IDENTITY_ID); + assertThat(result.getKind()).isEqualTo(IdentityDetails.KindEnum.DID_WEB); + assertThat(result.getValue()).isEqualTo(DID_WEB_IDENTITY); + assertThat(result.getStatus()).isEqualTo(IdentityLifecycleStatus.VERIFIED); + assertThat(result.getLinkedAgents()).hasSize(1); + } + + @Test + @DisplayName("Should throw AnsNotFoundException when identity not found") + void shouldThrowNotFoundExceptionWhenIdentityNotFound(WireMockRuntimeInfo wmRuntimeInfo) { + String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); + + stubFor(get(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID)) + .willReturn(aResponse() + .withStatus(404) + .withHeader("Content-Type", "application/json") + .withBody("{\"status\":\"error\",\"code\":\"NOT_FOUND\",\"message\":\"Identity not found\"}"))); + + IdentityClient client = client(baseUrl); + + assertThatThrownBy(() -> client.getIdentity(TEST_IDENTITY_ID)) + .isInstanceOf(AnsNotFoundException.class) + .hasMessageContaining("not found"); + } + + // ==================== Rotate Identity Tests ==================== + + @Test + @DisplayName("Should rotate identity and return a fresh challenge") + void shouldRotateIdentitySuccessfully(WireMockRuntimeInfo wmRuntimeInfo) { + String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); + + stubFor(put(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID)) + .willReturn(aResponse() + .withStatus(202) + .withHeader("Content-Type", "application/json") + .withBody(identityRegistrationResponse()))); + + IdentityClient client = client(baseUrl); + + IdentityRegistrationRequest request = new IdentityRegistrationRequest().value(DID_WEB_IDENTITY); + + IdentityChallengeResponse result = client.rotateIdentity(TEST_IDENTITY_ID, request); + + assertThat(result).isNotNull(); + assertThat(result.getIdentityId()).isEqualTo(TEST_IDENTITY_ID); + assertThat(result.getNonce()).isEqualTo(TEST_NONCE); + + verify(putRequestedFor(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID)) + .withRequestBody(containing("\"value\":\"" + DID_WEB_IDENTITY + "\"")) + .withHeader("Authorization", equalTo("sso-key " + TEST_API_KEY + ":" + TEST_API_KEY_SECRET))); + } + + // ==================== Verify Control Tests ==================== + + @Test + @DisplayName("Should verify control with signed JWS proofs") + void shouldVerifyControlWithSignedProofs(WireMockRuntimeInfo wmRuntimeInfo) { + String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); + + stubFor(post(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID + "/verify-control")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(identityDetailsResponse(IdentityLifecycleStatus.VERIFIED)))); + + IdentityClient client = client(baseUrl); + + VerifyControlRequest request = new VerifyControlRequest() + .signedProofs(List.of("eyJhbGciOiJFZERTQSJ9.payload.sig")); + + IdentityDetails result = client.verifyControl(TEST_IDENTITY_ID, request); + + assertThat(result).isNotNull(); + assertThat(result.getStatus()).isEqualTo(IdentityLifecycleStatus.VERIFIED); + + verify(postRequestedFor(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID + "/verify-control")) + .withRequestBody(containing("\"signedProofs\""))); + } + + @Test + @DisplayName("Should verify control with a CESR signature") + void shouldVerifyControlWithCesrSignature(WireMockRuntimeInfo wmRuntimeInfo) { + String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); + + stubFor(post(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID + "/verify-control")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(identityDetailsResponse(IdentityLifecycleStatus.VERIFIED)))); + + IdentityClient client = client(baseUrl); + + VerifyControlRequest request = new VerifyControlRequest().cesrSignature("AABxyzsignature"); + + IdentityDetails result = client.verifyControl(TEST_IDENTITY_ID, request); + + assertThat(result).isNotNull(); + assertThat(result.getStatus()).isEqualTo(IdentityLifecycleStatus.VERIFIED); + + verify(postRequestedFor(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID + "/verify-control")) + .withRequestBody(containing("\"cesrSignature\":\"AABxyzsignature\""))); + } + + @Test + @DisplayName("Should reject verify control carrying both proof families") + void shouldRejectVerifyControlWithBothProofFamilies() { + IdentityClient client = IdentityClient.builder() + .environment(Environment.OTE) + .credentialsProvider(new ApiKeyCredentialsProvider(TEST_API_KEY, TEST_API_KEY_SECRET)) + .build(); + + VerifyControlRequest request = new VerifyControlRequest() + .signedProofs(List.of("jws-proof")) + .cesrSignature("cesr-sig"); + + assertThatThrownBy(() -> client.verifyControl(TEST_IDENTITY_ID, request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exactly one proof family"); + } + + @Test + @DisplayName("Should reject verify control carrying no proof family") + void shouldRejectVerifyControlWithNoProofFamily() { + IdentityClient client = IdentityClient.builder() + .environment(Environment.OTE) + .credentialsProvider(new ApiKeyCredentialsProvider(TEST_API_KEY, TEST_API_KEY_SECRET)) + .build(); + + assertThatThrownBy(() -> client.verifyControl(TEST_IDENTITY_ID, new VerifyControlRequest())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exactly one proof family"); + } + + // ==================== Revoke Identity Tests ==================== + + @Test + @DisplayName("Should revoke identity successfully") + void shouldRevokeIdentitySuccessfully(WireMockRuntimeInfo wmRuntimeInfo) { + String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); + + stubFor(post(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID + "/revoke")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(identityDetailsResponse(IdentityLifecycleStatus.REVOKED)))); + + IdentityClient client = client(baseUrl); + + IdentityDetails result = client.revokeIdentity(TEST_IDENTITY_ID); + + assertThat(result).isNotNull(); + assertThat(result.getStatus()).isEqualTo(IdentityLifecycleStatus.REVOKED); + + verify(postRequestedFor(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID + "/revoke"))); + } + + // ==================== Link Agents Tests ==================== + + @Test + @DisplayName("Should link agents successfully") + void shouldLinkAgentsSuccessfully(WireMockRuntimeInfo wmRuntimeInfo) { + String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); + + stubFor(post(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID + "/links")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"linked\":2}"))); + + IdentityClient client = client(baseUrl); + + IdentityLinkRequest request = new IdentityLinkRequest() + .agentIds(List.of(UUID.fromString(TEST_AGENT_ID), UUID.fromString(TEST_OTHER_AGENT_ID))); + + IdentityLinkResponse result = client.linkAgents(TEST_IDENTITY_ID, request); + + assertThat(result).isNotNull(); + assertThat(result.getLinked()).isEqualTo(2); + + verify(postRequestedFor(urlEqualTo("/v2/ans/identities/" + TEST_IDENTITY_ID + "/links")) + .withRequestBody(containing(TEST_AGENT_ID)) + .withHeader("Authorization", equalTo("sso-key " + TEST_API_KEY + ":" + TEST_API_KEY_SECRET))); + } + + @Test + @DisplayName("Should reject link request with an empty agent batch") + void shouldRejectEmptyLinkBatch() { + IdentityClient client = IdentityClient.builder() + .environment(Environment.OTE) + .credentialsProvider(new ApiKeyCredentialsProvider(TEST_API_KEY, TEST_API_KEY_SECRET)) + .build(); + + assertThatThrownBy(() -> client.linkAgents(TEST_IDENTITY_ID, new IdentityLinkRequest())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("between 1 and 256"); + } + + @Test + @DisplayName("Should reject link request exceeding the 256 agent batch limit") + void shouldRejectOversizedLinkBatch() { + IdentityClient client = IdentityClient.builder() + .environment(Environment.OTE) + .credentialsProvider(new ApiKeyCredentialsProvider(TEST_API_KEY, TEST_API_KEY_SECRET)) + .build(); + + ListEach method is pinned to an exact string, so a typo in a path constant fails + * here at the source, not as an opaque WireMock stub miss. Every identity path sits + * under {@code /v2/ans/identities}.
+ */ +class IdentityPathsTest { + + private static final String IDENTITY_ID = "660e8400-e29b-41d4-a716-446655440000"; + private static final String AGENT_ID = "550e8400-e29b-41d4-a716-446655440000"; + private static final String COLLECTION = "/v2/ans/identities"; + + @Test + @DisplayName("identitiesCollectionPath returns the identities collection root") + void identitiesCollectionPath() { + assertThat(IdentityPaths.identitiesCollectionPath()).isEqualTo(COLLECTION); + } + + @Test + @DisplayName("identityPath with no trailing segments returns collection/{identityId}") + void identityPathNoSegments() { + assertThat(IdentityPaths.identityPath(IDENTITY_ID)).isEqualTo(COLLECTION + "/" + IDENTITY_ID); + } + + @Test + @DisplayName("identityPath appends trailing segments in order") + void identityPathMultipleSegments() { + assertThat(IdentityPaths.identityPath(IDENTITY_ID, "links", AGENT_ID)) + .isEqualTo(COLLECTION + "/" + IDENTITY_ID + "/links/" + AGENT_ID); + } + + @Test + @DisplayName("verifyControlPath targets the verify-control sub-resource") + void verifyControlPath() { + assertThat(IdentityPaths.verifyControlPath(IDENTITY_ID)) + .isEqualTo(COLLECTION + "/" + IDENTITY_ID + "/verify-control"); + } + + @Test + @DisplayName("revokePath targets the revoke sub-resource") + void revokePath() { + assertThat(IdentityPaths.revokePath(IDENTITY_ID)) + .isEqualTo(COLLECTION + "/" + IDENTITY_ID + "/revoke"); + } + + @Test + @DisplayName("linksPath targets the links collection") + void linksPath() { + assertThat(IdentityPaths.linksPath(IDENTITY_ID)) + .isEqualTo(COLLECTION + "/" + IDENTITY_ID + "/links"); + } + + @Test + @DisplayName("linkPath targets a single identity-to-agent link") + void linkPath() { + assertThat(IdentityPaths.linkPath(IDENTITY_ID, AGENT_ID)) + .isEqualTo(COLLECTION + "/" + IDENTITY_ID + "/links/" + AGENT_ID); + } + + @Test + @DisplayName("identityPath rejects a non-UUID identityId") + void identityPathRejectsNonUuid() { + assertThatThrownBy(() -> IdentityPaths.identityPath("did:web:example.com#key-1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("identityId"); + } + + @Test + @DisplayName("linkPath rejects a non-UUID agentId") + void linkPathRejectsNonUuidAgentId() { + assertThatThrownBy(() -> IdentityPaths.linkPath(IDENTITY_ID, "not-a-uuid/../secrets")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("agentId"); + } + + @Test + @DisplayName("every identity path is rooted at /v2/ans/identities") + void allPathsRootedAtCollection() { + assertThat(IdentityPaths.identitiesCollectionPath()).startsWith(COLLECTION); + assertThat(IdentityPaths.identityPath(IDENTITY_ID)).startsWith(COLLECTION); + assertThat(IdentityPaths.verifyControlPath(IDENTITY_ID)).startsWith(COLLECTION); + assertThat(IdentityPaths.revokePath(IDENTITY_ID)).startsWith(COLLECTION); + assertThat(IdentityPaths.linksPath(IDENTITY_ID)).startsWith(COLLECTION); + assertThat(IdentityPaths.linkPath(IDENTITY_ID, AGENT_ID)).startsWith(COLLECTION); + } +} diff --git a/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/IdentityServiceTest.java b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/IdentityServiceTest.java new file mode 100644 index 0000000..b5a28ee --- /dev/null +++ b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/IdentityServiceTest.java @@ -0,0 +1,640 @@ +package com.godaddy.ans.sdk.registration; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.godaddy.ans.sdk.auth.ApiKeyCredentialsProvider; +import com.godaddy.ans.sdk.config.AnsConfiguration; +import com.godaddy.ans.sdk.config.Environment; +import com.godaddy.ans.sdk.exception.AnsAuthenticationException; +import com.godaddy.ans.sdk.exception.AnsNotFoundException; +import com.godaddy.ans.sdk.exception.AnsServerException; +import com.godaddy.ans.sdk.exception.AnsValidationException; +import com.godaddy.ans.sdk.model.IdentityChallengeResponse; +import com.godaddy.ans.sdk.model.IdentityDetails; +import com.godaddy.ans.sdk.model.IdentityLinkRequest; +import com.godaddy.ans.sdk.model.IdentityLinkResponse; +import com.godaddy.ans.sdk.model.IdentityListResponse; +import com.godaddy.ans.sdk.model.IdentityRegistrationRequest; +import com.godaddy.ans.sdk.model.VerifyControlRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.delete; +import static com.github.tomakehurst.wiremock.client.WireMock.deleteRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.put; +import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link IdentityService}, the eight RA Verified-Identity + * management operations. + * + *Paths are pinned by {@link IdentityPathsTest}; here the focus is the + * service behaviour: wire method + body, response parsing, the 202 + * challenge-parse guards, and the two pre-flight validators + * ({@code verify-control} proof family and the {@code link} batch bound).
+ */ +@WireMockTest +class IdentityServiceTest { + + private static final String IDENTITY_ID = "660e8400-e29b-41d4-a716-446655440000"; + private static final String AGENT_ID = "550e8400-e29b-41d4-a716-446655440000"; + private static final String COLLECTION = "/v2/ans/identities"; + private static final String API_KEY = "test-api-key"; + private static final String API_SECRET = "test-api-secret"; + + private IdentityService createIdentityService(WireMockRuntimeInfo wmRuntimeInfo) { + AnsConfiguration config = AnsConfiguration.builder() + .environment(Environment.OTE) + .baseUrl(wmRuntimeInfo.getHttpBaseUrl()) + .credentialsProvider(new ApiKeyCredentialsProvider(API_KEY, API_SECRET)) + .build(); + return new IdentityService(new AnsApiClient(config)); + } + + private String challengeBody() { + return """ + { + "identityId": "%s", + "kind": "did:web", + "value": "did:web:example.com", + "status": "PENDING_CONTROL", + "nonce": "dGVzdC1ub25jZQ", + "expiresAt": "2026-01-01T00:00:00Z", + "challenges": [] + } + """.formatted(IDENTITY_ID); + } + + private String identityDetailsBody(String status) { + return """ + { + "identityId": "%s", + "kind": "did:web", + "value": "did:web:example.com", + "status": "%s", + "proofMethod": "did-web-sig", + "createdAt": "2026-01-01T00:00:00Z", + "linkedAgents": [] + } + """.formatted(IDENTITY_ID, status); + } + + // ==================== register ==================== + + @Test + @DisplayName("register POSTs to the collection and returns the 202 challenge") + void registerReturnsChallenge(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(post(urlEqualTo(COLLECTION)) + .willReturn(aResponse() + .withStatus(202) + .withHeader("Content-Type", "application/json") + .withBody(challengeBody()))); + + IdentityRegistrationRequest request = new IdentityRegistrationRequest().value("did:web:example.com"); + IdentityChallengeResponse challenge = createIdentityService(wmRuntimeInfo).register(request); + + assertThat(challenge.getIdentityId()).isEqualTo(IDENTITY_ID); + assertThat(challenge.getNonce()).isEqualTo("dGVzdC1ub25jZQ"); + verify(postRequestedFor(urlEqualTo(COLLECTION)) + .withHeader("Authorization", containing("sso-key")) + .withRequestBody(equalToJson("{\"value\":\"did:web:example.com\"}", true, true))); + } + + @Test + @DisplayName("register throws AnsServerException when the challenge omits identityId") + void registerRejectsMissingIdentityId(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(post(urlEqualTo(COLLECTION)) + .willReturn(aResponse() + .withStatus(202) + .withHeader("Content-Type", "application/json") + .withBody("{\"nonce\": \"dGVzdC1ub25jZQ\"}"))); + + IdentityRegistrationRequest request = new IdentityRegistrationRequest().value("did:web:example.com"); + + assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).register(request)) + .isInstanceOf(AnsServerException.class) + .hasMessageContaining("identityId"); + } + + @Test + @DisplayName("register throws AnsServerException when the challenge omits nonce") + void registerRejectsMissingNonce(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(post(urlEqualTo(COLLECTION)) + .willReturn(aResponse() + .withStatus(202) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "identityId": "%s", + "kind": "did:web", + "value": "did:web:example.com", + "status": "PENDING_CONTROL", + "expiresAt": "2026-01-01T00:00:00Z", + "challenges": [] + } + """.formatted(IDENTITY_ID)))); + + IdentityRegistrationRequest request = new IdentityRegistrationRequest().value("did:web:example.com"); + + assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).register(request)) + .isInstanceOf(AnsServerException.class) + .hasMessageContaining("nonce"); + } + + @Test + @DisplayName("register throws AnsServerException when the challenge omits expiresAt") + void registerRejectsMissingExpiresAt(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(post(urlEqualTo(COLLECTION)) + .willReturn(aResponse() + .withStatus(202) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "identityId": "%s", + "kind": "did:web", + "value": "did:web:example.com", + "status": "PENDING_CONTROL", + "nonce": "dGVzdC1ub25jZQ", + "challenges": [] + } + """.formatted(IDENTITY_ID)))); + + IdentityRegistrationRequest request = new IdentityRegistrationRequest().value("did:web:example.com"); + + assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).register(request)) + .isInstanceOf(AnsServerException.class) + .hasMessageContaining("expiresAt"); + } + + @Test + @DisplayName("register throws AnsServerException when the challenge omits kind") + void registerRejectsMissingKind(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(post(urlEqualTo(COLLECTION)) + .willReturn(aResponse() + .withStatus(202) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "identityId": "%s", + "value": "did:web:example.com", + "status": "PENDING_CONTROL", + "nonce": "dGVzdC1ub25jZQ", + "expiresAt": "2026-01-01T00:00:00Z", + "challenges": [] + } + """.formatted(IDENTITY_ID)))); + + IdentityRegistrationRequest request = new IdentityRegistrationRequest().value("did:web:example.com"); + + assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).register(request)) + .isInstanceOf(AnsServerException.class) + .hasMessageContaining("kind"); + } + + @Test + @DisplayName("register throws AnsServerException when the challenge omits value") + void registerRejectsMissingValue(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(post(urlEqualTo(COLLECTION)) + .willReturn(aResponse() + .withStatus(202) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "identityId": "%s", + "kind": "did:web", + "status": "PENDING_CONTROL", + "nonce": "dGVzdC1ub25jZQ", + "expiresAt": "2026-01-01T00:00:00Z", + "challenges": [] + } + """.formatted(IDENTITY_ID)))); + + IdentityRegistrationRequest request = new IdentityRegistrationRequest().value("did:web:example.com"); + + assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).register(request)) + .isInstanceOf(AnsServerException.class) + .hasMessageContaining("value"); + } + + @Test + @DisplayName("register throws AnsServerException when the challenge omits status") + void registerRejectsMissingStatus(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(post(urlEqualTo(COLLECTION)) + .willReturn(aResponse() + .withStatus(202) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "identityId": "%s", + "kind": "did:web", + "value": "did:web:example.com", + "nonce": "dGVzdC1ub25jZQ", + "expiresAt": "2026-01-01T00:00:00Z", + "challenges": [] + } + """.formatted(IDENTITY_ID)))); + + IdentityRegistrationRequest request = new IdentityRegistrationRequest().value("did:web:example.com"); + + assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).register(request)) + .isInstanceOf(AnsServerException.class) + .hasMessageContaining("status"); + } + + @Test + @DisplayName("register throws AnsServerException when the challenge sets challenges null") + void registerRejectsNullChallenges(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(post(urlEqualTo(COLLECTION)) + .willReturn(aResponse() + .withStatus(202) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "identityId": "%s", + "kind": "did:web", + "value": "did:web:example.com", + "status": "PENDING_CONTROL", + "nonce": "dGVzdC1ub25jZQ", + "expiresAt": "2026-01-01T00:00:00Z", + "challenges": null + } + """.formatted(IDENTITY_ID)))); + + IdentityRegistrationRequest request = new IdentityRegistrationRequest().value("did:web:example.com"); + + assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).register(request)) + .isInstanceOf(AnsServerException.class) + .hasMessageContaining("challenges"); + } + + @Test + @DisplayName("register throws AnsValidationException on 422") + void registerThrowsValidationOn422(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(post(urlEqualTo(COLLECTION)) + .willReturn(aResponse() + .withStatus(422) + .withHeader("Content-Type", "application/json") + .withBody("{\"message\": \"unrecognized identifier form\"}"))); + + IdentityRegistrationRequest request = new IdentityRegistrationRequest().value("not-an-identifier"); + + assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).register(request)) + .isInstanceOf(AnsValidationException.class); + } + + // ==================== list ==================== + + @Test + @DisplayName("list with no paging arguments GETs the bare collection") + void listNoArguments(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(get(urlEqualTo(COLLECTION)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(""" + {"items": [], "returnedCount": 0, "limit": 100, "hasMore": false} + """))); + + IdentityListResponse page = createIdentityService(wmRuntimeInfo).list(null, null); + + assertThat(page.getItems()).isEmpty(); + assertThat(page.getReturnedCount()).isZero(); + assertThat(page.getHasMore()).isFalse(); + verify(getRequestedFor(urlEqualTo(COLLECTION))); + } + + @Test + @DisplayName("list carries limit and URL-encoded cursor into the query string") + void listWithLimitAndCursor(WireMockRuntimeInfo wmRuntimeInfo) { + String path = COLLECTION + "?limit=25&cursor=next%2Fpage"; + stubFor(get(urlEqualTo(path)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(""" + {"items": [], "returnedCount": 0, "limit": 25, "nextCursor": null, "hasMore": true} + """))); + + IdentityListResponse page = createIdentityService(wmRuntimeInfo).list(25, "next/page"); + + assertThat(page.getLimit()).isEqualTo(25); + assertThat(page.getHasMore()).isTrue(); + verify(getRequestedFor(urlEqualTo(path))); + } + + // ==================== getDetails ==================== + + @Test + @DisplayName("getDetails GETs the identity resource and returns its details") + void getDetailsReturnsDetails(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(get(urlEqualTo(COLLECTION + "/" + IDENTITY_ID)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(identityDetailsBody("VERIFIED")))); + + IdentityDetails details = createIdentityService(wmRuntimeInfo).getDetails(IDENTITY_ID); + + assertThat(details.getIdentityId()).isEqualTo(IDENTITY_ID); + assertThat(details.getValue()).isEqualTo("did:web:example.com"); + verify(getRequestedFor(urlEqualTo(COLLECTION + "/" + IDENTITY_ID))); + } + + @Test + @DisplayName("getDetails throws AnsNotFoundException on 404") + void getDetailsThrowsNotFoundOn404(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(get(urlEqualTo(COLLECTION + "/" + IDENTITY_ID)) + .willReturn(aResponse() + .withStatus(404) + .withHeader("Content-Type", "application/json") + .withBody("{\"message\": \"identity not found\"}"))); + + assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).getDetails(IDENTITY_ID)) + .isInstanceOf(AnsNotFoundException.class); + } + + // ==================== rotate ==================== + + @Test + @DisplayName("rotate PUTs to the identity resource and returns a fresh challenge") + void rotateReturnsChallenge(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(put(urlEqualTo(COLLECTION + "/" + IDENTITY_ID)) + .willReturn(aResponse() + .withStatus(202) + .withHeader("Content-Type", "application/json") + .withBody(challengeBody()))); + + IdentityRegistrationRequest request = new IdentityRegistrationRequest().value("did:web:example.com"); + IdentityChallengeResponse challenge = createIdentityService(wmRuntimeInfo).rotate(IDENTITY_ID, request); + + assertThat(challenge.getIdentityId()).isEqualTo(IDENTITY_ID); + verify(putRequestedFor(urlEqualTo(COLLECTION + "/" + IDENTITY_ID)) + .withRequestBody(equalToJson("{\"value\":\"did:web:example.com\"}", true, true))); + } + + @Test + @DisplayName("rotate applies the same challenge-parse guard as register") + void rotateRejectsMissingNonce(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(put(urlEqualTo(COLLECTION + "/" + IDENTITY_ID)) + .willReturn(aResponse() + .withStatus(202) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "identityId": "%s", + "kind": "did:web", + "value": "did:web:example.com", + "status": "PENDING_CONTROL", + "expiresAt": "2026-01-01T00:00:00Z", + "challenges": [] + } + """.formatted(IDENTITY_ID)))); + + IdentityRegistrationRequest request = new IdentityRegistrationRequest().value("did:web:example.com"); + + assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).rotate(IDENTITY_ID, request)) + .isInstanceOf(AnsServerException.class) + .hasMessageContaining("nonce"); + } + + // ==================== verifyControl ==================== + + @Test + @DisplayName("verifyControl accepts a JWS-only request and POSTs to verify-control") + void verifyControlWithJws(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(post(urlEqualTo(COLLECTION + "/" + IDENTITY_ID + "/verify-control")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(identityDetailsBody("VERIFIED")))); + + VerifyControlRequest request = new VerifyControlRequest().addSignedProofsItem("eyJhbGciOiJFZERTQSJ9..sig"); + IdentityDetails details = createIdentityService(wmRuntimeInfo).verifyControl(IDENTITY_ID, request); + + assertThat(details.getStatus()).hasToString("VERIFIED"); + verify(postRequestedFor(urlEqualTo(COLLECTION + "/" + IDENTITY_ID + "/verify-control"))); + } + + @Test + @DisplayName("verifyControl accepts a CESR-only request") + void verifyControlWithCesr(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(post(urlEqualTo(COLLECTION + "/" + IDENTITY_ID + "/verify-control")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(identityDetailsBody("VERIFIED")))); + + VerifyControlRequest request = new VerifyControlRequest().signedProofs(null).cesrSignature("AABcesr..."); + IdentityDetails details = createIdentityService(wmRuntimeInfo).verifyControl(IDENTITY_ID, request); + + assertThat(details.getIdentityId()).isEqualTo(IDENTITY_ID); + } + + @Test + @DisplayName("verifyControl rejects a request carrying both proof families") + void verifyControlRejectsBothFamilies(WireMockRuntimeInfo wmRuntimeInfo) { + VerifyControlRequest request = new VerifyControlRequest() + .addSignedProofsItem("eyJhbGciOiJFZERTQSJ9..sig") + .cesrSignature("AABcesr..."); + + assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).verifyControl(IDENTITY_ID, request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exactly one proof family"); + } + + @Test + @DisplayName("verifyControl rejects a request with neither proof family") + void verifyControlRejectsNeitherFamily(WireMockRuntimeInfo wmRuntimeInfo) { + IdentityService service = createIdentityService(wmRuntimeInfo); + + assertThatThrownBy(() -> service.verifyControl(IDENTITY_ID, new VerifyControlRequest())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exactly one proof family"); + } + + @Test + @DisplayName("verifyControl treats an empty signedProofs list as absent") + void verifyControlRejectsEmptySignedProofs(WireMockRuntimeInfo wmRuntimeInfo) { + VerifyControlRequest request = new VerifyControlRequest().signedProofs(new ArrayList<>()); + + assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).verifyControl(IDENTITY_ID, request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exactly one proof family"); + } + + @Test + @DisplayName("verifyControl treats a blank cesrSignature as absent") + void verifyControlRejectsBlankCesr(WireMockRuntimeInfo wmRuntimeInfo) { + VerifyControlRequest request = new VerifyControlRequest().cesrSignature(""); + + assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).verifyControl(IDENTITY_ID, request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exactly one proof family"); + } + + // ==================== revoke ==================== + + @Test + @DisplayName("revoke POSTs an empty body to revoke and returns the updated details") + void revokeReturnsDetails(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(post(urlEqualTo(COLLECTION + "/" + IDENTITY_ID + "/revoke")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(identityDetailsBody("REVOKED")))); + + IdentityDetails details = createIdentityService(wmRuntimeInfo).revoke(IDENTITY_ID); + + assertThat(details.getStatus()).hasToString("REVOKED"); + verify(postRequestedFor(urlEqualTo(COLLECTION + "/" + IDENTITY_ID + "/revoke"))); + } + + // ==================== link ==================== + + @Test + @DisplayName("link POSTs the batch to links and returns the linked count") + void linkReturnsCount(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(post(urlEqualTo(COLLECTION + "/" + IDENTITY_ID + "/links")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"linked\": 2}"))); + + IdentityLinkRequest request = new IdentityLinkRequest() + .addAgentIdsItem(UUID.fromString(AGENT_ID)) + .addAgentIdsItem(UUID.randomUUID()); + IdentityLinkResponse response = createIdentityService(wmRuntimeInfo).link(IDENTITY_ID, request); + + assertThat(response.getLinked()).isEqualTo(2); + verify(postRequestedFor(urlEqualTo(COLLECTION + "/" + IDENTITY_ID + "/links"))); + } + + @Test + @DisplayName("link rejects an empty batch") + void linkRejectsEmptyBatch(WireMockRuntimeInfo wmRuntimeInfo) { + IdentityLinkRequest request = new IdentityLinkRequest().agentIds(new ArrayList<>()); + + assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).link(IDENTITY_ID, request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("between 1 and 256"); + } + + @Test + @DisplayName("link rejects a null agentIds list") + void linkRejectsNullBatch(WireMockRuntimeInfo wmRuntimeInfo) { + IdentityLinkRequest request = new IdentityLinkRequest(); + request.setAgentIds(null); + + assertThatThrownBy(() -> createIdentityService(wmRuntimeInfo).link(IDENTITY_ID, request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("between 1 and 256"); + } + + @Test + @DisplayName("link rejects a batch larger than 256 agents") + void linkRejectsOversizedBatch(WireMockRuntimeInfo wmRuntimeInfo) { + ListThis is a transient, retryable condition, not a hard error. The caller retries the receipt + * read after the delay in {@link #getRetryAfterSeconds()} (the server's {@code Retry-After} + * header). {@link #isRetryable()} returns {@code true}.
+ */ +public class TlLeafUncommittedException extends AnsServerException { + + /** The stable error code the transparency log returns for this condition. */ + public static final String ERROR_CODE = "TL_LEAF_UNCOMMITTED"; + + /** The HTTP status this condition always carries. Shared with the response mapper in the same package. */ + static final int STATUS_SERVICE_UNAVAILABLE = 503; + + private final int retryAfterSeconds; + + /** + * Creates a new exception for an uncommitted-leaf receipt read. + * + * @param message the error message + * @param retryAfterSeconds the retry delay from the {@code Retry-After} header, or 0 if absent + * @param requestId the request ID from the server response, may be null + */ + public TlLeafUncommittedException(String message, int retryAfterSeconds, String requestId) { + super(message, STATUS_SERVICE_UNAVAILABLE, requestId); + this.retryAfterSeconds = retryAfterSeconds; + } + + /** + * Returns the retry delay in seconds from the server's {@code Retry-After} header. + * + * @return the retry delay in seconds, or 0 if the server did not provide one + */ + public int getRetryAfterSeconds() { + return retryAfterSeconds; + } +} diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/TransparencyClient.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/TransparencyClient.java index 63dbb4e..db2dd83 100644 --- a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/TransparencyClient.java +++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/TransparencyClient.java @@ -2,9 +2,11 @@ import com.godaddy.ans.sdk.concurrent.AnsExecutors; import com.godaddy.ans.sdk.transparency.model.AgentAuditParams; +import com.godaddy.ans.sdk.transparency.model.AgentIdentitiesResponse; import com.godaddy.ans.sdk.transparency.model.CheckpointHistoryParams; import com.godaddy.ans.sdk.transparency.model.CheckpointHistoryResponse; import com.godaddy.ans.sdk.transparency.model.CheckpointResponse; +import com.godaddy.ans.sdk.transparency.model.IdentityLinkedAgentsResponse; import com.godaddy.ans.sdk.transparency.model.TransparencyLog; import com.godaddy.ans.sdk.transparency.model.TransparencyLogAudit; import com.godaddy.ans.sdk.transparency.scitt.RefreshDecision; @@ -218,6 +220,138 @@ public byte[] getStatusToken(String agentId) { return service.getStatusToken(agentId); } + // ==================== Verified-Identity Reads (Sync) ==================== + + /** + * Retrieves the identity badge for a verified identity. + * + *The badge is the latest sealed identity event plus its computed status + * ({@code VERIFIED} or {@code REVOKED}), served in the same shape as an agent + * transparency log entry. Read the status with {@link TransparencyLog#getStatus()}.
+ * + * @param identityId the identity's unique identifier + * @return the identity badge + * @throws com.godaddy.ans.sdk.exception.AnsNotFoundException if the identity is not found + */ + public TransparencyLog getIdentityBadge(String identityId) { + return service.getIdentityBadge(identityId); + } + + /** + * Retrieves a paginated list of transparency log records for an identity. + * + * @param identityId the identity's unique identifier + * @param params optional pagination parameters (limit, offset) + * @return the audit records + * @throws com.godaddy.ans.sdk.exception.AnsNotFoundException if the identity is not found + */ + public TransparencyLogAudit getIdentityAudit(String identityId, AgentAuditParams params) { + return service.getIdentityAudit(identityId, params); + } + + /** + * Retrieves all transparency log records for an identity. + * + * @param identityId the identity's unique identifier + * @return the audit records + */ + public TransparencyLogAudit getIdentityAudit(String identityId) { + return getIdentityAudit(identityId, null); + } + + /** + * Retrieves the SCITT receipt for an identity's latest sealed event. + * + *A {@code 503 TL_LEAF_UNCOMMITTED} response is a transient, retryable condition. + * The SDK surfaces it as {@link TlLeafUncommittedException} carrying the server's + * {@code Retry-After} delay, not as a hard error.
+ * + * @param identityId the identity's unique identifier + * @return the raw receipt bytes (COSE_Sign1) + * @throws TlLeafUncommittedException if the receipt is not yet available (retryable) + * @throws com.godaddy.ans.sdk.exception.AnsNotFoundException if the identity is not found + */ + public byte[] getIdentityReceipt(String identityId) { + return service.getIdentityReceipt(identityId); + } + + /** + * Retrieves the reverse join for an identity: the agents it currently links to. + * + *Each agent carries its own computed badge status, so a reader checks both ends of the + * link in one response. The result is paginated. Use {@link IdentityLinkedAgentsResponse#getTotal()} + * for the full count before pagination.
+ * + * @param identityId the identity's unique identifier + * @param params optional pagination parameters (limit, offset) + * @return the linked agents plus the full count + * @throws com.godaddy.ans.sdk.exception.AnsNotFoundException if the identity is not found + */ + public IdentityLinkedAgentsResponse getIdentityLinkedAgents(String identityId, AgentAuditParams params) { + return service.getIdentityLinkedAgents(identityId, params); + } + + /** + * Retrieves all agents an identity currently links to. + * + * @param identityId the identity's unique identifier + * @return the linked agents plus the full count + */ + public IdentityLinkedAgentsResponse getIdentityLinkedAgents(String identityId) { + return getIdentityLinkedAgents(identityId, null); + } + + /** + * Retrieves the forward join for an agent: the identities it currently links to. + * + *This is the overflow read target for the agent badge, which caps its inline + * {@code identities[]} at 25 entries. Use {@link AgentIdentitiesResponse#getTotal()} for the + * full count before pagination.
+ * + * @param agentId the agent's unique identifier + * @param params optional pagination parameters (limit, offset) + * @return the linked identities plus the full count + * @throws com.godaddy.ans.sdk.exception.AnsNotFoundException if the agent is not found + */ + public AgentIdentitiesResponse getAgentIdentities(String agentId, AgentAuditParams params) { + return service.getAgentIdentities(agentId, params); + } + + /** + * Retrieves all identities an agent currently links to. + * + * @param agentId the agent's unique identifier + * @return the linked identities plus the full count + */ + public AgentIdentitiesResponse getAgentIdentities(String agentId) { + return getAgentIdentities(agentId, null); + } + + /** + * Retrieves the identity link history for an agent. + * + *This is the audit trail of link and unlink events for the agent, in the same + * {@code {records}} envelope as the agent and identity audit trails.
+ * + * @param agentId the agent's unique identifier + * @param params optional pagination parameters (limit, offset) + * @return the history records + * @throws com.godaddy.ans.sdk.exception.AnsNotFoundException if the agent is not found + */ + public TransparencyLogAudit getAgentIdentityHistory(String agentId, AgentAuditParams params) { + return service.getAgentIdentityHistory(agentId, params); + } + + /** + * Retrieves the full identity link history for an agent. + * + * @param agentId the agent's unique identifier + * @return the history records + */ + public TransparencyLogAudit getAgentIdentityHistory(String agentId) { + return getAgentIdentityHistory(agentId, null); + } + /** * Invalidates the cached root public keys. * @@ -336,6 +470,21 @@ public CompletableFutureThis method uses non-blocking I/O and does not occupy a thread pool + * thread during the HTTP request. As with the sync variant, a + * {@code 503 TL_LEAF_UNCOMMITTED} response completes the future exceptionally + * with a {@link TlLeafUncommittedException} carrying the {@code Retry-After} delay.
+ * + * @param identityId the identity's unique identifier + * @return a CompletableFuture with the raw receipt bytes (COSE_Sign1) + */ + public CompletableFutureThe response is the same shape as an agent transparency log entry, but the payload is an + * identity event. This method does not run the agent V0/V1 payload parser, so the identity + * event stays in the raw {@link TransparencyLog#getPayload()} map.
+ * + * @param identityId the identity's unique identifier + * @return the identity badge + */ + TransparencyLog getIdentityBadge(String identityId) { + String path = "/v1/identities/" + URLEncoder.encode(identityId, StandardCharsets.UTF_8); + HttpRequest request = createRequestBuilder(path).GET().build(); + HttpResponseA {@code 503 TL_LEAF_UNCOMMITTED} response means the leaf is committed but no signed + * checkpoint covers it yet. That is a retryable condition. The SDK surfaces it as + * {@link TlLeafUncommittedException} carrying the server's {@code Retry-After} delay.
+ * + * @param identityId the identity's unique identifier + * @return the raw receipt bytes (COSE_Sign1) + * @throws TlLeafUncommittedException if the receipt is not yet available (retryable) + */ + byte[] getIdentityReceipt(String identityId) { + String path = "/v1/identities/" + URLEncoder.encode(identityId, StandardCharsets.UTF_8) + "/receipt"; + HttpRequest request = buildBinaryRequest(path, "application/scitt-receipt+cose"); + + try { + HttpResponseIf the receipt is not yet available, the returned future completes exceptionally with a + * retryable {@link TlLeafUncommittedException}.
+ * + * @param identityId the identity's unique identifier + * @return a CompletableFuture with the raw receipt bytes (COSE_Sign1) + */ + CompletableFutureA {@code 503} whose error body has {@code code} equal to {@link TlLeafUncommittedException#ERROR_CODE} + * means the leaf is committed but no signed checkpoint covers it yet. That is retryable and becomes a + * {@link TlLeafUncommittedException}. Any other non-2xx status falls through to + * {@link #throwForStatus(int, String, String)}.
+ */ + private byte[] handleIdentityReceiptResponse(HttpResponseA non-JSON body, or one without that code, returns false. So an unrelated {@code 503} stays a + * plain server error and is never mapped to the retryable case.
+ */ + private boolean isLeafUncommitted(String body) { + try { + JsonNode code = objectMapper.readTree(body).get("code"); + return code != null && TlLeafUncommittedException.ERROR_CODE.equals(code.asText()); + } catch (IOException notJson) { + return false; + } + } + + /** + * Parses a {@code Retry-After} header, accepting both RFC-7231 forms: delay-seconds and HTTP-date. + * + * @param headerValue the raw header value, may be null + * @return the delay in seconds (never negative), or 0 if absent or unparseable + */ + private int parseRetryAfter(String headerValue) { + if (headerValue == null || headerValue.isBlank()) { + return 0; + } + String trimmed = headerValue.trim(); + try { + return Math.max(0, Integer.parseInt(trimmed)); + } catch (NumberFormatException notSeconds) { + // Fall through to the HTTP-date form. + } + try { + Instant deadline = Instant.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(trimmed)); + long seconds = Duration.between(Instant.now(), deadline).getSeconds(); + return (int) Math.max(0, Math.min(seconds, Integer.MAX_VALUE)); + } catch (DateTimeException notDate) { + return 0; + } + } + /** * Returns the SCITT root public keys asynchronously, using cached values if available. * @@ -342,7 +576,10 @@ private void parseAndSetPayload(TransparencyLog result, String schemaVersion) { } try { - if ("V1".equalsIgnoreCase(schemaVersion)) { + if ("V2".equalsIgnoreCase(schemaVersion)) { + TransparencyLogV2 v2 = objectMapper.convertValue(result.getPayload(), TransparencyLogV2.class); + result.setParsedPayload(v2); + } else if ("V1".equalsIgnoreCase(schemaVersion)) { TransparencyLogV1 v1 = objectMapper.convertValue(result.getPayload(), TransparencyLogV1.class); result.setParsedPayload(v1); } else { diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/AgentIdentitiesResponse.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/AgentIdentitiesResponse.java new file mode 100644 index 0000000..2d0a03c --- /dev/null +++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/AgentIdentitiesResponse.java @@ -0,0 +1,62 @@ +package com.godaddy.ans.sdk.transparency.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.godaddy.ans.sdk.model.LinkedIdentity; + +import java.util.List; + +/** + * Paginated forward join: the identities an agent currently links to. + * + *This is the response of {@code GET /v1/agents/{agentId}/identities}. It is the overflow read + * target for the agent badge, which caps its inline {@code identities[]} at 25 entries. The + * {@code total} field carries the full count before pagination, so a caller pages the whole set + * even when a single page is capped by {@code limit}.
+ */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class AgentIdentitiesResponse { + + @JsonProperty("identities") + private ListV2 replaces the V0/V1 {@code dnsRecordsProvisioned} map with a list of typed records, and the + * singular {@code identityCert}/{@code serverCert} objects with {@code identityCerts}/{@code serverCerts} + * arrays.
+ */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class AttestationsV2 { + + @JsonProperty("dnsRecordsProvisioned") + private ListUnlike {@link CertificateInfo}, V2 carries an explicit {@code notAfter} expiry and appears + * inside the {@code identityCerts}/{@code serverCerts} arrays.
+ */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class CertificateInfoV2 { + + @JsonProperty("fingerprint") + private String fingerprint; + + @JsonProperty("notAfter") + private OffsetDateTime notAfter; + + @JsonProperty("type") + private CertType type; + + public CertificateInfoV2() { + } + + public String getFingerprint() { + return fingerprint; + } + + public void setFingerprint(String fingerprint) { + this.fingerprint = fingerprint; + } + + public OffsetDateTime getNotAfter() { + return notAfter; + } + + public void setNotAfter(OffsetDateTime notAfter) { + this.notAfter = notAfter; + } + + public CertType getType() { + return type; + } + + public void setType(CertType type) { + this.type = type; + } + + @Override + public String toString() { + return "CertificateInfoV2{" + + "fingerprint='" + fingerprint + '\'' + + ", notAfter=" + notAfter + + ", type=" + type + + '}'; + } +} diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/DnsRecordV2.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/DnsRecordV2.java new file mode 100644 index 0000000..609086b --- /dev/null +++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/DnsRecordV2.java @@ -0,0 +1,77 @@ +package com.godaddy.ans.sdk.transparency.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A provisioned DNS record in V2 schema attestations. + * + *V2 carries {@code dnsRecordsProvisioned} as a list of typed records, unlike the V0/V1 + * name-to-value map.
+ */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class DnsRecordV2 { + + @JsonProperty("data") + private String data; + + @JsonProperty("name") + private String name; + + @JsonProperty("type") + private String type; + + @JsonProperty("dnssecVerified") + private Boolean dnssecVerified; + + public DnsRecordV2() { + } + + public String getData() { + return data; + } + + public void setData(String data) { + this.data = data; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + /** + * Returns the DNSSEC authenticated-data signal for this TLSA binding — true when the record was + * resolved over a DNSSEC-validated chain. + * + * @return the DNSSEC-verified flag, or null when not provided + */ + public Boolean getDnssecVerified() { + return dnssecVerified; + } + + public void setDnssecVerified(Boolean dnssecVerified) { + this.dnssecVerified = dnssecVerified; + } + + @Override + public String toString() { + return "DnsRecordV2{" + + "name='" + name + '\'' + + ", type='" + type + '\'' + + ", data='" + data + '\'' + + ", dnssecVerified=" + dnssecVerified + + '}'; + } +} diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/EventV2.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/EventV2.java new file mode 100644 index 0000000..58aa28c --- /dev/null +++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/EventV2.java @@ -0,0 +1,163 @@ +package com.godaddy.ans.sdk.transparency.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.OffsetDateTime; + +/** + * Event structure in V2 schema. + * + *The event core matches V1, so it reuses {@link AgentV1} and {@link EventTypeV1}. Only the + * attestations shape differs, carried here as {@link AttestationsV2}.
+ */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class EventV2 { + + @JsonProperty("ansId") + private String ansId; + + @JsonProperty("ansName") + private String ansName; + + @JsonProperty("eventType") + private EventTypeV1 eventType; + + @JsonProperty("agent") + private AgentV1 agent; + + @JsonProperty("attestations") + private AttestationsV2 attestations; + + @JsonProperty("expiresAt") + private OffsetDateTime expiresAt; + + @JsonProperty("issuedAt") + private OffsetDateTime issuedAt; + + @JsonProperty("raId") + private String raId; + + @JsonProperty("renewalStatus") + private String renewalStatus; + + @JsonProperty("revocationReasonCode") + private RevocationReason revocationReasonCode; + + @JsonProperty("revokedAt") + private OffsetDateTime revokedAt; + + @JsonProperty("timestamp") + private OffsetDateTime timestamp; + + public EventV2() { + } + + public String getAnsId() { + return ansId; + } + + public void setAnsId(String ansId) { + this.ansId = ansId; + } + + public String getAnsName() { + return ansName; + } + + public void setAnsName(String ansName) { + this.ansName = ansName; + } + + public EventTypeV1 getEventType() { + return eventType; + } + + public void setEventType(EventTypeV1 eventType) { + this.eventType = eventType; + } + + public AgentV1 getAgent() { + return agent; + } + + public void setAgent(AgentV1 agent) { + this.agent = agent; + } + + public AttestationsV2 getAttestations() { + return attestations; + } + + public void setAttestations(AttestationsV2 attestations) { + this.attestations = attestations; + } + + public OffsetDateTime getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(OffsetDateTime expiresAt) { + this.expiresAt = expiresAt; + } + + public OffsetDateTime getIssuedAt() { + return issuedAt; + } + + public void setIssuedAt(OffsetDateTime issuedAt) { + this.issuedAt = issuedAt; + } + + public String getRaId() { + return raId; + } + + public void setRaId(String raId) { + this.raId = raId; + } + + public String getRenewalStatus() { + return renewalStatus; + } + + public void setRenewalStatus(String renewalStatus) { + this.renewalStatus = renewalStatus; + } + + public RevocationReason getRevocationReasonCode() { + return revocationReasonCode; + } + + public void setRevocationReasonCode(RevocationReason revocationReasonCode) { + this.revocationReasonCode = revocationReasonCode; + } + + public OffsetDateTime getRevokedAt() { + return revokedAt; + } + + public void setRevokedAt(OffsetDateTime revokedAt) { + this.revokedAt = revokedAt; + } + + public OffsetDateTime getTimestamp() { + return timestamp; + } + + public void setTimestamp(OffsetDateTime timestamp) { + this.timestamp = timestamp; + } + + @Override + public String toString() { + return "EventV2{" + + "ansId='" + ansId + '\'' + + ", ansName='" + ansName + '\'' + + ", eventType=" + eventType + + ", agent=" + agent + + ", issuedAt=" + issuedAt + + ", expiresAt=" + expiresAt + + '}'; + } +} diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/IdentityLinkedAgentsResponse.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/IdentityLinkedAgentsResponse.java new file mode 100644 index 0000000..3f113da --- /dev/null +++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/IdentityLinkedAgentsResponse.java @@ -0,0 +1,60 @@ +package com.godaddy.ans.sdk.transparency.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * Paginated reverse join: the agents an identity currently links to. + * + *This is the response of {@code GET /v1/identities/{identityId}/agents}. The {@code total} + * field carries the full count before pagination, so a caller pages the whole set even when a + * single page is capped by {@code limit}.
+ */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class IdentityLinkedAgentsResponse { + + @JsonProperty("agents") + private ListThe transparency log computes this view at query time from the link index. Each entry + * carries the linked agent's own computed badge status, so a reader checks both ends of the + * link in one response.
+ */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class LinkedAgentView { + + @JsonProperty("ansId") + private String ansId; + + @JsonProperty("linkedAt") + private String linkedAt; + + @JsonProperty("agentStatus") + private String agentStatus; + + public LinkedAgentView() { + } + + /** + * Returns the linked agent's ANS identifier. + * + * @return the agent ANS ID + */ + public String getAnsId() { + return ansId; + } + + public void setAnsId(String ansId) { + this.ansId = ansId; + } + + /** + * Returns the producer timestamp of the sealed link event that bound this agent. + * + * @return the link timestamp, or null if not provided + */ + public String getLinkedAt() { + return linkedAt; + } + + public void setLinkedAt(String linkedAt) { + this.linkedAt = linkedAt; + } + + /** + * Returns the linked agent's own computed badge status. + * + * @return the agent status, or null if not provided + */ + public String getAgentStatus() { + return agentStatus; + } + + public void setAgentStatus(String agentStatus) { + this.agentStatus = agentStatus; + } + + @Override + public String toString() { + return "LinkedAgentView{" + + "ansId='" + ansId + '\'' + + ", agentStatus='" + agentStatus + '\'' + + '}'; + } +} diff --git a/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/LinkedIdentityView.java b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/LinkedIdentityView.java new file mode 100644 index 0000000..ea873d2 --- /dev/null +++ b/ans-sdk-transparency/src/main/java/com/godaddy/ans/sdk/transparency/model/LinkedIdentityView.java @@ -0,0 +1,161 @@ +package com.godaddy.ans.sdk.transparency.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +import java.util.Map; + +/** + * One computed identities[] entry on the agent badge (transparency-log per-link view). + * + *The transparency log computes this view at query time. It carries the shared identity fields + * plus the per-link key material and audit references that the RA {@code LinkedIdentity} model does + * not model: {@link #getKeys()}, {@link #getKeysLogId()}, and {@link #getLinkLogId()}. A REVOKED + * identity stays visible, but its {@code keys} and {@code keysLogId} are withheld.
+ */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class LinkedIdentityView { + + @JsonProperty("identityId") + private String identityId; + + @JsonProperty("kind") + private String kind; + + @JsonProperty("value") + private String value; + + @JsonProperty("identityStatus") + private String identityStatus; + + @JsonProperty("linkedAt") + private String linkedAt; + + @JsonProperty("keys") + private List