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. */ @@ -316,7 +370,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/main/java/com/organization/accessgrid/Models.java b/src/main/java/com/organization/accessgrid/Models.java index fcd78a3..ccf802b 100644 --- a/src/main/java/com/organization/accessgrid/Models.java +++ b/src/main/java/com/organization/accessgrid/Models.java @@ -528,6 +528,65 @@ 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; + } + + /** + * 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 b185857..cf3a66d 100644
--- a/src/test/java/com/organization/accessgrid/AccessGridClientTest.java
+++ b/src/test/java/com/organization/accessgrid/AccessGridClientTest.java
@@ -306,6 +306,190 @@ 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: 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