diff --git a/pom.xml b/pom.xml
index b647b23..f8041d8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
com.accessgrid
access-grid-sdk
- 1.4.1
+ 1.4.2
Access Grid SDK
Java SDK for Access Grid API
diff --git a/src/main/java/com/organization/accessgrid/AccessGridClient.java b/src/main/java/com/organization/accessgrid/AccessGridClient.java
index 803787d..b0791e2 100644
--- a/src/main/java/com/organization/accessgrid/AccessGridClient.java
+++ b/src/main/java/com/organization/accessgrid/AccessGridClient.java
@@ -22,7 +22,7 @@
*/
public class AccessGridClient {
private static final String DEFAULT_BASE_URL = "https://api.accessgrid.com/v1";
- private static final String VERSION = "1.4.1";
+ private static final String VERSION = "1.4.2";
private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(30);
private final String accountId;
@@ -303,7 +303,7 @@ public Models.RevealTemplatePrivateKeyResponse revealTemplatePrivateKey(String t
);
if (raw == null || raw.getEncryptedPrivateKey() == null)
- throw new AccessGridException("Server response missing encrypted_private_key envelope");
+ throw new InvalidEnvelopeException("Server response missing encrypted_private_key envelope");
Models.SmartTapRevealEnvelope envelope = raw.getEncryptedPrivateKey();
byte[] iv = java.util.Base64.getDecoder().decode(envelope.getIv());
@@ -844,4 +844,34 @@ public AccessGridException(String message, Throwable cause) {
super(message, cause);
}
}
+
+ /**
+ * Thrown when a SmartTap reveal envelope is missing required fields,
+ * contains non-base64 / non-PEM data, or otherwise can't be parsed
+ * before the cryptographic operations begin.
+ */
+ public static class InvalidEnvelopeException extends AccessGridException {
+ public InvalidEnvelopeException(String message) {
+ super(message);
+ }
+
+ public InvalidEnvelopeException(String message, Throwable cause) {
+ super(message, cause);
+ }
+ }
+
+ /**
+ * Thrown when AES-GCM auth-tag verification fails while decrypting a
+ * SmartTap reveal envelope (wrong key, tampered envelope, or wire-format
+ * drift between server and SDK).
+ */
+ public static class DecryptException extends AccessGridException {
+ public DecryptException(String message) {
+ super(message);
+ }
+
+ public DecryptException(String message, Throwable cause) {
+ super(message, cause);
+ }
+ }
}
diff --git a/src/main/java/com/organization/accessgrid/SmartTapRevealCrypto.java b/src/main/java/com/organization/accessgrid/SmartTapRevealCrypto.java
index df3fb37..3493db4 100644
--- a/src/main/java/com/organization/accessgrid/SmartTapRevealCrypto.java
+++ b/src/main/java/com/organization/accessgrid/SmartTapRevealCrypto.java
@@ -71,7 +71,7 @@ static GeneratedKeyPair generateP256KeyPair() {
}
return new GeneratedKeyPair(pair, sw.toString());
} catch (Exception e) {
- throw new RuntimeException("Failed to generate P-256 keypair", e);
+ throw new AccessGridClient.AccessGridException("Failed to generate P-256 keypair", e);
}
}
@@ -95,9 +95,13 @@ private static ECPublicKeyParameters readEcPublicKeyFromPem(String pem) {
return (ECPublicKeyParameters) org.bouncycastle.crypto.util.PublicKeyFactory
.createKey((org.bouncycastle.asn1.x509.SubjectPublicKeyInfo) obj);
}
- throw new IllegalArgumentException("ephemeral_public_key is not a SubjectPublicKeyInfo PEM");
+ throw new AccessGridClient.InvalidEnvelopeException(
+ "ephemeral_public_key is not a SubjectPublicKeyInfo PEM");
+ } catch (AccessGridClient.InvalidEnvelopeException e) {
+ throw e;
} catch (Exception e) {
- throw new RuntimeException("Failed to parse server ephemeral public key", e);
+ throw new AccessGridClient.InvalidEnvelopeException(
+ "Failed to parse server ephemeral public key", e);
}
}
@@ -117,7 +121,8 @@ private static byte[] toFixedBytes(java.math.BigInteger value, int length) {
return trimmed;
}
if (unsigned.length > length) {
- throw new IllegalStateException("ECDH shared secret exceeds expected length");
+ throw new AccessGridClient.InvalidEnvelopeException(
+ "ECDH shared secret exceeds expected length");
}
byte[] padded = new byte[length];
System.arraycopy(unsigned, 0, padded, length - unsigned.length, unsigned.length);
@@ -151,7 +156,8 @@ private static byte[] decryptAesGcm(byte[] aesKey, byte[] iv, byte[] ciphertext,
System.arraycopy(out, 0, trimmed, 0, len);
return trimmed;
} catch (Exception e) {
- throw new RuntimeException("Failed to decrypt SmartTap envelope", e);
+ throw new AccessGridClient.DecryptException(
+ "AES-GCM decryption failed (auth tag verification)", e);
}
}
}
diff --git a/src/test/java/com/organization/accessgrid/AccessGridClientTest.java b/src/test/java/com/organization/accessgrid/AccessGridClientTest.java
index 0b9bcd5..61833a8 100644
--- a/src/test/java/com/organization/accessgrid/AccessGridClientTest.java
+++ b/src/test/java/com/organization/accessgrid/AccessGridClientTest.java
@@ -380,6 +380,85 @@ public void testRevealTemplatePrivateKeyRejectsEmptyTemplateId() {
() -> client.console().revealTemplatePrivateKey(""));
}
+ @Test
+ public void testRevealTemplatePrivateKeyThrowsInvalidEnvelopeOnMissingEnvelope()
+ throws IOException, InterruptedException {
+ // Server returns 200 but the response body has no encrypted_private_key field.
+ mockResponse("{\"key_version\":\"tmpl-42\"," +
+ "\"collector_id\":\"12345678\"," +
+ "\"fingerprint\":\"" + "a".repeat(64) + "\"}");
+
+ AccessGridClient.InvalidEnvelopeException ex = assertThrows(
+ AccessGridClient.InvalidEnvelopeException.class,
+ () -> client.console().revealTemplatePrivateKey("tmpl-42")
+ );
+ assertTrue(ex.getMessage().contains("encrypted_private_key"));
+ }
+
+ @Test
+ public void testRevealTemplatePrivateKeyThrowsInvalidEnvelopeOnMalformedEphemeralPubkey()
+ throws IOException, InterruptedException {
+ // Server returns 200 with a non-PEM ephemeral_public_key value.
+ mockResponse("{\"key_version\":\"tmpl-42\"," +
+ "\"collector_id\":\"12345678\"," +
+ "\"fingerprint\":\"" + "a".repeat(64) + "\"," +
+ "\"encrypted_private_key\":{" +
+ "\"alg\":\"ECDH-ES+A256GCM\"," +
+ "\"ephemeral_public_key\":\"NOT A PEM\"," +
+ "\"iv\":\"AAAAAAAAAAAAAAAA\"," +
+ "\"ciphertext\":\"AAAA\"," +
+ "\"tag\":\"AAAAAAAAAAAAAAAAAAAAAA==\"" +
+ "}}");
+
+ assertThrows(
+ AccessGridClient.InvalidEnvelopeException.class,
+ () -> client.console().revealTemplatePrivateKey("tmpl-42")
+ );
+ }
+
+ @Test
+ public void testRevealTemplatePrivateKeyThrowsDecryptExceptionOnTamperedTag() throws Exception {
+ // Server returns a well-formed envelope for the SDK's outgoing pubkey, but
+ // the auth tag is mutated so AES-GCM verification fails.
+ final String plaintextPem = "SENTINEL-NOT-A-CREDENTIAL";
+
+ @SuppressWarnings("unchecked")
+ HttpResponse response = mock(HttpResponse.class);
+ when(response.statusCode()).thenReturn(200);
+ when(mockSender.send(any(HttpRequest.class))).thenAnswer(invocation -> {
+ HttpRequest sent = invocation.getArgument(0);
+ String requestBody = bodyOf(sent);
+ String clientPublicKeyPem = client.objectMapper.readTree(requestBody)
+ .get("client_public_key").asText();
+
+ FakeServerEnvelope envelope = simulateServerEncrypt(plaintextPem, clientPublicKeyPem);
+ // Flip a bit in the tag.
+ envelope.tag[0] ^= 0x01;
+
+ String responseJson = client.objectMapper.writeValueAsString(java.util.Map.of(
+ "key_version", "tmpl-42",
+ "collector_id", "12345678",
+ "fingerprint", "a".repeat(64),
+ "encrypted_private_key", java.util.Map.of(
+ "alg", "ECDH-ES+A256GCM",
+ "ephemeral_public_key", envelope.ephemeralPublicKeyPem,
+ "iv", java.util.Base64.getEncoder().encodeToString(envelope.iv),
+ "ciphertext", java.util.Base64.getEncoder().encodeToString(envelope.ciphertext),
+ "tag", java.util.Base64.getEncoder().encodeToString(envelope.tag)
+ )
+ ));
+ when(response.body()).thenReturn(responseJson);
+ return response;
+ });
+
+ AccessGridClient.DecryptException ex = assertThrows(
+ AccessGridClient.DecryptException.class,
+ () -> client.console().revealTemplatePrivateKey("tmpl-42")
+ );
+ assertTrue(ex.getMessage().toLowerCase().contains("decryption failed")
+ || ex.getMessage().toLowerCase().contains("auth tag"));
+ }
+
private static String bodyOf(HttpRequest request) {
return request.bodyPublisher()
.map(p -> {