Skip to content
Draft
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
24 changes: 24 additions & 0 deletions sdk/src/main/java/io/opentdf/platform/sdk/AssertionJson.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Map;
Expand Down Expand Up @@ -53,6 +57,23 @@ static JsonObject parseAssertion(String wireJson) {
}
}

/**
* Decodes JSON text from strict UTF-8.
*
* @throws SDK.AssertionFailureException ({@code invalid-json}) if the bytes are not UTF-8
*/
static String decodeUtf8(byte[] json) {
try {
return StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(json))
.toString();
} catch (CharacterCodingException e) {
throw invalidJson("the JSON text is not valid UTF-8");
}
}

/**
* Applies only the manifest-level checks of Section 3 to a whole manifest, without building
* the typed manifest. Malformed JSON is reported as {@code invalid-json} here; the manifest
Expand All @@ -62,6 +83,9 @@ static JsonObject parseAssertion(String wireJson) {
*/
static void checkManifest(Reader manifestJson) {
try (StrictJsonReader reader = new StrictJsonReader(manifestJson, StrictJsonReader.Mode.MANIFEST)) {
if (reader.peek() != JsonToken.BEGIN_OBJECT) {
throw invalidJson("a manifest must be a JSON object");
}
reader.skipValue();
if (reader.peek() != JsonToken.END_DOCUMENT) {
throw invalidJson("unexpected content after the manifest");
Expand Down
708 changes: 708 additions & 0 deletions sdk/src/main/java/io/opentdf/platform/sdk/AssertionVerifier.java

Large diffs are not rendered by default.

53 changes: 46 additions & 7 deletions sdk/src/main/java/io/opentdf/platform/sdk/Config.java
Original file line number Diff line number Diff line change
Expand Up @@ -106,21 +106,60 @@ public static KASInfo fromSimpleKasKey(SimpleKasKey ki) {
}
}

/**
* The keys a reader verifies assertion bindings with.
* <p>
* Each assertion is verified with exactly one key, chosen by its exact {@code id}: the entry
* in {@link #keys} for that id if there is one, otherwise {@link #defaultKey} if it is set,
* otherwise the TDF's payload key with HS256. The choice is final. An assertion that fails
* with its per-ID key is not retried with the default key or the payload key, and one that
* fails with the default key is not retried with the payload key. An entry that is present
* but empty or unusable is a configuration error, not a reason to fall through.
* <p>
* A key embedded in the assertion's JWS header ({@code jwk} or {@code x5c}) is never used in
* place of the chosen key: it must be the same public key, or verification fails.
* <p>
* RS256 keys are {@link java.security.interfaces.RSAPublicKey}s or RSA
* {@link com.nimbusds.jose.jwk.JWK}s; HS256 keys are {@code byte[]} secrets.
*/
public static class AssertionVerificationKeys {
/** Verifies assertions that have no entry in {@link #keys}. Null, or a key with no algorithm and no key, means none. */
public AssertionConfig.AssertionKey defaultKey;
/** Per-assertion keys, by exact assertion {@code id}. */
public Map<String, AssertionConfig.AssertionKey> keys = new HashMap<>();

/** True if no per-ID key and no default key is configured. */
Boolean isEmpty() {
return this.defaultKey == null && this.keys.isEmpty();
return (keys == null || keys.isEmpty()) && !hasDefaultKey();
}

AssertionConfig.AssertionKey getKey(String key) {
var assertionKey = keys.get(key);
if (assertionKey != null) {
return assertionKey;
}
/** True if there is an entry for this exact id, even an empty one. */
boolean hasKeyFor(String id) {
return keys != null && keys.containsKey(id);
}

return defaultKey;
/**
* True if a default key is configured. A default with any field set counts, valid or not;
* only {@code null} or a zero value (no algorithm, no key) is absent.
*/
boolean hasDefaultKey() {
return defaultKey != null
&& (defaultKey.key != null
|| (defaultKey.alg != null && defaultKey.alg != AssertionConfig.AssertionKeyAlg.NotDefined)
|| defaultKey.jwk != null
|| defaultKey.x5c != null);
}

/**
* The configured key for the assertion with this exact id: its per-ID entry if it has one
* (possibly {@code null}, which is a configuration error), else the default key if one is
* configured, else {@code null}, meaning the payload key applies.
*/
AssertionConfig.AssertionKey getKey(String id) {
if (hasKeyFor(id)) {
return keys.get(id);
}
return hasDefaultKey() ? defaultKey : null;
}
}

Expand Down
126 changes: 34 additions & 92 deletions sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,11 @@
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.JWSSigner;
import com.nimbusds.jose.JWSVerifier;
import com.nimbusds.jose.KeyLengthException;
import com.nimbusds.jose.crypto.MACSigner;
import com.nimbusds.jose.crypto.MACVerifier;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.util.X509CertUtils;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import io.opentdf.platform.sdk.SDK.AssertionException;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
Expand All @@ -48,8 +42,6 @@
import java.nio.charset.CharacterCodingException;
import java.nio.charset.StandardCharsets;
import java.security.PrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.security.cert.X509Certificate;
import java.text.ParseException;
import java.util.AbstractList;
import java.util.ArrayList;
Expand Down Expand Up @@ -1000,8 +992,9 @@ public static String computeAssertionHash(String wireJson) {
/**
* The {@code assertionHash} of this assertion as it would be written to a manifest now:
* for an unmodified assertion read from a manifest, the hash of the assertion as
* received; after a typed field has been changed, the hash with that change. Reading a
* TDF never uses this: {@code loadTDF} verifies each assertion exactly as received.
* received; after a typed field has been changed, the hash with that change.
* {@link #verify} reads the same form. Reading a TDF uses neither: {@code loadTDF}
* verifies each assertion exactly as received.
*
* @throws SDK.AssertionFailureException if the assertion cannot be represented as I-JSON
*/
Expand Down Expand Up @@ -1170,59 +1163,28 @@ public void sign(final HashValues hashValues, final AssertionConfig.AssertionKey
this.binding.signature = signedJWT.serialize();
}

// Checks the binding signature of the assertion and
// returns the hash and the signature. It returns an error if the verification
// fails.
/**
* Checks this assertion's binding with exactly the given key and its algorithm, and
* returns the signed claims for the caller to compare with the recomputed hash and
* signature. A key embedded in the JWS header ({@code jwk} or {@code x5c}) is accepted
* only if it is the same public key as {@code assertionKey}; it is never used instead.
* This has no side effects on the assertion.
* <p>
* Like {@link #hash()}, this reads the assertion as it would be written now, so a
* changed {@link #binding} is the binding checked; for an unmodified assertion read from
* a manifest that is the binding as received. Readers verify assertions in
* {@code loadTDF}, which uses the assertion exactly as received, selects the key, and
* compares the claims; this method is for callers that do the comparison themselves.
*
* @throws SDK.AssertionFailureException if the binding does not verify with the key, or
* the key is not usable ({@code configuration-error})
*/
public Assertion.HashValues verify(AssertionConfig.AssertionKey assertionKey)
throws ParseException, JOSEException, java.security.cert.CertificateException {
if (binding == null) {
throw new AssertionException("Binding is null in assertion", this.id);
}

String signatureString = binding.signature;

SignedJWT signedJWT = SignedJWT.parse(signatureString);
JWSHeader header = signedJWT.getHeader();
JWSVerifier verifier = null;

// Check for JWK in header
if (header.getJWK() != null) {
try {
verifier = createVerifier(header.getJWK());
} catch (JOSEException e) {
throw new SDKException("Invalid JWK in JWT header", e);
}
}

// Check for X.509 certificate chain in header
if (verifier == null && header.getX509CertChain() != null && !header.getX509CertChain().isEmpty()) {
try {
X509Certificate cert = X509CertUtils.parse(header.getX509CertChain().get(0).decode());
if (cert.getPublicKey() instanceof RSAPublicKey) {
verifier = createVerifier((RSAPublicKey) cert.getPublicKey());
} else {
throw new SDKException("Unsupported public key type in X.509 certificate");
}
} catch (IllegalArgumentException e) {
throw new SDKException("Invalid Base64 in X.509 certificate in JWT header", e);
}
}


if (verifier == null) {
verifier = createVerifier(assertionKey);
}


if (!signedJWT.verify(verifier)) {
throw new SDKException("Unable to verify assertion signature");
}

JWTClaimsSet claimsSet = signedJWT.getJWTClaimsSet();
String assertionHash = claimsSet.getStringClaim(kAssertionHash);
String signature = claimsSet.getStringClaim(kAssertionSignature);

return new Assertion.HashValues(assertionHash, signature);
JsonObject json = toJsonTree();
String validId = id != null && !id.isEmpty() ? id : null;
return AssertionVerifier.verifyBinding(json, validId,
AssertionVerifier.configuredKey(assertionKey, validId, "the given key"));
}

private SignedJWT createSignedJWT(final JWTClaimsSet claims, final AssertionConfig.AssertionKey assertionKey)
Expand Down Expand Up @@ -1267,34 +1229,6 @@ private JWSSigner createSigner(final AssertionConfig.AssertionKey assertionKey)
throw new SDKException("Unknown signing algorithm: " + assertionKey.alg);
}
}

private JWSVerifier createVerifier(AssertionConfig.AssertionKey assertionKey) throws JOSEException {
switch (assertionKey.alg) {
case RS256:
if (assertionKey.key instanceof JWK) {
return createVerifier((JWK) assertionKey.key);
} else if (assertionKey.key instanceof RSAPublicKey) {
return createVerifier((RSAPublicKey) assertionKey.key);
} else {
throw new SDKException("Expected JWK or RSAPublicKey for RS256 algorithm");
}
case HS256:
return new MACVerifier((byte[]) assertionKey.key);
default:
throw new SDKException("Unknown verify key, unable to verify assertion signature");
}
}

private JWSVerifier createVerifier(JWK jwk) throws JOSEException {
if (jwk instanceof com.nimbusds.jose.jwk.RSAKey) {
return new RSASSAVerifier(jwk.toRSAKey());
}
throw new JOSEException("Unsupported JWK type: " + jwk.getKeyType() + ". Only RSA keys are supported.");
}

private JWSVerifier createVerifier(RSAPublicKey publicKey) {
return new RSASSAVerifier(publicKey);
}
}

private static final AssertionValueAdapter STATEMENT_ADAPTER = new AssertionValueAdapter();
Expand Down Expand Up @@ -1406,9 +1340,9 @@ protected static Manifest readManifest(Reader manifestJson) {
throw new JsonSyntaxException("unexpected content after the manifest object");
}
} catch (JsonParseException e) {
throw readFailure(reader, e);
throw readFailure(reader, jsonReader, e);
} catch (IOException e) {
throw readFailure(reader, new JsonSyntaxException(e));
throw readFailure(reader, jsonReader, new JsonSyntaxException(e));
}
if (result == null) {
throw new IllegalArgumentException("Manifest is empty");
Expand All @@ -1425,14 +1359,22 @@ static void checkManifestJson(String manifestJson) {
AssertionJson.checkManifest(new StringReader(manifestJson));
}

private static RuntimeException readFailure(FailureTrackingReader reader, JsonParseException e) {
private static RuntimeException readFailure(FailureTrackingReader reader, StrictJsonReader jsonReader,
JsonParseException e) {
if (reader.failure instanceof CharacterCodingException) {
return new SDK.AssertionFailureException(null, AssertionFailureReason.INVALID_JSON,
"the manifest is not valid UTF-8");
}
if (reader.failure != null) {
return new SDKException("error reading manifest from zip file", reader.failure);
}
if (e.getCause() instanceof IOException && jsonReader.inAssertion()) {
// JSON that does not parse inside an assertion object is not I-JSON there. This also
// covers integer literals that Gson's lexer cannot read: it accumulates digits in a
// long, and treats a 20+ digit integer whose prefix wraps to zero as malformed.
return new SDK.AssertionFailureException(jsonReader.openAssertionId(), AssertionFailureReason.INVALID_JSON,
"an assertion is not valid JSON");
}
return e;
}

Expand Down
10 changes: 10 additions & 0 deletions sdk/src/main/java/io/opentdf/platform/sdk/StrictJsonReader.java
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,16 @@ public void skipValue() throws IOException {
}
}

/** True while the reader is inside an assertion object, as when a syntax error stopped it there. */
boolean inAssertion() {
return assertionDepth >= 0;
}

/** The exact {@code id} of the open assertion object, if one has been read; otherwise null. */
String openAssertionId() {
return assertionDepth >= 0 ? frames[assertionDepth].exactId() : null;
}

private void fail(AssertionFailureReason reason, String detail) {
if (assertionDepth >= 0) {
Frame assertion = frames[assertionDepth];
Expand Down
56 changes: 6 additions & 50 deletions sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
import java.security.*;
import java.security.interfaces.ECPublicKey;
import java.security.spec.InvalidKeySpecException;
import java.text.ParseException;
import java.util.*;

/**
Expand Down Expand Up @@ -982,55 +981,12 @@ Reader loadTDF(SeekableByteChannel tdf, Config.TDFReaderConfig tdfReaderConfig)
"segment size mismatch. encrypted segment size differs from plaintext segment size. the TDF is invalid");
}

// Validate assertions
for (var assertion : manifest.assertions) {
// Skip assertion verification if disabled
if (tdfReaderConfig.disableAssertionVerification) {
break;
}

// Set default to HS256
var assertionKey = new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.HS256, payloadKey);
Config.AssertionVerificationKeys assertionVerificationKeys = tdfReaderConfig.assertionVerificationKeys;
if (!assertionVerificationKeys.isEmpty()) {
var keyForAssertion = assertionVerificationKeys.getKey(assertion.id);
if (keyForAssertion != null) {
assertionKey = keyForAssertion;
}
}

Manifest.Assertion.HashValues hashValues;
try {
hashValues = assertion.verify(assertionKey);
} catch (ParseException | JOSEException | java.security.cert.CertificateException e) {
throw new SDKException("error validating assertion hash", e);
}
// the assertion exactly as received, never a re-serialization of the typed fields
var hashOfAssertionAsHex = AssertionJson.hash(assertion.received());

if (!Objects.equals(hashOfAssertionAsHex, hashValues.getAssertionHash())) {
throw new SDK.AssertionFailureException(assertion.id, AssertionFailureReason.BINDING_MISMATCH,
"assertion hash mismatch");
}

byte[] hashOfAssertion;
if (isLegacyTdf) {
hashOfAssertion = hashOfAssertionAsHex.getBytes(StandardCharsets.UTF_8);
} else {
try {
hashOfAssertion = Hex.decodeHex(hashOfAssertionAsHex);
} catch (DecoderException e) {
throw new SDKException("error decoding assertion hash", e);
}
}
var signature = new byte[aggregateHash.length + hashOfAssertion.length];
System.arraycopy(aggregateHash, 0, signature, 0, aggregateHash.length);
System.arraycopy(hashOfAssertion, 0, signature, aggregateHash.length, hashOfAssertion.length);
var encodeSignature = Base64.getEncoder().encodeToString(signature);

if (!Objects.equals(encodeSignature, hashValues.getSignature())) {
throw new SDK.AssertionFailureException(assertion.id, AssertionFailureReason.BINDING_MISMATCH,
"failed integrity check on assertion signature");
// Verify assertions (assertions spec, Section 8). Manifest parsing already applied the
// JSON profile of Section 3, which holds even when verification is disabled.
if (!tdfReaderConfig.disableAssertionVerification) {
for (var assertion : manifest.assertions) {
AssertionVerifier.verify(assertion, tdfReaderConfig.assertionVerificationKeys, payloadKey,
aggregateHash, isLegacyTdf);
}
}

Expand Down
Loading
Loading