Skip to content

Commit 251fea2

Browse files
authored
feat(sdk): enhance assertion verification to support jwk and x509 certificates (#322)
This PR adds support for verifying TDF assertions using JWK and X.509 certificates embedded in the JWT header. - `CryptoUtils.java`: A new `getPublicKeyJWK` method was added to convert an RSA public key into a JWK string. - `Manifest.java`: The assertion verification logic was updated. It now checks for and uses jwk and x5c (X.509 certificate chain) headers within the JWT to verify signatures before falling back to the previous verification method. - `TDF.java`: Exception handling was updated to catch `CertificateException`. - `TDFTest.java`: New tests, `testSimpleTDFWithAssertionWithJWK` and `testSimpleTDFWithAssertionWithX5C`, were added to validate the new verification flows. - `TestUtil.java`: A `createTestCertificate` method was added to generate self-signed X.509 certificates for testing. --------- Signed-off-by: Scott Hamrick <2623452+cshamrick@users.noreply.github.com>
1 parent 63715d2 commit 251fea2

7 files changed

Lines changed: 1115 additions & 769 deletions

File tree

cmdline/src/main/java/io/opentdf/platform/Command.java

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
package io.opentdf.platform;
22

33
import com.google.gson.Gson;
4+
import com.google.gson.JsonDeserializationContext;
5+
import com.google.gson.JsonDeserializer;
6+
import com.google.gson.JsonElement;
7+
import com.google.gson.JsonObject;
8+
import com.google.gson.JsonParseException;
9+
import com.nimbusds.jose.jwk.JWK;
10+
import com.google.gson.GsonBuilder;
11+
import com.google.gson.reflect.TypeToken;
12+
13+
import java.text.ParseException;
414
import com.google.gson.JsonSyntaxException;
5-
import com.nimbusds.jose.JOSEException;
615
import io.opentdf.platform.sdk.AssertionConfig;
716
import io.opentdf.platform.sdk.AutoConfigureException;
817
import io.opentdf.platform.sdk.Config;
918
import io.opentdf.platform.sdk.KeyType;
10-
import io.opentdf.platform.sdk.Config.AssertionVerificationKeys;
1119
import io.opentdf.platform.sdk.SDK;
1220
import io.opentdf.platform.sdk.SDKBuilder;
1321
import nl.altindag.ssl.SSLFactory;
14-
import org.apache.commons.codec.DecoderException;
1522
import picocli.CommandLine;
1623
import picocli.CommandLine.HelpCommand;
1724
import picocli.CommandLine.Option;
@@ -38,7 +45,6 @@
3845
import java.util.List;
3946
import java.util.Map;
4047
import java.util.Optional;
41-
import java.util.concurrent.ExecutionException;
4248
import java.util.function.Consumer;
4349

4450
/**
@@ -60,6 +66,39 @@ class Command {
6066
@Option(names = { "-V", "--version" }, versionHelp = true, description = "display version info")
6167
boolean versionInfoRequested;
6268

69+
private static class AssertionKeyDeserializer implements JsonDeserializer<AssertionConfig.AssertionKey> {
70+
@Override
71+
public AssertionConfig.AssertionKey deserialize(JsonElement json, java.lang.reflect.Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
72+
JsonObject jsonObject = json.getAsJsonObject();
73+
AssertionConfig.AssertionKey assertionKey = new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.NotDefined, null);
74+
75+
if (jsonObject.has("alg")) {
76+
assertionKey.alg = context.deserialize(jsonObject.get("alg"), AssertionConfig.AssertionKeyAlg.class);
77+
}
78+
if (jsonObject.has("key")) {
79+
assertionKey.key = context.deserialize(jsonObject.get("key"), Object.class);
80+
}
81+
if (jsonObject.has("jwk")) {
82+
try {
83+
assertionKey.jwk = JWK.parse(jsonObject.get("jwk").toString());
84+
} catch (ParseException e) {
85+
throw new JsonParseException("Failed to parse jwk", e);
86+
}
87+
}
88+
if (jsonObject.has("x5c")) {
89+
assertionKey.x5c = context.deserialize(jsonObject.get("x5c"), new TypeToken<List<com.nimbusds.jose.util.Base64>>() {}.getType());
90+
}
91+
92+
return assertionKey;
93+
}
94+
}
95+
96+
private Gson buildGson() {
97+
return new GsonBuilder()
98+
.registerTypeAdapter(AssertionConfig.AssertionKey.class, new AssertionKeyDeserializer())
99+
.create();
100+
}
101+
63102
private static final String PRIVATE_KEY_HEADER = "-----BEGIN PRIVATE KEY-----";
64103
private static final String PRIVATE_KEY_FOOTER = "-----END PRIVATE KEY-----";
65104
private static final String PEM_HEADER = "-----BEGIN (.*)-----";
@@ -177,7 +216,7 @@ void encrypt(
177216

178217
if (assertion.isPresent()) {
179218
var assertionConfig = assertion.get();
180-
Gson gson = new Gson();
219+
Gson gson = buildGson();
181220

182221
AssertionConfig[] assertionConfigs;
183222
try {
@@ -235,7 +274,8 @@ private SDK buildSDK() {
235274
}
236275

237276
@CommandLine.Command(name = "decrypt")
238-
void decrypt(@Option(names = { "-f", "--file" }, required = true) Path tdfPath,
277+
void decrypt(
278+
@Option(names = { "-f", "--file" }, required = true) Path tdfPath,
239279
@Option(names = {
240280
"--rewrap-key-type" }, defaultValue = Option.NULL_VALUE, description = "Preferred rewrap algorithm, one of ${COMPLETION-CANDIDATES}") Optional<KeyType> rewrapKeyType,
241281
@Option(names = {
@@ -252,17 +292,17 @@ void decrypt(@Option(names = { "-f", "--file" }, required = true) Path tdfPath,
252292
try (var stdout = new BufferedOutputStream(System.out)) {
253293
if (assertionVerification.isPresent()) {
254294
var assertionVerificationInput = assertionVerification.get();
255-
Gson gson = new Gson();
295+
Gson gson = buildGson();
256296

257-
AssertionVerificationKeys assertionVerificationKeys;
297+
Config.AssertionVerificationKeys assertionVerificationKeys;
258298
try {
259299
assertionVerificationKeys = gson.fromJson(assertionVerificationInput,
260-
AssertionVerificationKeys.class);
300+
Config.AssertionVerificationKeys.class);
261301
} catch (JsonSyntaxException e) {
262302
// try it as a file path
263303
try {
264304
String fileJson = new String(Files.readAllBytes(Paths.get(assertionVerificationInput)));
265-
assertionVerificationKeys = gson.fromJson(fileJson, AssertionVerificationKeys.class);
305+
assertionVerificationKeys = gson.fromJson(fileJson, Config.AssertionVerificationKeys.class);
266306
} catch (JsonSyntaxException e2) {
267307
throw new RuntimeException("Failed to parse assertion verification keys from file", e2);
268308
} catch (Exception e3) {
@@ -302,7 +342,8 @@ void decrypt(@Option(names = { "-f", "--file" }, required = true) Path tdfPath,
302342
}
303343

304344
@CommandLine.Command(name = "metadata")
305-
void readMetadata(@Option(names = { "-f", "--file" }, required = true) Path tdfPath,
345+
void readMetadata(
346+
@Option(names = { "-f", "--file" }, required = true) Path tdfPath,
306347
@Option(names = { "--kas-allowlist" }, defaultValue = Option.NULL_VALUE) Optional<String> kasAllowlistStr,
307348
@Option(names = {
308349
"--ignore-kas-allowlist" }, defaultValue = Option.NULL_VALUE) Optional<Boolean> ignoreAllowlist)

sdk/src/main/java/io/opentdf/platform/sdk/AssertionConfig.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22

33
import com.google.gson.Gson;
44
import com.google.gson.annotations.SerializedName;
5+
import com.nimbusds.jose.jwk.JWK;
6+
import com.nimbusds.jose.util.Base64;
57

68
import java.net.InetAddress;
79
import java.net.UnknownHostException;
810
import java.time.OffsetDateTime;
911
import java.time.format.DateTimeFormatter;
12+
import java.util.List;
1013
import java.util.Objects;
1114

1215
/**
@@ -88,12 +91,24 @@ public String toString() {
8891
static public class AssertionKey {
8992
public Object key;
9093
public AssertionKeyAlg alg = AssertionKeyAlg.NotDefined;
94+
public transient JWK jwk;
95+
public transient List<Base64> x5c;
9196

9297
public AssertionKey(AssertionKeyAlg alg, Object key) {
9398
this.alg = alg;
9499
this.key = key;
95100
}
96101

102+
public AssertionKey withJwk(JWK jwk) {
103+
this.jwk = jwk;
104+
return this;
105+
}
106+
107+
public AssertionKey withX5c(List<Base64> x5c) {
108+
this.x5c = x5c;
109+
return this;
110+
}
111+
97112
public boolean isDefined() {
98113
return alg != AssertionKeyAlg.NotDefined;
99114
}

sdk/src/main/java/io/opentdf/platform/sdk/CryptoUtils.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package io.opentdf.platform.sdk;
22

3+
import com.nimbusds.jose.jwk.RSAKey;
4+
35
import javax.crypto.Mac;
46
import javax.crypto.spec.SecretKeySpec;
57
import java.security.*;
8+
import java.security.interfaces.RSAPublicKey;
69
import java.security.spec.ECGenParameterSpec;
710
import java.util.Base64;
811

@@ -58,6 +61,15 @@ public static String getPublicKeyPEM(PublicKey publicKey) {
5861
"\r\n-----END PUBLIC KEY-----";
5962
}
6063

64+
public static String getPublicKeyJWK(PublicKey publicKey) {
65+
if (publicKey instanceof RSAPublicKey) {
66+
RSAKey jwk = new RSAKey.Builder((RSAPublicKey) publicKey).build();
67+
return jwk.toString();
68+
} else {
69+
throw new IllegalArgumentException("Unsupported public key algorithm: " + publicKey.getAlgorithm());
70+
}
71+
}
72+
6173
public static String getPrivateKeyPEM(PrivateKey privateKey) {
6274
return "-----BEGIN PRIVATE KEY-----\r\n" +
6375
Base64.getMimeEncoder().encodeToString(privateKey.getEncoded()) +

sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
import com.nimbusds.jose.crypto.MACVerifier;
2121
import com.nimbusds.jose.crypto.RSASSASigner;
2222
import com.nimbusds.jose.crypto.RSASSAVerifier;
23+
import com.nimbusds.jose.crypto.factories.DefaultJWSVerifierFactory;
24+
import com.nimbusds.jose.jwk.JWK;
25+
import com.nimbusds.jose.util.X509CertUtils;
2326
import com.nimbusds.jwt.JWTClaimsSet;
2427
import com.nimbusds.jwt.SignedJWT;
2528
import io.opentdf.platform.sdk.SDK.AssertionException;
@@ -33,6 +36,7 @@
3336
import java.security.NoSuchAlgorithmException;
3437
import java.security.PrivateKey;
3538
import java.security.interfaces.RSAPublicKey;
39+
import java.security.cert.X509Certificate;
3640
import java.text.ParseException;
3741
import java.util.ArrayList;
3842
import java.util.Base64;
@@ -400,7 +404,7 @@ public void sign(final HashValues hashValues, final AssertionConfig.AssertionKey
400404
// returns the hash and the signature. It returns an error if the verification
401405
// fails.
402406
public Assertion.HashValues verify(AssertionConfig.AssertionKey assertionKey)
403-
throws ParseException, JOSEException {
407+
throws ParseException, JOSEException, java.security.cert.CertificateException {
404408
if (binding == null) {
405409
throw new AssertionException("Binding is null in assertion", this.id);
406410
}
@@ -409,7 +413,37 @@ public Assertion.HashValues verify(AssertionConfig.AssertionKey assertionKey)
409413
binding = null; // Clear the binding after use
410414

411415
SignedJWT signedJWT = SignedJWT.parse(signatureString);
412-
JWSVerifier verifier = createVerifier(assertionKey);
416+
JWSHeader header = signedJWT.getHeader();
417+
JWSVerifier verifier = null;
418+
419+
// Check for JWK in header
420+
if (header.getJWK() != null) {
421+
try {
422+
verifier = createVerifier(header.getJWK());
423+
} catch (JOSEException e) {
424+
throw new SDKException("Invalid JWK in JWT header", e);
425+
}
426+
}
427+
428+
// Check for X.509 certificate chain in header
429+
if (verifier == null && header.getX509CertChain() != null && !header.getX509CertChain().isEmpty()) {
430+
try {
431+
X509Certificate cert = X509CertUtils.parse(header.getX509CertChain().get(0).decode());
432+
if (cert.getPublicKey() instanceof RSAPublicKey) {
433+
verifier = createVerifier((RSAPublicKey) cert.getPublicKey());
434+
} else {
435+
throw new SDKException("Unsupported public key type in X.509 certificate");
436+
}
437+
} catch (IllegalArgumentException e) {
438+
throw new SDKException("Invalid Base64 in X.509 certificate in JWT header", e);
439+
}
440+
}
441+
442+
443+
if (verifier == null) {
444+
verifier = createVerifier(assertionKey);
445+
}
446+
413447

414448
if (!signedJWT.verify(verifier)) {
415449
throw new SDKException("Unable to verify assertion signature");
@@ -424,19 +458,27 @@ public Assertion.HashValues verify(AssertionConfig.AssertionKey assertionKey)
424458

425459
private SignedJWT createSignedJWT(final JWTClaimsSet claims, final AssertionConfig.AssertionKey assertionKey)
426460
throws SDKException {
427-
final JWSHeader jwsHeader;
461+
final JWSHeader.Builder headerBuilder;
428462
switch (assertionKey.alg) {
429463
case RS256:
430-
jwsHeader = new JWSHeader.Builder(JWSAlgorithm.RS256).build();
464+
headerBuilder = new JWSHeader.Builder(JWSAlgorithm.RS256);
431465
break;
432466
case HS256:
433-
jwsHeader = new JWSHeader.Builder(JWSAlgorithm.HS256).build();
467+
headerBuilder = new JWSHeader.Builder(JWSAlgorithm.HS256);
434468
break;
435469
default:
436470
throw new SDKException("Unknown assertion key algorithm, error signing assertion");
437471
}
438472

439-
return new SignedJWT(jwsHeader, claims);
473+
if (assertionKey.jwk != null) {
474+
headerBuilder.jwk(assertionKey.jwk);
475+
}
476+
477+
if (assertionKey.x5c != null) {
478+
headerBuilder.x509CertChain(assertionKey.x5c);
479+
}
480+
481+
return new SignedJWT(headerBuilder.build(), claims);
440482
}
441483

442484
private JWSSigner createSigner(final AssertionConfig.AssertionKey assertionKey)
@@ -460,13 +502,30 @@ private JWSSigner createSigner(final AssertionConfig.AssertionKey assertionKey)
460502
private JWSVerifier createVerifier(AssertionConfig.AssertionKey assertionKey) throws JOSEException {
461503
switch (assertionKey.alg) {
462504
case RS256:
463-
return new RSASSAVerifier((RSAPublicKey) assertionKey.key);
505+
if (assertionKey.key instanceof JWK) {
506+
return createVerifier((JWK) assertionKey.key);
507+
} else if (assertionKey.key instanceof RSAPublicKey) {
508+
return createVerifier((RSAPublicKey) assertionKey.key);
509+
} else {
510+
throw new SDKException("Expected JWK or RSAPublicKey for RS256 algorithm");
511+
}
464512
case HS256:
465513
return new MACVerifier((byte[]) assertionKey.key);
466514
default:
467515
throw new SDKException("Unknown verify key, unable to verify assertion signature");
468516
}
469517
}
518+
519+
private JWSVerifier createVerifier(JWK jwk) throws JOSEException {
520+
if (jwk instanceof com.nimbusds.jose.jwk.RSAKey) {
521+
return new RSASSAVerifier(jwk.toRSAKey());
522+
}
523+
throw new JOSEException("Unsupported JWK type: " + jwk.getKeyType() + ". Only RSA keys are supported.");
524+
}
525+
526+
private JWSVerifier createVerifier(RSAPublicKey publicKey) {
527+
return new RSASSAVerifier(publicKey);
528+
}
470529
}
471530

472531
public static class AssertionValueAdapter implements JsonDeserializer<AssertionConfig.Statement> {

sdk/src/main/java/io/opentdf/platform/sdk/TDF.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -695,7 +695,7 @@ Reader loadTDF(SeekableByteChannel tdf, Config.TDFReaderConfig tdfReaderConfig)
695695
Manifest.Assertion.HashValues hashValues;
696696
try {
697697
hashValues = assertion.verify(assertionKey);
698-
} catch (ParseException | JOSEException e) {
698+
} catch (ParseException | JOSEException | java.security.cert.CertificateException e) {
699699
throw new SDKException("error validating assertion hash", e);
700700
}
701701
var hashOfAssertionAsHex = assertion.hash();

0 commit comments

Comments
 (0)