diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/AssertionJson.java b/sdk/src/main/java/io/opentdf/platform/sdk/AssertionJson.java
index 377e95d3..31eaa0bb 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/AssertionJson.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/AssertionJson.java
@@ -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;
@@ -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
@@ -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");
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/AssertionVerifier.java b/sdk/src/main/java/io/opentdf/platform/sdk/AssertionVerifier.java
new file mode 100644
index 00000000..8defa011
--- /dev/null
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/AssertionVerifier.java
@@ -0,0 +1,708 @@
+package io.opentdf.platform.sdk;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSVerifier;
+import com.nimbusds.jose.crypto.MACVerifier;
+import com.nimbusds.jose.crypto.RSASSAVerifier;
+import com.nimbusds.jose.jwk.Curve;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jose.util.X509CertUtils;
+import org.apache.commons.codec.DecoderException;
+import org.apache.commons.codec.binary.Hex;
+
+import java.io.IOException;
+import java.math.BigInteger;
+import java.nio.ByteBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.security.PublicKey;
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPublicKey;
+import java.security.interfaces.RSAPublicKey;
+import java.security.spec.ECFieldFp;
+import java.security.spec.ECParameterSpec;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+
+import static io.opentdf.platform.sdk.AssertionFailureReason.ALGORITHM_MISMATCH;
+import static io.opentdf.platform.sdk.AssertionFailureReason.BINDING_MISMATCH;
+import static io.opentdf.platform.sdk.AssertionFailureReason.CONFIGURATION_ERROR;
+import static io.opentdf.platform.sdk.AssertionFailureReason.INVALID_BINDING;
+import static io.opentdf.platform.sdk.AssertionFailureReason.INVALID_EMBEDDED_KEY;
+import static io.opentdf.platform.sdk.AssertionFailureReason.INVALID_ID;
+import static io.opentdf.platform.sdk.AssertionFailureReason.SIGNATURE_INVALID;
+import static io.opentdf.platform.sdk.AssertionFailureReason.SIGNER_NOT_PERMITTED;
+import static io.opentdf.platform.sdk.AssertionFailureReason.UNSUPPORTED_ALGORITHM;
+import static io.opentdf.platform.sdk.AssertionFailureReason.UNSUPPORTED_BINDING;
+
+/**
+ * Verifies assertion bindings as Section 8 of the ZTDF assertions specification lays out, in its
+ * order, so the same input fails with the same reason in every SDK.
+ *
+ * The verification key comes from the reader's configuration, or is the payload key when nothing
+ * is configured, and is selected once per assertion. A key embedded in the JWS header, as
+ * {@code jwk} or as the leaf of {@code x5c}, never replaces it and never chooses the algorithm:
+ * it is accepted only when it is the same public key as the selected one. The signature is
+ * always checked with the selected key and its configured algorithm. Nothing here reads the
+ * network, and an {@code x5c} chain is not validated, so a pass says nothing about certificates.
+ */
+final class AssertionVerifier {
+
+ private static final String ASSERTION_HASH = "assertionHash";
+ private static final String ASSERTION_SIG = "assertionSig";
+ private static final List PRIVATE_JWK_MEMBERS = List.of("d", "p", "q", "dp", "dq", "qi", "oth");
+ /** Untrusted values quoted in messages are cut to this many characters. */
+ private static final int MAX_QUOTED = 64;
+
+ private AssertionVerifier() {
+ }
+
+ /** The key one assertion is verified with, and the only key it can verify with. */
+ static final class SelectedKey {
+ final AssertionConfig.AssertionKeyAlg alg;
+ private final JWSVerifier verifier;
+ /** The public key's {@link #publicKeyIdentity identity}; null for HMAC keys. */
+ private final String identity;
+
+ private SelectedKey(AssertionConfig.AssertionKeyAlg alg, JWSVerifier verifier, String identity) {
+ this.alg = alg;
+ this.verifier = verifier;
+ this.identity = identity;
+ }
+
+ static SelectedKey rsa(RSAPublicKey publicKey) {
+ try {
+ return new SelectedKey(AssertionConfig.AssertionKeyAlg.RS256, new RSASSAVerifier(publicKey),
+ publicKeyIdentity(publicKey));
+ } catch (RuntimeException e) {
+ throw new IllegalArgumentException("unusable RSA public key", e);
+ }
+ }
+
+ static SelectedKey hmac(byte[] secret) {
+ try {
+ return new SelectedKey(AssertionConfig.AssertionKeyAlg.HS256, new MACVerifier(secret), null);
+ } catch (JOSEException | RuntimeException e) {
+ // MACVerifier refuses secrets shorter than 256 bits, as it always has
+ throw new IllegalArgumentException("unusable HS256 secret: " + e.getMessage(), e);
+ }
+ }
+
+ boolean isHmac() {
+ return alg == AssertionConfig.AssertionKeyAlg.HS256;
+ }
+
+ private JWSAlgorithm jwsAlgorithm() {
+ return isHmac() ? JWSAlgorithm.HS256 : JWSAlgorithm.RS256;
+ }
+ }
+
+ /**
+ * Verifies one assertion read from a manifest: Section 8, steps 3.1 to 3.9.
+ *
+ * @param keys the reader's configured keys; null means none
+ * @param payloadKey used with HS256 only when no key is configured for the assertion
+ * @param aggregateHash the decoded segment hashes, concatenated in manifest order
+ * @param legacy whether {@code assertionSig} carries the assertion hash as hex text
+ * @throws SDK.AssertionFailureException on the first failed check
+ */
+ static void verify(Manifest.Assertion assertion, Config.AssertionVerificationKeys keys, byte[] payloadKey,
+ byte[] aggregateHash, boolean legacy) {
+ JsonObject json = assertion.received() != null ? assertion.received() : assertion.toJsonTree();
+ String id = exactId(json);
+ SelectedKey key = select(keys, id, payloadKey);
+ verifyWithKey(json, id, key, aggregateHash, legacy);
+ }
+
+ /** Section 3.3: the exact {@code id}, which must be a nonempty JSON string. */
+ static String exactId(JsonObject assertion) {
+ JsonElement id = assertion.get("id");
+ if (id == null || id.isJsonNull()) {
+ throw new SDK.AssertionFailureException(null, INVALID_ID, "the assertion has no id");
+ }
+ if (!isString(id)) {
+ throw new SDK.AssertionFailureException(null, INVALID_ID, "the assertion id is not a string");
+ }
+ if (id.getAsString().isEmpty()) {
+ throw new SDK.AssertionFailureException(null, INVALID_ID, "the assertion id is empty");
+ }
+ return id.getAsString();
+ }
+
+ /**
+ * Section 6.1: the key for the assertion with this exact id. A per-ID entry wins, then the
+ * default key, then the payload key with HS256. An empty or invalid configured entry is a
+ * {@code configuration-error}, never a reason to look further down the list.
+ */
+ static SelectedKey select(Config.AssertionVerificationKeys keys, String id, byte[] payloadKey) {
+ if (keys != null && (keys.hasKeyFor(id) || keys.hasDefaultKey())) {
+ String which = keys.hasKeyFor(id) ? "the verification key for this id" : "the default verification key";
+ return configuredKey(keys.getKey(id), id, which);
+ }
+ try {
+ return SelectedKey.hmac(payloadKey);
+ } catch (IllegalArgumentException e) {
+ throw new SDKException("the payload key cannot verify assertions", e);
+ }
+ }
+
+ /**
+ * A configured key as a {@link SelectedKey}: RS256 with an RSA public key (as
+ * {@link RSAPublicKey} or an RSA {@link com.nimbusds.jose.jwk.JWK}), or HS256 with a {@code byte[]} secret.
+ */
+ static SelectedKey configuredKey(AssertionConfig.AssertionKey key, String id, String which) {
+ if (key == null) {
+ throw configurationError(id, which + " is empty");
+ }
+ if (key.alg == AssertionConfig.AssertionKeyAlg.RS256) {
+ RSAPublicKey publicKey;
+ if (key.key instanceof RSAPublicKey) {
+ publicKey = (RSAPublicKey) key.key;
+ } else if (key.key instanceof RSAKey) {
+ try {
+ publicKey = ((RSAKey) key.key).toRSAPublicKey();
+ } catch (JOSEException e) {
+ throw configurationError(id, which + " is not a usable RSA key");
+ }
+ } else {
+ throw configurationError(id, which + " is RS256 but is not an RSA public key");
+ }
+ try {
+ return SelectedKey.rsa(publicKey);
+ } catch (IllegalArgumentException e) {
+ throw configurationError(id, which + " is not a usable RSA key");
+ }
+ }
+ if (key.alg == AssertionConfig.AssertionKeyAlg.HS256) {
+ if (!(key.key instanceof byte[]) || ((byte[]) key.key).length == 0) {
+ throw configurationError(id, which + " is HS256 but is not a nonempty byte[] secret");
+ }
+ if (isPemText((byte[]) key.key)) {
+ // public-key bytes must never serve as an HMAC secret (spec Section 7)
+ throw configurationError(id, which + " is HS256 but holds PEM text");
+ }
+ try {
+ return SelectedKey.hmac((byte[]) key.key);
+ } catch (IllegalArgumentException e) {
+ throw configurationError(id, which + " is not a usable HS256 secret");
+ }
+ }
+ throw configurationError(id, which + " has no supported algorithm");
+ }
+
+ /**
+ * Section 8, steps 3.3 to 3.9, with the key already selected.
+ *
+ * @param assertion the assertion as parsed under Section 3, binding included
+ * @param id the assertion's exact id, for error reports; may be null
+ */
+ static void verifyWithKey(JsonObject assertion, String id, SelectedKey key, byte[] aggregateHash, boolean legacy) {
+ Manifest.Assertion.HashValues claimed = verifyBinding(assertion, id, key);
+
+ String assertionHash = AssertionJson.hash(assertion);
+ if (!assertionHash.equals(claimed.getAssertionHash())) {
+ throw new SDK.AssertionFailureException(id, BINDING_MISMATCH,
+ "the signed assertionHash does not match the assertion");
+ }
+
+ byte[] hashBytes;
+ if (legacy) {
+ hashBytes = assertionHash.getBytes(StandardCharsets.US_ASCII);
+ } else {
+ try {
+ hashBytes = Hex.decodeHex(assertionHash);
+ } catch (DecoderException e) {
+ throw new IllegalStateException("assertion hash is not hex", e);
+ }
+ }
+ byte[] expected = new byte[aggregateHash.length + hashBytes.length];
+ System.arraycopy(aggregateHash, 0, expected, 0, aggregateHash.length);
+ System.arraycopy(hashBytes, 0, expected, aggregateHash.length, hashBytes.length);
+ if (!Base64.getEncoder().encodeToString(expected).equals(claimed.getSignature())) {
+ // the other encoding is deliberately not tried
+ throw new SDK.AssertionFailureException(id, BINDING_MISMATCH,
+ "failed integrity check on assertion signature: assertionSig does not match the segment hashes");
+ }
+ }
+
+ /**
+ * Section 8, steps 3.3 to 3.7: the binding form, the JWS and its header, any embedded keys,
+ * the signature under the selected key, and the presence of the claims.
+ *
+ * @return the signed claims, still to be compared with the recomputed values
+ */
+ static Manifest.Assertion.HashValues verifyBinding(JsonObject assertion, String id, SelectedKey key) {
+ Jws jws = Jws.parse(jwsBinding(assertion, id), id);
+
+ // 3.4: the header
+ if (jws.header.has("crit")) {
+ throw invalidBinding(id, "the JWS header has a crit member");
+ }
+ JsonElement alg = jws.header.get("alg");
+ if (alg == null || !isString(alg)) {
+ throw new SDK.AssertionFailureException(id, UNSUPPORTED_ALGORITHM, "the JWS header has no alg");
+ }
+ String algName = alg.getAsString();
+ if (!JWSAlgorithm.RS256.getName().equals(algName) && !JWSAlgorithm.HS256.getName().equals(algName)) {
+ throw new SDK.AssertionFailureException(id, UNSUPPORTED_ALGORITHM,
+ "JWS alg " + quoted(algName) + " is not supported");
+ }
+ if (!algName.equals(key.jwsAlgorithm().getName())) {
+ throw new SDK.AssertionFailureException(id, ALGORITHM_MISMATCH,
+ "JWS alg " + algName + " does not match the selected " + key.alg + " key");
+ }
+
+ // 3.5: embedded keys may only name the selected key
+ checkEmbeddedKeys(jws.header, id, key);
+
+ // 3.6: the signature, with the selected key and its algorithm only
+ boolean verified = false;
+ byte[] signature = strictBase64Url(jws.encodedSignature);
+ if (signature != null) {
+ try {
+ verified = key.verifier.verify(new JWSHeader(key.jwsAlgorithm()), jws.signingInput(),
+ Base64URL.encode(signature));
+ } catch (JOSEException e) {
+ verified = false;
+ }
+ }
+ if (!verified) {
+ throw new SDK.AssertionFailureException(id, SIGNATURE_INVALID,
+ "unable to verify the assertion signature with the selected key");
+ }
+
+ // 3.7: the claims
+ JsonElement assertionHash = jws.claims.get(ASSERTION_HASH);
+ JsonElement assertionSig = jws.claims.get(ASSERTION_SIG);
+ if (!isString(assertionHash) || !isString(assertionSig)) {
+ throw new SDK.AssertionFailureException(id, BINDING_MISMATCH,
+ "the binding claims assertionHash and assertionSig must both be strings");
+ }
+ return new Manifest.Assertion.HashValues(assertionHash.getAsString(), assertionSig.getAsString());
+ }
+
+ /**
+ * Section 5.1: the compact JWS of a JWS binding. The form is decided by which of
+ * {@code signature} and {@code bdo} are present, before {@code method} is looked at.
+ */
+ static String jwsBinding(JsonObject assertion, String id) {
+ JsonElement binding = assertion.get("binding");
+ if (binding == null) {
+ throw invalidBinding(id, "the assertion has no binding");
+ }
+ if (!binding.isJsonObject()) {
+ throw invalidBinding(id, "the binding is not a JSON object");
+ }
+ JsonObject members = binding.getAsJsonObject();
+ boolean hasSignature = members.has("signature");
+ boolean hasBindingObject = members.has("bdo");
+ if (hasSignature && hasBindingObject) {
+ throw invalidBinding(id, "the binding has both a signature and a bdo");
+ }
+ if (!hasSignature && !hasBindingObject) {
+ throw invalidBinding(id, "the binding has neither a signature nor a bdo");
+ }
+ if (hasBindingObject) {
+ throw new SDK.AssertionFailureException(id, UNSUPPORTED_BINDING,
+ "STANAG 4778 binding objects are not supported");
+ }
+ JsonElement signature = members.get("signature");
+ if (!isString(signature)) {
+ throw invalidBinding(id, "the binding signature is not a string");
+ }
+ // a missing method means jws
+ JsonElement method = members.get("method");
+ if (method != null) {
+ if (!isString(method)) {
+ throw invalidBinding(id, "the binding method is not a string");
+ }
+ String name = method.getAsString();
+ // current Java writers emitted the upper-case form
+ if (!"jws".equals(name) && !"JWS".equals(name)) {
+ throw new SDK.AssertionFailureException(id, UNSUPPORTED_BINDING,
+ "binding method " + quoted(name) + " is not supported");
+ }
+ }
+ return signature.getAsString();
+ }
+
+ /**
+ * A compact JWS, split, with its header and claims decoded and parsed as strict JSON. The
+ * signature is decoded only when it is checked, so a malformed one is
+ * {@code signature-invalid}.
+ */
+ private static final class Jws {
+ final String encodedHeader;
+ final String encodedPayload;
+ final String encodedSignature;
+ final JsonObject header;
+ final JsonObject claims;
+
+ private Jws(String[] parts, JsonObject header, JsonObject claims) {
+ this.encodedHeader = parts[0];
+ this.encodedPayload = parts[1];
+ this.encodedSignature = parts[2];
+ this.header = header;
+ this.claims = claims;
+ }
+
+ static Jws parse(String compact, String id) {
+ String[] parts = compact.split("\\.", -1);
+ if (parts.length != 3) {
+ throw invalidBinding(id, "the binding signature is not a compact JWS of three parts");
+ }
+ JsonObject header = decodeObject(parts[0], id, "header");
+ JsonObject claims = decodeObject(parts[1], id, "payload");
+ return new Jws(parts, header, claims);
+ }
+
+ byte[] signingInput() {
+ return (encodedHeader + "." + encodedPayload).getBytes(StandardCharsets.US_ASCII);
+ }
+
+ private static JsonObject decodeObject(String part, String id, String what) {
+ byte[] bytes = strictBase64Url(part);
+ if (bytes == null) {
+ throw invalidBinding(id, "the JWS " + what + " is not base64url");
+ }
+ JsonElement json;
+ try {
+ String text = StandardCharsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
+ .decode(ByteBuffer.wrap(bytes))
+ .toString();
+ json = AssertionJson.parseStrict(text);
+ } catch (CharacterCodingException e) {
+ throw invalidBinding(id, "the JWS " + what + " is not UTF-8");
+ } catch (IOException | SDK.AssertionFailureException e) {
+ throw invalidBinding(id, "the JWS " + what + " is not a strict JSON object");
+ }
+ if (!json.isJsonObject()) {
+ throw invalidBinding(id, "the JWS " + what + " is not a JSON object");
+ }
+ return json.getAsJsonObject();
+ }
+ }
+
+ /**
+ * Unpadded base64url, as JWS (RFC 7515) requires, or null: the URL alphabet only, no
+ * padding, and no non-zero bits after the last whole byte, so each byte string has exactly
+ * one accepted encoding.
+ */
+ static byte[] strictBase64Url(String text) {
+ if (text.length() % 4 == 1) {
+ return null;
+ }
+ for (int i = 0; i < text.length(); i++) {
+ char c = text.charAt(i);
+ boolean alphabet = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
+ || c == '-' || c == '_';
+ if (!alphabet) {
+ return null;
+ }
+ }
+ byte[] decoded = Base64.getUrlDecoder().decode(text);
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(decoded).equals(text) ? decoded : null;
+ }
+
+ /**
+ * Padded standard base64, as {@code x5c} (RFC 7515 Section 4.1.6) requires, or null: the
+ * standard alphabet, correct padding, and no non-zero bits after the last whole byte.
+ */
+ private static byte[] strictBase64(String text) {
+ if (text.length() % 4 != 0) {
+ return null;
+ }
+ int padding = text.endsWith("==") ? 2 : text.endsWith("=") ? 1 : 0;
+ for (int i = 0; i < text.length() - padding; i++) {
+ char c = text.charAt(i);
+ boolean alphabet = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
+ || c == '+' || c == '/';
+ if (!alphabet) {
+ return null;
+ }
+ }
+ byte[] decoded;
+ try {
+ decoded = Base64.getDecoder().decode(text);
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ return Base64.getEncoder().encodeToString(decoded).equals(text) ? decoded : null;
+ }
+
+ /** Section 6.2. */
+ private static void checkEmbeddedKeys(JsonObject header, String id, SelectedKey key) {
+ boolean hasJwk = header.has("jwk");
+ boolean hasX5c = header.has("x5c");
+ if (!hasJwk && !hasX5c) {
+ return;
+ }
+ if (key.isHmac()) {
+ throw new SDK.AssertionFailureException(id, INVALID_EMBEDDED_KEY,
+ "the JWS header embeds a key, but the selected key is an HMAC secret");
+ }
+ String fromJwk = hasJwk ? jwkIdentity(header.get("jwk"), id) : null;
+ String fromX5c = hasX5c ? x5cLeafIdentity(header.get("x5c"), id) : null;
+ if (fromJwk != null && fromX5c != null && !fromJwk.equals(fromX5c)) {
+ throw new SDK.AssertionFailureException(id, INVALID_EMBEDDED_KEY,
+ "the jwk and x5c headers hold different keys");
+ }
+ String embedded = fromJwk != null ? fromJwk : fromX5c;
+ if (!embedded.equals(key.identity)) {
+ throw new SDK.AssertionFailureException(id, SIGNER_NOT_PERMITTED,
+ "the key embedded in the JWS header is not the selected verification key");
+ }
+ }
+
+ /** The curves an EC jwk may name, with the byte length of each coordinate (RFC 7518 Section 6.2.1). */
+ private static final Map EC_CURVES = Map.of(
+ Curve.P_256.getName(), Curve.P_256, Curve.P_384.getName(), Curve.P_384, Curve.P_521.getName(), Curve.P_521);
+
+ /**
+ * The OKP signature curves of RFC 8037, with the length of {@code x} and the DER prefix of the
+ * key's SPKI. The key-agreement curves X25519 and X448 are not signing keys.
+ */
+ private enum OkpCurve {
+ ED25519("Ed25519", 32, "302a300506032b6570032100"),
+ ED448("Ed448", 57, "3043300506032b6571033a00");
+
+ final String jwkName;
+ final int length;
+ final byte[] spkiPrefix;
+
+ OkpCurve(String jwkName, int length, String spkiPrefix) {
+ this.jwkName = jwkName;
+ this.length = length;
+ try {
+ this.spkiPrefix = Hex.decodeHex(spkiPrefix);
+ } catch (DecoderException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ static OkpCurve named(String name) {
+ for (OkpCurve curve : values()) {
+ if (curve.jwkName.equals(name)) {
+ return curve;
+ }
+ }
+ return null;
+ }
+ }
+
+ /**
+ * The identity of an embedded public JWK, or {@code invalid-embedded-key} if it is not a
+ * well-formed public key of its key type. Members are read from strict base64url. A
+ * well-formed key of another type than the selected key simply has a different identity.
+ */
+ private static String jwkIdentity(JsonElement element, String id) {
+ if (!element.isJsonObject()) {
+ throw invalidEmbeddedKey(id, "the jwk header is not a JSON object");
+ }
+ JsonObject members = element.getAsJsonObject();
+ JsonElement kty = members.get("kty");
+ if (!isString(kty)) {
+ throw invalidEmbeddedKey(id, "the jwk header has no kty");
+ }
+ if ("oct".equals(kty.getAsString())) {
+ throw invalidEmbeddedKey(id, "the jwk header holds a symmetric key");
+ }
+ for (String member : PRIVATE_JWK_MEMBERS) {
+ if (members.has(member)) {
+ throw invalidEmbeddedKey(id, "the jwk header holds private key material");
+ }
+ }
+ switch (kty.getAsString()) {
+ case "RSA":
+ return rsaIdentity(jwkUnsigned(members, "n", id), jwkUnsigned(members, "e", id));
+ case "EC": {
+ JsonElement crv = members.get("crv");
+ Curve curve = isString(crv) ? EC_CURVES.get(crv.getAsString()) : null;
+ if (curve == null) {
+ throw invalidEmbeddedKey(id, "the jwk header names no supported EC curve");
+ }
+ byte[] x = jwkBytes(members, "x", id);
+ byte[] y = jwkBytes(members, "y", id);
+ int size = (curve.toECParameterSpec().getCurve().getField().getFieldSize() + 7) / 8;
+ if (x.length != size || y.length != size) {
+ throw invalidEmbeddedKey(id, "the jwk header has EC coordinates of the wrong length");
+ }
+ BigInteger affineX = new BigInteger(1, x);
+ BigInteger affineY = new BigInteger(1, y);
+ if (!isOnCurve(curve, affineX, affineY)) {
+ throw invalidEmbeddedKey(id, "the jwk header has an EC point that is not on its curve");
+ }
+ return ecIdentity(curve, affineX, affineY);
+ }
+ case "OKP": {
+ JsonElement crv = members.get("crv");
+ OkpCurve curve = isString(crv) ? OkpCurve.named(crv.getAsString()) : null;
+ if (curve == null) {
+ throw invalidEmbeddedKey(id, "the jwk header names no OKP signature curve");
+ }
+ byte[] x = jwkBytes(members, "x", id);
+ if (x.length != curve.length) {
+ throw invalidEmbeddedKey(id, "the jwk header has an OKP key of the wrong length");
+ }
+ return okpIdentity(curve, x);
+ }
+ default:
+ throw invalidEmbeddedKey(id, "the jwk header has an unsupported key type");
+ }
+ }
+
+ /** An RSA jwk integer, in the minimum number of octets RFC 7518 Section 6.3.1 requires. */
+ private static BigInteger jwkUnsigned(JsonObject jwk, String member, String id) {
+ byte[] bytes = jwkBytes(jwk, member, id);
+ if (bytes[0] == 0) {
+ throw invalidEmbeddedKey(id, "the jwk member " + member + " has a leading zero octet");
+ }
+ return new BigInteger(1, bytes);
+ }
+
+ private static byte[] jwkBytes(JsonObject jwk, String member, String id) {
+ JsonElement value = jwk.get(member);
+ byte[] bytes = isString(value) ? strictBase64Url(value.getAsString()) : null;
+ if (bytes == null || bytes.length == 0) {
+ throw invalidEmbeddedKey(id, "the jwk member " + member + " is not base64url");
+ }
+ return bytes;
+ }
+
+ private static boolean isOnCurve(Curve curve, BigInteger x, BigInteger y) {
+ ECParameterSpec spec = curve.toECParameterSpec();
+ BigInteger p = ((ECFieldFp) spec.getCurve().getField()).getP();
+ if (x.compareTo(p) >= 0 || y.compareTo(p) >= 0) {
+ return false;
+ }
+ BigInteger right = x.pow(3).add(spec.getCurve().getA().multiply(x)).add(spec.getCurve().getB());
+ return y.pow(2).subtract(right).mod(p).signum() == 0;
+ }
+
+ /**
+ * The identity of the public key in the leaf of an embedded {@code x5c}, or
+ * {@code invalid-embedded-key}. Certificates after the leaf must decode as base64 and are
+ * otherwise ignored: never parsed, no chain is built or validated, and nothing is said
+ * about validity.
+ */
+ private static String x5cLeafIdentity(JsonElement element, String id) {
+ if (!element.isJsonArray() || element.getAsJsonArray().size() == 0) {
+ throw invalidEmbeddedKey(id, "the x5c header is not a nonempty array");
+ }
+ JsonArray chain = element.getAsJsonArray();
+ byte[] leaf = null;
+ for (JsonElement certificate : chain) {
+ byte[] der = isString(certificate) ? strictBase64(certificate.getAsString()) : null;
+ if (der == null || der.length == 0) {
+ throw invalidEmbeddedKey(id, "the x5c header holds a value that is not padded standard base64");
+ }
+ if (leaf == null) {
+ leaf = der;
+ }
+ }
+ X509Certificate certificate;
+ try {
+ certificate = X509CertUtils.parseWithException(leaf);
+ } catch (CertificateException | RuntimeException e) {
+ throw invalidEmbeddedKey(id, "the x5c leaf is not a DER certificate");
+ }
+ // the parser also takes PEM text and ignores bytes after the certificate; accept only
+ // exactly one DER certificate
+ try {
+ if (certificate == null || !Arrays.equals(certificate.getEncoded(), leaf)) {
+ throw invalidEmbeddedKey(id, "the x5c leaf is not exactly one DER certificate");
+ }
+ } catch (CertificateException e) {
+ throw invalidEmbeddedKey(id, "the x5c leaf is not a DER certificate");
+ }
+ return publicKeyIdentity(certificate.getPublicKey());
+ }
+
+ /**
+ * Two keys are the same key exactly when their identities are equal. This is equivalent to
+ * comparing RFC 7638 SHA-256 thumbprints over the required public members: an embedded jwk
+ * must already use the canonical encoding (minimal RSA integers, full-length EC
+ * coordinates, strict base64url), so its members encode exactly these values.
+ */
+ static String publicKeyIdentity(PublicKey publicKey) {
+ if (publicKey instanceof RSAPublicKey) {
+ RSAPublicKey rsa = (RSAPublicKey) publicKey;
+ return rsaIdentity(rsa.getModulus(), rsa.getPublicExponent());
+ }
+ if (publicKey instanceof ECPublicKey) {
+ ECPublicKey ec = (ECPublicKey) publicKey;
+ Curve curve = Curve.forECParameterSpec(ec.getParams());
+ if (curve != null && EC_CURVES.containsKey(curve.getName())) {
+ return ecIdentity(curve, ec.getW().getAffineX(), ec.getW().getAffineY());
+ }
+ }
+ byte[] spki = publicKey.getEncoded();
+ for (OkpCurve curve : OkpCurve.values()) {
+ if (spki.length == curve.spkiPrefix.length + curve.length
+ && Arrays.equals(spki, 0, curve.spkiPrefix.length, curve.spkiPrefix, 0, curve.spkiPrefix.length)) {
+ return okpIdentity(curve, Arrays.copyOfRange(spki, curve.spkiPrefix.length, spki.length));
+ }
+ }
+ return "spki:" + Base64.getEncoder().encodeToString(spki);
+ }
+
+ private static String rsaIdentity(BigInteger modulus, BigInteger exponent) {
+ return "rsa:" + modulus.toString(16) + ":" + exponent.toString(16);
+ }
+
+ private static String ecIdentity(Curve curve, BigInteger x, BigInteger y) {
+ return "ec:" + curve.getName() + ":" + x.toString(16) + ":" + y.toString(16);
+ }
+
+ private static String okpIdentity(OkpCurve curve, byte[] x) {
+ return "okp:" + curve.jwkName + ":" + Hex.encodeHexString(x);
+ }
+
+ private static final byte[] PEM_MARKER = "-----BEGIN".getBytes(StandardCharsets.US_ASCII);
+
+ /** True if the secret contains a PEM armour line, as public-key text does. */
+ static boolean isPemText(byte[] secret) {
+ outer:
+ for (int i = 0; i + PEM_MARKER.length <= secret.length; i++) {
+ for (int j = 0; j < PEM_MARKER.length; j++) {
+ if (secret[i + j] != PEM_MARKER[j]) {
+ continue outer;
+ }
+ }
+ return true;
+ }
+ return false;
+ }
+
+ private static boolean isString(JsonElement element) {
+ return element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isString();
+ }
+
+ private static String quoted(String untrusted) {
+ return "\"" + (untrusted.length() > MAX_QUOTED ? untrusted.substring(0, MAX_QUOTED) + "..." : untrusted) + "\"";
+ }
+
+ private static SDK.AssertionFailureException invalidBinding(String id, String detail) {
+ return new SDK.AssertionFailureException(id, INVALID_BINDING, detail);
+ }
+
+ private static SDK.AssertionFailureException invalidEmbeddedKey(String id, String detail) {
+ return new SDK.AssertionFailureException(id, INVALID_EMBEDDED_KEY, detail);
+ }
+
+ private static SDK.AssertionFailureException configurationError(String id, String detail) {
+ return new SDK.AssertionFailureException(id, CONFIGURATION_ERROR, detail);
+ }
+}
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Config.java b/sdk/src/main/java/io/opentdf/platform/sdk/Config.java
index f4dbc4f5..9299148c 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/Config.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/Config.java
@@ -106,21 +106,60 @@ public static KASInfo fromSimpleKasKey(SimpleKasKey ki) {
}
}
+ /**
+ * The keys a reader verifies assertion bindings with.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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 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;
}
}
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java b/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java
index 76426857..e7796f10 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java
@@ -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;
@@ -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;
@@ -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
*/
@@ -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.
+ *
+ * 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)
@@ -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();
@@ -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");
@@ -1425,7 +1359,8 @@ 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");
@@ -1433,6 +1368,13 @@ private static RuntimeException readFailure(FailureTrackingReader reader, JsonPa
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;
}
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/StrictJsonReader.java b/sdk/src/main/java/io/opentdf/platform/sdk/StrictJsonReader.java
index 32f536b3..d7e61825 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/StrictJsonReader.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/StrictJsonReader.java
@@ -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];
diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
index 51b6e977..524a9da0 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
@@ -26,7 +26,6 @@
import java.security.*;
import java.security.interfaces.ECPublicKey;
import java.security.spec.InvalidKeySpecException;
-import java.text.ParseException;
import java.util.*;
/**
@@ -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);
}
}
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/AssertionVerificationTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/AssertionVerificationTest.java
new file mode 100644
index 00000000..6162d81c
--- /dev/null
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/AssertionVerificationTest.java
@@ -0,0 +1,668 @@
+package io.opentdf.platform.sdk;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.crypto.MACSigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.commons.codec.binary.Hex;
+import org.apache.commons.compress.utils.SeekableInMemoryByteChannel;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.interfaces.RSAPublicKey;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Assertion verification on read: the key is selected once from the reader's configuration
+ * (per-ID key, then default key, then the payload key with HS256), keys embedded in the JWS
+ * header never add trust, and the hash covers the assertion as received.
+ *
+ * Every negative case has a positive control showing that the same reader configuration accepts
+ * the authorized signer.
+ */
+class AssertionVerificationTest {
+
+ private static final String KAS_URL = "https://example.com/kas0";
+ private static final String ID = "assertion1";
+ private static final byte[] PLAINTEXT = "assertion verification".getBytes(StandardCharsets.UTF_8);
+
+ private static KeyPair kasKeyPair;
+ /** The signer the reader is configured with. */
+ private static KeyPair issuer;
+ /** A key the reader is not configured with. */
+ private static KeyPair unrelated;
+
+ private static final SDK.KAS KAS = new SDK.KAS() {
+ @Override
+ public void close() {
+ // nothing to release
+ }
+
+ @Override
+ public Config.KASInfo getPublicKey(Config.KASInfo kasInfo) {
+ var resolved = new Config.KASInfo();
+ resolved.URL = kasInfo.URL;
+ resolved.KID = "r1";
+ resolved.PublicKey = CryptoUtils.getPublicKeyPEM(kasKeyPair.getPublic());
+ return resolved;
+ }
+
+ @Override
+ public byte[] unwrap(Manifest.KeyAccess keyAccess, String policy, KeyType sessionKeyType) {
+ return new AsymDecryption(kasKeyPair.getPrivate()).decrypt(Base64.getDecoder().decode(keyAccess.wrappedKey));
+ }
+
+ @Override
+ public KASKeyCache getKeyCache() {
+ return new KASKeyCache();
+ }
+ };
+
+ @BeforeAll
+ static void generateKeys() {
+ kasKeyPair = CryptoUtils.generateRSAKeypair();
+ issuer = CryptoUtils.generateRSAKeypair();
+ unrelated = CryptoUtils.generateRSAKeypair();
+ }
+
+ // ------------------------------------------------------------------ fixtures
+
+ private static AssertionConfig assertionConfig(String id, AssertionConfig.AssertionKey signingKey) {
+ var config = new AssertionConfig();
+ config.id = id;
+ config.type = AssertionConfig.Type.HandlingAssertion;
+ config.scope = AssertionConfig.Scope.Payload;
+ config.appliesToState = AssertionConfig.AppliesToState.Unencrypted;
+ config.statement = new AssertionConfig.Statement();
+ config.statement.format = "json+stanag5636";
+ config.statement.schema = "urn:nato:stanag:5636:A:1:elements:json";
+ config.statement.value = "{\"ocl\":{\"cls\":\"SECRET\"}}";
+ config.signingKey = signingKey;
+ return config;
+ }
+
+ private static AssertionConfig.AssertionKey issuerSigningKey() {
+ return new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.RS256, issuer.getPrivate());
+ }
+
+ private static byte[] createTdf(AssertionConfig... assertions) throws IOException {
+ var kasInfo = new Config.KASInfo();
+ kasInfo.URL = KAS_URL;
+ var out = new ByteArrayOutputStream();
+ new TDF(new FakeServicesBuilder().setKas(KAS).build()).createTDF(new ByteArrayInputStream(PLAINTEXT), out,
+ Config.newTDFConfig(Config.withAutoconfigure(false), Config.withKasInformation(kasInfo),
+ Config.withAssertionConfig(assertions)));
+ return out.toByteArray();
+ }
+
+ private static TDF.Reader load(byte[] tdf, Config.AssertionVerificationKeys keys) throws IOException {
+ var options = new ArrayList>();
+ options.add(Config.WithIgnoreKasAllowlist(true));
+ if (keys != null) {
+ options.add(Config.withAssertionVerificationKeys(keys));
+ }
+ @SuppressWarnings("unchecked")
+ Consumer[] asArray = options.toArray(new Consumer[0]);
+ return new TDF(new FakeServicesBuilder().setKas(KAS).build())
+ .loadTDF(new SeekableInMemoryByteChannel(tdf), Config.newTDFReaderConfig(asArray));
+ }
+
+ private static void assertDecrypts(byte[] tdf, Config.AssertionVerificationKeys keys) throws IOException {
+ var reader = load(tdf, keys);
+ var out = new ByteArrayOutputStream();
+ reader.readPayload(out);
+ assertThat(out.toByteArray()).isEqualTo(PLAINTEXT);
+ }
+
+ private static void assertFails(byte[] tdf, Config.AssertionVerificationKeys keys, AssertionFailureReason reason,
+ String id) {
+ assertThatThrownBy(() -> load(tdf, keys))
+ .isInstanceOfSatisfying(SDK.AssertionFailureException.class, e -> {
+ assertThat(e.getReason()).isEqualTo(reason);
+ assertThat(e.getAssertionId()).isEqualTo(id);
+ assertThat(e.getMessage()).contains("reason=" + reason.code()).contains("assertion")
+ .contains("tamper");
+ if (id != null) {
+ assertThat(e.getMessage()).contains("id: " + id);
+ }
+ })
+ .isInstanceOf(SDK.AssertionException.class);
+ }
+
+ private static Config.AssertionVerificationKeys perId(String id, AssertionConfig.AssertionKey key) {
+ var keys = new Config.AssertionVerificationKeys();
+ keys.keys.put(id, key);
+ return keys;
+ }
+
+ private static Config.AssertionVerificationKeys defaultKey(AssertionConfig.AssertionKey key) {
+ var keys = new Config.AssertionVerificationKeys();
+ keys.defaultKey = key;
+ return keys;
+ }
+
+ private static AssertionConfig.AssertionKey rs256(KeyPair keyPair) {
+ return new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.RS256, keyPair.getPublic());
+ }
+
+ private static AssertionConfig.AssertionKey hs256(byte[] secret) {
+ return new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.HS256, secret);
+ }
+
+ // ------------------------------------------------------------ manifest surgery
+
+ private static JsonObject manifestOf(byte[] tdf) throws IOException {
+ return JsonParser.parseString(new String(entry(tdf, TDFWriter.TDF_MANIFEST_FILE_NAME), StandardCharsets.UTF_8))
+ .getAsJsonObject();
+ }
+
+ private static byte[] entry(byte[] tdf, String name) throws IOException {
+ for (var entry : new ZipReader(new SeekableInMemoryByteChannel(tdf)).getEntries()) {
+ if (entry.getName().equals(name)) {
+ try (InputStream data = entry.getData()) {
+ return data.readAllBytes();
+ }
+ }
+ }
+ throw new IllegalArgumentException("no entry named " + name);
+ }
+
+ /** A copy of the TDF with its manifest JSON edited. */
+ private static byte[] rewrite(byte[] tdf, Consumer edit) throws IOException {
+ var manifest = manifestOf(tdf);
+ edit.accept(manifest);
+ var out = new ByteArrayOutputStream();
+ var writer = new TDFWriter(out);
+ try (var payload = writer.payload()) {
+ payload.write(entry(tdf, TDFWriter.TDF_PAYLOAD_FILE_NAME));
+ }
+ try (var manifestOut = writer.manifest()) {
+ manifestOut.write(new Gson().toJson(manifest).getBytes(StandardCharsets.UTF_8));
+ }
+ writer.finish();
+ return out.toByteArray();
+ }
+
+ private static JsonArray assertions(JsonObject manifest) {
+ return manifest.getAsJsonArray("assertions");
+ }
+
+ private static byte[] aggregate(JsonObject manifest) {
+ var aggregate = new ByteArrayOutputStream();
+ for (var segment : manifest.getAsJsonObject("encryptionInformation").getAsJsonObject("integrityInformation")
+ .getAsJsonArray("segments")) {
+ aggregate.writeBytes(Base64.getDecoder().decode(segment.getAsJsonObject().get("hash").getAsString()));
+ }
+ return aggregate.toByteArray();
+ }
+
+ enum Embedded { NONE, JWK, X5C, BOTH }
+
+ /** The wire form of an assertion, without its binding. */
+ private static JsonObject wire(String id, String classification) {
+ var statement = new JsonObject();
+ statement.addProperty("format", "json+stanag5636");
+ statement.addProperty("schema", "urn:nato:stanag:5636:A:1:elements:json");
+ statement.addProperty("value", "{\"ocl\":{\"cls\":\"" + classification + "\"}}");
+ var assertion = new JsonObject();
+ assertion.addProperty("id", id);
+ assertion.addProperty("type", "handling");
+ assertion.addProperty("scope", "payload");
+ assertion.addProperty("appliesToState", "unencrypted");
+ assertion.add("statement", statement);
+ return assertion;
+ }
+
+ /**
+ * Binds {@code assertion} to {@code manifest}'s payload exactly as a writer would, signing
+ * with {@code signer} (RS256) and embedding {@code embedded} in the JWS header.
+ */
+ private static JsonObject bind(JsonObject manifest, JsonObject assertion, KeyPair signer, KeyPair embedded,
+ Embedded what) throws Exception {
+ var header = new JWSHeader.Builder(JWSAlgorithm.RS256);
+ if (what == Embedded.JWK || what == Embedded.BOTH) {
+ header.jwk(new RSAKey.Builder((RSAPublicKey) embedded.getPublic()).build());
+ }
+ if (what == Embedded.X5C || what == Embedded.BOTH) {
+ var certificate = TestUtil.createTestCertificate(embedded.getPublic(), embedded.getPrivate());
+ header.x509CertChain(List.of(com.nimbusds.jose.util.Base64.encode(certificate.getEncoded())));
+ }
+ var jwt = new SignedJWT(header.build(), claims(manifest, assertion));
+ jwt.sign(new RSASSASigner(signer.getPrivate()));
+ return withBinding(assertion, jwt.serialize());
+ }
+
+ private static JsonObject bindHs256(JsonObject manifest, JsonObject assertion, byte[] secret,
+ Consumer headerEdit) throws Exception {
+ var header = new JWSHeader.Builder(JWSAlgorithm.HS256);
+ headerEdit.accept(header);
+ var jwt = new SignedJWT(header.build(), claims(manifest, assertion));
+ jwt.sign(new MACSigner(secret));
+ return withBinding(assertion, jwt.serialize());
+ }
+
+ private static JWTClaimsSet claims(JsonObject manifest, JsonObject assertion) throws Exception {
+ String hash = Manifest.Assertion.computeAssertionHash(assertion.toString());
+ byte[] aggregate = aggregate(manifest);
+ byte[] hashBytes = Hex.decodeHex(hash);
+ byte[] bound = new byte[aggregate.length + hashBytes.length];
+ System.arraycopy(aggregate, 0, bound, 0, aggregate.length);
+ System.arraycopy(hashBytes, 0, bound, aggregate.length, hashBytes.length);
+ return new JWTClaimsSet.Builder()
+ .claim("assertionHash", hash)
+ .claim("assertionSig", Base64.getEncoder().encodeToString(bound))
+ .build();
+ }
+
+ private static JsonObject withBinding(JsonObject assertion, String signature) {
+ var bound = assertion.deepCopy();
+ var binding = new JsonObject();
+ binding.addProperty("method", "jws");
+ binding.addProperty("signature", signature);
+ bound.add("binding", binding);
+ return bound;
+ }
+
+ // ---------------------------------------------------------------- controls
+
+ @Test
+ void theConfiguredSignerVerifiesPerIdAndAsTheDefault() throws Exception {
+ var tdf = createTdf(assertionConfig(ID, issuerSigningKey()));
+ assertDecrypts(tdf, perId(ID, rs256(issuer)));
+ assertDecrypts(tdf, defaultKey(rs256(issuer)));
+ // an RSA JWK is accepted as the configured key too
+ assertDecrypts(tdf, perId(ID, new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.RS256,
+ new RSAKey.Builder((RSAPublicKey) issuer.getPublic()).build())));
+ }
+
+ @Test
+ void anUnkeyedAssertionVerifiesWithThePayloadKey() throws Exception {
+ var tdf = createTdf(assertionConfig(ID, null));
+ assertDecrypts(tdf, null);
+ assertDecrypts(tdf, new Config.AssertionVerificationKeys());
+ // a zero-value default key is the same as none
+ assertDecrypts(tdf, defaultKey(new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.NotDefined, null)));
+ }
+
+ /** The Java writer may embed its key; that still verifies when the reader configured the same key. */
+ @ParameterizedTest
+ @EnumSource(value = Embedded.class, names = {"JWK", "X5C", "BOTH"})
+ void javaWriterEmbeddedKeysVerifyWithThatKeyConfigured(Embedded embedded) throws Exception {
+ var signingKey = issuerSigningKey();
+ if (embedded == Embedded.JWK || embedded == Embedded.BOTH) {
+ signingKey.withJwk(new RSAKey.Builder((RSAPublicKey) issuer.getPublic()).build());
+ }
+ if (embedded == Embedded.X5C || embedded == Embedded.BOTH) {
+ var certificate = TestUtil.createTestCertificate(issuer.getPublic(), issuer.getPrivate());
+ signingKey.withX5c(List.of(com.nimbusds.jose.util.Base64.encode(certificate.getEncoded())));
+ }
+ var tdf = createTdf(assertionConfig(ID, signingKey));
+ assertDecrypts(tdf, perId(ID, rs256(issuer)));
+ assertDecrypts(tdf, defaultKey(rs256(issuer)));
+ // the embedded key alone grants nothing
+ assertFails(tdf, null, AssertionFailureReason.ALGORITHM_MISMATCH, ID);
+ assertFails(tdf, defaultKey(rs256(unrelated)), AssertionFailureReason.SIGNER_NOT_PERMITTED, ID);
+ }
+
+ // ------------------------------------------------- embedded keys of other signers
+
+ static Stream unrelatedSignerCases() {
+ var arguments = new ArrayList();
+ for (var embedded : List.of(Embedded.JWK, Embedded.X5C, Embedded.BOTH)) {
+ for (var replace : List.of(false, true)) {
+ for (var asDefault : List.of(false, true)) {
+ arguments.add(Arguments.of(embedded, replace, asDefault));
+ }
+ }
+ }
+ return arguments.stream();
+ }
+
+ /**
+ * A binding signed by an unrelated key, with that key embedded as jwk, x5c or both, fails
+ * against the configured key, per ID or default, whether its assertion is added alongside
+ * the original or replaces it.
+ */
+ @ParameterizedTest(name = "embedded={0} replace={1} defaultKey={2}")
+ @MethodSource("unrelatedSignerCases")
+ void anUnrelatedSignerWithAnEmbeddedKeyFailsAgainstTheConfiguredKey(Embedded embedded, boolean replace,
+ boolean asDefault)
+ throws Exception {
+ var original = createTdf(assertionConfig(ID, issuerSigningKey()));
+ var keys = asDefault ? defaultKey(rs256(issuer)) : perId(ID, rs256(issuer));
+ assertDecrypts(original, keys);
+
+ var edited = rewrite(original, manifest -> {
+ try {
+ var other = bind(manifest, wire(ID, "UNCLASSIFIED"), unrelated, unrelated, embedded);
+ if (replace) {
+ assertions(manifest).set(0, other);
+ } else {
+ assertions(manifest).add(other);
+ }
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ });
+ assertFails(edited, keys, AssertionFailureReason.SIGNER_NOT_PERMITTED, ID);
+ }
+
+ @Test
+ void anAssertionUnderAnotherIdGetsTheDefaultOrPayloadKeyNotItsHeaderKey() throws Exception {
+ var original = createTdf(assertionConfig(ID, issuerSigningKey()));
+ var edited = rewrite(original, manifest -> {
+ try {
+ assertions(manifest).add(bind(manifest, wire("other", "UNCLASSIFIED"), unrelated, unrelated, Embedded.JWK));
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ });
+ // with a default pin, the new id gets the default key
+ assertDecrypts(original, defaultKey(rs256(issuer)));
+ assertFails(edited, defaultKey(rs256(issuer)), AssertionFailureReason.SIGNER_NOT_PERMITTED, "other");
+ // with only a per-ID pin, the new id gets the payload key, never the header's key
+ assertDecrypts(original, perId(ID, rs256(issuer)));
+ assertFails(edited, perId(ID, rs256(issuer)), AssertionFailureReason.ALGORITHM_MISMATCH, "other");
+ }
+
+ @Test
+ void bindingsFromOtherKeysFailWhenThePayloadKeyIsSelected() throws Exception {
+ var original = createTdf(assertionConfig(ID, null));
+ assertDecrypts(original, null);
+
+ for (var embedded : List.of(Embedded.JWK, Embedded.X5C)) {
+ var rsaBinding = rewrite(original, manifest -> {
+ try {
+ assertions(manifest).set(0, bind(manifest, wire(ID, "UNCLASSIFIED"), unrelated, unrelated, embedded));
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ });
+ assertFails(rsaBinding, null, AssertionFailureReason.ALGORITHM_MISMATCH, ID);
+ }
+
+ byte[] otherSecret = AesGcm.generateKey();
+ var hmacWithJwk = rewrite(original, manifest -> {
+ try {
+ assertions(manifest).set(0, bindHs256(manifest, wire(ID, "UNCLASSIFIED"), otherSecret,
+ header -> header.jwk(new RSAKey.Builder((RSAPublicKey) unrelated.getPublic()).build())));
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ });
+ assertFails(hmacWithJwk, null, AssertionFailureReason.INVALID_EMBEDDED_KEY, ID);
+
+ var hmacWithoutHeaderKey = rewrite(original, manifest -> {
+ try {
+ assertions(manifest).set(0, bindHs256(manifest, wire(ID, "UNCLASSIFIED"), otherSecret, header -> { }));
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ });
+ assertFails(hmacWithoutHeaderKey, null, AssertionFailureReason.SIGNATURE_INVALID, ID);
+ }
+
+ @Test
+ void theSelectedKeysOwnJwkSignedByAnotherKeyFailsTheSignature() throws Exception {
+ var original = createTdf(assertionConfig(ID, issuerSigningKey()));
+ var edited = rewrite(original, manifest -> {
+ try {
+ assertions(manifest).set(0, bind(manifest, wire(ID, "UNCLASSIFIED"), unrelated, issuer, Embedded.BOTH));
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ });
+ assertFails(edited, perId(ID, rs256(issuer)), AssertionFailureReason.SIGNATURE_INVALID, ID);
+ }
+
+ @Test
+ void contradictoryEmbeddedKeysAreRejected() throws Exception {
+ var original = createTdf(assertionConfig(ID, issuerSigningKey()));
+ var edited = rewrite(original, manifest -> {
+ try {
+ var assertion = wire(ID, "SECRET");
+ var certificate = TestUtil.createTestCertificate(unrelated.getPublic(), unrelated.getPrivate());
+ var header = new JWSHeader.Builder(JWSAlgorithm.RS256)
+ .jwk(new RSAKey.Builder((RSAPublicKey) issuer.getPublic()).build())
+ .x509CertChain(List.of(com.nimbusds.jose.util.Base64.encode(certificate.getEncoded())));
+ var jwt = new SignedJWT(header.build(), claims(manifest, assertion));
+ jwt.sign(new RSASSASigner(issuer.getPrivate()));
+ assertions(manifest).set(0, withBinding(assertion, jwt.serialize()));
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ });
+ assertFails(edited, perId(ID, rs256(issuer)), AssertionFailureReason.INVALID_EMBEDDED_KEY, ID);
+ }
+
+ // ------------------------------------------------------------ selection order
+
+ @Test
+ void aFailedPerIdKeyDoesNotFallBackToTheDefaultKey() throws Exception {
+ var tdf = createTdf(assertionConfig(ID, issuerSigningKey()));
+ var keys = perId(ID, rs256(unrelated));
+ keys.defaultKey = rs256(issuer);
+ assertFails(tdf, keys, AssertionFailureReason.SIGNATURE_INVALID, ID);
+ // control: the same default verifies when there is no per-ID entry
+ assertDecrypts(tdf, defaultKey(rs256(issuer)));
+ }
+
+ @Test
+ void aFailedConfiguredKeyDoesNotFallBackToThePayloadKey() throws Exception {
+ var tdf = createTdf(assertionConfig(ID, null));
+ assertDecrypts(tdf, null);
+ assertFails(tdf, perId(ID, hs256(AesGcm.generateKey())), AssertionFailureReason.SIGNATURE_INVALID, ID);
+ assertFails(tdf, defaultKey(hs256(AesGcm.generateKey())), AssertionFailureReason.SIGNATURE_INVALID, ID);
+ assertFails(tdf, defaultKey(rs256(issuer)), AssertionFailureReason.ALGORITHM_MISMATCH, ID);
+ }
+
+ @Test
+ void perIdKeysMatchTheExactId() throws Exception {
+ var tdf = createTdf(assertionConfig(ID, issuerSigningKey()));
+ // a differently cased entry does not apply, so the payload key is selected
+ assertFails(tdf, perId("Assertion1", rs256(issuer)), AssertionFailureReason.ALGORITHM_MISMATCH, ID);
+ assertDecrypts(tdf, perId(ID, rs256(issuer)));
+ }
+
+ @Test
+ void theAlgorithmComesFromTheConfiguredKey() throws Exception {
+ var tdf = createTdf(assertionConfig(ID, issuerSigningKey()));
+ assertFails(tdf, perId(ID, hs256(AesGcm.generateKey())), AssertionFailureReason.ALGORITHM_MISMATCH, ID);
+ }
+
+ static Stream invalidConfiguredKeys() {
+ var rsaPublic = CryptoUtils.generateRSAKeypair().getPublic();
+ return Stream.of(
+ Arguments.of("empty per-ID entry", perId(ID, null)),
+ Arguments.of("RS256 with a secret", perId(ID,
+ new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.RS256, AesGcm.generateKey()))),
+ Arguments.of("HS256 with public-key material", perId(ID,
+ new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.HS256, rsaPublic))),
+ Arguments.of("HS256 with an empty secret", perId(ID, hs256(new byte[0]))),
+ Arguments.of("HS256 secret shorter than 256 bits", perId(ID, hs256(new byte[16]))),
+ Arguments.of("no algorithm", perId(ID,
+ new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.NotDefined, rsaPublic))),
+ Arguments.of("default key with a key but no algorithm", defaultKey(
+ new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.NotDefined, rsaPublic))),
+ Arguments.of("default key with an algorithm but no key", defaultKey(
+ new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.RS256, null))));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("invalidConfiguredKeys")
+ void anInvalidConfiguredKeyIsAConfigurationError(String name, Config.AssertionVerificationKeys keys)
+ throws Exception {
+ // signed with the payload key, which would verify if the reader fell through to it
+ var tdf = createTdf(assertionConfig(ID, null));
+ assertDecrypts(tdf, null);
+ assertFails(tdf, keys, AssertionFailureReason.CONFIGURATION_ERROR, ID);
+ }
+
+ // --------------------------------------------------------- hash as received
+
+ @Test
+ void unmodelledMembersAreAuthenticated() throws Exception {
+ var original = createTdf(assertionConfig(ID, issuerSigningKey()));
+ var signed = rewrite(original, manifest -> {
+ try {
+ var assertion = wire(ID, "SECRET");
+ assertion.add("extensions", JsonParser.parseString("{\"releasableTo\":[\"USA\"]}"));
+ assertion.getAsJsonObject("statement").addProperty("hash", "part of the statement");
+ assertions(manifest).set(0, bind(manifest, assertion, issuer, issuer, Embedded.NONE));
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ });
+ assertDecrypts(signed, perId(ID, rs256(issuer)));
+
+ var tampered = rewrite(signed, manifest -> assertions(manifest).get(0).getAsJsonObject()
+ .getAsJsonObject("extensions").getAsJsonArray("releasableTo").add("XXX"));
+ assertFails(tampered, perId(ID, rs256(issuer)), AssertionFailureReason.BINDING_MISMATCH, ID);
+
+ var nestedTamper = rewrite(signed, manifest -> assertions(manifest).get(0).getAsJsonObject()
+ .getAsJsonObject("statement").addProperty("hash", "changed"));
+ assertFails(nestedTamper, perId(ID, rs256(issuer)), AssertionFailureReason.BINDING_MISMATCH, ID);
+
+ var added = rewrite(original, manifest ->
+ assertions(manifest).get(0).getAsJsonObject().addProperty("note", "added after signing"));
+ assertFails(added, perId(ID, rs256(issuer)), AssertionFailureReason.BINDING_MISMATCH, ID);
+ }
+
+ @Test
+ void anObjectValuedStatementIsHashedAsAnObject() throws Exception {
+ var original = createTdf(assertionConfig(ID, issuerSigningKey()));
+ var acp240 = AssertionHashTest.acp240Assertion();
+ acp240.addProperty("id", ID);
+ var signed = rewrite(original, manifest -> {
+ try {
+ assertions(manifest).set(0, bind(manifest, acp240, issuer, issuer, Embedded.NONE));
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ });
+ var reader = load(signed, perId(ID, rs256(issuer)));
+ var read = reader.getManifest().assertions.get(0);
+ assertThat(AssertionJson.hash(read.received())).isEqualTo(Manifest.Assertion.computeAssertionHash(acp240.toString()));
+ assertThat(read.received().getAsJsonObject("statement").get("value").isJsonObject()).isTrue();
+
+ var changed = rewrite(signed, manifest -> assertions(manifest).get(0).getAsJsonObject()
+ .getAsJsonObject("statement").getAsJsonObject("value").getAsJsonObject("ocl")
+ .addProperty("cls", "UNCLASSIFIED"));
+ assertFails(changed, perId(ID, rs256(issuer)), AssertionFailureReason.BINDING_MISMATCH, ID);
+ }
+
+ // ----------------------------------------------------- ids and binding forms
+
+ @Test
+ void anInvalidIdFailsVerificationButNotParsing() throws Exception {
+ var original = createTdf(assertionConfig(ID, null));
+ for (JsonElement badId : List.of(new com.google.gson.JsonPrimitive(5), new com.google.gson.JsonPrimitive(""))) {
+ var edited = rewrite(original, manifest -> assertions(manifest).get(0).getAsJsonObject().add("id", badId));
+ assertFails(edited, null, AssertionFailureReason.INVALID_ID, null);
+ var disabled = new TDF(new FakeServicesBuilder().setKas(KAS).build()).loadTDF(
+ new SeekableInMemoryByteChannel(edited),
+ Config.newTDFReaderConfig(Config.WithIgnoreKasAllowlist(true),
+ Config.withDisableAssertionVerification(true)));
+ // the typed view never coerces a non-string id
+ assertThat(disabled.getManifest().assertions.get(0).id).isEqualTo(badId.getAsJsonPrimitive().isString() ? "" : null);
+ }
+ var missing = rewrite(original, manifest -> assertions(manifest).get(0).getAsJsonObject().remove("id"));
+ assertFails(missing, null, AssertionFailureReason.INVALID_ID, null);
+ }
+
+ @Test
+ void bindingFormsFollowTheSpec() throws Exception {
+ var original = createTdf(assertionConfig(ID, issuerSigningKey()));
+ var keys = perId(ID, rs256(issuer));
+
+ var upperCase = rewrite(original, manifest -> binding(manifest).addProperty("method", "JWS"));
+ assertDecrypts(upperCase, keys);
+ var noMethod = rewrite(original, manifest -> binding(manifest).remove("method"));
+ assertDecrypts(noMethod, keys);
+
+ assertFails(rewrite(original, manifest -> binding(manifest).addProperty("method", "jwt")), keys,
+ AssertionFailureReason.UNSUPPORTED_BINDING, ID);
+ assertFails(rewrite(original, manifest -> {
+ var binding = binding(manifest);
+ binding.remove("signature");
+ binding.addProperty("bdo", "PGJkbz4=");
+ }), keys, AssertionFailureReason.UNSUPPORTED_BINDING, ID);
+ assertFails(rewrite(original, manifest -> binding(manifest).addProperty("bdo", "PGJkbz4=")), keys,
+ AssertionFailureReason.INVALID_BINDING, ID);
+ assertFails(rewrite(original, manifest -> binding(manifest).remove("signature")), keys,
+ AssertionFailureReason.INVALID_BINDING, ID);
+ assertFails(rewrite(original, manifest -> assertions(manifest).get(0).getAsJsonObject().remove("binding")),
+ keys, AssertionFailureReason.INVALID_BINDING, ID);
+ assertFails(rewrite(original, manifest -> assertions(manifest).get(0).getAsJsonObject()
+ .addProperty("binding", "a.b.c")), keys, AssertionFailureReason.INVALID_BINDING, ID);
+ assertFails(rewrite(original, manifest -> binding(manifest).addProperty("signature", "a.b")), keys,
+ AssertionFailureReason.INVALID_BINDING, ID);
+ }
+
+ private static JsonObject binding(JsonObject manifest) {
+ return assertions(manifest).get(0).getAsJsonObject().getAsJsonObject("binding");
+ }
+
+ @Test
+ void verifyingLeavesTheParsedManifestUntouched() throws Exception {
+ var tdf = createTdf(assertionConfig(ID, issuerSigningKey()));
+ var reader = load(tdf, perId(ID, rs256(issuer)));
+ var assertion = reader.getManifest().assertions.get(0);
+ assertThat(assertion.binding).isNotNull();
+ assertThat(assertion.binding.method).isEqualTo("jws");
+ assertThat(assertion.binding.signature).isNotEmpty();
+ }
+
+ @Test
+ void verifyChecksTheBindingWithExactlyTheGivenKey() throws Exception {
+ var tdf = createTdf(assertionConfig(ID, issuerSigningKey().withJwk(
+ new RSAKey.Builder((RSAPublicKey) issuer.getPublic()).build())));
+ var assertion = SDK.readManifest(new SeekableInMemoryByteChannel(tdf)).assertions.get(0);
+
+ var claims = assertion.verify(rs256(issuer));
+ assertThat(claims.getAssertionHash()).isEqualTo(assertion.hash());
+
+ assertThatThrownBy(() -> assertion.verify(rs256(unrelated)))
+ .isInstanceOfSatisfying(SDK.AssertionFailureException.class,
+ e -> assertThat(e.getReason()).isEqualTo(AssertionFailureReason.SIGNER_NOT_PERMITTED));
+ assertThatThrownBy(() -> assertion.verify(null))
+ .isInstanceOfSatisfying(SDK.AssertionFailureException.class,
+ e -> assertThat(e.getReason()).isEqualTo(AssertionFailureReason.CONFIGURATION_ERROR));
+ // and has no side effects
+ assertThat(assertion.binding).isNotNull();
+
+ // verify() and hash() read the same form: after a typed edit, the signed claims no
+ // longer match the hash, and a changed binding is the binding checked
+ assertion.statement.value = "changed";
+ assertThat(assertion.verify(rs256(issuer)).getAssertionHash()).isNotEqualTo(assertion.hash());
+ assertion.binding.signature = "not.a.jws.binding";
+ assertThatThrownBy(() -> assertion.verify(rs256(issuer)))
+ .isInstanceOfSatisfying(SDK.AssertionFailureException.class,
+ e -> assertThat(e.getReason()).isEqualTo(AssertionFailureReason.INVALID_BINDING));
+ }
+}
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/AssertionVerifierTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/AssertionVerifierTest.java
new file mode 100644
index 00000000..725cfdd0
--- /dev/null
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/AssertionVerifierTest.java
@@ -0,0 +1,321 @@
+package io.opentdf.platform.sdk;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.apache.commons.codec.binary.Hex;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.PrivateKey;
+import java.security.Signature;
+import java.security.interfaces.ECPublicKey;
+import java.security.interfaces.RSAPublicKey;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Date;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Section 8 steps 3.3 to 3.9 on hand-built JWS bindings, for the cases the shared vectors do not
+ * cover yet: duplicate JOSE members, signature encoding, embedded keys of other key types, and
+ * claims other than the two the binding uses.
+ */
+class AssertionVerifierTest {
+
+ private static final byte[] AGGREGATE = "segment hashes".getBytes(StandardCharsets.US_ASCII);
+ private static KeyPair signer;
+ private static KeyPair ecKey;
+ private static AssertionVerifier.SelectedKey selected;
+
+ @BeforeAll
+ static void keys() {
+ signer = CryptoUtils.generateRSAKeypair();
+ ecKey = CryptoUtils.generateECKeypair(KeyType.EC256Key.getECCurve().getCurveName());
+ selected = AssertionVerifier.SelectedKey.rsa((RSAPublicKey) signer.getPublic());
+ }
+
+ private static String b64url(byte[] bytes) {
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+ }
+
+ private static String b64url(String text) {
+ return b64url(text.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static JsonObject assertion() {
+ return JsonParser.parseString("{\"id\":\"a\",\"type\":\"handling\",\"scope\":\"payload\","
+ + "\"statement\":{\"format\":\"string\",\"value\":\"SECRET\"}}").getAsJsonObject();
+ }
+
+ /** The claims a writer signs for {@link #assertion()}, plus {@code extra} members. */
+ private static String claims(String extra) throws Exception {
+ String hash = Manifest.Assertion.computeAssertionHash(assertion().toString());
+ byte[] hashBytes = Hex.decodeHex(hash);
+ byte[] bound = Arrays.copyOf(AGGREGATE, AGGREGATE.length + hashBytes.length);
+ System.arraycopy(hashBytes, 0, bound, AGGREGATE.length, hashBytes.length);
+ return "{\"assertionHash\":\"" + hash + "\",\"assertionSig\":\"" + Base64.getEncoder().encodeToString(bound)
+ + "\"" + extra + "}";
+ }
+
+ private static String sign(String headerJson, String claimsJson, PrivateKey key) throws Exception {
+ String signingInput = b64url(headerJson) + "." + b64url(claimsJson);
+ var signature = Signature.getInstance("SHA256withRSA");
+ signature.initSign(key);
+ signature.update(signingInput.getBytes(StandardCharsets.US_ASCII));
+ return signingInput + "." + b64url(signature.sign());
+ }
+
+ private static JsonObject bound(String compactJws) {
+ var bound = assertion();
+ var binding = new JsonObject();
+ binding.addProperty("method", "jws");
+ binding.addProperty("signature", compactJws);
+ bound.add("binding", binding);
+ return bound;
+ }
+
+ private static void verify(String compactJws) {
+ AssertionVerifier.verifyWithKey(bound(compactJws), "a", selected, AGGREGATE, false);
+ }
+
+ private static void assertReason(String compactJws, AssertionFailureReason reason) {
+ assertThatThrownBy(() -> verify(compactJws))
+ .isInstanceOfSatisfying(SDK.AssertionFailureException.class, e -> {
+ assertThat(e.getReason()).isEqualTo(reason);
+ assertThat(e.getAssertionId()).isEqualTo("a");
+ });
+ }
+
+ private static String headerWith(String members) {
+ return "{\"alg\":\"RS256\"" + members + "}";
+ }
+
+ @Test
+ void aWellFormedBindingVerifies() throws Exception {
+ assertThatCode(() -> verify(sign(headerWith(""), claims(""), signer.getPrivate()))).doesNotThrowAnyException();
+ }
+
+ @Test
+ void duplicateJoseMembersAreInvalidBindings() throws Exception {
+ assertReason(sign("{\"alg\":\"RS256\",\"alg\":\"RS256\"}", claims(""), signer.getPrivate()),
+ AssertionFailureReason.INVALID_BINDING);
+ assertReason(sign("{\"alg\":\"RS256\",\"\\u0061lg\":\"HS256\"}", claims(""), signer.getPrivate()),
+ AssertionFailureReason.INVALID_BINDING);
+ String claims = claims("");
+ String duplicated = claims.substring(0, claims.length() - 1) + ",\"assertionHash\":\"0\"}";
+ assertReason(sign(headerWith(""), duplicated, signer.getPrivate()), AssertionFailureReason.INVALID_BINDING);
+ }
+
+ @Test
+ void theSignatureSegmentMustBeUnpaddedBase64Url() throws Exception {
+ String jws = sign(headerWith(""), claims(""), signer.getPrivate());
+ String signature = jws.substring(jws.lastIndexOf('.') + 1);
+ String signingInput = jws.substring(0, jws.lastIndexOf('.') + 1);
+ byte[] raw = Base64.getUrlDecoder().decode(signature);
+ assertReason(signingInput + Base64.getUrlEncoder().encodeToString(raw) + (raw.length % 3 == 0 ? "=" : ""),
+ AssertionFailureReason.SIGNATURE_INVALID);
+ assertReason(signingInput + Base64.getEncoder().withoutPadding().encodeToString(raw).replace('-', '+') + "+",
+ AssertionFailureReason.SIGNATURE_INVALID);
+ assertReason(signingInput, AssertionFailureReason.SIGNATURE_INVALID);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ ",\"exp\":1",
+ ",\"nbf\":99999999999",
+ ",\"iat\":99999999999",
+ ",\"aud\":\"someone else\",\"iss\":\"anyone\",\"sub\":5"
+ })
+ void claimsOtherThanTheBindingsAreIgnored(String extra) throws Exception {
+ assertThatCode(() -> verify(sign(headerWith(""), claims(extra), signer.getPrivate()))).doesNotThrowAnyException();
+ }
+
+ @Test
+ void headerMembersOtherThanTheKeysDoNotMatter() throws Exception {
+ assertThatCode(() -> verify(sign(headerWith(",\"kid\":5,\"typ\":{},\"x5u\":\"https://example.com/x\","
+ + "\"jku\":\"https://example.com/k\""), claims(""), signer.getPrivate()))).doesNotThrowAnyException();
+ }
+
+ private static String ecJwk(ECPublicKey key, String crv) {
+ return "{\"kty\":\"EC\",\"crv\":\"" + crv + "\",\"x\":\"" + b64url(coordinate(key.getW().getAffineX()))
+ + "\",\"y\":\"" + b64url(coordinate(key.getW().getAffineY())) + "\"}";
+ }
+
+ private static byte[] coordinate(BigInteger value) {
+ byte[] bytes = value.toByteArray();
+ byte[] fixed = new byte[32];
+ int copy = Math.min(bytes.length, 32);
+ System.arraycopy(bytes, bytes.length - copy, fixed, 32 - copy, copy);
+ return fixed;
+ }
+
+ private static String ecCertificate() throws Exception {
+ var owner = new X500Name("CN=EC");
+ var builder = new JcaX509v3CertificateBuilder(owner, BigInteger.ONE,
+ new Date(System.currentTimeMillis() - 86_400_000L), new Date(System.currentTimeMillis() + 86_400_000L),
+ owner, ecKey.getPublic());
+ var certificate = new JcaX509CertificateConverter()
+ .getCertificate(builder.build(new JcaContentSignerBuilder("SHA256withECDSA").build(ecKey.getPrivate())));
+ return Base64.getEncoder().encodeToString(certificate.getEncoded());
+ }
+
+ @Test
+ void aWellFormedKeyOfAnotherTypeIsNotPermitted() throws Exception {
+ var ec = (ECPublicKey) ecKey.getPublic();
+ assertReason(sign(headerWith(",\"jwk\":" + ecJwk(ec, "P-256")), claims(""), signer.getPrivate()),
+ AssertionFailureReason.SIGNER_NOT_PERMITTED);
+ assertReason(sign(headerWith(",\"x5c\":[\"" + ecCertificate() + "\"]"), claims(""), signer.getPrivate()),
+ AssertionFailureReason.SIGNER_NOT_PERMITTED);
+ // a jwk and an x5c of the same EC key agree with each other
+ assertReason(sign(headerWith(",\"jwk\":" + ecJwk(ec, "P-256") + ",\"x5c\":[\"" + ecCertificate() + "\"]"),
+ claims(""), signer.getPrivate()), AssertionFailureReason.SIGNER_NOT_PERMITTED);
+ String okp = "{\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"x\":\"" + b64url(new byte[32]) + "\"}";
+ assertReason(sign(headerWith(",\"jwk\":" + okp), claims(""), signer.getPrivate()),
+ AssertionFailureReason.SIGNER_NOT_PERMITTED);
+ }
+
+ @Test
+ void anEcJwkThatDisagreesWithTheX5cIsContradictory() throws Exception {
+ var other = (ECPublicKey) CryptoUtils.generateECKeypair(KeyType.EC256Key.getECCurve().getCurveName()).getPublic();
+ assertReason(sign(headerWith(",\"jwk\":" + ecJwk(other, "P-256") + ",\"x5c\":[\"" + ecCertificate() + "\"]"),
+ claims(""), signer.getPrivate()), AssertionFailureReason.INVALID_EMBEDDED_KEY);
+ }
+
+ @Test
+ void aJwkMalformedForItsKeyTypeIsInvalid() throws Exception {
+ var ec = (ECPublicKey) ecKey.getPublic();
+ String x = b64url(coordinate(ec.getW().getAffineX()));
+ String y = b64url(coordinate(ec.getW().getAffineY()));
+ String offCurveY = b64url(coordinate(ec.getW().getAffineY().add(BigInteger.ONE)));
+ for (String jwk : new String[] {
+ "{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"" + x + "\"}",
+ "{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"" + x + "\",\"y\":\"" + offCurveY + "\"}",
+ "{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"" + x + "\",\"y\":\"" + y + "=\"}",
+ "{\"kty\":\"EC\",\"crv\":\"P-384\",\"x\":\"" + x + "\",\"y\":\"" + y + "\"}",
+ "{\"kty\":\"EC\",\"crv\":\"P-257\",\"x\":\"" + x + "\",\"y\":\"" + y + "\"}",
+ "{\"kty\":\"EC\",\"x\":\"" + x + "\",\"y\":\"" + y + "\"}",
+ "{\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"x\":\"" + b64url(new byte[31]) + "\"}",
+ "{\"kty\":\"OKP\",\"crv\":\"Ed25519\"}",
+ "{\"kty\":\"OKP\",\"crv\":\"P-256\",\"x\":\"" + b64url(new byte[32]) + "\"}",
+ "{\"kty\":\"OKP\",\"crv\":\"X25519\",\"x\":\"" + b64url(new byte[32]) + "\"}",
+ "{\"kty\":\"XYZ\",\"x\":\"" + x + "\"}",
+ "{\"kty\":\"RSA\",\"n\":\"" + b64url(new byte[256]).substring(1) + "+\",\"e\":\"AQAB\"}",
+ "{\"kty\":\"RSA\",\"n\":\"\",\"e\":\"AQAB\"}",
+ "{\"kty\":5}",
+ "{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"" + x + "\",\"y\":\"" + y + "\",\"d\":\"" + x + "\"}",
+ }) {
+ assertThat(jwk).as("fixture is JSON").satisfies(json -> JsonParser.parseString(json));
+ assertThatThrownBy(() -> verify(sign(headerWith(",\"jwk\":" + jwk), claims(""), signer.getPrivate())))
+ .as(jwk)
+ .isInstanceOfSatisfying(SDK.AssertionFailureException.class,
+ e -> assertThat(e.getReason()).isEqualTo(AssertionFailureReason.INVALID_EMBEDDED_KEY));
+ }
+ }
+
+ private static String rsaJwk(byte[] n, byte[] e) {
+ return "{\"kty\":\"RSA\",\"n\":\"" + b64url(n) + "\",\"e\":\"" + b64url(e) + "\"}";
+ }
+
+ private static byte[] unsigned(BigInteger value) {
+ byte[] bytes = value.toByteArray();
+ return bytes[0] == 0 ? Arrays.copyOfRange(bytes, 1, bytes.length) : bytes;
+ }
+
+ private static byte[] withLeadingZero(byte[] bytes) {
+ byte[] padded = new byte[bytes.length + 1];
+ System.arraycopy(bytes, 0, padded, 1, bytes.length);
+ return padded;
+ }
+
+ @Test
+ void theSelectedKeysJwkMustUseMinimalIntegers() throws Exception {
+ var rsa = (RSAPublicKey) signer.getPublic();
+ byte[] n = unsigned(rsa.getModulus());
+ byte[] e = unsigned(rsa.getPublicExponent());
+ String minimal = rsaJwk(n, e);
+ assertThatCode(() -> verify(sign(headerWith(",\"jwk\":" + minimal + ",\"kid\":\"anything\""), claims(""),
+ signer.getPrivate()))).doesNotThrowAnyException();
+ // the same key with a leading zero octet is malformed, never equal to the selected key
+ assertReason(sign(headerWith(",\"jwk\":" + rsaJwk(withLeadingZero(n), e)), claims(""), signer.getPrivate()),
+ AssertionFailureReason.INVALID_EMBEDDED_KEY);
+ assertReason(sign(headerWith(",\"jwk\":" + rsaJwk(n, withLeadingZero(e))), claims(""), signer.getPrivate()),
+ AssertionFailureReason.INVALID_EMBEDDED_KEY);
+ }
+
+ /** The same bytes, encoded with a non-zero bit after the last whole byte. */
+ private static String withTrailingBits(String base64Url) {
+ assertThat(base64Url.length() % 4).as("an encoding with spare bits").isNotZero();
+ String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
+ char last = base64Url.charAt(base64Url.length() - 1);
+ String changed = base64Url.substring(0, base64Url.length() - 1) + alphabet.charAt(alphabet.indexOf(last) | 1);
+ assertThat(Base64.getUrlDecoder().decode(changed)).isEqualTo(Base64.getUrlDecoder().decode(base64Url));
+ return changed;
+ }
+
+ @Test
+ void base64UrlWithNonZeroTrailingBitsIsRejected() throws Exception {
+ // a header whose encoding has spare bits
+ String header = headerWith(",\"x\":12");
+ String jws = sign(header, claims(""), signer.getPrivate());
+ String[] parts = jws.split("\\.");
+ assertThatCode(() -> verify(jws)).doesNotThrowAnyException();
+ assertReason(withTrailingBits(parts[0]) + "." + parts[1] + "." + parts[2], AssertionFailureReason.INVALID_BINDING);
+ assertReason(parts[0] + "." + parts[1] + "." + withTrailingBits(parts[2]), AssertionFailureReason.SIGNATURE_INVALID);
+
+ var rsa = (RSAPublicKey) signer.getPublic();
+ String n = b64url(unsigned(rsa.getModulus()));
+ String jwk = "{\"kty\":\"RSA\",\"n\":\"" + withTrailingBits(n) + "\",\"e\":\"AQAB\"}";
+ assertReason(sign(headerWith(",\"jwk\":" + jwk), claims(""), signer.getPrivate()),
+ AssertionFailureReason.INVALID_EMBEDDED_KEY);
+ }
+
+ @Test
+ void x5cMustBeExactlyOneDerCertificateInStrictBase64() throws Exception {
+ byte[] der = TestUtil.createTestCertificate(signer.getPublic(), signer.getPrivate()).getEncoded();
+ String leaf = Base64.getEncoder().encodeToString(der);
+ assertThatCode(() -> verify(sign(headerWith(",\"x5c\":[\"" + leaf + "\"]"), claims(""), signer.getPrivate())))
+ .doesNotThrowAnyException();
+ // later elements must decode, but are never parsed
+ String notACertificate = Base64.getEncoder().encodeToString("not a certificate".getBytes(StandardCharsets.US_ASCII));
+ assertThatCode(() -> verify(sign(headerWith(",\"x5c\":[\"" + leaf + "\",\"" + notACertificate + "\"]"),
+ claims(""), signer.getPrivate()))).doesNotThrowAnyException();
+
+ String pem = "-----BEGIN CERTIFICATE-----\n" + Base64.getMimeEncoder().encodeToString(der)
+ + "\n-----END CERTIFICATE-----\n";
+ byte[] trailing = Arrays.copyOf(der, der.length + 2);
+ for (String x5c : new String[] {
+ "[\"" + Base64.getEncoder().encodeToString(pem.getBytes(StandardCharsets.US_ASCII)) + "\"]",
+ "[\"" + Base64.getEncoder().encodeToString(trailing) + "\"]",
+ "[\"" + leaf + "\",\"not base64!\"]",
+ "[\"" + leaf + "\",5]",
+ "[\"" + leaf.replace("=", "") + "\"]",
+ "[\"" + Base64.getUrlEncoder().encodeToString(der) + "\"]",
+ }) {
+ assertThatThrownBy(() -> verify(sign(headerWith(",\"x5c\":" + x5c), claims(""), signer.getPrivate())))
+ .as(x5c.length() > 80 ? x5c.substring(0, 80) : x5c)
+ .isInstanceOfSatisfying(SDK.AssertionFailureException.class,
+ e -> assertThat(e.getReason()).isEqualTo(AssertionFailureReason.INVALID_EMBEDDED_KEY));
+ }
+ }
+
+ @Test
+ void pemTextIsNeverAnHs256Secret() {
+ byte[] pem = CryptoUtils.getPublicKeyPEM(signer.getPublic()).getBytes(StandardCharsets.US_ASCII);
+ assertThatThrownBy(() -> AssertionVerifier.configuredKey(
+ new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.HS256, pem), "a", "the key"))
+ .isInstanceOfSatisfying(SDK.AssertionFailureException.class,
+ e -> assertThat(e.getReason()).isEqualTo(AssertionFailureReason.CONFIGURATION_ERROR));
+ }
+}
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/StrictManifestParsingTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/StrictManifestParsingTest.java
index 9e8f410d..f17198d1 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/StrictManifestParsingTest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/StrictManifestParsingTest.java
@@ -123,6 +123,18 @@ void reportsInvalidUtf8AsInvalidJson() throws IOException {
e -> assertThat(e.getReason()).isEqualTo(AssertionFailureReason.INVALID_JSON));
}
+ @Test
+ void reportsInvalidUtf8OutsideTheAssertionsAsInvalidJsonToo() throws IOException {
+ var valid = manifest(",'extra':'XY'").getBytes(StandardCharsets.UTF_8);
+ var invalid = valid.clone();
+ invalid[new String(valid, StandardCharsets.UTF_8).indexOf("XY")] = (byte) 0xFF;
+ assertThatThrownBy(() -> SDK.readManifest(new SeekableInMemoryByteChannel(tdfWithManifest(invalid))))
+ .isInstanceOfSatisfying(SDK.AssertionFailureException.class, e -> {
+ assertThat(e.getReason()).isEqualTo(AssertionFailureReason.INVALID_JSON);
+ assertThat(e).hasMessageContaining("reason=invalid-json");
+ });
+ }
+
static byte[] tdfWithManifest(byte[] manifest) throws IOException {
var out = new ByteArrayOutputStream();
var writer = new TDFWriter(out);
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java
index b1ce4e24..418f872e 100644
--- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java
@@ -3,7 +3,6 @@
import com.connectrpc.ResponseMessage;
import com.connectrpc.UnaryBlockingCall;
import com.google.gson.reflect.TypeToken;
-import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.jwk.JWK;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
@@ -456,11 +455,39 @@ void testVerifyAssertionWithJwkHeaderInJWT() throws Exception {
.setKeyAccessServerRegistryService(kasRegistryService).build());
tdf.createTDF(plainTextInputStream, tdfOutputStream, config);
+ assertEmbeddedKeyAddsNoTrust(tdf, tdfOutputStream.toByteArray(), keypair, plainText);
+ }
+
+ /**
+ * A TDF whose assertion was signed by {@code signer} and carries the signer's public key in
+ * its JWS header. The embedded key grants nothing by itself: with no key configured the
+ * payload key with HS256 is selected and the RS256 binding fails; configuring the signer's
+ * key, per ID or as the default, is what makes it verify.
+ */
+ private void assertEmbeddedKeyAddsNoTrust(TDF tdf, byte[] tdfBytes, KeyPair signer, String plainText)
+ throws Exception {
+ assertThatThrownBy(() -> tdf.loadTDF(new SeekableInMemoryByteChannel(tdfBytes), Config.newTDFReaderConfig(),
+ platformUrl))
+ .isInstanceOfSatisfying(SDK.AssertionFailureException.class, e -> {
+ assertThat(e.getReason()).isEqualTo(AssertionFailureReason.ALGORITHM_MISMATCH);
+ assertThat(e).hasMessageContaining("reason=algorithm-mismatch");
+ });
+
+ var unrelated = new Config.AssertionVerificationKeys();
+ unrelated.defaultKey = new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.RS256,
+ CryptoUtils.generateRSAKeypair().getPublic());
+ assertThatThrownBy(() -> tdf.loadTDF(new SeekableInMemoryByteChannel(tdfBytes),
+ Config.newTDFReaderConfig(Config.withAssertionVerificationKeys(unrelated)), platformUrl))
+ .isInstanceOfSatisfying(SDK.AssertionFailureException.class, e ->
+ assertThat(e.getReason()).isEqualTo(AssertionFailureReason.SIGNER_NOT_PERMITTED));
+
+ var configured = new Config.AssertionVerificationKeys();
+ configured.defaultKey = new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.RS256,
+ signer.getPublic());
var unwrappedData = new ByteArrayOutputStream();
- var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray()),
- Config.newTDFReaderConfig(), platformUrl);
+ var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(tdfBytes),
+ Config.newTDFReaderConfig(Config.withAssertionVerificationKeys(configured)), platformUrl);
reader.readPayload(unwrappedData);
-
assertThat(unwrappedData.toString(StandardCharsets.UTF_8))
.withFailMessage("extracted data does not match")
.isEqualTo(plainText);
@@ -504,14 +531,7 @@ void testVerifyAssertionWithX5cHeaderInJWT() throws Exception {
.setKeyAccessServerRegistryService(kasRegistryService).build());
tdf.createTDF(plainTextInputStream, tdfOutputStream, config);
- var unwrappedData = new ByteArrayOutputStream();
- var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray()),
- Config.newTDFReaderConfig(), platformUrl);
- reader.readPayload(unwrappedData);
-
- assertThat(unwrappedData.toString(StandardCharsets.UTF_8))
- .withFailMessage("extracted data does not match")
- .isEqualTo(plainText);
+ assertEmbeddedKeyAddsNoTrust(tdf, tdfOutputStream.toByteArray(), keypair, plainText);
}
@Test
@@ -552,10 +572,12 @@ void testWithAssertionVerificationDisabled() throws Exception {
var unwrappedData = new ByteArrayOutputStream();
var dataToUnwrap = new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray());
var emptyConfig = Config.newTDFReaderConfig();
- var thrown = assertThrows(SDKException.class, () -> {
+ var thrown = assertThrows(SDK.AssertionFailureException.class, () -> {
tdf.loadTDF(dataToUnwrap, emptyConfig, platformUrl);
});
- assertThat(thrown.getCause()).isInstanceOf(JOSEException.class);
+ // no key configured, so the payload key with HS256 is selected, and the RS256 binding fails
+ assertThat(thrown.getReason()).isEqualTo(AssertionFailureReason.ALGORITHM_MISMATCH);
+ assertThat(thrown.getAssertionId()).isEqualTo(assertion1Id);
// try with assertion verification disabled and not passing the assertion
// verification keys