From 0dabeff74ca3155fec51030d39ede49e7bd09525 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Mon, 9 Nov 2026 09:17:41 -0500 Subject: [PATCH] fix(sdk): DSPX-1561 verify assertion bindings under the assertions specification Each assertion is verified with exactly one key, selected once by its exact id: the per-ID key, else the default key, else the payload key with HS256 (spec Section 6.1). A failure is final, and an empty or invalid configured key, including PEM text given as an HS256 secret, is a configuration error. Each binding is verified only with the key selected from configuration and that key's algorithm; an embedded jwk or x5c must match it (Section 6.2). Embedded keys that are symmetric, private, malformed for their key type, not canonically encoded, or contradictory, and any embedded key in HMAC mode, are rejected. The JWS alg must equal the selected key's algorithm, crit headers and duplicate JOSE members are rejected, base64 is decoded strictly, claims other than assertionHash and assertionSig are ignored, and both binding forms of Section 5.1 are recognized, with binding objects reported as unsupported. Checks follow the Section 8 order, and every failure is reported as an SDK.AssertionFailureException with its reason. JSON that does not parse inside an assertion is reported as invalid-json. Key selection stays inside loadTDF, and Manifest.Assertion.verify has no side effects. Signed-off-by: Dave Mihalcik --- .../opentdf/platform/sdk/AssertionJson.java | 24 + .../platform/sdk/AssertionVerifier.java | 708 ++++++++++++++++++ .../java/io/opentdf/platform/sdk/Config.java | 53 +- .../io/opentdf/platform/sdk/Manifest.java | 126 +--- .../platform/sdk/StrictJsonReader.java | 10 + .../java/io/opentdf/platform/sdk/TDF.java | 56 +- .../sdk/AssertionVerificationTest.java | 668 +++++++++++++++++ .../platform/sdk/AssertionVerifierTest.java | 321 ++++++++ .../sdk/StrictManifestParsingTest.java | 12 + .../java/io/opentdf/platform/sdk/TDFTest.java | 50 +- 10 files changed, 1865 insertions(+), 163 deletions(-) create mode 100644 sdk/src/main/java/io/opentdf/platform/sdk/AssertionVerifier.java create mode 100644 sdk/src/test/java/io/opentdf/platform/sdk/AssertionVerificationTest.java create mode 100644 sdk/src/test/java/io/opentdf/platform/sdk/AssertionVerifierTest.java 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 377e95d37..31eaa0bb1 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 000000000..8defa0117 --- /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 f4dbc4f5a..9299148c1 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 764268576..e7796f102 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 32f536b31..d7e618258 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 51b6e9772..524a9da0b 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 000000000..6162d81c5 --- /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 000000000..725cfdd04 --- /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 9e8f410d2..f17198d1a 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 b1ce4e249..418f872e8 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