diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/AssertionJson.java b/sdk/src/main/java/io/opentdf/platform/sdk/AssertionJson.java index 377e95d3..31eaa0bb 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/AssertionJson.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/AssertionJson.java @@ -11,6 +11,10 @@ import java.io.IOException; import java.io.Reader; import java.io.StringReader; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Map; @@ -53,6 +57,23 @@ static JsonObject parseAssertion(String wireJson) { } } + /** + * Decodes JSON text from strict UTF-8. + * + * @throws SDK.AssertionFailureException ({@code invalid-json}) if the bytes are not UTF-8 + */ + static String decodeUtf8(byte[] json) { + try { + return StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(json)) + .toString(); + } catch (CharacterCodingException e) { + throw invalidJson("the JSON text is not valid UTF-8"); + } + } + /** * Applies only the manifest-level checks of Section 3 to a whole manifest, without building * the typed manifest. Malformed JSON is reported as {@code invalid-json} here; the manifest @@ -62,6 +83,9 @@ static JsonObject parseAssertion(String wireJson) { */ static void checkManifest(Reader manifestJson) { try (StrictJsonReader reader = new StrictJsonReader(manifestJson, StrictJsonReader.Mode.MANIFEST)) { + if (reader.peek() != JsonToken.BEGIN_OBJECT) { + throw invalidJson("a manifest must be a JSON object"); + } reader.skipValue(); if (reader.peek() != JsonToken.END_DOCUMENT) { throw invalidJson("unexpected content after the manifest"); diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/AssertionVerifier.java b/sdk/src/main/java/io/opentdf/platform/sdk/AssertionVerifier.java new file mode 100644 index 00000000..8defa011 --- /dev/null +++ b/sdk/src/main/java/io/opentdf/platform/sdk/AssertionVerifier.java @@ -0,0 +1,708 @@ +package io.opentdf.platform.sdk; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.crypto.MACVerifier; +import com.nimbusds.jose.crypto.RSASSAVerifier; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.util.Base64URL; +import com.nimbusds.jose.util.X509CertUtils; +import org.apache.commons.codec.DecoderException; +import org.apache.commons.codec.binary.Hex; + +import java.io.IOException; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.security.PublicKey; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.security.interfaces.ECPublicKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.ECFieldFp; +import java.security.spec.ECParameterSpec; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import static io.opentdf.platform.sdk.AssertionFailureReason.ALGORITHM_MISMATCH; +import static io.opentdf.platform.sdk.AssertionFailureReason.BINDING_MISMATCH; +import static io.opentdf.platform.sdk.AssertionFailureReason.CONFIGURATION_ERROR; +import static io.opentdf.platform.sdk.AssertionFailureReason.INVALID_BINDING; +import static io.opentdf.platform.sdk.AssertionFailureReason.INVALID_EMBEDDED_KEY; +import static io.opentdf.platform.sdk.AssertionFailureReason.INVALID_ID; +import static io.opentdf.platform.sdk.AssertionFailureReason.SIGNATURE_INVALID; +import static io.opentdf.platform.sdk.AssertionFailureReason.SIGNER_NOT_PERMITTED; +import static io.opentdf.platform.sdk.AssertionFailureReason.UNSUPPORTED_ALGORITHM; +import static io.opentdf.platform.sdk.AssertionFailureReason.UNSUPPORTED_BINDING; + +/** + * Verifies assertion bindings as Section 8 of the ZTDF assertions specification lays out, in its + * order, so the same input fails with the same reason in every SDK. + *

+ * The verification key comes from the reader's configuration, or is the payload key when nothing + * is configured, and is selected once per assertion. A key embedded in the JWS header, as + * {@code jwk} or as the leaf of {@code x5c}, never replaces it and never chooses the algorithm: + * it is accepted only when it is the same public key as the selected one. The signature is + * always checked with the selected key and its configured algorithm. Nothing here reads the + * network, and an {@code x5c} chain is not validated, so a pass says nothing about certificates. + */ +final class AssertionVerifier { + + private static final String ASSERTION_HASH = "assertionHash"; + private static final String ASSERTION_SIG = "assertionSig"; + private static final List PRIVATE_JWK_MEMBERS = List.of("d", "p", "q", "dp", "dq", "qi", "oth"); + /** Untrusted values quoted in messages are cut to this many characters. */ + private static final int MAX_QUOTED = 64; + + private AssertionVerifier() { + } + + /** The key one assertion is verified with, and the only key it can verify with. */ + static final class SelectedKey { + final AssertionConfig.AssertionKeyAlg alg; + private final JWSVerifier verifier; + /** The public key's {@link #publicKeyIdentity identity}; null for HMAC keys. */ + private final String identity; + + private SelectedKey(AssertionConfig.AssertionKeyAlg alg, JWSVerifier verifier, String identity) { + this.alg = alg; + this.verifier = verifier; + this.identity = identity; + } + + static SelectedKey rsa(RSAPublicKey publicKey) { + try { + return new SelectedKey(AssertionConfig.AssertionKeyAlg.RS256, new RSASSAVerifier(publicKey), + publicKeyIdentity(publicKey)); + } catch (RuntimeException e) { + throw new IllegalArgumentException("unusable RSA public key", e); + } + } + + static SelectedKey hmac(byte[] secret) { + try { + return new SelectedKey(AssertionConfig.AssertionKeyAlg.HS256, new MACVerifier(secret), null); + } catch (JOSEException | RuntimeException e) { + // MACVerifier refuses secrets shorter than 256 bits, as it always has + throw new IllegalArgumentException("unusable HS256 secret: " + e.getMessage(), e); + } + } + + boolean isHmac() { + return alg == AssertionConfig.AssertionKeyAlg.HS256; + } + + private JWSAlgorithm jwsAlgorithm() { + return isHmac() ? JWSAlgorithm.HS256 : JWSAlgorithm.RS256; + } + } + + /** + * Verifies one assertion read from a manifest: Section 8, steps 3.1 to 3.9. + * + * @param keys the reader's configured keys; null means none + * @param payloadKey used with HS256 only when no key is configured for the assertion + * @param aggregateHash the decoded segment hashes, concatenated in manifest order + * @param legacy whether {@code assertionSig} carries the assertion hash as hex text + * @throws SDK.AssertionFailureException on the first failed check + */ + static void verify(Manifest.Assertion assertion, Config.AssertionVerificationKeys keys, byte[] payloadKey, + byte[] aggregateHash, boolean legacy) { + JsonObject json = assertion.received() != null ? assertion.received() : assertion.toJsonTree(); + String id = exactId(json); + SelectedKey key = select(keys, id, payloadKey); + verifyWithKey(json, id, key, aggregateHash, legacy); + } + + /** Section 3.3: the exact {@code id}, which must be a nonempty JSON string. */ + static String exactId(JsonObject assertion) { + JsonElement id = assertion.get("id"); + if (id == null || id.isJsonNull()) { + throw new SDK.AssertionFailureException(null, INVALID_ID, "the assertion has no id"); + } + if (!isString(id)) { + throw new SDK.AssertionFailureException(null, INVALID_ID, "the assertion id is not a string"); + } + if (id.getAsString().isEmpty()) { + throw new SDK.AssertionFailureException(null, INVALID_ID, "the assertion id is empty"); + } + return id.getAsString(); + } + + /** + * Section 6.1: the key for the assertion with this exact id. A per-ID entry wins, then the + * default key, then the payload key with HS256. An empty or invalid configured entry is a + * {@code configuration-error}, never a reason to look further down the list. + */ + static SelectedKey select(Config.AssertionVerificationKeys keys, String id, byte[] payloadKey) { + if (keys != null && (keys.hasKeyFor(id) || keys.hasDefaultKey())) { + String which = keys.hasKeyFor(id) ? "the verification key for this id" : "the default verification key"; + return configuredKey(keys.getKey(id), id, which); + } + try { + return SelectedKey.hmac(payloadKey); + } catch (IllegalArgumentException e) { + throw new SDKException("the payload key cannot verify assertions", e); + } + } + + /** + * A configured key as a {@link SelectedKey}: RS256 with an RSA public key (as + * {@link RSAPublicKey} or an RSA {@link com.nimbusds.jose.jwk.JWK}), or HS256 with a {@code byte[]} secret. + */ + static SelectedKey configuredKey(AssertionConfig.AssertionKey key, String id, String which) { + if (key == null) { + throw configurationError(id, which + " is empty"); + } + if (key.alg == AssertionConfig.AssertionKeyAlg.RS256) { + RSAPublicKey publicKey; + if (key.key instanceof RSAPublicKey) { + publicKey = (RSAPublicKey) key.key; + } else if (key.key instanceof RSAKey) { + try { + publicKey = ((RSAKey) key.key).toRSAPublicKey(); + } catch (JOSEException e) { + throw configurationError(id, which + " is not a usable RSA key"); + } + } else { + throw configurationError(id, which + " is RS256 but is not an RSA public key"); + } + try { + return SelectedKey.rsa(publicKey); + } catch (IllegalArgumentException e) { + throw configurationError(id, which + " is not a usable RSA key"); + } + } + if (key.alg == AssertionConfig.AssertionKeyAlg.HS256) { + if (!(key.key instanceof byte[]) || ((byte[]) key.key).length == 0) { + throw configurationError(id, which + " is HS256 but is not a nonempty byte[] secret"); + } + if (isPemText((byte[]) key.key)) { + // public-key bytes must never serve as an HMAC secret (spec Section 7) + throw configurationError(id, which + " is HS256 but holds PEM text"); + } + try { + return SelectedKey.hmac((byte[]) key.key); + } catch (IllegalArgumentException e) { + throw configurationError(id, which + " is not a usable HS256 secret"); + } + } + throw configurationError(id, which + " has no supported algorithm"); + } + + /** + * Section 8, steps 3.3 to 3.9, with the key already selected. + * + * @param assertion the assertion as parsed under Section 3, binding included + * @param id the assertion's exact id, for error reports; may be null + */ + static void verifyWithKey(JsonObject assertion, String id, SelectedKey key, byte[] aggregateHash, boolean legacy) { + Manifest.Assertion.HashValues claimed = verifyBinding(assertion, id, key); + + String assertionHash = AssertionJson.hash(assertion); + if (!assertionHash.equals(claimed.getAssertionHash())) { + throw new SDK.AssertionFailureException(id, BINDING_MISMATCH, + "the signed assertionHash does not match the assertion"); + } + + byte[] hashBytes; + if (legacy) { + hashBytes = assertionHash.getBytes(StandardCharsets.US_ASCII); + } else { + try { + hashBytes = Hex.decodeHex(assertionHash); + } catch (DecoderException e) { + throw new IllegalStateException("assertion hash is not hex", e); + } + } + byte[] expected = new byte[aggregateHash.length + hashBytes.length]; + System.arraycopy(aggregateHash, 0, expected, 0, aggregateHash.length); + System.arraycopy(hashBytes, 0, expected, aggregateHash.length, hashBytes.length); + if (!Base64.getEncoder().encodeToString(expected).equals(claimed.getSignature())) { + // the other encoding is deliberately not tried + throw new SDK.AssertionFailureException(id, BINDING_MISMATCH, + "failed integrity check on assertion signature: assertionSig does not match the segment hashes"); + } + } + + /** + * Section 8, steps 3.3 to 3.7: the binding form, the JWS and its header, any embedded keys, + * the signature under the selected key, and the presence of the claims. + * + * @return the signed claims, still to be compared with the recomputed values + */ + static Manifest.Assertion.HashValues verifyBinding(JsonObject assertion, String id, SelectedKey key) { + Jws jws = Jws.parse(jwsBinding(assertion, id), id); + + // 3.4: the header + if (jws.header.has("crit")) { + throw invalidBinding(id, "the JWS header has a crit member"); + } + JsonElement alg = jws.header.get("alg"); + if (alg == null || !isString(alg)) { + throw new SDK.AssertionFailureException(id, UNSUPPORTED_ALGORITHM, "the JWS header has no alg"); + } + String algName = alg.getAsString(); + if (!JWSAlgorithm.RS256.getName().equals(algName) && !JWSAlgorithm.HS256.getName().equals(algName)) { + throw new SDK.AssertionFailureException(id, UNSUPPORTED_ALGORITHM, + "JWS alg " + quoted(algName) + " is not supported"); + } + if (!algName.equals(key.jwsAlgorithm().getName())) { + throw new SDK.AssertionFailureException(id, ALGORITHM_MISMATCH, + "JWS alg " + algName + " does not match the selected " + key.alg + " key"); + } + + // 3.5: embedded keys may only name the selected key + checkEmbeddedKeys(jws.header, id, key); + + // 3.6: the signature, with the selected key and its algorithm only + boolean verified = false; + byte[] signature = strictBase64Url(jws.encodedSignature); + if (signature != null) { + try { + verified = key.verifier.verify(new JWSHeader(key.jwsAlgorithm()), jws.signingInput(), + Base64URL.encode(signature)); + } catch (JOSEException e) { + verified = false; + } + } + if (!verified) { + throw new SDK.AssertionFailureException(id, SIGNATURE_INVALID, + "unable to verify the assertion signature with the selected key"); + } + + // 3.7: the claims + JsonElement assertionHash = jws.claims.get(ASSERTION_HASH); + JsonElement assertionSig = jws.claims.get(ASSERTION_SIG); + if (!isString(assertionHash) || !isString(assertionSig)) { + throw new SDK.AssertionFailureException(id, BINDING_MISMATCH, + "the binding claims assertionHash and assertionSig must both be strings"); + } + return new Manifest.Assertion.HashValues(assertionHash.getAsString(), assertionSig.getAsString()); + } + + /** + * Section 5.1: the compact JWS of a JWS binding. The form is decided by which of + * {@code signature} and {@code bdo} are present, before {@code method} is looked at. + */ + static String jwsBinding(JsonObject assertion, String id) { + JsonElement binding = assertion.get("binding"); + if (binding == null) { + throw invalidBinding(id, "the assertion has no binding"); + } + if (!binding.isJsonObject()) { + throw invalidBinding(id, "the binding is not a JSON object"); + } + JsonObject members = binding.getAsJsonObject(); + boolean hasSignature = members.has("signature"); + boolean hasBindingObject = members.has("bdo"); + if (hasSignature && hasBindingObject) { + throw invalidBinding(id, "the binding has both a signature and a bdo"); + } + if (!hasSignature && !hasBindingObject) { + throw invalidBinding(id, "the binding has neither a signature nor a bdo"); + } + if (hasBindingObject) { + throw new SDK.AssertionFailureException(id, UNSUPPORTED_BINDING, + "STANAG 4778 binding objects are not supported"); + } + JsonElement signature = members.get("signature"); + if (!isString(signature)) { + throw invalidBinding(id, "the binding signature is not a string"); + } + // a missing method means jws + JsonElement method = members.get("method"); + if (method != null) { + if (!isString(method)) { + throw invalidBinding(id, "the binding method is not a string"); + } + String name = method.getAsString(); + // current Java writers emitted the upper-case form + if (!"jws".equals(name) && !"JWS".equals(name)) { + throw new SDK.AssertionFailureException(id, UNSUPPORTED_BINDING, + "binding method " + quoted(name) + " is not supported"); + } + } + return signature.getAsString(); + } + + /** + * A compact JWS, split, with its header and claims decoded and parsed as strict JSON. The + * signature is decoded only when it is checked, so a malformed one is + * {@code signature-invalid}. + */ + private static final class Jws { + final String encodedHeader; + final String encodedPayload; + final String encodedSignature; + final JsonObject header; + final JsonObject claims; + + private Jws(String[] parts, JsonObject header, JsonObject claims) { + this.encodedHeader = parts[0]; + this.encodedPayload = parts[1]; + this.encodedSignature = parts[2]; + this.header = header; + this.claims = claims; + } + + static Jws parse(String compact, String id) { + String[] parts = compact.split("\\.", -1); + if (parts.length != 3) { + throw invalidBinding(id, "the binding signature is not a compact JWS of three parts"); + } + JsonObject header = decodeObject(parts[0], id, "header"); + JsonObject claims = decodeObject(parts[1], id, "payload"); + return new Jws(parts, header, claims); + } + + byte[] signingInput() { + return (encodedHeader + "." + encodedPayload).getBytes(StandardCharsets.US_ASCII); + } + + private static JsonObject decodeObject(String part, String id, String what) { + byte[] bytes = strictBase64Url(part); + if (bytes == null) { + throw invalidBinding(id, "the JWS " + what + " is not base64url"); + } + JsonElement json; + try { + String text = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString(); + json = AssertionJson.parseStrict(text); + } catch (CharacterCodingException e) { + throw invalidBinding(id, "the JWS " + what + " is not UTF-8"); + } catch (IOException | SDK.AssertionFailureException e) { + throw invalidBinding(id, "the JWS " + what + " is not a strict JSON object"); + } + if (!json.isJsonObject()) { + throw invalidBinding(id, "the JWS " + what + " is not a JSON object"); + } + return json.getAsJsonObject(); + } + } + + /** + * Unpadded base64url, as JWS (RFC 7515) requires, or null: the URL alphabet only, no + * padding, and no non-zero bits after the last whole byte, so each byte string has exactly + * one accepted encoding. + */ + static byte[] strictBase64Url(String text) { + if (text.length() % 4 == 1) { + return null; + } + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + boolean alphabet = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') + || c == '-' || c == '_'; + if (!alphabet) { + return null; + } + } + byte[] decoded = Base64.getUrlDecoder().decode(text); + return Base64.getUrlEncoder().withoutPadding().encodeToString(decoded).equals(text) ? decoded : null; + } + + /** + * Padded standard base64, as {@code x5c} (RFC 7515 Section 4.1.6) requires, or null: the + * standard alphabet, correct padding, and no non-zero bits after the last whole byte. + */ + private static byte[] strictBase64(String text) { + if (text.length() % 4 != 0) { + return null; + } + int padding = text.endsWith("==") ? 2 : text.endsWith("=") ? 1 : 0; + for (int i = 0; i < text.length() - padding; i++) { + char c = text.charAt(i); + boolean alphabet = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') + || c == '+' || c == '/'; + if (!alphabet) { + return null; + } + } + byte[] decoded; + try { + decoded = Base64.getDecoder().decode(text); + } catch (IllegalArgumentException e) { + return null; + } + return Base64.getEncoder().encodeToString(decoded).equals(text) ? decoded : null; + } + + /** Section 6.2. */ + private static void checkEmbeddedKeys(JsonObject header, String id, SelectedKey key) { + boolean hasJwk = header.has("jwk"); + boolean hasX5c = header.has("x5c"); + if (!hasJwk && !hasX5c) { + return; + } + if (key.isHmac()) { + throw new SDK.AssertionFailureException(id, INVALID_EMBEDDED_KEY, + "the JWS header embeds a key, but the selected key is an HMAC secret"); + } + String fromJwk = hasJwk ? jwkIdentity(header.get("jwk"), id) : null; + String fromX5c = hasX5c ? x5cLeafIdentity(header.get("x5c"), id) : null; + if (fromJwk != null && fromX5c != null && !fromJwk.equals(fromX5c)) { + throw new SDK.AssertionFailureException(id, INVALID_EMBEDDED_KEY, + "the jwk and x5c headers hold different keys"); + } + String embedded = fromJwk != null ? fromJwk : fromX5c; + if (!embedded.equals(key.identity)) { + throw new SDK.AssertionFailureException(id, SIGNER_NOT_PERMITTED, + "the key embedded in the JWS header is not the selected verification key"); + } + } + + /** The curves an EC jwk may name, with the byte length of each coordinate (RFC 7518 Section 6.2.1). */ + private static final Map EC_CURVES = Map.of( + Curve.P_256.getName(), Curve.P_256, Curve.P_384.getName(), Curve.P_384, Curve.P_521.getName(), Curve.P_521); + + /** + * The OKP signature curves of RFC 8037, with the length of {@code x} and the DER prefix of the + * key's SPKI. The key-agreement curves X25519 and X448 are not signing keys. + */ + private enum OkpCurve { + ED25519("Ed25519", 32, "302a300506032b6570032100"), + ED448("Ed448", 57, "3043300506032b6571033a00"); + + final String jwkName; + final int length; + final byte[] spkiPrefix; + + OkpCurve(String jwkName, int length, String spkiPrefix) { + this.jwkName = jwkName; + this.length = length; + try { + this.spkiPrefix = Hex.decodeHex(spkiPrefix); + } catch (DecoderException e) { + throw new IllegalStateException(e); + } + } + + static OkpCurve named(String name) { + for (OkpCurve curve : values()) { + if (curve.jwkName.equals(name)) { + return curve; + } + } + return null; + } + } + + /** + * The identity of an embedded public JWK, or {@code invalid-embedded-key} if it is not a + * well-formed public key of its key type. Members are read from strict base64url. A + * well-formed key of another type than the selected key simply has a different identity. + */ + private static String jwkIdentity(JsonElement element, String id) { + if (!element.isJsonObject()) { + throw invalidEmbeddedKey(id, "the jwk header is not a JSON object"); + } + JsonObject members = element.getAsJsonObject(); + JsonElement kty = members.get("kty"); + if (!isString(kty)) { + throw invalidEmbeddedKey(id, "the jwk header has no kty"); + } + if ("oct".equals(kty.getAsString())) { + throw invalidEmbeddedKey(id, "the jwk header holds a symmetric key"); + } + for (String member : PRIVATE_JWK_MEMBERS) { + if (members.has(member)) { + throw invalidEmbeddedKey(id, "the jwk header holds private key material"); + } + } + switch (kty.getAsString()) { + case "RSA": + return rsaIdentity(jwkUnsigned(members, "n", id), jwkUnsigned(members, "e", id)); + case "EC": { + JsonElement crv = members.get("crv"); + Curve curve = isString(crv) ? EC_CURVES.get(crv.getAsString()) : null; + if (curve == null) { + throw invalidEmbeddedKey(id, "the jwk header names no supported EC curve"); + } + byte[] x = jwkBytes(members, "x", id); + byte[] y = jwkBytes(members, "y", id); + int size = (curve.toECParameterSpec().getCurve().getField().getFieldSize() + 7) / 8; + if (x.length != size || y.length != size) { + throw invalidEmbeddedKey(id, "the jwk header has EC coordinates of the wrong length"); + } + BigInteger affineX = new BigInteger(1, x); + BigInteger affineY = new BigInteger(1, y); + if (!isOnCurve(curve, affineX, affineY)) { + throw invalidEmbeddedKey(id, "the jwk header has an EC point that is not on its curve"); + } + return ecIdentity(curve, affineX, affineY); + } + case "OKP": { + JsonElement crv = members.get("crv"); + OkpCurve curve = isString(crv) ? OkpCurve.named(crv.getAsString()) : null; + if (curve == null) { + throw invalidEmbeddedKey(id, "the jwk header names no OKP signature curve"); + } + byte[] x = jwkBytes(members, "x", id); + if (x.length != curve.length) { + throw invalidEmbeddedKey(id, "the jwk header has an OKP key of the wrong length"); + } + return okpIdentity(curve, x); + } + default: + throw invalidEmbeddedKey(id, "the jwk header has an unsupported key type"); + } + } + + /** An RSA jwk integer, in the minimum number of octets RFC 7518 Section 6.3.1 requires. */ + private static BigInteger jwkUnsigned(JsonObject jwk, String member, String id) { + byte[] bytes = jwkBytes(jwk, member, id); + if (bytes[0] == 0) { + throw invalidEmbeddedKey(id, "the jwk member " + member + " has a leading zero octet"); + } + return new BigInteger(1, bytes); + } + + private static byte[] jwkBytes(JsonObject jwk, String member, String id) { + JsonElement value = jwk.get(member); + byte[] bytes = isString(value) ? strictBase64Url(value.getAsString()) : null; + if (bytes == null || bytes.length == 0) { + throw invalidEmbeddedKey(id, "the jwk member " + member + " is not base64url"); + } + return bytes; + } + + private static boolean isOnCurve(Curve curve, BigInteger x, BigInteger y) { + ECParameterSpec spec = curve.toECParameterSpec(); + BigInteger p = ((ECFieldFp) spec.getCurve().getField()).getP(); + if (x.compareTo(p) >= 0 || y.compareTo(p) >= 0) { + return false; + } + BigInteger right = x.pow(3).add(spec.getCurve().getA().multiply(x)).add(spec.getCurve().getB()); + return y.pow(2).subtract(right).mod(p).signum() == 0; + } + + /** + * The identity of the public key in the leaf of an embedded {@code x5c}, or + * {@code invalid-embedded-key}. Certificates after the leaf must decode as base64 and are + * otherwise ignored: never parsed, no chain is built or validated, and nothing is said + * about validity. + */ + private static String x5cLeafIdentity(JsonElement element, String id) { + if (!element.isJsonArray() || element.getAsJsonArray().size() == 0) { + throw invalidEmbeddedKey(id, "the x5c header is not a nonempty array"); + } + JsonArray chain = element.getAsJsonArray(); + byte[] leaf = null; + for (JsonElement certificate : chain) { + byte[] der = isString(certificate) ? strictBase64(certificate.getAsString()) : null; + if (der == null || der.length == 0) { + throw invalidEmbeddedKey(id, "the x5c header holds a value that is not padded standard base64"); + } + if (leaf == null) { + leaf = der; + } + } + X509Certificate certificate; + try { + certificate = X509CertUtils.parseWithException(leaf); + } catch (CertificateException | RuntimeException e) { + throw invalidEmbeddedKey(id, "the x5c leaf is not a DER certificate"); + } + // the parser also takes PEM text and ignores bytes after the certificate; accept only + // exactly one DER certificate + try { + if (certificate == null || !Arrays.equals(certificate.getEncoded(), leaf)) { + throw invalidEmbeddedKey(id, "the x5c leaf is not exactly one DER certificate"); + } + } catch (CertificateException e) { + throw invalidEmbeddedKey(id, "the x5c leaf is not a DER certificate"); + } + return publicKeyIdentity(certificate.getPublicKey()); + } + + /** + * Two keys are the same key exactly when their identities are equal. This is equivalent to + * comparing RFC 7638 SHA-256 thumbprints over the required public members: an embedded jwk + * must already use the canonical encoding (minimal RSA integers, full-length EC + * coordinates, strict base64url), so its members encode exactly these values. + */ + static String publicKeyIdentity(PublicKey publicKey) { + if (publicKey instanceof RSAPublicKey) { + RSAPublicKey rsa = (RSAPublicKey) publicKey; + return rsaIdentity(rsa.getModulus(), rsa.getPublicExponent()); + } + if (publicKey instanceof ECPublicKey) { + ECPublicKey ec = (ECPublicKey) publicKey; + Curve curve = Curve.forECParameterSpec(ec.getParams()); + if (curve != null && EC_CURVES.containsKey(curve.getName())) { + return ecIdentity(curve, ec.getW().getAffineX(), ec.getW().getAffineY()); + } + } + byte[] spki = publicKey.getEncoded(); + for (OkpCurve curve : OkpCurve.values()) { + if (spki.length == curve.spkiPrefix.length + curve.length + && Arrays.equals(spki, 0, curve.spkiPrefix.length, curve.spkiPrefix, 0, curve.spkiPrefix.length)) { + return okpIdentity(curve, Arrays.copyOfRange(spki, curve.spkiPrefix.length, spki.length)); + } + } + return "spki:" + Base64.getEncoder().encodeToString(spki); + } + + private static String rsaIdentity(BigInteger modulus, BigInteger exponent) { + return "rsa:" + modulus.toString(16) + ":" + exponent.toString(16); + } + + private static String ecIdentity(Curve curve, BigInteger x, BigInteger y) { + return "ec:" + curve.getName() + ":" + x.toString(16) + ":" + y.toString(16); + } + + private static String okpIdentity(OkpCurve curve, byte[] x) { + return "okp:" + curve.jwkName + ":" + Hex.encodeHexString(x); + } + + private static final byte[] PEM_MARKER = "-----BEGIN".getBytes(StandardCharsets.US_ASCII); + + /** True if the secret contains a PEM armour line, as public-key text does. */ + static boolean isPemText(byte[] secret) { + outer: + for (int i = 0; i + PEM_MARKER.length <= secret.length; i++) { + for (int j = 0; j < PEM_MARKER.length; j++) { + if (secret[i + j] != PEM_MARKER[j]) { + continue outer; + } + } + return true; + } + return false; + } + + private static boolean isString(JsonElement element) { + return element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isString(); + } + + private static String quoted(String untrusted) { + return "\"" + (untrusted.length() > MAX_QUOTED ? untrusted.substring(0, MAX_QUOTED) + "..." : untrusted) + "\""; + } + + private static SDK.AssertionFailureException invalidBinding(String id, String detail) { + return new SDK.AssertionFailureException(id, INVALID_BINDING, detail); + } + + private static SDK.AssertionFailureException invalidEmbeddedKey(String id, String detail) { + return new SDK.AssertionFailureException(id, INVALID_EMBEDDED_KEY, detail); + } + + private static SDK.AssertionFailureException configurationError(String id, String detail) { + return new SDK.AssertionFailureException(id, CONFIGURATION_ERROR, detail); + } +} diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Config.java b/sdk/src/main/java/io/opentdf/platform/sdk/Config.java index f4dbc4f5..9299148c 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/Config.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/Config.java @@ -106,21 +106,60 @@ public static KASInfo fromSimpleKasKey(SimpleKasKey ki) { } } + /** + * The keys a reader verifies assertion bindings with. + *

+ * Each assertion is verified with exactly one key, chosen by its exact {@code id}: the entry + * in {@link #keys} for that id if there is one, otherwise {@link #defaultKey} if it is set, + * otherwise the TDF's payload key with HS256. The choice is final. An assertion that fails + * with its per-ID key is not retried with the default key or the payload key, and one that + * fails with the default key is not retried with the payload key. An entry that is present + * but empty or unusable is a configuration error, not a reason to fall through. + *

+ * A key embedded in the assertion's JWS header ({@code jwk} or {@code x5c}) is never used in + * place of the chosen key: it must be the same public key, or verification fails. + *

+ * RS256 keys are {@link java.security.interfaces.RSAPublicKey}s or RSA + * {@link com.nimbusds.jose.jwk.JWK}s; HS256 keys are {@code byte[]} secrets. + */ public static class AssertionVerificationKeys { + /** Verifies assertions that have no entry in {@link #keys}. Null, or a key with no algorithm and no key, means none. */ public AssertionConfig.AssertionKey defaultKey; + /** Per-assertion keys, by exact assertion {@code id}. */ public Map keys = new HashMap<>(); + /** True if no per-ID key and no default key is configured. */ Boolean isEmpty() { - return this.defaultKey == null && this.keys.isEmpty(); + return (keys == null || keys.isEmpty()) && !hasDefaultKey(); } - AssertionConfig.AssertionKey getKey(String key) { - var assertionKey = keys.get(key); - if (assertionKey != null) { - return assertionKey; - } + /** True if there is an entry for this exact id, even an empty one. */ + boolean hasKeyFor(String id) { + return keys != null && keys.containsKey(id); + } - return defaultKey; + /** + * True if a default key is configured. A default with any field set counts, valid or not; + * only {@code null} or a zero value (no algorithm, no key) is absent. + */ + boolean hasDefaultKey() { + return defaultKey != null + && (defaultKey.key != null + || (defaultKey.alg != null && defaultKey.alg != AssertionConfig.AssertionKeyAlg.NotDefined) + || defaultKey.jwk != null + || defaultKey.x5c != null); + } + + /** + * The configured key for the assertion with this exact id: its per-ID entry if it has one + * (possibly {@code null}, which is a configuration error), else the default key if one is + * configured, else {@code null}, meaning the payload key applies. + */ + AssertionConfig.AssertionKey getKey(String id) { + if (hasKeyFor(id)) { + return keys.get(id); + } + return hasDefaultKey() ? defaultKey : null; } } diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java b/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java index 76426857..e7796f10 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java @@ -24,17 +24,11 @@ import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.JWSHeader; import com.nimbusds.jose.JWSSigner; -import com.nimbusds.jose.JWSVerifier; import com.nimbusds.jose.KeyLengthException; import com.nimbusds.jose.crypto.MACSigner; -import com.nimbusds.jose.crypto.MACVerifier; import com.nimbusds.jose.crypto.RSASSASigner; -import com.nimbusds.jose.crypto.RSASSAVerifier; -import com.nimbusds.jose.jwk.JWK; -import com.nimbusds.jose.util.X509CertUtils; import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; -import io.opentdf.platform.sdk.SDK.AssertionException; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -48,8 +42,6 @@ import java.nio.charset.CharacterCodingException; import java.nio.charset.StandardCharsets; import java.security.PrivateKey; -import java.security.interfaces.RSAPublicKey; -import java.security.cert.X509Certificate; import java.text.ParseException; import java.util.AbstractList; import java.util.ArrayList; @@ -1000,8 +992,9 @@ public static String computeAssertionHash(String wireJson) { /** * The {@code assertionHash} of this assertion as it would be written to a manifest now: * for an unmodified assertion read from a manifest, the hash of the assertion as - * received; after a typed field has been changed, the hash with that change. Reading a - * TDF never uses this: {@code loadTDF} verifies each assertion exactly as received. + * received; after a typed field has been changed, the hash with that change. + * {@link #verify} reads the same form. Reading a TDF uses neither: {@code loadTDF} + * verifies each assertion exactly as received. * * @throws SDK.AssertionFailureException if the assertion cannot be represented as I-JSON */ @@ -1170,59 +1163,28 @@ public void sign(final HashValues hashValues, final AssertionConfig.AssertionKey this.binding.signature = signedJWT.serialize(); } - // Checks the binding signature of the assertion and - // returns the hash and the signature. It returns an error if the verification - // fails. + /** + * Checks this assertion's binding with exactly the given key and its algorithm, and + * returns the signed claims for the caller to compare with the recomputed hash and + * signature. A key embedded in the JWS header ({@code jwk} or {@code x5c}) is accepted + * only if it is the same public key as {@code assertionKey}; it is never used instead. + * This has no side effects on the assertion. + *

+ * Like {@link #hash()}, this reads the assertion as it would be written now, so a + * changed {@link #binding} is the binding checked; for an unmodified assertion read from + * a manifest that is the binding as received. Readers verify assertions in + * {@code loadTDF}, which uses the assertion exactly as received, selects the key, and + * compares the claims; this method is for callers that do the comparison themselves. + * + * @throws SDK.AssertionFailureException if the binding does not verify with the key, or + * the key is not usable ({@code configuration-error}) + */ public Assertion.HashValues verify(AssertionConfig.AssertionKey assertionKey) throws ParseException, JOSEException, java.security.cert.CertificateException { - if (binding == null) { - throw new AssertionException("Binding is null in assertion", this.id); - } - - String signatureString = binding.signature; - - SignedJWT signedJWT = SignedJWT.parse(signatureString); - JWSHeader header = signedJWT.getHeader(); - JWSVerifier verifier = null; - - // Check for JWK in header - if (header.getJWK() != null) { - try { - verifier = createVerifier(header.getJWK()); - } catch (JOSEException e) { - throw new SDKException("Invalid JWK in JWT header", e); - } - } - - // Check for X.509 certificate chain in header - if (verifier == null && header.getX509CertChain() != null && !header.getX509CertChain().isEmpty()) { - try { - X509Certificate cert = X509CertUtils.parse(header.getX509CertChain().get(0).decode()); - if (cert.getPublicKey() instanceof RSAPublicKey) { - verifier = createVerifier((RSAPublicKey) cert.getPublicKey()); - } else { - throw new SDKException("Unsupported public key type in X.509 certificate"); - } - } catch (IllegalArgumentException e) { - throw new SDKException("Invalid Base64 in X.509 certificate in JWT header", e); - } - } - - - if (verifier == null) { - verifier = createVerifier(assertionKey); - } - - - if (!signedJWT.verify(verifier)) { - throw new SDKException("Unable to verify assertion signature"); - } - - JWTClaimsSet claimsSet = signedJWT.getJWTClaimsSet(); - String assertionHash = claimsSet.getStringClaim(kAssertionHash); - String signature = claimsSet.getStringClaim(kAssertionSignature); - - return new Assertion.HashValues(assertionHash, signature); + JsonObject json = toJsonTree(); + String validId = id != null && !id.isEmpty() ? id : null; + return AssertionVerifier.verifyBinding(json, validId, + AssertionVerifier.configuredKey(assertionKey, validId, "the given key")); } private SignedJWT createSignedJWT(final JWTClaimsSet claims, final AssertionConfig.AssertionKey assertionKey) @@ -1267,34 +1229,6 @@ private JWSSigner createSigner(final AssertionConfig.AssertionKey assertionKey) throw new SDKException("Unknown signing algorithm: " + assertionKey.alg); } } - - private JWSVerifier createVerifier(AssertionConfig.AssertionKey assertionKey) throws JOSEException { - switch (assertionKey.alg) { - case RS256: - if (assertionKey.key instanceof JWK) { - return createVerifier((JWK) assertionKey.key); - } else if (assertionKey.key instanceof RSAPublicKey) { - return createVerifier((RSAPublicKey) assertionKey.key); - } else { - throw new SDKException("Expected JWK or RSAPublicKey for RS256 algorithm"); - } - case HS256: - return new MACVerifier((byte[]) assertionKey.key); - default: - throw new SDKException("Unknown verify key, unable to verify assertion signature"); - } - } - - private JWSVerifier createVerifier(JWK jwk) throws JOSEException { - if (jwk instanceof com.nimbusds.jose.jwk.RSAKey) { - return new RSASSAVerifier(jwk.toRSAKey()); - } - throw new JOSEException("Unsupported JWK type: " + jwk.getKeyType() + ". Only RSA keys are supported."); - } - - private JWSVerifier createVerifier(RSAPublicKey publicKey) { - return new RSASSAVerifier(publicKey); - } } private static final AssertionValueAdapter STATEMENT_ADAPTER = new AssertionValueAdapter(); @@ -1406,9 +1340,9 @@ protected static Manifest readManifest(Reader manifestJson) { throw new JsonSyntaxException("unexpected content after the manifest object"); } } catch (JsonParseException e) { - throw readFailure(reader, e); + throw readFailure(reader, jsonReader, e); } catch (IOException e) { - throw readFailure(reader, new JsonSyntaxException(e)); + throw readFailure(reader, jsonReader, new JsonSyntaxException(e)); } if (result == null) { throw new IllegalArgumentException("Manifest is empty"); @@ -1425,7 +1359,8 @@ static void checkManifestJson(String manifestJson) { AssertionJson.checkManifest(new StringReader(manifestJson)); } - private static RuntimeException readFailure(FailureTrackingReader reader, JsonParseException e) { + private static RuntimeException readFailure(FailureTrackingReader reader, StrictJsonReader jsonReader, + JsonParseException e) { if (reader.failure instanceof CharacterCodingException) { return new SDK.AssertionFailureException(null, AssertionFailureReason.INVALID_JSON, "the manifest is not valid UTF-8"); @@ -1433,6 +1368,13 @@ private static RuntimeException readFailure(FailureTrackingReader reader, JsonPa if (reader.failure != null) { return new SDKException("error reading manifest from zip file", reader.failure); } + if (e.getCause() instanceof IOException && jsonReader.inAssertion()) { + // JSON that does not parse inside an assertion object is not I-JSON there. This also + // covers integer literals that Gson's lexer cannot read: it accumulates digits in a + // long, and treats a 20+ digit integer whose prefix wraps to zero as malformed. + return new SDK.AssertionFailureException(jsonReader.openAssertionId(), AssertionFailureReason.INVALID_JSON, + "an assertion is not valid JSON"); + } return e; } diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/StrictJsonReader.java b/sdk/src/main/java/io/opentdf/platform/sdk/StrictJsonReader.java index 32f536b3..d7e61825 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/StrictJsonReader.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/StrictJsonReader.java @@ -388,6 +388,16 @@ public void skipValue() throws IOException { } } + /** True while the reader is inside an assertion object, as when a syntax error stopped it there. */ + boolean inAssertion() { + return assertionDepth >= 0; + } + + /** The exact {@code id} of the open assertion object, if one has been read; otherwise null. */ + String openAssertionId() { + return assertionDepth >= 0 ? frames[assertionDepth].exactId() : null; + } + private void fail(AssertionFailureReason reason, String detail) { if (assertionDepth >= 0) { Frame assertion = frames[assertionDepth]; diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java index 51b6e977..524a9da0 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java @@ -26,7 +26,6 @@ import java.security.*; import java.security.interfaces.ECPublicKey; import java.security.spec.InvalidKeySpecException; -import java.text.ParseException; import java.util.*; /** @@ -982,55 +981,12 @@ Reader loadTDF(SeekableByteChannel tdf, Config.TDFReaderConfig tdfReaderConfig) "segment size mismatch. encrypted segment size differs from plaintext segment size. the TDF is invalid"); } - // Validate assertions - for (var assertion : manifest.assertions) { - // Skip assertion verification if disabled - if (tdfReaderConfig.disableAssertionVerification) { - break; - } - - // Set default to HS256 - var assertionKey = new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.HS256, payloadKey); - Config.AssertionVerificationKeys assertionVerificationKeys = tdfReaderConfig.assertionVerificationKeys; - if (!assertionVerificationKeys.isEmpty()) { - var keyForAssertion = assertionVerificationKeys.getKey(assertion.id); - if (keyForAssertion != null) { - assertionKey = keyForAssertion; - } - } - - Manifest.Assertion.HashValues hashValues; - try { - hashValues = assertion.verify(assertionKey); - } catch (ParseException | JOSEException | java.security.cert.CertificateException e) { - throw new SDKException("error validating assertion hash", e); - } - // the assertion exactly as received, never a re-serialization of the typed fields - var hashOfAssertionAsHex = AssertionJson.hash(assertion.received()); - - if (!Objects.equals(hashOfAssertionAsHex, hashValues.getAssertionHash())) { - throw new SDK.AssertionFailureException(assertion.id, AssertionFailureReason.BINDING_MISMATCH, - "assertion hash mismatch"); - } - - byte[] hashOfAssertion; - if (isLegacyTdf) { - hashOfAssertion = hashOfAssertionAsHex.getBytes(StandardCharsets.UTF_8); - } else { - try { - hashOfAssertion = Hex.decodeHex(hashOfAssertionAsHex); - } catch (DecoderException e) { - throw new SDKException("error decoding assertion hash", e); - } - } - var signature = new byte[aggregateHash.length + hashOfAssertion.length]; - System.arraycopy(aggregateHash, 0, signature, 0, aggregateHash.length); - System.arraycopy(hashOfAssertion, 0, signature, aggregateHash.length, hashOfAssertion.length); - var encodeSignature = Base64.getEncoder().encodeToString(signature); - - if (!Objects.equals(encodeSignature, hashValues.getSignature())) { - throw new SDK.AssertionFailureException(assertion.id, AssertionFailureReason.BINDING_MISMATCH, - "failed integrity check on assertion signature"); + // Verify assertions (assertions spec, Section 8). Manifest parsing already applied the + // JSON profile of Section 3, which holds even when verification is disabled. + if (!tdfReaderConfig.disableAssertionVerification) { + for (var assertion : manifest.assertions) { + AssertionVerifier.verify(assertion, tdfReaderConfig.assertionVerificationKeys, payloadKey, + aggregateHash, isLegacyTdf); } } diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/AssertionVerificationTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/AssertionVerificationTest.java new file mode 100644 index 00000000..6162d81c --- /dev/null +++ b/sdk/src/test/java/io/opentdf/platform/sdk/AssertionVerificationTest.java @@ -0,0 +1,668 @@ +package io.opentdf.platform.sdk; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import org.apache.commons.codec.binary.Hex; +import org.apache.commons.compress.utils.SeekableInMemoryByteChannel; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.interfaces.RSAPublicKey; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Assertion verification on read: the key is selected once from the reader's configuration + * (per-ID key, then default key, then the payload key with HS256), keys embedded in the JWS + * header never add trust, and the hash covers the assertion as received. + *

+ * Every negative case has a positive control showing that the same reader configuration accepts + * the authorized signer. + */ +class AssertionVerificationTest { + + private static final String KAS_URL = "https://example.com/kas0"; + private static final String ID = "assertion1"; + private static final byte[] PLAINTEXT = "assertion verification".getBytes(StandardCharsets.UTF_8); + + private static KeyPair kasKeyPair; + /** The signer the reader is configured with. */ + private static KeyPair issuer; + /** A key the reader is not configured with. */ + private static KeyPair unrelated; + + private static final SDK.KAS KAS = new SDK.KAS() { + @Override + public void close() { + // nothing to release + } + + @Override + public Config.KASInfo getPublicKey(Config.KASInfo kasInfo) { + var resolved = new Config.KASInfo(); + resolved.URL = kasInfo.URL; + resolved.KID = "r1"; + resolved.PublicKey = CryptoUtils.getPublicKeyPEM(kasKeyPair.getPublic()); + return resolved; + } + + @Override + public byte[] unwrap(Manifest.KeyAccess keyAccess, String policy, KeyType sessionKeyType) { + return new AsymDecryption(kasKeyPair.getPrivate()).decrypt(Base64.getDecoder().decode(keyAccess.wrappedKey)); + } + + @Override + public KASKeyCache getKeyCache() { + return new KASKeyCache(); + } + }; + + @BeforeAll + static void generateKeys() { + kasKeyPair = CryptoUtils.generateRSAKeypair(); + issuer = CryptoUtils.generateRSAKeypair(); + unrelated = CryptoUtils.generateRSAKeypair(); + } + + // ------------------------------------------------------------------ fixtures + + private static AssertionConfig assertionConfig(String id, AssertionConfig.AssertionKey signingKey) { + var config = new AssertionConfig(); + config.id = id; + config.type = AssertionConfig.Type.HandlingAssertion; + config.scope = AssertionConfig.Scope.Payload; + config.appliesToState = AssertionConfig.AppliesToState.Unencrypted; + config.statement = new AssertionConfig.Statement(); + config.statement.format = "json+stanag5636"; + config.statement.schema = "urn:nato:stanag:5636:A:1:elements:json"; + config.statement.value = "{\"ocl\":{\"cls\":\"SECRET\"}}"; + config.signingKey = signingKey; + return config; + } + + private static AssertionConfig.AssertionKey issuerSigningKey() { + return new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.RS256, issuer.getPrivate()); + } + + private static byte[] createTdf(AssertionConfig... assertions) throws IOException { + var kasInfo = new Config.KASInfo(); + kasInfo.URL = KAS_URL; + var out = new ByteArrayOutputStream(); + new TDF(new FakeServicesBuilder().setKas(KAS).build()).createTDF(new ByteArrayInputStream(PLAINTEXT), out, + Config.newTDFConfig(Config.withAutoconfigure(false), Config.withKasInformation(kasInfo), + Config.withAssertionConfig(assertions))); + return out.toByteArray(); + } + + private static TDF.Reader load(byte[] tdf, Config.AssertionVerificationKeys keys) throws IOException { + var options = new ArrayList>(); + options.add(Config.WithIgnoreKasAllowlist(true)); + if (keys != null) { + options.add(Config.withAssertionVerificationKeys(keys)); + } + @SuppressWarnings("unchecked") + Consumer[] asArray = options.toArray(new Consumer[0]); + return new TDF(new FakeServicesBuilder().setKas(KAS).build()) + .loadTDF(new SeekableInMemoryByteChannel(tdf), Config.newTDFReaderConfig(asArray)); + } + + private static void assertDecrypts(byte[] tdf, Config.AssertionVerificationKeys keys) throws IOException { + var reader = load(tdf, keys); + var out = new ByteArrayOutputStream(); + reader.readPayload(out); + assertThat(out.toByteArray()).isEqualTo(PLAINTEXT); + } + + private static void assertFails(byte[] tdf, Config.AssertionVerificationKeys keys, AssertionFailureReason reason, + String id) { + assertThatThrownBy(() -> load(tdf, keys)) + .isInstanceOfSatisfying(SDK.AssertionFailureException.class, e -> { + assertThat(e.getReason()).isEqualTo(reason); + assertThat(e.getAssertionId()).isEqualTo(id); + assertThat(e.getMessage()).contains("reason=" + reason.code()).contains("assertion") + .contains("tamper"); + if (id != null) { + assertThat(e.getMessage()).contains("id: " + id); + } + }) + .isInstanceOf(SDK.AssertionException.class); + } + + private static Config.AssertionVerificationKeys perId(String id, AssertionConfig.AssertionKey key) { + var keys = new Config.AssertionVerificationKeys(); + keys.keys.put(id, key); + return keys; + } + + private static Config.AssertionVerificationKeys defaultKey(AssertionConfig.AssertionKey key) { + var keys = new Config.AssertionVerificationKeys(); + keys.defaultKey = key; + return keys; + } + + private static AssertionConfig.AssertionKey rs256(KeyPair keyPair) { + return new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.RS256, keyPair.getPublic()); + } + + private static AssertionConfig.AssertionKey hs256(byte[] secret) { + return new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.HS256, secret); + } + + // ------------------------------------------------------------ manifest surgery + + private static JsonObject manifestOf(byte[] tdf) throws IOException { + return JsonParser.parseString(new String(entry(tdf, TDFWriter.TDF_MANIFEST_FILE_NAME), StandardCharsets.UTF_8)) + .getAsJsonObject(); + } + + private static byte[] entry(byte[] tdf, String name) throws IOException { + for (var entry : new ZipReader(new SeekableInMemoryByteChannel(tdf)).getEntries()) { + if (entry.getName().equals(name)) { + try (InputStream data = entry.getData()) { + return data.readAllBytes(); + } + } + } + throw new IllegalArgumentException("no entry named " + name); + } + + /** A copy of the TDF with its manifest JSON edited. */ + private static byte[] rewrite(byte[] tdf, Consumer edit) throws IOException { + var manifest = manifestOf(tdf); + edit.accept(manifest); + var out = new ByteArrayOutputStream(); + var writer = new TDFWriter(out); + try (var payload = writer.payload()) { + payload.write(entry(tdf, TDFWriter.TDF_PAYLOAD_FILE_NAME)); + } + try (var manifestOut = writer.manifest()) { + manifestOut.write(new Gson().toJson(manifest).getBytes(StandardCharsets.UTF_8)); + } + writer.finish(); + return out.toByteArray(); + } + + private static JsonArray assertions(JsonObject manifest) { + return manifest.getAsJsonArray("assertions"); + } + + private static byte[] aggregate(JsonObject manifest) { + var aggregate = new ByteArrayOutputStream(); + for (var segment : manifest.getAsJsonObject("encryptionInformation").getAsJsonObject("integrityInformation") + .getAsJsonArray("segments")) { + aggregate.writeBytes(Base64.getDecoder().decode(segment.getAsJsonObject().get("hash").getAsString())); + } + return aggregate.toByteArray(); + } + + enum Embedded { NONE, JWK, X5C, BOTH } + + /** The wire form of an assertion, without its binding. */ + private static JsonObject wire(String id, String classification) { + var statement = new JsonObject(); + statement.addProperty("format", "json+stanag5636"); + statement.addProperty("schema", "urn:nato:stanag:5636:A:1:elements:json"); + statement.addProperty("value", "{\"ocl\":{\"cls\":\"" + classification + "\"}}"); + var assertion = new JsonObject(); + assertion.addProperty("id", id); + assertion.addProperty("type", "handling"); + assertion.addProperty("scope", "payload"); + assertion.addProperty("appliesToState", "unencrypted"); + assertion.add("statement", statement); + return assertion; + } + + /** + * Binds {@code assertion} to {@code manifest}'s payload exactly as a writer would, signing + * with {@code signer} (RS256) and embedding {@code embedded} in the JWS header. + */ + private static JsonObject bind(JsonObject manifest, JsonObject assertion, KeyPair signer, KeyPair embedded, + Embedded what) throws Exception { + var header = new JWSHeader.Builder(JWSAlgorithm.RS256); + if (what == Embedded.JWK || what == Embedded.BOTH) { + header.jwk(new RSAKey.Builder((RSAPublicKey) embedded.getPublic()).build()); + } + if (what == Embedded.X5C || what == Embedded.BOTH) { + var certificate = TestUtil.createTestCertificate(embedded.getPublic(), embedded.getPrivate()); + header.x509CertChain(List.of(com.nimbusds.jose.util.Base64.encode(certificate.getEncoded()))); + } + var jwt = new SignedJWT(header.build(), claims(manifest, assertion)); + jwt.sign(new RSASSASigner(signer.getPrivate())); + return withBinding(assertion, jwt.serialize()); + } + + private static JsonObject bindHs256(JsonObject manifest, JsonObject assertion, byte[] secret, + Consumer headerEdit) throws Exception { + var header = new JWSHeader.Builder(JWSAlgorithm.HS256); + headerEdit.accept(header); + var jwt = new SignedJWT(header.build(), claims(manifest, assertion)); + jwt.sign(new MACSigner(secret)); + return withBinding(assertion, jwt.serialize()); + } + + private static JWTClaimsSet claims(JsonObject manifest, JsonObject assertion) throws Exception { + String hash = Manifest.Assertion.computeAssertionHash(assertion.toString()); + byte[] aggregate = aggregate(manifest); + byte[] hashBytes = Hex.decodeHex(hash); + byte[] bound = new byte[aggregate.length + hashBytes.length]; + System.arraycopy(aggregate, 0, bound, 0, aggregate.length); + System.arraycopy(hashBytes, 0, bound, aggregate.length, hashBytes.length); + return new JWTClaimsSet.Builder() + .claim("assertionHash", hash) + .claim("assertionSig", Base64.getEncoder().encodeToString(bound)) + .build(); + } + + private static JsonObject withBinding(JsonObject assertion, String signature) { + var bound = assertion.deepCopy(); + var binding = new JsonObject(); + binding.addProperty("method", "jws"); + binding.addProperty("signature", signature); + bound.add("binding", binding); + return bound; + } + + // ---------------------------------------------------------------- controls + + @Test + void theConfiguredSignerVerifiesPerIdAndAsTheDefault() throws Exception { + var tdf = createTdf(assertionConfig(ID, issuerSigningKey())); + assertDecrypts(tdf, perId(ID, rs256(issuer))); + assertDecrypts(tdf, defaultKey(rs256(issuer))); + // an RSA JWK is accepted as the configured key too + assertDecrypts(tdf, perId(ID, new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.RS256, + new RSAKey.Builder((RSAPublicKey) issuer.getPublic()).build()))); + } + + @Test + void anUnkeyedAssertionVerifiesWithThePayloadKey() throws Exception { + var tdf = createTdf(assertionConfig(ID, null)); + assertDecrypts(tdf, null); + assertDecrypts(tdf, new Config.AssertionVerificationKeys()); + // a zero-value default key is the same as none + assertDecrypts(tdf, defaultKey(new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.NotDefined, null))); + } + + /** The Java writer may embed its key; that still verifies when the reader configured the same key. */ + @ParameterizedTest + @EnumSource(value = Embedded.class, names = {"JWK", "X5C", "BOTH"}) + void javaWriterEmbeddedKeysVerifyWithThatKeyConfigured(Embedded embedded) throws Exception { + var signingKey = issuerSigningKey(); + if (embedded == Embedded.JWK || embedded == Embedded.BOTH) { + signingKey.withJwk(new RSAKey.Builder((RSAPublicKey) issuer.getPublic()).build()); + } + if (embedded == Embedded.X5C || embedded == Embedded.BOTH) { + var certificate = TestUtil.createTestCertificate(issuer.getPublic(), issuer.getPrivate()); + signingKey.withX5c(List.of(com.nimbusds.jose.util.Base64.encode(certificate.getEncoded()))); + } + var tdf = createTdf(assertionConfig(ID, signingKey)); + assertDecrypts(tdf, perId(ID, rs256(issuer))); + assertDecrypts(tdf, defaultKey(rs256(issuer))); + // the embedded key alone grants nothing + assertFails(tdf, null, AssertionFailureReason.ALGORITHM_MISMATCH, ID); + assertFails(tdf, defaultKey(rs256(unrelated)), AssertionFailureReason.SIGNER_NOT_PERMITTED, ID); + } + + // ------------------------------------------------- embedded keys of other signers + + static Stream unrelatedSignerCases() { + var arguments = new ArrayList(); + for (var embedded : List.of(Embedded.JWK, Embedded.X5C, Embedded.BOTH)) { + for (var replace : List.of(false, true)) { + for (var asDefault : List.of(false, true)) { + arguments.add(Arguments.of(embedded, replace, asDefault)); + } + } + } + return arguments.stream(); + } + + /** + * A binding signed by an unrelated key, with that key embedded as jwk, x5c or both, fails + * against the configured key, per ID or default, whether its assertion is added alongside + * the original or replaces it. + */ + @ParameterizedTest(name = "embedded={0} replace={1} defaultKey={2}") + @MethodSource("unrelatedSignerCases") + void anUnrelatedSignerWithAnEmbeddedKeyFailsAgainstTheConfiguredKey(Embedded embedded, boolean replace, + boolean asDefault) + throws Exception { + var original = createTdf(assertionConfig(ID, issuerSigningKey())); + var keys = asDefault ? defaultKey(rs256(issuer)) : perId(ID, rs256(issuer)); + assertDecrypts(original, keys); + + var edited = rewrite(original, manifest -> { + try { + var other = bind(manifest, wire(ID, "UNCLASSIFIED"), unrelated, unrelated, embedded); + if (replace) { + assertions(manifest).set(0, other); + } else { + assertions(manifest).add(other); + } + } catch (Exception e) { + throw new IllegalStateException(e); + } + }); + assertFails(edited, keys, AssertionFailureReason.SIGNER_NOT_PERMITTED, ID); + } + + @Test + void anAssertionUnderAnotherIdGetsTheDefaultOrPayloadKeyNotItsHeaderKey() throws Exception { + var original = createTdf(assertionConfig(ID, issuerSigningKey())); + var edited = rewrite(original, manifest -> { + try { + assertions(manifest).add(bind(manifest, wire("other", "UNCLASSIFIED"), unrelated, unrelated, Embedded.JWK)); + } catch (Exception e) { + throw new IllegalStateException(e); + } + }); + // with a default pin, the new id gets the default key + assertDecrypts(original, defaultKey(rs256(issuer))); + assertFails(edited, defaultKey(rs256(issuer)), AssertionFailureReason.SIGNER_NOT_PERMITTED, "other"); + // with only a per-ID pin, the new id gets the payload key, never the header's key + assertDecrypts(original, perId(ID, rs256(issuer))); + assertFails(edited, perId(ID, rs256(issuer)), AssertionFailureReason.ALGORITHM_MISMATCH, "other"); + } + + @Test + void bindingsFromOtherKeysFailWhenThePayloadKeyIsSelected() throws Exception { + var original = createTdf(assertionConfig(ID, null)); + assertDecrypts(original, null); + + for (var embedded : List.of(Embedded.JWK, Embedded.X5C)) { + var rsaBinding = rewrite(original, manifest -> { + try { + assertions(manifest).set(0, bind(manifest, wire(ID, "UNCLASSIFIED"), unrelated, unrelated, embedded)); + } catch (Exception e) { + throw new IllegalStateException(e); + } + }); + assertFails(rsaBinding, null, AssertionFailureReason.ALGORITHM_MISMATCH, ID); + } + + byte[] otherSecret = AesGcm.generateKey(); + var hmacWithJwk = rewrite(original, manifest -> { + try { + assertions(manifest).set(0, bindHs256(manifest, wire(ID, "UNCLASSIFIED"), otherSecret, + header -> header.jwk(new RSAKey.Builder((RSAPublicKey) unrelated.getPublic()).build()))); + } catch (Exception e) { + throw new IllegalStateException(e); + } + }); + assertFails(hmacWithJwk, null, AssertionFailureReason.INVALID_EMBEDDED_KEY, ID); + + var hmacWithoutHeaderKey = rewrite(original, manifest -> { + try { + assertions(manifest).set(0, bindHs256(manifest, wire(ID, "UNCLASSIFIED"), otherSecret, header -> { })); + } catch (Exception e) { + throw new IllegalStateException(e); + } + }); + assertFails(hmacWithoutHeaderKey, null, AssertionFailureReason.SIGNATURE_INVALID, ID); + } + + @Test + void theSelectedKeysOwnJwkSignedByAnotherKeyFailsTheSignature() throws Exception { + var original = createTdf(assertionConfig(ID, issuerSigningKey())); + var edited = rewrite(original, manifest -> { + try { + assertions(manifest).set(0, bind(manifest, wire(ID, "UNCLASSIFIED"), unrelated, issuer, Embedded.BOTH)); + } catch (Exception e) { + throw new IllegalStateException(e); + } + }); + assertFails(edited, perId(ID, rs256(issuer)), AssertionFailureReason.SIGNATURE_INVALID, ID); + } + + @Test + void contradictoryEmbeddedKeysAreRejected() throws Exception { + var original = createTdf(assertionConfig(ID, issuerSigningKey())); + var edited = rewrite(original, manifest -> { + try { + var assertion = wire(ID, "SECRET"); + var certificate = TestUtil.createTestCertificate(unrelated.getPublic(), unrelated.getPrivate()); + var header = new JWSHeader.Builder(JWSAlgorithm.RS256) + .jwk(new RSAKey.Builder((RSAPublicKey) issuer.getPublic()).build()) + .x509CertChain(List.of(com.nimbusds.jose.util.Base64.encode(certificate.getEncoded()))); + var jwt = new SignedJWT(header.build(), claims(manifest, assertion)); + jwt.sign(new RSASSASigner(issuer.getPrivate())); + assertions(manifest).set(0, withBinding(assertion, jwt.serialize())); + } catch (Exception e) { + throw new IllegalStateException(e); + } + }); + assertFails(edited, perId(ID, rs256(issuer)), AssertionFailureReason.INVALID_EMBEDDED_KEY, ID); + } + + // ------------------------------------------------------------ selection order + + @Test + void aFailedPerIdKeyDoesNotFallBackToTheDefaultKey() throws Exception { + var tdf = createTdf(assertionConfig(ID, issuerSigningKey())); + var keys = perId(ID, rs256(unrelated)); + keys.defaultKey = rs256(issuer); + assertFails(tdf, keys, AssertionFailureReason.SIGNATURE_INVALID, ID); + // control: the same default verifies when there is no per-ID entry + assertDecrypts(tdf, defaultKey(rs256(issuer))); + } + + @Test + void aFailedConfiguredKeyDoesNotFallBackToThePayloadKey() throws Exception { + var tdf = createTdf(assertionConfig(ID, null)); + assertDecrypts(tdf, null); + assertFails(tdf, perId(ID, hs256(AesGcm.generateKey())), AssertionFailureReason.SIGNATURE_INVALID, ID); + assertFails(tdf, defaultKey(hs256(AesGcm.generateKey())), AssertionFailureReason.SIGNATURE_INVALID, ID); + assertFails(tdf, defaultKey(rs256(issuer)), AssertionFailureReason.ALGORITHM_MISMATCH, ID); + } + + @Test + void perIdKeysMatchTheExactId() throws Exception { + var tdf = createTdf(assertionConfig(ID, issuerSigningKey())); + // a differently cased entry does not apply, so the payload key is selected + assertFails(tdf, perId("Assertion1", rs256(issuer)), AssertionFailureReason.ALGORITHM_MISMATCH, ID); + assertDecrypts(tdf, perId(ID, rs256(issuer))); + } + + @Test + void theAlgorithmComesFromTheConfiguredKey() throws Exception { + var tdf = createTdf(assertionConfig(ID, issuerSigningKey())); + assertFails(tdf, perId(ID, hs256(AesGcm.generateKey())), AssertionFailureReason.ALGORITHM_MISMATCH, ID); + } + + static Stream invalidConfiguredKeys() { + var rsaPublic = CryptoUtils.generateRSAKeypair().getPublic(); + return Stream.of( + Arguments.of("empty per-ID entry", perId(ID, null)), + Arguments.of("RS256 with a secret", perId(ID, + new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.RS256, AesGcm.generateKey()))), + Arguments.of("HS256 with public-key material", perId(ID, + new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.HS256, rsaPublic))), + Arguments.of("HS256 with an empty secret", perId(ID, hs256(new byte[0]))), + Arguments.of("HS256 secret shorter than 256 bits", perId(ID, hs256(new byte[16]))), + Arguments.of("no algorithm", perId(ID, + new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.NotDefined, rsaPublic))), + Arguments.of("default key with a key but no algorithm", defaultKey( + new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.NotDefined, rsaPublic))), + Arguments.of("default key with an algorithm but no key", defaultKey( + new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.RS256, null)))); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidConfiguredKeys") + void anInvalidConfiguredKeyIsAConfigurationError(String name, Config.AssertionVerificationKeys keys) + throws Exception { + // signed with the payload key, which would verify if the reader fell through to it + var tdf = createTdf(assertionConfig(ID, null)); + assertDecrypts(tdf, null); + assertFails(tdf, keys, AssertionFailureReason.CONFIGURATION_ERROR, ID); + } + + // --------------------------------------------------------- hash as received + + @Test + void unmodelledMembersAreAuthenticated() throws Exception { + var original = createTdf(assertionConfig(ID, issuerSigningKey())); + var signed = rewrite(original, manifest -> { + try { + var assertion = wire(ID, "SECRET"); + assertion.add("extensions", JsonParser.parseString("{\"releasableTo\":[\"USA\"]}")); + assertion.getAsJsonObject("statement").addProperty("hash", "part of the statement"); + assertions(manifest).set(0, bind(manifest, assertion, issuer, issuer, Embedded.NONE)); + } catch (Exception e) { + throw new IllegalStateException(e); + } + }); + assertDecrypts(signed, perId(ID, rs256(issuer))); + + var tampered = rewrite(signed, manifest -> assertions(manifest).get(0).getAsJsonObject() + .getAsJsonObject("extensions").getAsJsonArray("releasableTo").add("XXX")); + assertFails(tampered, perId(ID, rs256(issuer)), AssertionFailureReason.BINDING_MISMATCH, ID); + + var nestedTamper = rewrite(signed, manifest -> assertions(manifest).get(0).getAsJsonObject() + .getAsJsonObject("statement").addProperty("hash", "changed")); + assertFails(nestedTamper, perId(ID, rs256(issuer)), AssertionFailureReason.BINDING_MISMATCH, ID); + + var added = rewrite(original, manifest -> + assertions(manifest).get(0).getAsJsonObject().addProperty("note", "added after signing")); + assertFails(added, perId(ID, rs256(issuer)), AssertionFailureReason.BINDING_MISMATCH, ID); + } + + @Test + void anObjectValuedStatementIsHashedAsAnObject() throws Exception { + var original = createTdf(assertionConfig(ID, issuerSigningKey())); + var acp240 = AssertionHashTest.acp240Assertion(); + acp240.addProperty("id", ID); + var signed = rewrite(original, manifest -> { + try { + assertions(manifest).set(0, bind(manifest, acp240, issuer, issuer, Embedded.NONE)); + } catch (Exception e) { + throw new IllegalStateException(e); + } + }); + var reader = load(signed, perId(ID, rs256(issuer))); + var read = reader.getManifest().assertions.get(0); + assertThat(AssertionJson.hash(read.received())).isEqualTo(Manifest.Assertion.computeAssertionHash(acp240.toString())); + assertThat(read.received().getAsJsonObject("statement").get("value").isJsonObject()).isTrue(); + + var changed = rewrite(signed, manifest -> assertions(manifest).get(0).getAsJsonObject() + .getAsJsonObject("statement").getAsJsonObject("value").getAsJsonObject("ocl") + .addProperty("cls", "UNCLASSIFIED")); + assertFails(changed, perId(ID, rs256(issuer)), AssertionFailureReason.BINDING_MISMATCH, ID); + } + + // ----------------------------------------------------- ids and binding forms + + @Test + void anInvalidIdFailsVerificationButNotParsing() throws Exception { + var original = createTdf(assertionConfig(ID, null)); + for (JsonElement badId : List.of(new com.google.gson.JsonPrimitive(5), new com.google.gson.JsonPrimitive(""))) { + var edited = rewrite(original, manifest -> assertions(manifest).get(0).getAsJsonObject().add("id", badId)); + assertFails(edited, null, AssertionFailureReason.INVALID_ID, null); + var disabled = new TDF(new FakeServicesBuilder().setKas(KAS).build()).loadTDF( + new SeekableInMemoryByteChannel(edited), + Config.newTDFReaderConfig(Config.WithIgnoreKasAllowlist(true), + Config.withDisableAssertionVerification(true))); + // the typed view never coerces a non-string id + assertThat(disabled.getManifest().assertions.get(0).id).isEqualTo(badId.getAsJsonPrimitive().isString() ? "" : null); + } + var missing = rewrite(original, manifest -> assertions(manifest).get(0).getAsJsonObject().remove("id")); + assertFails(missing, null, AssertionFailureReason.INVALID_ID, null); + } + + @Test + void bindingFormsFollowTheSpec() throws Exception { + var original = createTdf(assertionConfig(ID, issuerSigningKey())); + var keys = perId(ID, rs256(issuer)); + + var upperCase = rewrite(original, manifest -> binding(manifest).addProperty("method", "JWS")); + assertDecrypts(upperCase, keys); + var noMethod = rewrite(original, manifest -> binding(manifest).remove("method")); + assertDecrypts(noMethod, keys); + + assertFails(rewrite(original, manifest -> binding(manifest).addProperty("method", "jwt")), keys, + AssertionFailureReason.UNSUPPORTED_BINDING, ID); + assertFails(rewrite(original, manifest -> { + var binding = binding(manifest); + binding.remove("signature"); + binding.addProperty("bdo", "PGJkbz4="); + }), keys, AssertionFailureReason.UNSUPPORTED_BINDING, ID); + assertFails(rewrite(original, manifest -> binding(manifest).addProperty("bdo", "PGJkbz4=")), keys, + AssertionFailureReason.INVALID_BINDING, ID); + assertFails(rewrite(original, manifest -> binding(manifest).remove("signature")), keys, + AssertionFailureReason.INVALID_BINDING, ID); + assertFails(rewrite(original, manifest -> assertions(manifest).get(0).getAsJsonObject().remove("binding")), + keys, AssertionFailureReason.INVALID_BINDING, ID); + assertFails(rewrite(original, manifest -> assertions(manifest).get(0).getAsJsonObject() + .addProperty("binding", "a.b.c")), keys, AssertionFailureReason.INVALID_BINDING, ID); + assertFails(rewrite(original, manifest -> binding(manifest).addProperty("signature", "a.b")), keys, + AssertionFailureReason.INVALID_BINDING, ID); + } + + private static JsonObject binding(JsonObject manifest) { + return assertions(manifest).get(0).getAsJsonObject().getAsJsonObject("binding"); + } + + @Test + void verifyingLeavesTheParsedManifestUntouched() throws Exception { + var tdf = createTdf(assertionConfig(ID, issuerSigningKey())); + var reader = load(tdf, perId(ID, rs256(issuer))); + var assertion = reader.getManifest().assertions.get(0); + assertThat(assertion.binding).isNotNull(); + assertThat(assertion.binding.method).isEqualTo("jws"); + assertThat(assertion.binding.signature).isNotEmpty(); + } + + @Test + void verifyChecksTheBindingWithExactlyTheGivenKey() throws Exception { + var tdf = createTdf(assertionConfig(ID, issuerSigningKey().withJwk( + new RSAKey.Builder((RSAPublicKey) issuer.getPublic()).build()))); + var assertion = SDK.readManifest(new SeekableInMemoryByteChannel(tdf)).assertions.get(0); + + var claims = assertion.verify(rs256(issuer)); + assertThat(claims.getAssertionHash()).isEqualTo(assertion.hash()); + + assertThatThrownBy(() -> assertion.verify(rs256(unrelated))) + .isInstanceOfSatisfying(SDK.AssertionFailureException.class, + e -> assertThat(e.getReason()).isEqualTo(AssertionFailureReason.SIGNER_NOT_PERMITTED)); + assertThatThrownBy(() -> assertion.verify(null)) + .isInstanceOfSatisfying(SDK.AssertionFailureException.class, + e -> assertThat(e.getReason()).isEqualTo(AssertionFailureReason.CONFIGURATION_ERROR)); + // and has no side effects + assertThat(assertion.binding).isNotNull(); + + // verify() and hash() read the same form: after a typed edit, the signed claims no + // longer match the hash, and a changed binding is the binding checked + assertion.statement.value = "changed"; + assertThat(assertion.verify(rs256(issuer)).getAssertionHash()).isNotEqualTo(assertion.hash()); + assertion.binding.signature = "not.a.jws.binding"; + assertThatThrownBy(() -> assertion.verify(rs256(issuer))) + .isInstanceOfSatisfying(SDK.AssertionFailureException.class, + e -> assertThat(e.getReason()).isEqualTo(AssertionFailureReason.INVALID_BINDING)); + } +} diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/AssertionVerifierTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/AssertionVerifierTest.java new file mode 100644 index 00000000..725cfdd0 --- /dev/null +++ b/sdk/src/test/java/io/opentdf/platform/sdk/AssertionVerifierTest.java @@ -0,0 +1,321 @@ +package io.opentdf.platform.sdk; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.apache.commons.codec.binary.Hex; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.interfaces.ECPublicKey; +import java.security.interfaces.RSAPublicKey; +import java.util.Arrays; +import java.util.Base64; +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Section 8 steps 3.3 to 3.9 on hand-built JWS bindings, for the cases the shared vectors do not + * cover yet: duplicate JOSE members, signature encoding, embedded keys of other key types, and + * claims other than the two the binding uses. + */ +class AssertionVerifierTest { + + private static final byte[] AGGREGATE = "segment hashes".getBytes(StandardCharsets.US_ASCII); + private static KeyPair signer; + private static KeyPair ecKey; + private static AssertionVerifier.SelectedKey selected; + + @BeforeAll + static void keys() { + signer = CryptoUtils.generateRSAKeypair(); + ecKey = CryptoUtils.generateECKeypair(KeyType.EC256Key.getECCurve().getCurveName()); + selected = AssertionVerifier.SelectedKey.rsa((RSAPublicKey) signer.getPublic()); + } + + private static String b64url(byte[] bytes) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private static String b64url(String text) { + return b64url(text.getBytes(StandardCharsets.UTF_8)); + } + + private static JsonObject assertion() { + return JsonParser.parseString("{\"id\":\"a\",\"type\":\"handling\",\"scope\":\"payload\"," + + "\"statement\":{\"format\":\"string\",\"value\":\"SECRET\"}}").getAsJsonObject(); + } + + /** The claims a writer signs for {@link #assertion()}, plus {@code extra} members. */ + private static String claims(String extra) throws Exception { + String hash = Manifest.Assertion.computeAssertionHash(assertion().toString()); + byte[] hashBytes = Hex.decodeHex(hash); + byte[] bound = Arrays.copyOf(AGGREGATE, AGGREGATE.length + hashBytes.length); + System.arraycopy(hashBytes, 0, bound, AGGREGATE.length, hashBytes.length); + return "{\"assertionHash\":\"" + hash + "\",\"assertionSig\":\"" + Base64.getEncoder().encodeToString(bound) + + "\"" + extra + "}"; + } + + private static String sign(String headerJson, String claimsJson, PrivateKey key) throws Exception { + String signingInput = b64url(headerJson) + "." + b64url(claimsJson); + var signature = Signature.getInstance("SHA256withRSA"); + signature.initSign(key); + signature.update(signingInput.getBytes(StandardCharsets.US_ASCII)); + return signingInput + "." + b64url(signature.sign()); + } + + private static JsonObject bound(String compactJws) { + var bound = assertion(); + var binding = new JsonObject(); + binding.addProperty("method", "jws"); + binding.addProperty("signature", compactJws); + bound.add("binding", binding); + return bound; + } + + private static void verify(String compactJws) { + AssertionVerifier.verifyWithKey(bound(compactJws), "a", selected, AGGREGATE, false); + } + + private static void assertReason(String compactJws, AssertionFailureReason reason) { + assertThatThrownBy(() -> verify(compactJws)) + .isInstanceOfSatisfying(SDK.AssertionFailureException.class, e -> { + assertThat(e.getReason()).isEqualTo(reason); + assertThat(e.getAssertionId()).isEqualTo("a"); + }); + } + + private static String headerWith(String members) { + return "{\"alg\":\"RS256\"" + members + "}"; + } + + @Test + void aWellFormedBindingVerifies() throws Exception { + assertThatCode(() -> verify(sign(headerWith(""), claims(""), signer.getPrivate()))).doesNotThrowAnyException(); + } + + @Test + void duplicateJoseMembersAreInvalidBindings() throws Exception { + assertReason(sign("{\"alg\":\"RS256\",\"alg\":\"RS256\"}", claims(""), signer.getPrivate()), + AssertionFailureReason.INVALID_BINDING); + assertReason(sign("{\"alg\":\"RS256\",\"\\u0061lg\":\"HS256\"}", claims(""), signer.getPrivate()), + AssertionFailureReason.INVALID_BINDING); + String claims = claims(""); + String duplicated = claims.substring(0, claims.length() - 1) + ",\"assertionHash\":\"0\"}"; + assertReason(sign(headerWith(""), duplicated, signer.getPrivate()), AssertionFailureReason.INVALID_BINDING); + } + + @Test + void theSignatureSegmentMustBeUnpaddedBase64Url() throws Exception { + String jws = sign(headerWith(""), claims(""), signer.getPrivate()); + String signature = jws.substring(jws.lastIndexOf('.') + 1); + String signingInput = jws.substring(0, jws.lastIndexOf('.') + 1); + byte[] raw = Base64.getUrlDecoder().decode(signature); + assertReason(signingInput + Base64.getUrlEncoder().encodeToString(raw) + (raw.length % 3 == 0 ? "=" : ""), + AssertionFailureReason.SIGNATURE_INVALID); + assertReason(signingInput + Base64.getEncoder().withoutPadding().encodeToString(raw).replace('-', '+') + "+", + AssertionFailureReason.SIGNATURE_INVALID); + assertReason(signingInput, AssertionFailureReason.SIGNATURE_INVALID); + } + + @ParameterizedTest + @ValueSource(strings = { + ",\"exp\":1", + ",\"nbf\":99999999999", + ",\"iat\":99999999999", + ",\"aud\":\"someone else\",\"iss\":\"anyone\",\"sub\":5" + }) + void claimsOtherThanTheBindingsAreIgnored(String extra) throws Exception { + assertThatCode(() -> verify(sign(headerWith(""), claims(extra), signer.getPrivate()))).doesNotThrowAnyException(); + } + + @Test + void headerMembersOtherThanTheKeysDoNotMatter() throws Exception { + assertThatCode(() -> verify(sign(headerWith(",\"kid\":5,\"typ\":{},\"x5u\":\"https://example.com/x\"," + + "\"jku\":\"https://example.com/k\""), claims(""), signer.getPrivate()))).doesNotThrowAnyException(); + } + + private static String ecJwk(ECPublicKey key, String crv) { + return "{\"kty\":\"EC\",\"crv\":\"" + crv + "\",\"x\":\"" + b64url(coordinate(key.getW().getAffineX())) + + "\",\"y\":\"" + b64url(coordinate(key.getW().getAffineY())) + "\"}"; + } + + private static byte[] coordinate(BigInteger value) { + byte[] bytes = value.toByteArray(); + byte[] fixed = new byte[32]; + int copy = Math.min(bytes.length, 32); + System.arraycopy(bytes, bytes.length - copy, fixed, 32 - copy, copy); + return fixed; + } + + private static String ecCertificate() throws Exception { + var owner = new X500Name("CN=EC"); + var builder = new JcaX509v3CertificateBuilder(owner, BigInteger.ONE, + new Date(System.currentTimeMillis() - 86_400_000L), new Date(System.currentTimeMillis() + 86_400_000L), + owner, ecKey.getPublic()); + var certificate = new JcaX509CertificateConverter() + .getCertificate(builder.build(new JcaContentSignerBuilder("SHA256withECDSA").build(ecKey.getPrivate()))); + return Base64.getEncoder().encodeToString(certificate.getEncoded()); + } + + @Test + void aWellFormedKeyOfAnotherTypeIsNotPermitted() throws Exception { + var ec = (ECPublicKey) ecKey.getPublic(); + assertReason(sign(headerWith(",\"jwk\":" + ecJwk(ec, "P-256")), claims(""), signer.getPrivate()), + AssertionFailureReason.SIGNER_NOT_PERMITTED); + assertReason(sign(headerWith(",\"x5c\":[\"" + ecCertificate() + "\"]"), claims(""), signer.getPrivate()), + AssertionFailureReason.SIGNER_NOT_PERMITTED); + // a jwk and an x5c of the same EC key agree with each other + assertReason(sign(headerWith(",\"jwk\":" + ecJwk(ec, "P-256") + ",\"x5c\":[\"" + ecCertificate() + "\"]"), + claims(""), signer.getPrivate()), AssertionFailureReason.SIGNER_NOT_PERMITTED); + String okp = "{\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"x\":\"" + b64url(new byte[32]) + "\"}"; + assertReason(sign(headerWith(",\"jwk\":" + okp), claims(""), signer.getPrivate()), + AssertionFailureReason.SIGNER_NOT_PERMITTED); + } + + @Test + void anEcJwkThatDisagreesWithTheX5cIsContradictory() throws Exception { + var other = (ECPublicKey) CryptoUtils.generateECKeypair(KeyType.EC256Key.getECCurve().getCurveName()).getPublic(); + assertReason(sign(headerWith(",\"jwk\":" + ecJwk(other, "P-256") + ",\"x5c\":[\"" + ecCertificate() + "\"]"), + claims(""), signer.getPrivate()), AssertionFailureReason.INVALID_EMBEDDED_KEY); + } + + @Test + void aJwkMalformedForItsKeyTypeIsInvalid() throws Exception { + var ec = (ECPublicKey) ecKey.getPublic(); + String x = b64url(coordinate(ec.getW().getAffineX())); + String y = b64url(coordinate(ec.getW().getAffineY())); + String offCurveY = b64url(coordinate(ec.getW().getAffineY().add(BigInteger.ONE))); + for (String jwk : new String[] { + "{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"" + x + "\"}", + "{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"" + x + "\",\"y\":\"" + offCurveY + "\"}", + "{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"" + x + "\",\"y\":\"" + y + "=\"}", + "{\"kty\":\"EC\",\"crv\":\"P-384\",\"x\":\"" + x + "\",\"y\":\"" + y + "\"}", + "{\"kty\":\"EC\",\"crv\":\"P-257\",\"x\":\"" + x + "\",\"y\":\"" + y + "\"}", + "{\"kty\":\"EC\",\"x\":\"" + x + "\",\"y\":\"" + y + "\"}", + "{\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"x\":\"" + b64url(new byte[31]) + "\"}", + "{\"kty\":\"OKP\",\"crv\":\"Ed25519\"}", + "{\"kty\":\"OKP\",\"crv\":\"P-256\",\"x\":\"" + b64url(new byte[32]) + "\"}", + "{\"kty\":\"OKP\",\"crv\":\"X25519\",\"x\":\"" + b64url(new byte[32]) + "\"}", + "{\"kty\":\"XYZ\",\"x\":\"" + x + "\"}", + "{\"kty\":\"RSA\",\"n\":\"" + b64url(new byte[256]).substring(1) + "+\",\"e\":\"AQAB\"}", + "{\"kty\":\"RSA\",\"n\":\"\",\"e\":\"AQAB\"}", + "{\"kty\":5}", + "{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"" + x + "\",\"y\":\"" + y + "\",\"d\":\"" + x + "\"}", + }) { + assertThat(jwk).as("fixture is JSON").satisfies(json -> JsonParser.parseString(json)); + assertThatThrownBy(() -> verify(sign(headerWith(",\"jwk\":" + jwk), claims(""), signer.getPrivate()))) + .as(jwk) + .isInstanceOfSatisfying(SDK.AssertionFailureException.class, + e -> assertThat(e.getReason()).isEqualTo(AssertionFailureReason.INVALID_EMBEDDED_KEY)); + } + } + + private static String rsaJwk(byte[] n, byte[] e) { + return "{\"kty\":\"RSA\",\"n\":\"" + b64url(n) + "\",\"e\":\"" + b64url(e) + "\"}"; + } + + private static byte[] unsigned(BigInteger value) { + byte[] bytes = value.toByteArray(); + return bytes[0] == 0 ? Arrays.copyOfRange(bytes, 1, bytes.length) : bytes; + } + + private static byte[] withLeadingZero(byte[] bytes) { + byte[] padded = new byte[bytes.length + 1]; + System.arraycopy(bytes, 0, padded, 1, bytes.length); + return padded; + } + + @Test + void theSelectedKeysJwkMustUseMinimalIntegers() throws Exception { + var rsa = (RSAPublicKey) signer.getPublic(); + byte[] n = unsigned(rsa.getModulus()); + byte[] e = unsigned(rsa.getPublicExponent()); + String minimal = rsaJwk(n, e); + assertThatCode(() -> verify(sign(headerWith(",\"jwk\":" + minimal + ",\"kid\":\"anything\""), claims(""), + signer.getPrivate()))).doesNotThrowAnyException(); + // the same key with a leading zero octet is malformed, never equal to the selected key + assertReason(sign(headerWith(",\"jwk\":" + rsaJwk(withLeadingZero(n), e)), claims(""), signer.getPrivate()), + AssertionFailureReason.INVALID_EMBEDDED_KEY); + assertReason(sign(headerWith(",\"jwk\":" + rsaJwk(n, withLeadingZero(e))), claims(""), signer.getPrivate()), + AssertionFailureReason.INVALID_EMBEDDED_KEY); + } + + /** The same bytes, encoded with a non-zero bit after the last whole byte. */ + private static String withTrailingBits(String base64Url) { + assertThat(base64Url.length() % 4).as("an encoding with spare bits").isNotZero(); + String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + char last = base64Url.charAt(base64Url.length() - 1); + String changed = base64Url.substring(0, base64Url.length() - 1) + alphabet.charAt(alphabet.indexOf(last) | 1); + assertThat(Base64.getUrlDecoder().decode(changed)).isEqualTo(Base64.getUrlDecoder().decode(base64Url)); + return changed; + } + + @Test + void base64UrlWithNonZeroTrailingBitsIsRejected() throws Exception { + // a header whose encoding has spare bits + String header = headerWith(",\"x\":12"); + String jws = sign(header, claims(""), signer.getPrivate()); + String[] parts = jws.split("\\."); + assertThatCode(() -> verify(jws)).doesNotThrowAnyException(); + assertReason(withTrailingBits(parts[0]) + "." + parts[1] + "." + parts[2], AssertionFailureReason.INVALID_BINDING); + assertReason(parts[0] + "." + parts[1] + "." + withTrailingBits(parts[2]), AssertionFailureReason.SIGNATURE_INVALID); + + var rsa = (RSAPublicKey) signer.getPublic(); + String n = b64url(unsigned(rsa.getModulus())); + String jwk = "{\"kty\":\"RSA\",\"n\":\"" + withTrailingBits(n) + "\",\"e\":\"AQAB\"}"; + assertReason(sign(headerWith(",\"jwk\":" + jwk), claims(""), signer.getPrivate()), + AssertionFailureReason.INVALID_EMBEDDED_KEY); + } + + @Test + void x5cMustBeExactlyOneDerCertificateInStrictBase64() throws Exception { + byte[] der = TestUtil.createTestCertificate(signer.getPublic(), signer.getPrivate()).getEncoded(); + String leaf = Base64.getEncoder().encodeToString(der); + assertThatCode(() -> verify(sign(headerWith(",\"x5c\":[\"" + leaf + "\"]"), claims(""), signer.getPrivate()))) + .doesNotThrowAnyException(); + // later elements must decode, but are never parsed + String notACertificate = Base64.getEncoder().encodeToString("not a certificate".getBytes(StandardCharsets.US_ASCII)); + assertThatCode(() -> verify(sign(headerWith(",\"x5c\":[\"" + leaf + "\",\"" + notACertificate + "\"]"), + claims(""), signer.getPrivate()))).doesNotThrowAnyException(); + + String pem = "-----BEGIN CERTIFICATE-----\n" + Base64.getMimeEncoder().encodeToString(der) + + "\n-----END CERTIFICATE-----\n"; + byte[] trailing = Arrays.copyOf(der, der.length + 2); + for (String x5c : new String[] { + "[\"" + Base64.getEncoder().encodeToString(pem.getBytes(StandardCharsets.US_ASCII)) + "\"]", + "[\"" + Base64.getEncoder().encodeToString(trailing) + "\"]", + "[\"" + leaf + "\",\"not base64!\"]", + "[\"" + leaf + "\",5]", + "[\"" + leaf.replace("=", "") + "\"]", + "[\"" + Base64.getUrlEncoder().encodeToString(der) + "\"]", + }) { + assertThatThrownBy(() -> verify(sign(headerWith(",\"x5c\":" + x5c), claims(""), signer.getPrivate()))) + .as(x5c.length() > 80 ? x5c.substring(0, 80) : x5c) + .isInstanceOfSatisfying(SDK.AssertionFailureException.class, + e -> assertThat(e.getReason()).isEqualTo(AssertionFailureReason.INVALID_EMBEDDED_KEY)); + } + } + + @Test + void pemTextIsNeverAnHs256Secret() { + byte[] pem = CryptoUtils.getPublicKeyPEM(signer.getPublic()).getBytes(StandardCharsets.US_ASCII); + assertThatThrownBy(() -> AssertionVerifier.configuredKey( + new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.HS256, pem), "a", "the key")) + .isInstanceOfSatisfying(SDK.AssertionFailureException.class, + e -> assertThat(e.getReason()).isEqualTo(AssertionFailureReason.CONFIGURATION_ERROR)); + } +} diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/StrictManifestParsingTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/StrictManifestParsingTest.java index 9e8f410d..f17198d1 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/StrictManifestParsingTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/StrictManifestParsingTest.java @@ -123,6 +123,18 @@ void reportsInvalidUtf8AsInvalidJson() throws IOException { e -> assertThat(e.getReason()).isEqualTo(AssertionFailureReason.INVALID_JSON)); } + @Test + void reportsInvalidUtf8OutsideTheAssertionsAsInvalidJsonToo() throws IOException { + var valid = manifest(",'extra':'XY'").getBytes(StandardCharsets.UTF_8); + var invalid = valid.clone(); + invalid[new String(valid, StandardCharsets.UTF_8).indexOf("XY")] = (byte) 0xFF; + assertThatThrownBy(() -> SDK.readManifest(new SeekableInMemoryByteChannel(tdfWithManifest(invalid)))) + .isInstanceOfSatisfying(SDK.AssertionFailureException.class, e -> { + assertThat(e.getReason()).isEqualTo(AssertionFailureReason.INVALID_JSON); + assertThat(e).hasMessageContaining("reason=invalid-json"); + }); + } + static byte[] tdfWithManifest(byte[] manifest) throws IOException { var out = new ByteArrayOutputStream(); var writer = new TDFWriter(out); diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java index b1ce4e24..418f872e 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java @@ -3,7 +3,6 @@ import com.connectrpc.ResponseMessage; import com.connectrpc.UnaryBlockingCall; import com.google.gson.reflect.TypeToken; -import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.jwk.JWK; import com.google.gson.Gson; import com.google.gson.JsonObject; @@ -456,11 +455,39 @@ void testVerifyAssertionWithJwkHeaderInJWT() throws Exception { .setKeyAccessServerRegistryService(kasRegistryService).build()); tdf.createTDF(plainTextInputStream, tdfOutputStream, config); + assertEmbeddedKeyAddsNoTrust(tdf, tdfOutputStream.toByteArray(), keypair, plainText); + } + + /** + * A TDF whose assertion was signed by {@code signer} and carries the signer's public key in + * its JWS header. The embedded key grants nothing by itself: with no key configured the + * payload key with HS256 is selected and the RS256 binding fails; configuring the signer's + * key, per ID or as the default, is what makes it verify. + */ + private void assertEmbeddedKeyAddsNoTrust(TDF tdf, byte[] tdfBytes, KeyPair signer, String plainText) + throws Exception { + assertThatThrownBy(() -> tdf.loadTDF(new SeekableInMemoryByteChannel(tdfBytes), Config.newTDFReaderConfig(), + platformUrl)) + .isInstanceOfSatisfying(SDK.AssertionFailureException.class, e -> { + assertThat(e.getReason()).isEqualTo(AssertionFailureReason.ALGORITHM_MISMATCH); + assertThat(e).hasMessageContaining("reason=algorithm-mismatch"); + }); + + var unrelated = new Config.AssertionVerificationKeys(); + unrelated.defaultKey = new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.RS256, + CryptoUtils.generateRSAKeypair().getPublic()); + assertThatThrownBy(() -> tdf.loadTDF(new SeekableInMemoryByteChannel(tdfBytes), + Config.newTDFReaderConfig(Config.withAssertionVerificationKeys(unrelated)), platformUrl)) + .isInstanceOfSatisfying(SDK.AssertionFailureException.class, e -> + assertThat(e.getReason()).isEqualTo(AssertionFailureReason.SIGNER_NOT_PERMITTED)); + + var configured = new Config.AssertionVerificationKeys(); + configured.defaultKey = new AssertionConfig.AssertionKey(AssertionConfig.AssertionKeyAlg.RS256, + signer.getPublic()); var unwrappedData = new ByteArrayOutputStream(); - var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray()), - Config.newTDFReaderConfig(), platformUrl); + var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(tdfBytes), + Config.newTDFReaderConfig(Config.withAssertionVerificationKeys(configured)), platformUrl); reader.readPayload(unwrappedData); - assertThat(unwrappedData.toString(StandardCharsets.UTF_8)) .withFailMessage("extracted data does not match") .isEqualTo(plainText); @@ -504,14 +531,7 @@ void testVerifyAssertionWithX5cHeaderInJWT() throws Exception { .setKeyAccessServerRegistryService(kasRegistryService).build()); tdf.createTDF(plainTextInputStream, tdfOutputStream, config); - var unwrappedData = new ByteArrayOutputStream(); - var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray()), - Config.newTDFReaderConfig(), platformUrl); - reader.readPayload(unwrappedData); - - assertThat(unwrappedData.toString(StandardCharsets.UTF_8)) - .withFailMessage("extracted data does not match") - .isEqualTo(plainText); + assertEmbeddedKeyAddsNoTrust(tdf, tdfOutputStream.toByteArray(), keypair, plainText); } @Test @@ -552,10 +572,12 @@ void testWithAssertionVerificationDisabled() throws Exception { var unwrappedData = new ByteArrayOutputStream(); var dataToUnwrap = new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray()); var emptyConfig = Config.newTDFReaderConfig(); - var thrown = assertThrows(SDKException.class, () -> { + var thrown = assertThrows(SDK.AssertionFailureException.class, () -> { tdf.loadTDF(dataToUnwrap, emptyConfig, platformUrl); }); - assertThat(thrown.getCause()).isInstanceOf(JOSEException.class); + // no key configured, so the payload key with HS256 is selected, and the RS256 binding fails + assertThat(thrown.getReason()).isEqualTo(AssertionFailureReason.ALGORITHM_MISMATCH); + assertThat(thrown.getAssertionId()).isEqualTo(assertion1Id); // try with assertion verification disabled and not passing the assertion // verification keys