diff --git a/cmdline/src/main/java/io/opentdf/platform/Command.java b/cmdline/src/main/java/io/opentdf/platform/Command.java index b073b955..17795db3 100644 --- a/cmdline/src/main/java/io/opentdf/platform/Command.java +++ b/cmdline/src/main/java/io/opentdf/platform/Command.java @@ -259,7 +259,8 @@ void encrypt( "--encap-key-type" }, defaultValue = Option.NULL_VALUE, description = "Preferred key access key wrap algorithm, one of ${COMPLETION-CANDIDATES}") Optional encapKeyType, @Option(names = { "--mime-type" }, defaultValue = Option.NULL_VALUE) Optional mimeType, @Option(names = { "--with-assertions" }, defaultValue = Option.NULL_VALUE) Optional assertion, - @Option(names = { "--with-target-mode" }, defaultValue = Option.NULL_VALUE) Optional targetMode) + @Option(names = { "--with-target-mode" }, defaultValue = Option.NULL_VALUE) Optional targetMode, + @Option(names = { "--hexless-policy-binding" }) boolean hexlessPolicyBinding) throws IOException, AutoConfigureException { @@ -315,6 +316,9 @@ void encrypt( attributes.ifPresent(s -> configs.add(Config.withDataAttributes(s.split(",")))); targetMode.map(Config::withTargetMode).ifPresent(configs::add); + if (hexlessPolicyBinding) { + configs.add(Config.withHexlessPolicyBinding()); + } var tdfConfig = Config.newTDFConfig(configs.toArray(Consumer[]::new)); try (var in = file.isEmpty() ? new BufferedInputStream(System.in) : new FileInputStream(file.get())) { try (var out = new BufferedOutputStream(System.out)) { diff --git a/cmdline/src/test/java/io/opentdf/platform/CommandTest.java b/cmdline/src/test/java/io/opentdf/platform/CommandTest.java index 8995271d..20ad0c93 100644 --- a/cmdline/src/test/java/io/opentdf/platform/CommandTest.java +++ b/cmdline/src/test/java/io/opentdf/platform/CommandTest.java @@ -137,4 +137,17 @@ void supports_unknownFeature_json_false() { assertThat(out.trim()).isEqualTo("{\"unknown_feature\":false}"); } + @Test + void encrypt_hexlessPolicyBindingFlag_acceptedByPicocli() { + StringWriter err = new StringWriter(); + CommandLine cli = new CommandLine(new Command()); + cli.setErr(new PrintWriter(err)); + + int code = cli.execute("encrypt", "-k", "https://kas.example.com", "-f", "/dev/null", "--hexless-policy-binding"); + + // Picocli accepts the flag; command fails later on missing endpoint + assertThat(code).isEqualTo(CommandLine.ExitCode.USAGE); + assertThat(err.toString()).contains("Missing required option: '--platform-endpoint='"); + } + } 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..a96a50e7 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/Config.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/Config.java @@ -194,6 +194,7 @@ public static class TDFConfig { public boolean hexEncodeRootAndSegmentHashes; public boolean renderVersionInfoInManifest; public boolean systemMetadataAssertion; + public boolean hexlessPolicyBinding; public TDFConfig() { this.autoconfigure = true; @@ -211,6 +212,7 @@ public TDFConfig() { this.hexEncodeRootAndSegmentHashes = false; this.renderVersionInfoInManifest = true; this.systemMetadataAssertion = false; + this.hexlessPolicyBinding = false; } } @@ -325,6 +327,19 @@ public static Consumer withTargetMode(String targetVersion) { }; } + /** + * Writes each key access object's policy binding hash as Base64(HMAC), dropping the hex layer of + * the legacy Base64(hex(HMAC)) written by default. Base64(HMAC) is the form the TDF spec defines. + *

+ * Only a KAS carrying the policy binding dual-accept fix can verify the hexless form directly. + * This SDK's reader re-encodes it for older KAS, but other and older readers forward it unchanged, + * so a TDF written with this option may not decrypt through an older reader against an older KAS. + * A pre-4.3.0 {@link #withTargetMode(String)} overrides this option and keeps the legacy form. + */ + public static Consumer withHexlessPolicyBinding() { + return (TDFConfig config) -> config.hexlessPolicyBinding = true; + } + public static Consumer WithWrappingKeyAlg(KeyType keyType) { return (TDFConfig config) -> config.wrappingKeyType = keyType; } 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 fddead94..e00adf5b 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java @@ -81,6 +81,109 @@ private static byte[] tdfECKeySaltCompute() { private static final Gson gson = new GsonBuilder().create(); + /** + * Computes the policy binding hash for a key access object. + * + * @param symKey the symmetric key + * @param base64Policy the base64-encoded policy + * @param hexless if true, returns Base64(HMAC); if false, returns Base64(hex(HMAC)) + * @return the policy binding hash + */ + static String policyBindingHash(byte[] symKey, String base64Policy, boolean hexless) { + byte[] hmac = CryptoUtils.CalculateSHA256Hmac(symKey, base64Policy.getBytes(StandardCharsets.UTF_8)); + Base64.Encoder encoder = Base64.getEncoder(); + + if (hexless) { + return encoder.encodeToString(hmac); + } else { + String hexBinding = Hex.encodeHexString(hmac); + return encoder.encodeToString(hexBinding.getBytes(StandardCharsets.UTF_8)); + } + } + + /** + * Re-encodes a hexless policy binding hash to the legacy form for KAS compatibility. + * Returns Base64(hex(raw)) when the input decodes to exactly 32 bytes; otherwise + * returns the input unchanged. + *

+ * KAS releases before the dual-accept fix only verify the Base64(hex(HMAC)) form. + * This method ensures compatibility by re-encoding hexless bindings when reading + * a TDF for decryption through such a KAS. + * + * @param hash the policy binding hash + * @return the legacy-encoded hash or the original if not a 32-byte binding + */ + static String legacyPolicyBindingHash(String hash) { + if (hash == null) { + return null; + } + try { + Base64.Decoder decoder = Base64.getDecoder(); + byte[] decoded = decoder.decode(hash); + + if (decoded.length == 32) { + // This is a hexless binding (32 bytes of raw HMAC) + // Re-encode to legacy form: Base64(hex(raw)) + String hexEncoded = Hex.encodeHexString(decoded); + return Base64.getEncoder().encodeToString(hexEncoded.getBytes(StandardCharsets.UTF_8)); + } + + // Not a 32-byte binding, return unchanged + return hash; + } catch (IllegalArgumentException e) { + // Invalid base64, return unchanged + return hash; + } + } + + /** + * Creates a copy of the given KeyAccess with its policy binding hash transformed + * through legacyPolicyBindingHash for compatibility with older KAS implementations. + * Returns the original object if re-encoding does not change the hash. + */ + private static Manifest.KeyAccess copyKeyAccessWithLegacyBinding(Manifest.KeyAccess original) { + String legacyHash = null; + String originalHash = null; + + // Extract and transform the hash + if (original.policyBinding instanceof Manifest.PolicyBinding) { + Manifest.PolicyBinding originalBinding = (Manifest.PolicyBinding) original.policyBinding; + originalHash = originalBinding.hash; + legacyHash = legacyPolicyBindingHash(originalHash); + } else if (original.policyBinding instanceof String) { + originalHash = (String) original.policyBinding; + legacyHash = legacyPolicyBindingHash(originalHash); + } + + // Only copy if re-encoding changed the hash + if (legacyHash != null && !legacyHash.equals(originalHash)) { + Manifest.KeyAccess copy = new Manifest.KeyAccess(); + copy.keyType = original.keyType; + copy.url = original.url; + copy.protocol = original.protocol; + copy.wrappedKey = original.wrappedKey; + copy.encryptedMetadata = original.encryptedMetadata; + copy.kid = original.kid; + copy.sid = original.sid; + copy.schemaVersion = original.schemaVersion; + copy.ephemeralPublicKey = original.ephemeralPublicKey; + + if (original.policyBinding instanceof Manifest.PolicyBinding) { + Manifest.PolicyBinding copyBinding = new Manifest.PolicyBinding(); + copyBinding.alg = ((Manifest.PolicyBinding) original.policyBinding).alg; + copyBinding.hash = legacyHash; + copy.policyBinding = copyBinding; + } else { + copy.policyBinding = legacyHash; + } + + return copy; + } + + // No change needed, return original + return original; + } + /** * An AES-GCM IV generator using the deterministic construction of NIST SP 800-38D * section 8.2.1: a 64-bit fixed field followed by a 32-bit big-endian invocation field. @@ -226,6 +329,8 @@ private void prepareManifest(Config.TDFConfig tdfConfig, Map symKeys = new ArrayList<>(splits.size()); for (var split : splits.entrySet()) { @@ -236,11 +341,9 @@ private void prepareManifest(Config.TDFConfig tdfConfig, Map= 'a' && b <= 'f') || (b >= '0' && b <= '9'); } + + @Test + void hexlessPolicyBindingOnEveryKeyAccess() throws Exception { + var tdf = new TDF(new FakeServicesBuilder().setKas(kas) + .setKeyAccessServerRegistryService(kasRegistryService).build()); + var tdfOutputStream = new ByteArrayOutputStream(); + + // Create TDF with two KAS (two splits) and hexless policy binding + var manifest = tdf.createTDF( + new ByteArrayInputStream("test data".getBytes()), + tdfOutputStream, + Config.newTDFConfig( + Config.withAutoconfigure(false), + Config.withKasInformation(getSingleRSAKASInfo()[0], getSingleRSAKASInfo()[0]), + Config.withHexlessPolicyBinding() + ) + ).getManifest(); + + // Verify both key access objects have hexless bindings (32 bytes) + var keyAccessObjects = manifest.encryptionInformation.keyAccessObj; + assertThat(keyAccessObjects).hasSize(2); + + for (Manifest.KeyAccess keyAccess : keyAccessObjects) { + Manifest.PolicyBinding policyBinding = (Manifest.PolicyBinding) keyAccess.policyBinding; + byte[] decoded = Base64.getDecoder().decode(policyBinding.hash); + assertThat(decoded).hasSize(32); + } + } + + @Test + void legacyTargetModeOverridesHexlessPolicyBinding() throws Exception { + var tdf = new TDF(new FakeServicesBuilder().setKas(kas) + .setKeyAccessServerRegistryService(kasRegistryService).build()); + + // Test order 1: hexless then legacy + var tdfOutputStream1 = new ByteArrayOutputStream(); + var manifest1 = tdf.createTDF( + new ByteArrayInputStream("test data".getBytes()), + tdfOutputStream1, + Config.newTDFConfig( + Config.withAutoconfigure(false), + Config.withKasInformation(getSingleRSAKASInfo()[0]), + Config.withHexlessPolicyBinding(), + Config.withTargetMode("4.2.2") + ) + ).getManifest(); + + Manifest.PolicyBinding pb1 = (Manifest.PolicyBinding) manifest1.encryptionInformation.keyAccessObj.get(0).policyBinding; + byte[] decoded1 = Base64.getDecoder().decode(pb1.hash); + assertThat(decoded1).hasSize(64); // Legacy form is 64 hex chars, i.e., 64 bytes + for (byte b : decoded1) { + assertThat(isHexChar(b)).isTrue(); + } + + // Test order 2: legacy then hexless + var tdfOutputStream2 = new ByteArrayOutputStream(); + var manifest2 = tdf.createTDF( + new ByteArrayInputStream("test data".getBytes()), + tdfOutputStream2, + Config.newTDFConfig( + Config.withAutoconfigure(false), + Config.withKasInformation(getSingleRSAKASInfo()[0]), + Config.withTargetMode("4.2.2"), + Config.withHexlessPolicyBinding() + ) + ).getManifest(); + + Manifest.PolicyBinding pb2 = (Manifest.PolicyBinding) manifest2.encryptionInformation.keyAccessObj.get(0).policyBinding; + byte[] decoded2 = Base64.getDecoder().decode(pb2.hash); + assertThat(decoded2).hasSize(64); // Legacy form in both orders + for (byte b : decoded2) { + assertThat(isHexChar(b)).isTrue(); + } + } + + @Test + void rewrapRequestCarriesLegacyBinding() throws Exception { + // Capture the keyAccess sent to KAS during unwrap + final Manifest.KeyAccess[] capturedKeyAccess = new Manifest.KeyAccess[1]; + + SDK.KAS capturingKas = new SDK.KAS() { + @Override + public void close() {} + + @Override + public Config.KASInfo getPublicKey(Config.KASInfo kasInfo) { + return kas.getPublicKey(kasInfo); + } + + @Override + public byte[] unwrap(Manifest.KeyAccess keyAccess, String policy, KeyType sessionKeyType) { + capturedKeyAccess[0] = keyAccess; + return kas.unwrap(keyAccess, policy, sessionKeyType); + } + + @Override + public KASKeyCache getKeyCache() { + return kas.getKeyCache(); + } + }; + + var tdf = new TDF(new FakeServicesBuilder().setKas(capturingKas) + .setKeyAccessServerRegistryService(kasRegistryService).build()); + + // Create TDF with hexless policy binding + var tdfOutputStream = new ByteArrayOutputStream(); + tdf.createTDF( + new ByteArrayInputStream("test data".getBytes()), + tdfOutputStream, + Config.newTDFConfig( + Config.withAutoconfigure(false), + Config.withKasInformation(getSingleRSAKASInfo()[0]), + Config.withHexlessPolicyBinding() + ) + ); + + // Read it back - this should trigger rewrap with legacy binding + var reader = tdf.loadTDF(new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray()), platformUrl); + var unwrappedData = new ByteArrayOutputStream(); + reader.readPayload(unwrappedData); + + // Verify the plaintext equals "test data" + assertThat(unwrappedData.toString()).isEqualTo("test data"); + + // Verify the manifest still holds the 32-byte binding after reading + Manifest.PolicyBinding manifestBinding = (Manifest.PolicyBinding) reader.getManifest().encryptionInformation.keyAccessObj.get(0).policyBinding; + byte[] manifestHash = Base64.getDecoder().decode(manifestBinding.hash); + assertThat(manifestHash).hasSize(32); + + // Verify the KAS received a legacy binding (64 hex chars) + assertThat(capturedKeyAccess[0]).isNotNull(); + Manifest.PolicyBinding sentBinding = (Manifest.PolicyBinding) capturedKeyAccess[0].policyBinding; + byte[] decoded = Base64.getDecoder().decode(sentBinding.hash); + assertThat(decoded).hasSize(64); + + // Verify all bytes are valid hex characters + for (byte b : decoded) { + assertThat(isHexChar(b)).isTrue(); + } + + // Verify sent.hash.equals(TDF.legacyPolicyBindingHash(manifestHash)) + assertThat(sentBinding.hash).isEqualTo(TDF.legacyPolicyBindingHash(manifestBinding.hash)); + } }