From 170fea38bf06d2f4fc512517cca6377cb6802718 Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Wed, 27 May 2026 13:17:31 -0400 Subject: [PATCH] throw typed exceptions from reveal crypto path --- pom.xml | 2 +- .../accessgrid/AccessGridClient.java | 34 +++++++- .../accessgrid/SmartTapRevealCrypto.java | 16 ++-- .../accessgrid/AccessGridClientTest.java | 79 +++++++++++++++++++ 4 files changed, 123 insertions(+), 8 deletions(-) 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 -> {