From 8857813429c2c317d38de954dbe17e28e86a266b Mon Sep 17 00:00:00 2001 From: Auston Bunsen Date: Fri, 8 May 2026 17:42:09 -0400 Subject: [PATCH 1/3] Fix updateLandingPage to use PUT instead of PATCH The rails route at PUT /v1/console/landing-pages/:id only accepts PUT, not PATCH, so the SDK's prior PATCH request would 404. Updates the production client, the corresponding test, and the feature matrix row. --- README.md | 2 +- .../java/com/organization/accessgrid/AccessGridClient.java | 2 +- .../com/organization/accessgrid/AccessGridClientTest.java | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 64d03f1..aa6d9a2 100644 --- a/README.md +++ b/README.md @@ -372,7 +372,7 @@ try { | GET /v1/console/ledger-items | `console().ledgerItems()` | Y | | GET /v1/console/landing-pages | `console().listLandingPages()` | Y | | POST /v1/console/landing-pages | `console().createLandingPage()` | Y | -| PATCH /v1/console/landing-pages/{id} | `console().updateLandingPage()` | Y | +| PUT /v1/console/landing-pages/{id} | `console().updateLandingPage()` | Y | | GET /v1/console/credential-profiles | `console().credentialProfiles().list()` | Y | | POST /v1/console/credential-profiles | `console().credentialProfiles().create()` | Y | | GET /v1/console/webhooks | `console().webhooks().list()` | Y | diff --git a/src/main/java/com/organization/accessgrid/AccessGridClient.java b/src/main/java/com/organization/accessgrid/AccessGridClient.java index 8d0b8b0..785471d 100644 --- a/src/main/java/com/organization/accessgrid/AccessGridClient.java +++ b/src/main/java/com/organization/accessgrid/AccessGridClient.java @@ -316,7 +316,7 @@ public Models.LandingPage createLandingPage(Models.CreateLandingPageRequest requ */ public Models.LandingPage updateLandingPage(Models.UpdateLandingPageRequest request) { String payload = client.serialize(request); - return client.patch("/console/landing-pages/" + request.getLandingPageId(), payload, Models.LandingPage.class); + return client.put("/console/landing-pages/" + request.getLandingPageId(), payload, Models.LandingPage.class); } /** diff --git a/src/test/java/com/organization/accessgrid/AccessGridClientTest.java b/src/test/java/com/organization/accessgrid/AccessGridClientTest.java index b185857..f550f9b 100644 --- a/src/test/java/com/organization/accessgrid/AccessGridClientTest.java +++ b/src/test/java/com/organization/accessgrid/AccessGridClientTest.java @@ -668,8 +668,8 @@ public void testUpdateLandingPageSendsPatchToLandingPages() throws IOException, Models.LandingPage page = client.console().updateLandingPage(request); HttpRequest captured = captureRequest(); - assertTrue(captured.uri().getPath().contains("/console/landing-pages/lp-1"), "Should PATCH /console/landing-pages/{id}"); - assertEquals("PATCH", captured.method()); + assertTrue(captured.uri().getPath().contains("/console/landing-pages/lp-1"), "Should PUT /console/landing-pages/{id}"); + assertEquals("PUT", captured.method()); assertEquals("Updated Page", page.getName()); } From 608e8ac45af875018303925c7dd6a9bf0b6bc74a Mon Sep 17 00:00:00 2001 From: Auston Bunsen Date: Fri, 8 May 2026 17:43:48 -0400 Subject: [PATCH 2/3] Add console().publishTemplate() for POST /v1/console/card-templates/{id}/publish Adds method, response model, README example, matrix row, and test. --- README.md | 10 ++++++++++ .../accessgrid/AccessGridClient.java | 13 +++++++++++++ .../java/com/organization/accessgrid/Models.java | 11 +++++++++++ .../accessgrid/AccessGridClientTest.java | 16 ++++++++++++++++ 4 files changed, 50 insertions(+) diff --git a/README.md b/README.md index aa6d9a2..16a0b05 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,15 @@ System.out.printf("Protocol: %s%n", template.getProtocol()); System.out.printf("Multi-device: %b%n", template.isAllowOnMultipleDevices()); ``` +### Publishing a Card Template + +```java +PublishTemplateResponse result = client.console().publishTemplate("0xd3adb00b5"); + +System.out.printf("Template ID: %s%n", result.getId()); +System.out.printf("Status: %s%n", result.getStatus()); +``` + ### Event Logs ```java @@ -365,6 +374,7 @@ try { | POST /v1/console/card-templates | `console().createTemplate()` | Y | | PUT /v1/console/card-templates/{id} | `console().updateTemplate()` | Y | | GET /v1/console/card-templates/{id} | `console().readTemplate()` | Y | +| POST /v1/console/card-templates/{id}/publish | `console().publishTemplate()` | Y | | GET /v1/console/card-templates/{id}/logs | `console().eventLog()` | Y | | GET /v1/console/card-template-pairs | `console().listPassTemplatePairs()` | Y | | POST /v1/console/card-template-pairs | `console().createPassTemplatePair()` | Y | diff --git a/src/main/java/com/organization/accessgrid/AccessGridClient.java b/src/main/java/com/organization/accessgrid/AccessGridClient.java index 785471d..a7320bd 100644 --- a/src/main/java/com/organization/accessgrid/AccessGridClient.java +++ b/src/main/java/com/organization/accessgrid/AccessGridClient.java @@ -224,6 +224,19 @@ public Models.Template readTemplate(String templateId) { return client.get("/console/card-templates/" + templateId, templateId, Models.Template.class); } + /** + * Publish a card template. For Apple templates this transitions the + * template to "in-review"; for Android (Google) templates it becomes + * "ready" immediately. + */ + public Models.PublishTemplateResponse publishTemplate(String templateId) { + return client.post( + "/console/card-templates/" + templateId + "/publish", + "", + Models.PublishTemplateResponse.class + ); + } + /** * Get event logs for a card template. */ diff --git a/src/main/java/com/organization/accessgrid/Models.java b/src/main/java/com/organization/accessgrid/Models.java index fcd78a3..96a53fb 100644 --- a/src/main/java/com/organization/accessgrid/Models.java +++ b/src/main/java/com/organization/accessgrid/Models.java @@ -528,6 +528,17 @@ public static class IosPreflightResponse { private String environmentIdentifier; } + /** + * Response from publishing a card template. + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class PublishTemplateResponse { + private String id; + private String status; + } + /** * Landing page response model. */ diff --git a/src/test/java/com/organization/accessgrid/AccessGridClientTest.java b/src/test/java/com/organization/accessgrid/AccessGridClientTest.java index f550f9b..dc41291 100644 --- a/src/test/java/com/organization/accessgrid/AccessGridClientTest.java +++ b/src/test/java/com/organization/accessgrid/AccessGridClientTest.java @@ -306,6 +306,22 @@ public void testReadTemplateSendsGetToConsoleCardTemplates() throws IOException, assertTrue(template.isAllowOnMultipleDevices()); } + // --- Console: Publish Template --- + + @Test + public void testPublishTemplateSendsPostToPublishEndpoint() throws IOException, InterruptedException { + mockResponse("{\"id\":\"tmpl-1\",\"status\":\"in-review\"}"); + + Models.PublishTemplateResponse response = client.console().publishTemplate("tmpl-1"); + + HttpRequest captured = captureRequest(); + assertTrue(captured.uri().getPath().contains("/console/card-templates/tmpl-1/publish"), + "Should POST /console/card-templates/{id}/publish"); + assertEquals("POST", captured.method()); + assertEquals("tmpl-1", response.getId()); + assertEquals("in-review", response.getStatus()); + } + // --- Console: Event Log --- @Test From b9161f4fbe7c2a949d01c61f5f4a536d9bfe3900 Mon Sep 17 00:00:00 2001 From: Auston Bunsen Date: Fri, 8 May 2026 20:55:10 -0400 Subject: [PATCH 3/3] Add console().revealTemplatePrivateKey() with client-side ECDH-ES + AES-GCM decrypt The SDK generates a P-256 keypair locally and decrypts the server-returned envelope (HKDF-SHA256 with info accessgrid-smart-tap-reveal-v1, AES-256-GCM) so the private key never leaves the host. Adds BouncyCastle (bcprov + bcpkix, 1.78.1) for the crypto primitives and PEM I/O. --- README.md | 14 ++ pom.xml | 13 ++ .../accessgrid/AccessGridClient.java | 41 +++++ .../com/organization/accessgrid/Models.java | 48 +++++ .../accessgrid/SmartTapRevealCrypto.java | 157 ++++++++++++++++ .../accessgrid/AccessGridClientTest.java | 168 ++++++++++++++++++ 6 files changed, 441 insertions(+) create mode 100644 src/main/java/com/organization/accessgrid/SmartTapRevealCrypto.java diff --git a/README.md b/README.md index 16a0b05..4ed8acc 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,19 @@ System.out.printf("Template ID: %s%n", result.getId()); System.out.printf("Status: %s%n", result.getStatus()); ``` +### Revealing a SmartTap Private Key + +```java +// SDK generates a P-256 keypair locally, submits the public key, and +// decrypts the server's response. The private key never leaves the host. +RevealTemplatePrivateKeyResponse result = client.console().revealTemplatePrivateKey("0xd3adb00b5"); + +System.out.printf("Key version: %s%n", result.getKeyVersion()); +System.out.printf("Collector ID: %s%n", result.getCollectorId()); +System.out.printf("Fingerprint: %s%n", result.getFingerprint()); +System.out.println(result.getPrivateKey()); // PEM — store in your reader/collector key vault +``` + ### Event Logs ```java @@ -375,6 +388,7 @@ try { | PUT /v1/console/card-templates/{id} | `console().updateTemplate()` | Y | | GET /v1/console/card-templates/{id} | `console().readTemplate()` | Y | | POST /v1/console/card-templates/{id}/publish | `console().publishTemplate()` | Y | +| POST /v1/console/card-templates/{id}/smart-tap/reveal | `console().revealTemplatePrivateKey()` | Y | | GET /v1/console/card-templates/{id}/logs | `console().eventLog()` | Y | | GET /v1/console/card-template-pairs | `console().listPassTemplatePairs()` | Y | | POST /v1/console/card-template-pairs | `console().createPassTemplatePair()` | Y | diff --git a/pom.xml b/pom.xml index 70bba23..2872677 100644 --- a/pom.xml +++ b/pom.xml @@ -42,6 +42,7 @@ 2.15.2 1.18.44 + 1.78.1 @@ -72,6 +73,18 @@ 1.7.32 + + + org.bouncycastle + bcprov-jdk18on + ${bouncycastle.version} + + + org.bouncycastle + bcpkix-jdk18on + ${bouncycastle.version} + + org.junit.jupiter diff --git a/src/main/java/com/organization/accessgrid/AccessGridClient.java b/src/main/java/com/organization/accessgrid/AccessGridClient.java index a7320bd..a735082 100644 --- a/src/main/java/com/organization/accessgrid/AccessGridClient.java +++ b/src/main/java/com/organization/accessgrid/AccessGridClient.java @@ -237,6 +237,47 @@ public Models.PublishTemplateResponse publishTemplate(String templateId) { ); } + /** + * Reveal the SmartTap private key for a card template. + * + *

