Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion cmdline/src/main/java/io/opentdf/platform/Command.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<KeyType> encapKeyType,
@Option(names = { "--mime-type" }, defaultValue = Option.NULL_VALUE) Optional<String> mimeType,
@Option(names = { "--with-assertions" }, defaultValue = Option.NULL_VALUE) Optional<String> assertion,
@Option(names = { "--with-target-mode" }, defaultValue = Option.NULL_VALUE) Optional<String> targetMode)
@Option(names = { "--with-target-mode" }, defaultValue = Option.NULL_VALUE) Optional<String> targetMode,
@Option(names = { "--hexless-policy-binding" }) boolean hexlessPolicyBinding)

throws IOException, AutoConfigureException {

Expand Down Expand Up @@ -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)) {
Expand Down
13 changes: 13 additions & 0 deletions cmdline/src/test/java/io/opentdf/platform/CommandTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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=<platformEndpoint>'");
}

}
15 changes: 15 additions & 0 deletions sdk/src/main/java/io/opentdf/platform/sdk/Config.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -211,6 +212,7 @@ public TDFConfig() {
this.hexEncodeRootAndSegmentHashes = false;
this.renderVersionInfoInManifest = true;
this.systemMetadataAssertion = false;
this.hexlessPolicyBinding = false;
}
}

Expand Down Expand Up @@ -325,6 +327,19 @@ public static Consumer<TDFConfig> 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.
* <p>
* 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<TDFConfig> withHexlessPolicyBinding() {
return (TDFConfig config) -> config.hexlessPolicyBinding = true;
}

