Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>com.accessgrid</groupId>
<artifactId>access-grid-sdk</artifactId>
<version>1.4.1</version>
<version>1.4.2</version>

<name>Access Grid SDK</name>
<description>Java SDK for Access Grid API</description>
Expand Down
34 changes: 32 additions & 2 deletions src/main/java/com/organization/accessgrid/AccessGridClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

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

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