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
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,28 @@ 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());
```

### 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
Expand Down Expand Up @@ -365,14 +387,16 @@ 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 |
| 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 |
| POST /v1/console/card-templates/{id}/ios_preflight | `console().iosPreflight()` | Y |
| 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 |
Expand Down
13 changes: 13 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
<!-- Dependency versions -->
<jackson.version>2.15.2</jackson.version>
<lombok.version>1.18.44</lombok.version>
<bouncycastle.version>1.78.1</bouncycastle.version>
</properties>

<dependencies>
Expand Down Expand Up @@ -72,6 +73,18 @@
<version>1.7.32</version>
</dependency>

<!-- BouncyCastle for SmartTap reveal crypto (ECDH-ES + HKDF-SHA256 + AES-GCM, PEM I/O) -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk18on</artifactId>
<version>${bouncycastle.version}</version>
</dependency>

<!-- Testing -->
<dependency>
<groupId>org.junit.jupiter</groupId>
Expand Down
56 changes: 55 additions & 1 deletion src/main/java/com/organization/accessgrid/AccessGridClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,60 @@ 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
);
}

/**
* Reveal the SmartTap private key for a card template.
*
* <p>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.
*/
Expand Down Expand Up @@ -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);
}

/**
Expand Down
59 changes: 59 additions & 0 deletions src/main/java/com/organization/accessgrid/Models.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
157 changes: 157 additions & 0 deletions src/main/java/com/organization/accessgrid/SmartTapRevealCrypto.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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);
}
}
}
Loading
Loading