public static Consumer<TDFConfig> WithWrappingKeyAlg(KeyType keyType) {
return (TDFConfig config) -> config.wrappingKeyType = keyType;
}
Expand Down
115 changes: 111 additions & 4 deletions sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* 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.
Expand Down Expand Up @@ -226,6 +329,8 @@ private void prepareManifest(Config.TDFConfig tdfConfig, Map<String, List<KASInf
String base64PolicyObject = encoder
.encodeToString(gson.toJson(policyObject).getBytes(StandardCharsets.UTF_8));

// A pre-4.3.0 target keeps the legacy binding
boolean useHexless = tdfConfig.hexlessPolicyBinding && !tdfConfig.hexEncodeRootAndSegmentHashes;

List<byte[]> symKeys = new ArrayList<>(splits.size());
for (var split : splits.entrySet()) {
Expand All @@ -236,11 +341,9 @@ private void prepareManifest(Config.TDFConfig tdfConfig, Map<String, List<KASInf
symKeys.add(symKey);

// Add policyBinding
var hexBinding = Hex.encodeHexString(
CryptoUtils.CalculateSHA256Hmac(symKey, base64PolicyObject.getBytes(StandardCharsets.UTF_8)));
var policyBinding = new Manifest.PolicyBinding();
policyBinding.alg = kHmacIntegrityAlgorithm;
policyBinding.hash = encoder.encodeToString(hexBinding.getBytes(StandardCharsets.UTF_8));
policyBinding.hash = policyBindingHash(symKey, base64PolicyObject, useHexless);

// Add meta data
var encryptedMetadata = "";
Expand Down Expand Up @@ -839,7 +942,11 @@ Reader loadTDF(SeekableByteChannel tdf, Config.TDFReaderConfig tdfReaderConfig)
logger.error("KasAllowlist: kas url {} is not allowed", realAddress);
throw new SDK.KasAllowlistException("KasAllowlist: kas url " + realAddress + " is not allowed");
}
unwrappedKey = services.kas().unwrap(keyAccess, manifest.encryptionInformation.policy,

// Transform the policy binding to legacy form for KAS compatibility
Manifest.KeyAccess kasKeyAccess = copyKeyAccessWithLegacyBinding(keyAccess);

unwrappedKey = services.kas().unwrap(kasKeyAccess, manifest.encryptionInformation.policy,
tdfReaderConfig.sessionKeyType);
} catch (Exception e) {
skippedSplits.put(ss, e);
Expand Down
6 changes: 6 additions & 0 deletions sdk/src/test/java/io/opentdf/platform/sdk/ConfigTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,10 @@ void withMimeType_shouldSetMimeType() {
Config.TDFConfig config = Config.newTDFConfig(Config.withMimeType(mimeType));
assertEquals(mimeType, config.mimeType);
}

@Test
void withHexlessPolicyBinding_shouldSetHexlessPolicyBinding() {
Config.TDFConfig config = Config.newTDFConfig(Config.withHexlessPolicyBinding());
assertTrue(config.hexlessPolicyBinding);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package io.opentdf.platform.sdk;

import org.junit.jupiter.api.Test;

import java.nio.charset.StandardCharsets;
import java.util.Base64;

import static org.junit.jupiter.api.Assertions.assertEquals;

class PolicyBindingTest {

@Test
void knownAnswerLegacyAndHexless() throws Exception {

Check warning on line 13 in sdk/src/test/java/io/opentdf/platform/sdk/PolicyBindingTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the declaration of thrown exception 'java.lang.Exception', as it cannot be thrown from method's body.

See more on https://sonarcloud.io/project/issues?id=opentdf_java-sdk&issues=AaFWaTKWi9zO9i5uRTF_&open=AaFWaTKWi9zO9i5uRTF_&pullRequest=421
// Fixed key: bytes 0..31
byte[] fixedKey = new byte[32];
for (int i = 0; i < fixedKey.length; i++) {
fixedKey[i] = (byte) i;
}

String base64Policy = Base64.getEncoder().encodeToString("{\"uuid\":\"test\"}".getBytes(StandardCharsets.UTF_8));

// Legacy form: Base64(hex(HMAC))
String legacyHash = TDF.policyBindingHash(fixedKey, base64Policy, false);
assertEquals(
"YzFjZTM3OWQ0Y2FiMTZkNmRhNzJkYjllYWQ2NGQ3Y2I0Y2E5YmRhY2FiOGMwNjg1ZmY5MmUzZjc0YWEyYzEyZA==",
legacyHash,
"Legacy form should be Base64(hex(HMAC))"
);

// Hexless form: Base64(HMAC)
String hexlessHash = TDF.policyBindingHash(fixedKey, base64Policy, true);
assertEquals(
"wc43nUyrFtbactuerWTXy0ypvayrjAaF/5Lj90qiwS0=",
hexlessHash,
"Hexless form should be Base64(HMAC)"
);
}

@Test
void legacyPolicyBindingHashReencodesOnly32ByteBindings() {
final String raw = "wc43nUyrFtbactuerWTXy0ypvayrjAaF/5Lj90qiwS0=";
final String legacy = "YzFjZTM3OWQ0Y2FiMTZkNmRhNzJkYjllYWQ2NGQ3Y2I0Y2E5YmRhY2FiOGMwNjg1ZmY5MmUzZjc0YWEyYzEyZA==";

// Hexless binding (32 bytes) is re-encoded
assertEquals(legacy, TDF.legacyPolicyBindingHash(raw), "32-byte binding should be re-encoded to legacy form");

// Legacy binding passes through
assertEquals(legacy, TDF.legacyPolicyBindingHash(legacy), "Legacy binding should pass through");

// Invalid base64 passes through
assertEquals("not!base64", TDF.legacyPolicyBindingHash("not!base64"), "Invalid base64 should pass through");

// Other lengths pass through
String other = "AAAAAAAAAAAAAAAAAAAAAA=="; // 16 bytes
assertEquals(other, TDF.legacyPolicyBindingHash(other), "Non-32-byte binding should pass through");

// Null passes through
assertEquals(null, TDF.legacyPolicyBindingHash(null), "Null should return null");
}
}
Loading
Loading