The SDK generates a P-256 keypair locally, submits the public key to + * the server, and decrypts the returned envelope (ECDH-ES + HKDF-SHA256 + * + AES-256-GCM) so the private key never leaves this host in + * plaintext. Each call must use a fresh public key — the server rejects + * reuse. + */ + public Models.RevealTemplatePrivateKeyResponse revealTemplatePrivateKey(String templateId) { + if (templateId == null || templateId.isEmpty()) + throw new AccessGridException("templateId is required"); + + SmartTapRevealCrypto.GeneratedKeyPair generated = SmartTapRevealCrypto.generateP256KeyPair(); + String body = client.serialize(java.util.Map.of("client_public_key", generated.publicKeyPem)); + + Models.SmartTapRevealRawResponse raw = client.post( + "/console/card-templates/" + templateId + "/smart-tap/reveal", + body, + Models.SmartTapRevealRawResponse.class + ); + + if (raw == null || raw.getEncryptedPrivateKey() == null) + throw new AccessGridException("Server response missing encrypted_private_key envelope"); + + Models.SmartTapRevealEnvelope envelope = raw.getEncryptedPrivateKey(); + byte[] iv = java.util.Base64.getDecoder().decode(envelope.getIv()); + byte[] ciphertext = java.util.Base64.getDecoder().decode(envelope.getCiphertext()); + byte[] tag = java.util.Base64.getDecoder().decode(envelope.getTag()); + + byte[] plaintext = SmartTapRevealCrypto.decryptEnvelope( + generated.keyPair, envelope.getEphemeralPublicKey(), iv, ciphertext, tag); + + return new Models.RevealTemplatePrivateKeyResponse( + raw.getKeyVersion(), + raw.getCollectorId(), + raw.getFingerprint(), + new String(plaintext, java.nio.charset.StandardCharsets.UTF_8) + ); + } + /** * Get event logs for a card template. */ diff --git a/src/main/java/com/organization/accessgrid/Models.java b/src/main/java/com/organization/accessgrid/Models.java index 96a53fb..ccf802b 100644 --- a/src/main/java/com/organization/accessgrid/Models.java +++ b/src/main/java/com/organization/accessgrid/Models.java @@ -539,6 +539,54 @@ public static class PublishTemplateResponse { private String status; } + /** + * Encrypted envelope returned by POST /v1/console/card-templates/{id}/smart-tap/reveal. + * Used internally during decryption; not surfaced to SDK callers. + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + static class SmartTapRevealEnvelope { + private String alg; + @JsonProperty("ephemeral_public_key") + private String ephemeralPublicKey; + private String iv; + private String ciphertext; + private String tag; + } + + /** + * Raw response from the SmartTap reveal endpoint. + * Internal: callers receive {@link RevealTemplatePrivateKeyResponse} instead. + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + static class SmartTapRevealRawResponse { + @JsonProperty("key_version") + private String keyVersion; + @JsonProperty("collector_id") + private String collectorId; + private String fingerprint; + @JsonProperty("encrypted_private_key") + private SmartTapRevealEnvelope encryptedPrivateKey; + } + + /** + * Result of a SmartTap private key reveal. The private key has already been + * decrypted client-side; the encryption envelope is not exposed. + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class RevealTemplatePrivateKeyResponse { + private String keyVersion; + private String collectorId; + private String fingerprint; + /** PEM-encoded private key. Sensitive — store in your reader/collector key vault. */ + private String privateKey; + } + /** * Landing page response model. */ diff --git a/src/main/java/com/organization/accessgrid/SmartTapRevealCrypto.java b/src/main/java/com/organization/accessgrid/SmartTapRevealCrypto.java new file mode 100644 index 0000000..df3fb37 --- /dev/null +++ b/src/main/java/com/organization/accessgrid/SmartTapRevealCrypto.java @@ -0,0 +1,157 @@ +package com.organization.accessgrid; + +import java.io.StringReader; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; + +import org.bouncycastle.asn1.x9.ECNamedCurveTable; +import org.bouncycastle.asn1.x9.X9ECParameters; +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.agreement.ECDHBasicAgreement; +import org.bouncycastle.crypto.digests.SHA256Digest; +import org.bouncycastle.crypto.engines.AESEngine; +import org.bouncycastle.crypto.generators.ECKeyPairGenerator; +import org.bouncycastle.crypto.generators.HKDFBytesGenerator; +import org.bouncycastle.crypto.modes.GCMBlockCipher; +import org.bouncycastle.crypto.params.AEADParameters; +import org.bouncycastle.crypto.params.ECDomainParameters; +import org.bouncycastle.crypto.params.ECKeyGenerationParameters; +import org.bouncycastle.crypto.params.ECPublicKeyParameters; +import org.bouncycastle.crypto.params.HKDFParameters; +import org.bouncycastle.crypto.params.KeyParameter; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; + +import java.security.SecureRandom; + +/** + * Client-side crypto helpers for the SmartTap reveal endpoint. + * + *

The server returns the template's private key encrypted under + * {@code ECDH-ES + HKDF-SHA256 + AES-256-GCM}. This class generates the local + * P-256 keypair, exposes the public key as PEM, and decrypts the server's + * envelope so the plaintext private key is reconstructed without leaving the + * caller's process. + */ +final class SmartTapRevealCrypto { + private static final String CURVE_NAME = "P-256"; + private static final String HKDF_INFO = "accessgrid-smart-tap-reveal-v1"; + private static final int AES_KEY_LENGTH = 32; + private static final int SHARED_SECRET_LENGTH = 32; + private static final int GCM_TAG_BITS = 128; + + private SmartTapRevealCrypto() {} + + static final class GeneratedKeyPair { + final AsymmetricCipherKeyPair keyPair; + final String publicKeyPem; + + GeneratedKeyPair(AsymmetricCipherKeyPair keyPair, String publicKeyPem) { + this.keyPair = keyPair; + this.publicKeyPem = publicKeyPem; + } + } + + static GeneratedKeyPair generateP256KeyPair() { + try { + X9ECParameters curve = ECNamedCurveTable.getByName(CURVE_NAME); + ECDomainParameters domain = new ECDomainParameters( + curve.getCurve(), curve.getG(), curve.getN(), curve.getH(), curve.getSeed()); + + ECKeyPairGenerator gen = new ECKeyPairGenerator(); + gen.init(new ECKeyGenerationParameters(domain, new SecureRandom())); + AsymmetricCipherKeyPair pair = gen.generateKeyPair(); + + // SubjectPublicKeyInfo PEM (matches OpenSSL's `public_to_pem` on the server). + org.bouncycastle.asn1.x509.SubjectPublicKeyInfo spki = + org.bouncycastle.crypto.util.SubjectPublicKeyInfoFactory.createSubjectPublicKeyInfo(pair.getPublic()); + StringWriter sw = new StringWriter(); + try (JcaPEMWriter writer = new JcaPEMWriter(sw)) { + writer.writeObject(spki); + } + return new GeneratedKeyPair(pair, sw.toString()); + } catch (Exception e) { + throw new RuntimeException("Failed to generate P-256 keypair", e); + } + } + + static byte[] decryptEnvelope( + AsymmetricCipherKeyPair localKeyPair, + String serverEphemeralPublicKeyPem, + byte[] iv, + byte[] ciphertext, + byte[] tag) { + ECPublicKeyParameters serverPublicKey = readEcPublicKeyFromPem(serverEphemeralPublicKeyPem); + + byte[] sharedSecret = computeSharedSecret(localKeyPair, serverPublicKey); + byte[] aesKey = deriveAesKey(sharedSecret); + return decryptAesGcm(aesKey, iv, ciphertext, tag); + } + + private static ECPublicKeyParameters readEcPublicKeyFromPem(String pem) { + try (PEMParser parser = new PEMParser(new StringReader(pem))) { + Object obj = parser.readObject(); + if (obj instanceof org.bouncycastle.asn1.x509.SubjectPublicKeyInfo) { + 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"); + } catch (Exception e) { + throw new RuntimeException("Failed to parse server ephemeral public key", e); + } + } + + private static byte[] computeSharedSecret(AsymmetricCipherKeyPair local, ECPublicKeyParameters serverPub) { + ECDHBasicAgreement agreement = new ECDHBasicAgreement(); + agreement.init(local.getPrivate()); + java.math.BigInteger shared = agreement.calculateAgreement(serverPub); + return toFixedBytes(shared, SHARED_SECRET_LENGTH); + } + + private static byte[] toFixedBytes(java.math.BigInteger value, int length) { + byte[] unsigned = value.toByteArray(); + if (unsigned.length == length) return unsigned; + if (unsigned.length == length + 1 && unsigned[0] == 0) { + byte[] trimmed = new byte[length]; + System.arraycopy(unsigned, 1, trimmed, 0, length); + return trimmed; + } + if (unsigned.length > length) { + throw new IllegalStateException("ECDH shared secret exceeds expected length"); + } + byte[] padded = new byte[length]; + System.arraycopy(unsigned, 0, padded, length - unsigned.length, unsigned.length); + return padded; + } + + private static byte[] deriveAesKey(byte[] sharedSecret) { + HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA256Digest()); + hkdf.init(new HKDFParameters(sharedSecret, new byte[0], HKDF_INFO.getBytes(StandardCharsets.UTF_8))); + byte[] aesKey = new byte[AES_KEY_LENGTH]; + hkdf.generateBytes(aesKey, 0, aesKey.length); + return aesKey; + } + + private static byte[] decryptAesGcm(byte[] aesKey, byte[] iv, byte[] ciphertext, byte[] tag) { + try { + GCMBlockCipher gcm = new GCMBlockCipher(new AESEngine()); + gcm.init(false, new AEADParameters(new KeyParameter(aesKey), GCM_TAG_BITS, iv, new byte[0])); + + // BouncyCastle expects ciphertext||tag concatenated. + byte[] combined = new byte[ciphertext.length + tag.length]; + System.arraycopy(ciphertext, 0, combined, 0, ciphertext.length); + System.arraycopy(tag, 0, combined, ciphertext.length, tag.length); + + byte[] out = new byte[gcm.getOutputSize(combined.length)]; + int len = gcm.processBytes(combined, 0, combined.length, out, 0); + len += gcm.doFinal(out, len); + + if (len == out.length) return out; + byte[] trimmed = new byte[len]; + System.arraycopy(out, 0, trimmed, 0, len); + return trimmed; + } catch (Exception e) { + throw new RuntimeException("Failed to decrypt SmartTap envelope", e); + } + } +} diff --git a/src/test/java/com/organization/accessgrid/AccessGridClientTest.java b/src/test/java/com/organization/accessgrid/AccessGridClientTest.java index dc41291..cf3a66d 100644 --- a/src/test/java/com/organization/accessgrid/AccessGridClientTest.java +++ b/src/test/java/com/organization/accessgrid/AccessGridClientTest.java @@ -322,6 +322,174 @@ public void testPublishTemplateSendsPostToPublishEndpoint() throws IOException, assertEquals("in-review", response.getStatus()); } + // --- Console: Reveal Smart Tap Private Key --- + + @Test + public void testRevealTemplatePrivateKeyDecryptsServerEnvelope() throws Exception { + final String plaintextPem = + "-----BEGIN EC PRIVATE KEY-----\n" + + "MHcCAQEEIBmlx2KqB7+RLMrHWLMm6hh3JwFrL2ZxZTLkW1yX8OabAoGCCqGSM49\n" + + "AwEHoUQDQgAEs5bJrjEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXA\n" + + "MPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEE=\n" + + "-----END EC PRIVATE KEY-----\n"; + + // Capture the SDK's outgoing client_public_key, then encrypt against it. + @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); + 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; + }); + + Models.RevealTemplatePrivateKeyResponse result = + client.console().revealTemplatePrivateKey("tmpl-42"); + + assertEquals("tmpl-42", result.getKeyVersion()); + assertEquals("12345678", result.getCollectorId()); + assertEquals(64, result.getFingerprint().length()); + assertEquals(plaintextPem, result.getPrivateKey()); + + HttpRequest captured = captureRequest(); + assertTrue(captured.uri().getPath().contains("/console/card-templates/tmpl-42/smart-tap/reveal"), + "Should POST to smart-tap/reveal"); + assertEquals("POST", captured.method()); + } + + @Test + public void testRevealTemplatePrivateKeyRejectsEmptyTemplateId() { + assertThrows(AccessGridClient.AccessGridException.class, + () -> client.console().revealTemplatePrivateKey("")); + } + + private static String bodyOf(HttpRequest request) { + return request.bodyPublisher() + .map(p -> { + java.util.concurrent.Flow.Subscriber noop; + java.util.List received = new java.util.ArrayList<>(); + java.util.concurrent.CountDownLatch done = new java.util.concurrent.CountDownLatch(1); + p.subscribe(new java.util.concurrent.Flow.Subscriber<>() { + public void onSubscribe(java.util.concurrent.Flow.Subscription s) { s.request(Long.MAX_VALUE); } + public void onNext(java.nio.ByteBuffer b) { received.add(b); } + public void onError(Throwable t) { done.countDown(); } + public void onComplete() { done.countDown(); } + }); + try { done.await(); } catch (InterruptedException ignored) {} + int total = received.stream().mapToInt(java.nio.ByteBuffer::remaining).sum(); + byte[] all = new byte[total]; + int offset = 0; + for (java.nio.ByteBuffer buf : received) { + int n = buf.remaining(); + buf.get(all, offset, n); + offset += n; + } + return new String(all, java.nio.charset.StandardCharsets.UTF_8); + }) + .orElse(""); + } + + private static class FakeServerEnvelope { + String ephemeralPublicKeyPem; + byte[] iv; + byte[] ciphertext; + byte[] tag; + } + + // Mirrors rails' SmartTap::RevealEncryption.encrypt using BouncyCastle so the + // test exercises the SDK's full ECDH/HKDF/AES-GCM round-trip. + private static FakeServerEnvelope simulateServerEncrypt(String plaintextPem, String clientPublicKeyPem) throws Exception { + org.bouncycastle.asn1.x9.X9ECParameters curve = + org.bouncycastle.asn1.x9.ECNamedCurveTable.getByName("P-256"); + org.bouncycastle.crypto.params.ECDomainParameters domain = + new org.bouncycastle.crypto.params.ECDomainParameters( + curve.getCurve(), curve.getG(), curve.getN(), curve.getH(), curve.getSeed()); + + org.bouncycastle.crypto.generators.ECKeyPairGenerator gen = + new org.bouncycastle.crypto.generators.ECKeyPairGenerator(); + gen.init(new org.bouncycastle.crypto.params.ECKeyGenerationParameters( + domain, new java.security.SecureRandom())); + org.bouncycastle.crypto.AsymmetricCipherKeyPair ephemeral = gen.generateKeyPair(); + + org.bouncycastle.crypto.params.ECPublicKeyParameters clientPub; + try (org.bouncycastle.openssl.PEMParser parser = + new org.bouncycastle.openssl.PEMParser(new java.io.StringReader(clientPublicKeyPem))) { + Object o = parser.readObject(); + clientPub = (org.bouncycastle.crypto.params.ECPublicKeyParameters) + org.bouncycastle.crypto.util.PublicKeyFactory.createKey( + (org.bouncycastle.asn1.x509.SubjectPublicKeyInfo) o); + } + + org.bouncycastle.crypto.agreement.ECDHBasicAgreement agreement = + new org.bouncycastle.crypto.agreement.ECDHBasicAgreement(); + agreement.init(ephemeral.getPrivate()); + java.math.BigInteger sharedBig = agreement.calculateAgreement(clientPub); + byte[] unsigned = sharedBig.toByteArray(); + byte[] sharedSecret = new byte[32]; + if (unsigned.length == 33 && unsigned[0] == 0) { + System.arraycopy(unsigned, 1, sharedSecret, 0, 32); + } else { + System.arraycopy(unsigned, 0, sharedSecret, 32 - unsigned.length, unsigned.length); + } + + org.bouncycastle.crypto.generators.HKDFBytesGenerator hkdf = + new org.bouncycastle.crypto.generators.HKDFBytesGenerator( + new org.bouncycastle.crypto.digests.SHA256Digest()); + hkdf.init(new org.bouncycastle.crypto.params.HKDFParameters( + sharedSecret, new byte[0], + "accessgrid-smart-tap-reveal-v1".getBytes(java.nio.charset.StandardCharsets.UTF_8))); + byte[] aesKey = new byte[32]; + hkdf.generateBytes(aesKey, 0, aesKey.length); + + byte[] iv = new byte[12]; + new java.security.SecureRandom().nextBytes(iv); + org.bouncycastle.crypto.modes.GCMBlockCipher gcm = + new org.bouncycastle.crypto.modes.GCMBlockCipher( + new org.bouncycastle.crypto.engines.AESEngine()); + gcm.init(true, new org.bouncycastle.crypto.params.AEADParameters( + new org.bouncycastle.crypto.params.KeyParameter(aesKey), + 128, iv, new byte[0])); + byte[] plaintextBytes = plaintextPem.getBytes(java.nio.charset.StandardCharsets.UTF_8); + byte[] combined = new byte[gcm.getOutputSize(plaintextBytes.length)]; + int written = gcm.processBytes(plaintextBytes, 0, plaintextBytes.length, combined, 0); + written += gcm.doFinal(combined, written); + byte[] ciphertext = new byte[combined.length - 16]; + byte[] tag = new byte[16]; + System.arraycopy(combined, 0, ciphertext, 0, ciphertext.length); + System.arraycopy(combined, ciphertext.length, tag, 0, 16); + + org.bouncycastle.asn1.x509.SubjectPublicKeyInfo spki = + org.bouncycastle.crypto.util.SubjectPublicKeyInfoFactory.createSubjectPublicKeyInfo(ephemeral.getPublic()); + java.io.StringWriter sw = new java.io.StringWriter(); + try (org.bouncycastle.openssl.jcajce.JcaPEMWriter w = new org.bouncycastle.openssl.jcajce.JcaPEMWriter(sw)) { + w.writeObject(spki); + } + + FakeServerEnvelope env = new FakeServerEnvelope(); + env.ephemeralPublicKeyPem = sw.toString(); + env.iv = iv; + env.ciphertext = ciphertext; + env.tag = tag; + return env; + } + // --- Console: Event Log --- @Test