Add kdf module: Key Derivation Function API (JEP 510, final in JDK 25), HKDF, RFC 5869 vectors, interop with Python cryptography and openssl, per-tenant keys

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b7rnC7jppGGdHGESvy
This commit is contained in:
2026-10-28 18:07:57 +00:00
co-authored by Claude Sonnet 5.5
parent c93a1fffbb
commit eca73ad544
21 changed files with 995 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
import javax.crypto.KDF;
import javax.crypto.SecretKey;
import javax.crypto.spec.HKDFParameterSpec;
import java.security.NoSuchAlgorithmException;
import java.security.Provider;
import java.security.Security;
import java.util.Arrays;
/** What the JDK 25 KDF API offers: algorithm names, the three spec shapes, and what each returns. */
public class ApiTour {
public static void main(String[] args) throws Exception {
Fx.header("KDF algorithms registered by the providers of this JDK");
for (Provider p : Security.getProviders())
for (Provider.Service s : p.getServices())
if (s.getType().equals("KDF"))
System.out.println(p.getName() + " " + s.getAlgorithm());
Fx.header("getInstance: what works and what does not");
KDF kdf = KDF.getInstance("HKDF-SHA256");
System.out.println("algorithm=" + kdf.getAlgorithm() + " provider=" + kdf.getProviderName() + " parameters=" + kdf.getParameters());
for (String bad : new String[] {"HKDF", "HKDF-SHA1", "hkdf-sha256", "PBKDF2WithHmacSHA256", "Argon2"}) {
try {
KDF.getInstance(bad);
System.out.println(bad + " -> available");
} catch (NoSuchAlgorithmException e) {
System.out.println(bad + " -> NoSuchAlgorithmException: " + e.getMessage());
}
}
byte[] ikm = Fx.fill(0x0b, 22), salt = Fx.range(0, 13), info = Fx.range(0xf0, 10);
Fx.header("Shape 1: extract then expand in one call (the common case)");
HKDFParameterSpec both = HKDFParameterSpec.ofExtract().addIKM(ikm).addSalt(salt).thenExpand(info, 42);
byte[] okm = kdf.deriveData(both);
System.out.println("deriveData -> byte[" + okm.length + "] " + Fx.hex(okm));
SecretKey k = kdf.deriveKey("AES", HKDFParameterSpec.ofExtract().addIKM(ikm).addSalt(salt).thenExpand(info, 32));
System.out.println("deriveKey -> algorithm=" + k.getAlgorithm() + " format=" + k.getFormat() + " bytes=" + k.getEncoded().length);
Fx.header("Shape 2: extract only (returns the pseudorandom key, PRK)");
SecretKey prk = kdf.deriveKey("Generic", HKDFParameterSpec.ofExtract().addIKM(ikm).addSalt(salt).extractOnly());
System.out.println("PRK = " + Fx.hex(prk.getEncoded()));
Fx.header("Shape 3: expand only (you already hold a uniformly random key)");
byte[] again = kdf.deriveData(HKDFParameterSpec.expandOnly(prk, info, 42));
Fx.check("extractOnly + expandOnly equals the one-call result", Arrays.equals(okm, again));
Fx.header("The same KDF object can be used again, and gives the same bytes");
Fx.check("second call with the same spec is identical", Arrays.equals(okm, kdf.deriveData(both)));
Fx.header("Several IKM or salt pieces are concatenated in the order added");
byte[] split = kdf.deriveData(HKDFParameterSpec.ofExtract()
.addIKM(Arrays.copyOfRange(ikm, 0, 10)).addIKM(Arrays.copyOfRange(ikm, 10, 22))
.addSalt(salt).thenExpand(info, 42));
Fx.check("addIKM(first 10) + addIKM(last 12) == addIKM(all 22)", Arrays.equals(okm, split));
Fx.header("deriveKey does not check key sizes; Cipher.init does");
for (int len : new int[] {16, 24, 32, 20}) {
try {
SecretKey a = kdf.deriveKey("AES", HKDFParameterSpec.ofExtract().addIKM(ikm).thenExpand(info, len));
System.out.println("deriveKey(\"AES\") length " + len + " -> ok, " + a.getEncoded().length + " bytes");
javax.crypto.Cipher.getInstance("AES/GCM/NoPadding").init(javax.crypto.Cipher.ENCRYPT_MODE, a);
System.out.println(" Cipher.init -> ok");
} catch (Exception e) {
System.out.println(" Cipher.init -> " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
Fx.done();
}
}
+44
View File
@@ -0,0 +1,44 @@
import java.util.HexFormat;
/** Tiny helpers shared by the demos: hex, a self-checking assert that prints PASS/FAIL, section headers. */
final class Fx {
private static final HexFormat HEX = HexFormat.of();
static int failures = 0;
static String hex(byte[] b) { return HEX.formatHex(b); }
static byte[] unhex(String s) { return HEX.parseHex(s.replaceAll("\\s+", "")); }
/** Bytes 0x00..n-1 starting at {@code from} (RFC 5869 test case 2 uses such ranges). */
static byte[] range(int from, int count) {
byte[] b = new byte[count];
for (int i = 0; i < count; i++) b[i] = (byte) (from + i);
return b;
}
static byte[] fill(int value, int count) {
byte[] b = new byte[count];
java.util.Arrays.fill(b, (byte) value);
return b;
}
static void check(String what, boolean ok) {
System.out.println((ok ? "PASS " : "FAIL ") + what);
if (!ok) failures++;
}
static void checkEq(String what, byte[] expected, byte[] actual) {
check(what, java.util.Arrays.equals(expected, actual));
if (!java.util.Arrays.equals(expected, actual)) {
System.out.println(" expected " + hex(expected));
System.out.println(" actual " + hex(actual));
}
}
static void header(String s) { System.out.println("== " + s); }
/** Exit non-zero if any check failed, so run.sh and CI notice. */
static void done() {
System.out.println(failures == 0 ? "ALL CHECKS PASSED" : failures + " CHECK(S) FAILED");
if (failures != 0) System.exit(1);
}
}
+36
View File
@@ -0,0 +1,36 @@
import javax.crypto.KDF;
import javax.crypto.spec.HKDFParameterSpec;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
/**
* Three-way comparison on every row of interop/cases.tsv:
* JDK KDF API vs Python "cryptography" (expected OKM column) vs RefHkdf (hand-written from the RFC).
*/
public class Interop {
public static void main(String[] args) throws Exception {
List<String> lines = Files.readAllLines(Path.of(args.length > 0 ? args[0] : "interop/cases.tsv"));
int jdkVsPy = 0, refVsPy = 0, total = 0, maxLen = 0;
java.util.Map<String, Integer> perHash = new java.util.TreeMap<>();
for (String line : lines) {
String[] f = line.split("\t", -1);
String hash = f[0];
byte[] ikm = Fx.unhex(f[1]), salt = Fx.unhex(f[2]), info = Fx.unhex(f[3]), expected = Fx.unhex(f[5]);
int len = Integer.parseInt(f[4]);
KDF kdf = KDF.getInstance("HKDF-" + hash);
byte[] jdk = kdf.deriveData(HKDFParameterSpec.ofExtract().addIKM(ikm).addSalt(salt).thenExpand(info, len));
byte[] ref = RefHkdf.hkdf("Hmac" + hash, ikm, salt, info, len);
total++;
perHash.merge(hash, 1, Integer::sum);
maxLen = Math.max(maxLen, len);
if (Arrays.equals(jdk, expected)) jdkVsPy++; else System.out.println("MISMATCH JDK vs Python: " + line);
if (Arrays.equals(ref, expected)) refVsPy++; else System.out.println("MISMATCH RefHkdf vs Python: " + line);
}
Fx.header("interop/cases.tsv: " + total + " cases " + perHash + ", longest output " + maxLen + " bytes");
Fx.check("JDK KDF API matches Python cryptography on " + jdkVsPy + " of " + total, jdkVsPy == total);
Fx.check("hand-written RefHkdf matches Python cryptography on " + refVsPy + " of " + total, refVsPy == total);
Fx.done();
}
}
+79
View File
@@ -0,0 +1,79 @@
import javax.crypto.KDF;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.HKDFParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
/** The mistakes that actually bite, each one reproduced. */
public class Mistakes {
static final KDF KDF256;
static { try { KDF256 = KDF.getInstance("HKDF-SHA256"); } catch (Exception e) { throw new ExceptionInInitializerError(e); } }
/** One shared KDF object is fine here because this demo is single-threaded (KDF objects are not thread-safe). */
static byte[] hk(byte[] ikm, byte[] salt, byte[] info, int len) throws Exception {
return KDF256.deriveData(
HKDFParameterSpec.ofExtract().addIKM(ikm).addSalt(salt).thenExpand(info, len));
}
static byte[] b(String s) { return s.getBytes(StandardCharsets.UTF_8); }
public static void main(String[] args) throws Exception {
byte[] master = Fx.range(1, 32);
Fx.header("1. Same info for two purposes: the 'two' keys are one key");
byte[] enc = hk(master, b("salt"), b("key"), 32);
byte[] mac = hk(master, b("salt"), b("key"), 32);
Fx.check("encryption key == MAC key when info is the same", Arrays.equals(enc, mac));
byte[] enc2 = hk(master, b("salt"), b("enc"), 32), mac2 = hk(master, b("salt"), b("mac"), 32);
Fx.check("distinct info labels give distinct keys", !Arrays.equals(enc2, mac2));
Fx.header("2. Gluing fields together without a delimiter: tenant 'ab' + 'c' collides with 'a' + 'bc'");
byte[] k1 = hk(master, b("salt"), b("ab" + "c"), 32), k2 = hk(master, b("salt"), b("a" + "bc"), 32);
Fx.check("naive concatenation collides", Arrays.equals(k1, k2));
byte[] l1 = hk(master, b("salt"), Tenants.info("aes-gcm", "ab", 1), 32);
byte[] l2 = hk(master, b("salt"), Tenants.info("aes-gcm", "a", 1), 32);
Fx.check("length-prefixed fields (Tenants.info) do not collide", !Arrays.equals(l1, l2));
Fx.header("3. Salt: leaving it out is the same as HashLen zero bytes, and a salt is not a secret");
byte[] noSalt = KDF.getInstance("HKDF-SHA256").deriveData(
HKDFParameterSpec.ofExtract().addIKM(master).thenExpand(b("ctx"), 32));
byte[] zeroSalt = hk(master, new byte[32], b("ctx"), 32);
Fx.check("no salt == 32 zero bytes (SHA-256)", Arrays.equals(noSalt, zeroSalt));
Fx.check("a different salt changes every output, so a salt change is a key change",
!Arrays.equals(noSalt, hk(master, b("another salt"), b("ctx"), 32)));
Fx.header("4. Asking for more than 255 * HashLen bytes");
for (int len : new int[] {255 * 32, 255 * 32 + 1}) {
try {
byte[] o = hk(master, b("s"), b("i"), len);
System.out.println("length " + len + " -> ok (" + o.length + " bytes)");
} catch (Exception e) {
System.out.println("length " + len + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
Fx.header("5. Deriving a key from a low-entropy secret (a 4-digit PIN)");
byte[] pinKey = hk(b("4821"), b("per-user-salt"), b("pin-key"), 32);
long t0 = System.nanoTime();
String found = null; int guesses = 0;
for (int p = 0; p < 10_000 && found == null; p++) {
String pin = String.format("%04d", p); guesses++;
if (Arrays.equals(hk(b(pin), b("per-user-salt"), b("pin-key"), 32), pinKey)) found = pin;
}
long hkdfNs = System.nanoTime() - t0;
System.out.println("HKDF-derived key: attacker finds PIN " + found + " after " + guesses + " guesses");
SecretKeyFactory pbkdf2 = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
int iterations = 600_000;
t0 = System.nanoTime();
int sample = 20;
for (int p = 0; p < sample; p++)
pbkdf2.generateSecret(new PBEKeySpec(String.format("%04d", p).toCharArray(), b("per-user-salt"), iterations, 256));
long pbNs = System.nanoTime() - t0;
double hkdfRate = guesses / (hkdfNs / 1e9), pbRate = sample / (pbNs / 1e9);
Fx.check("PBKDF2 (600000 iterations) is at least 1000 times slower per guess than HKDF",
hkdfRate / pbRate >= 1000);
Fx.done();
}
}
+11
View File
@@ -0,0 +1,11 @@
/** The hand-written reference must itself reproduce RFC 5869, otherwise it proves nothing. */
public class RefCheck {
public static void main(String[] args) throws Exception {
for (Rfc5869Vectors.Case c : Rfc5869Vectors.CASES) {
Fx.header("RefHkdf (HmacSHA256 only) on RFC 5869 " + c.name());
Fx.checkEq("PRK", Fx.unhex(c.prk()), RefHkdf.extract("HmacSHA256", c.salt(), c.ikm()));
Fx.checkEq("OKM", Fx.unhex(c.okm()), RefHkdf.hkdf("HmacSHA256", c.ikm(), c.salt(), c.info(), c.len()));
}
Fx.done();
}
}
+42
View File
@@ -0,0 +1,42 @@
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayOutputStream;
import java.util.Arrays;
/**
* HKDF written straight from RFC 5869 section 2, using only javax.crypto.Mac. No javax.crypto.KDF involved,
* so comparing it with the KDF API is a real second opinion (the first being Python's "cryptography").
*/
public final class RefHkdf {
private RefHkdf() {}
/** HKDF-Extract: PRK = HMAC-Hash(salt, IKM); an absent salt is HashLen zero bytes. */
static byte[] extract(String hmac, byte[] salt, byte[] ikm) throws Exception {
Mac mac = Mac.getInstance(hmac);
byte[] s = (salt == null || salt.length == 0) ? new byte[mac.getMacLength()] : salt;
mac.init(new SecretKeySpec(s, hmac));
return mac.doFinal(ikm);
}
/** HKDF-Expand: T(i) = HMAC(PRK, T(i-1) | info | i), OKM = first L bytes of T(1) | T(2) | ... */
static byte[] expand(String hmac, byte[] prk, byte[] info, int length) throws Exception {
Mac mac = Mac.getInstance(hmac);
int hashLen = mac.getMacLength();
if (length > 255 * hashLen) throw new IllegalArgumentException("L > 255*HashLen");
mac.init(new SecretKeySpec(prk, hmac));
ByteArrayOutputStream okm = new ByteArrayOutputStream();
byte[] t = new byte[0];
for (int i = 1; okm.size() < length; i++) {
mac.update(t);
mac.update(info);
mac.update((byte) i);
t = mac.doFinal();
okm.write(t);
}
return Arrays.copyOf(okm.toByteArray(), length);
}
static byte[] hkdf(String hmac, byte[] ikm, byte[] salt, byte[] info, int length) throws Exception {
return expand(hmac, extract(hmac, salt, ikm), info, length);
}
}
+42
View File
@@ -0,0 +1,42 @@
import javax.crypto.KDF;
import javax.crypto.SecretKey;
import javax.crypto.spec.HKDFParameterSpec;
/** The three SHA-256 test cases of RFC 5869 appendix A, run through the JDK KDF API three ways each. */
public class Rfc5869Vectors {
record Case(String name, byte[] ikm, byte[] salt, byte[] info, int len, String prk, String okm) {}
static final Case[] CASES = {
new Case("A.1 basic", Fx.fill(0x0b, 22), Fx.range(0x00, 13), Fx.range(0xf0, 10), 42,
"077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5",
"3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865"),
new Case("A.2 long inputs", Fx.range(0x00, 80), Fx.range(0x60, 80), Fx.range(0xb0, 80), 82,
"06a6b88c5853361a06104c9ceb35b45cef760014904671014a193f40c15fc244",
"b11e398dc80327a1c8e7f78c596a49344f012eda2d4efad8a050cc4c19afa97c59045a99cac7827271cb41c65e590e09da3275600c2f09b8367793a9aca3db71cc30c58179ec3e87c14c01d5c1f3434f1d87"),
new Case("A.3 empty salt and info", Fx.fill(0x0b, 22), new byte[0], new byte[0], 42,
"19ef24a32c717b167f33a91d6f648bdf96596776afdb6377ac434c1c293ccb04",
"8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8"),
};
public static void main(String[] args) throws Exception {
KDF kdf = KDF.getInstance("HKDF-SHA256");
for (Case c : CASES) {
Fx.header("RFC 5869 " + c.name() + " (IKM " + c.ikm().length + " B, salt " + c.salt().length + " B, info " + c.info().length + " B, L=" + c.len() + ")");
byte[] okm = kdf.deriveData(HKDFParameterSpec.ofExtract()
.addIKM(c.ikm()).addSalt(c.salt()).thenExpand(c.info(), c.len()));
Fx.checkEq("extract+expand -> OKM", Fx.unhex(c.okm()), okm);
SecretKey prk = kdf.deriveKey("Generic", HKDFParameterSpec.ofExtract()
.addIKM(c.ikm()).addSalt(c.salt()).extractOnly());
Fx.checkEq("extractOnly -> PRK", Fx.unhex(c.prk()), prk.getEncoded());
byte[] expanded = kdf.deriveData(HKDFParameterSpec.expandOnly(prk, c.info(), c.len()));
Fx.checkEq("expandOnly(PRK) -> OKM", Fx.unhex(c.okm()), expanded);
}
Fx.header("RFC 5869 cases 4 to 7 use SHA-1, which this provider does not offer");
try { KDF.getInstance("HKDF-SHA1"); Fx.check("HKDF-SHA1 unexpectedly available", false); }
catch (java.security.NoSuchAlgorithmException e) { Fx.check("HKDF-SHA1 -> NoSuchAlgorithmException", true); }
Fx.done();
}
}
+91
View File
@@ -0,0 +1,91 @@
import javax.crypto.Cipher;
import javax.crypto.KDF;
import javax.crypto.Mac;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.HKDFParameterSpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.util.Arrays;
/**
* One master secret, many tenants. Each tenant gets its own AES key and its own HMAC key, for each key
* version, derived on demand and never stored. The context goes in {@code info}; the salt is a fixed
* application constant. A KDF object is not thread-safe, so every call gets its own.
*/
public class Tenants {
static final byte[] APP_SALT = "acme-billing/salt/v1".getBytes(StandardCharsets.UTF_8);
/** Unambiguous info: a fixed label, then each field prefixed with its 2-byte length. */
static byte[] info(String purpose, String tenant, int keyVersion) {
byte[][] fields = {
"acme-billing/v1".getBytes(StandardCharsets.UTF_8),
purpose.getBytes(StandardCharsets.UTF_8),
tenant.getBytes(StandardCharsets.UTF_8),
ByteBuffer.allocate(4).putInt(keyVersion).array()};
int size = 0;
for (byte[] f : fields) size += 2 + f.length;
ByteBuffer b = ByteBuffer.allocate(size);
for (byte[] f : fields) b.putShort((short) f.length).put(f);
return b.array();
}
static SecretKey derive(SecretKey master, String alg, int bytes, String purpose, String tenant, int version)
throws GeneralSecurityException {
KDF kdf = KDF.getInstance("HKDF-SHA256");
return kdf.deriveKey(alg, HKDFParameterSpec.ofExtract()
.addIKM(master).addSalt(APP_SALT).thenExpand(info(purpose, tenant, version), bytes));
}
static String fp(SecretKey k) { return Fx.hex(k.getEncoded()).substring(0, 16); }
public static void main(String[] args) throws Exception {
// In production the master key comes from a KMS/HSM/secret store. Here: 32 fixed bytes so output is stable.
SecretKey master = new javax.crypto.spec.SecretKeySpec(Fx.range(1, 32), "Generic");
Fx.header("Per-tenant, per-purpose, per-version keys (first 8 bytes shown)");
String[] tenants = {"tenant-acme", "tenant-globex"};
for (String t : tenants) {
System.out.println(t + " aes v1 = " + fp(derive(master, "AES", 32, "aes-gcm", t, 1))
+ " hmac v1 = " + fp(derive(master, "HmacSHA256", 32, "hmac", t, 1))
+ " aes v2 = " + fp(derive(master, "AES", 32, "aes-gcm", t, 2)));
}
SecretKey a1 = derive(master, "AES", 32, "aes-gcm", "tenant-acme", 1);
SecretKey a1again = derive(master, "AES", 32, "aes-gcm", "tenant-acme", 1);
SecretKey g1 = derive(master, "AES", 32, "aes-gcm", "tenant-globex", 1);
SecretKey acmeHmac = derive(master, "HmacSHA256", 32, "hmac", "tenant-acme", 1);
SecretKey a2 = derive(master, "AES", 32, "aes-gcm", "tenant-acme", 2);
Fx.check("same inputs give the same key every time (nothing to store)", Arrays.equals(a1.getEncoded(), a1again.getEncoded()));
Fx.check("two tenants get different keys", !Arrays.equals(a1.getEncoded(), g1.getEncoded()));
Fx.check("encryption key and MAC key of one tenant differ", !Arrays.equals(a1.getEncoded(), acmeHmac.getEncoded()));
Fx.check("key version 2 differs from version 1 (rotation)", !Arrays.equals(a1.getEncoded(), a2.getEncoded()));
Fx.header("Isolation in practice: tenant A's ciphertext is opaque to tenant B's key");
byte[] iv = Fx.range(0, 12);
Cipher enc = Cipher.getInstance("AES/GCM/NoPadding");
enc.init(Cipher.ENCRYPT_MODE, a1, new GCMParameterSpec(128, iv));
byte[] ct = enc.doFinal("invoice #4711 for acme".getBytes(StandardCharsets.UTF_8));
Cipher dec = Cipher.getInstance("AES/GCM/NoPadding");
dec.init(Cipher.DECRYPT_MODE, a1again, new GCMParameterSpec(128, iv));
System.out.println("acme key decrypts: " + new String(dec.doFinal(ct), StandardCharsets.UTF_8));
try {
dec.init(Cipher.DECRYPT_MODE, g1, new GCMParameterSpec(128, iv));
dec.doFinal(ct);
Fx.check("globex key must not decrypt", false);
} catch (javax.crypto.AEADBadTagException e) {
System.out.println("globex key fails: AEADBadTagException: " + e.getMessage());
Fx.check("tenant B cannot open tenant A's data", true);
}
Fx.header("HMAC key from the same master, used for a signed token");
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(acmeHmac);
System.out.println("tag = " + Fx.hex(mac.doFinal("tenant-acme|user=42".getBytes(StandardCharsets.UTF_8))).substring(0, 32) + "...");
Fx.header("The info bytes that separate the contexts (hex) for tenant-acme / aes-gcm / v1");
System.out.println(Fx.hex(info("aes-gcm", "tenant-acme", 1)));
Fx.done();
}
}