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
+24
View File
@@ -0,0 +1,24 @@
# kdf
Companion code for the ankurm.com article **Key Derivation Function API in Java (JEP 510): HKDF Explained with Real Examples**.
`javax.crypto.KDF` was a preview API in JDK 24 (JEP 478) and is **final in JDK 25 (JEP 510)**: no `--enable-preview`. The JDK ships exactly three algorithms,
all HKDF (RFC 5869), all from the `SunJCE` provider: `HKDF-SHA256`, `HKDF-SHA384`, `HKDF-SHA512`. Everything here ran on Temurin 25.0.5+8; no dependencies except
the comparison tools (Python `cryptography` 50.0.1 and the `openssl` 3.0.13 command line) for the interop steps.
| File | What it shows | Output |
|---|---|---|
| (script) | `javap` of `KDF`, `KDFParameters`, `KDFSpi`, `HKDFParameterSpec` and its nested types; proof it is not a preview API | `output/01-api-shape.txt` |
| `src/ApiTour.java` | registered algorithms, `getInstance` failures, the three spec shapes (extract+expand, extract only, expand only), several IKM pieces, `deriveKey` vs `Cipher.init` key sizes | `output/02-api-tour.txt` |
| `src/Rfc5869Vectors.java` | RFC 5869 appendix A cases 1 to 3 (SHA-256) asserted three ways each: OKM, PRK, expand-only from the PRK | `output/03-rfc5869-vectors.txt` |
| `src/RefHkdf.java`, `src/RefCheck.java`, `src/Interop.java`, `interop/cases.tsv` | HKDF written by hand from the RFC with `javax.crypto.Mac`, checked against the RFC, then JDK vs hand-written vs Python `cryptography` on 300 generated cases (SHA-256/384/512) | `output/04-interop-java-vs-python-vs-reference.txt` |
| `interop/gen_cases.py`, `interop/openssl_check.py` | how `cases.tsv` was made (fixed seed, regenerates byte-identically) and a third opinion from `openssl kdf` | `output/05-interop-python-openssl.txt` |
| `src/Tenants.java` | per-tenant, per-purpose, per-version keys from one master key; length-prefixed `info`; AES-GCM isolation | `output/06-tenants.txt` |
| `src/Mistakes.java` | same `info` twice, ambiguous concatenation, salt facts, the 255 x HashLen limit, a 4-digit PIN through HKDF vs PBKDF2 | `output/07-mistakes.txt` |
```bash
JDK25=/path/to/jdk-25 ./run.sh # needs python3 with `cryptography`, and openssl, for output/05
```
The demos assert as they go (`PASS`/`FAIL` lines, non-zero exit on failure), following the other standalone modules in this repo; there is no JUnit here. Output is byte-stable.
Not reproduced: JDK 24's preview behaviour (no JDK 24 was installed), and `openssl` was only run with 3.0.13.
File diff suppressed because one or more lines are too long
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""Generate interop/cases.tsv: random HKDF inputs plus the expected OKM from Python's `cryptography` package
(a thin wrapper over OpenSSL's HKDF). Deterministic: fixed seed, so the file is byte-stable.
Columns: hash <TAB> ikm <TAB> salt <TAB> info <TAB> length <TAB> okm (hex; an empty field is an empty value)"""
import random, sys
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
HASHES = {"SHA256": hashes.SHA256, "SHA384": hashes.SHA384, "SHA512": hashes.SHA512}
SIZE = {"SHA256": 32, "SHA384": 48, "SHA512": 64}
rnd = random.Random(5869)
def rb(n): return bytes(rnd.getrandbits(8) for _ in range(n))
rows = []
for h in HASHES:
for i in range(100):
ikm = rb(rnd.choice([1, 16, 22, 32, 64, 100]))
salt = b"" if i % 5 == 0 else rb(rnd.choice([1, 13, 32, 80]))
info = b"" if i % 7 == 0 else rb(rnd.choice([1, 10, 40, 80]))
length = rnd.choice([1, 16, 32, 42, SIZE[h], SIZE[h] + 1, 82, 255 * SIZE[h]]) if i % 10 else 255 * SIZE[h]
okm = HKDF(algorithm=HASHES[h](), length=length, salt=salt or None, info=info).derive(ikm)
rows.append((h, ikm.hex(), salt.hex(), info.hex(), str(length), okm.hex()))
with open(sys.argv[1], "w") as f:
for r in rows: f.write("\t".join(r) + "\n")
print(f"wrote {len(rows)} cases")
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Third opinion: ask the `openssl kdf` command line tool (no Python crypto, no Java) for the RFC 5869 cases
and for the first rows of cases.tsv, and compare with the expected OKM."""
import subprocess, sys, re
def openssl_hkdf(digest, ikm, salt, info, length):
cmd = ["openssl", "kdf", "-keylen", str(length), "-kdfopt", f"digest:{digest}", "-kdfopt", f"hexkey:{ikm}",
"-kdfopt", f"hexinfo:{info}", "-kdfopt", "mode:EXTRACT_AND_EXPAND"]
if salt: cmd += ["-kdfopt", f"hexsalt:{salt}"]
cmd += ["HKDF"]
out = subprocess.run(cmd, capture_output=True, text=True, check=True).stdout.strip()
return out.replace(":", "").lower()
print(subprocess.run(["openssl", "version"], capture_output=True, text=True).stdout.strip())
ok = bad = 0
rfc = [("SHA256", "0b"*22, "000102030405060708090a0b0c", "f0f1f2f3f4f5f6f7f8f9", 42,
"3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865"),
("SHA256", "0b"*22, "", "", 42,
"8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8")]
rows = [tuple(l.rstrip("\n").split("\t")) for l in open(sys.argv[1])]
rows = [(h, i, s, n, int(L), o) for h, i, s, n, L, o in rows]
for h, i, s, n, L, o in rfc + rows[::10]:
got = openssl_hkdf(h, i, s, n, L)
if got == o: ok += 1
else:
bad += 1; print("MISMATCH", h, i[:16], L)
print(f"openssl kdf HKDF: {ok} of {ok+bad} cases match ({len(rfc)} RFC 5869 + every 10th row of cases.tsv)")
sys.exit(1 if bad else 0)
+56
View File
@@ -0,0 +1,56 @@
$ javap javax.crypto.KDF javax.crypto.KDFParameters javax.crypto.KDFSpi javax.crypto.spec.HKDFParameterSpec 'javax.crypto.spec.HKDFParameterSpec$Builder' ... (25.0.5+8-LTS)
public final class javax.crypto.KDF {
static final boolean $assertionsDisabled;
public java.lang.String getAlgorithm();
public java.lang.String getProviderName();
public javax.crypto.KDFParameters getParameters();
public static javax.crypto.KDF getInstance(java.lang.String) throws java.security.NoSuchAlgorithmException;
public static javax.crypto.KDF getInstance(java.lang.String, java.lang.String) throws java.security.NoSuchAlgorithmException, java.security.NoSuchProviderException;
public static javax.crypto.KDF getInstance(java.lang.String, java.security.Provider) throws java.security.NoSuchAlgorithmException;
public static javax.crypto.KDF getInstance(java.lang.String, javax.crypto.KDFParameters) throws java.security.NoSuchAlgorithmException, java.security.InvalidAlgorithmParameterException;
public static javax.crypto.KDF getInstance(java.lang.String, javax.crypto.KDFParameters, java.lang.String) throws java.security.NoSuchAlgorithmException, java.security.NoSuchProviderException, java.security.InvalidAlgorithmParameterException;
public static javax.crypto.KDF getInstance(java.lang.String, javax.crypto.KDFParameters, java.security.Provider) throws java.security.NoSuchAlgorithmException, java.security.InvalidAlgorithmParameterException;
public javax.crypto.SecretKey deriveKey(java.lang.String, java.security.spec.AlgorithmParameterSpec) throws java.security.InvalidAlgorithmParameterException, java.security.NoSuchAlgorithmException;
public byte[] deriveData(java.security.spec.AlgorithmParameterSpec) throws java.security.InvalidAlgorithmParameterException;
static {};
}
public interface javax.crypto.KDFParameters {
}
public abstract class javax.crypto.KDFSpi {
protected javax.crypto.KDFSpi(javax.crypto.KDFParameters) throws java.security.InvalidAlgorithmParameterException;
protected abstract javax.crypto.KDFParameters engineGetParameters();
protected abstract javax.crypto.SecretKey engineDeriveKey(java.lang.String, java.security.spec.AlgorithmParameterSpec) throws java.security.InvalidAlgorithmParameterException, java.security.NoSuchAlgorithmException;
protected abstract byte[] engineDeriveData(java.security.spec.AlgorithmParameterSpec) throws java.security.InvalidAlgorithmParameterException;
}
public interface javax.crypto.spec.HKDFParameterSpec extends java.security.spec.AlgorithmParameterSpec {
public static javax.crypto.spec.HKDFParameterSpec$Builder ofExtract();
public static javax.crypto.spec.HKDFParameterSpec$Expand expandOnly(javax.crypto.SecretKey, byte[], int);
}
public final class javax.crypto.spec.HKDFParameterSpec$Builder {
public javax.crypto.spec.HKDFParameterSpec$Extract extractOnly();
public javax.crypto.spec.HKDFParameterSpec$ExtractThenExpand thenExpand(byte[], int);
public javax.crypto.spec.HKDFParameterSpec$Builder addIKM(javax.crypto.SecretKey);
public javax.crypto.spec.HKDFParameterSpec$Builder addIKM(byte[]);
public javax.crypto.spec.HKDFParameterSpec$Builder addSalt(javax.crypto.SecretKey);
public javax.crypto.spec.HKDFParameterSpec$Builder addSalt(byte[]);
}
public final class javax.crypto.spec.HKDFParameterSpec$Extract implements javax.crypto.spec.HKDFParameterSpec {
public java.util.List<javax.crypto.SecretKey> ikms();
public java.util.List<javax.crypto.SecretKey> salts();
}
public final class javax.crypto.spec.HKDFParameterSpec$Expand implements javax.crypto.spec.HKDFParameterSpec {
public javax.crypto.SecretKey prk();
public byte[] info();
public int length();
}
public final class javax.crypto.spec.HKDFParameterSpec$ExtractThenExpand implements javax.crypto.spec.HKDFParameterSpec {
public java.util.List<javax.crypto.SecretKey> ikms();
public java.util.List<javax.crypto.SecretKey> salts();
public byte[] info();
public int length();
}
$ javap -v javax.crypto.KDF | grep -ci PreviewFeature (0 = not a preview API)
0
$ unzip -p src.zip java.base/javax/crypto/KDF.java | grep @since
* @since 25
+34
View File
@@ -0,0 +1,34 @@
$ java -cp classes ApiTour (25.0.5+8-LTS)
== KDF algorithms registered by the providers of this JDK
SunJCE HKDF-SHA256
SunJCE HKDF-SHA384
SunJCE HKDF-SHA512
== getInstance: what works and what does not
algorithm=HKDF-SHA256 provider=SunJCE parameters=null
HKDF -> NoSuchAlgorithmException: No available provider supports the specified algorithm
HKDF-SHA1 -> NoSuchAlgorithmException: No available provider supports the specified algorithm
hkdf-sha256 -> available
PBKDF2WithHmacSHA256 -> NoSuchAlgorithmException: No available provider supports the specified algorithm
Argon2 -> NoSuchAlgorithmException: No available provider supports the specified algorithm
== Shape 1: extract then expand in one call (the common case)
deriveData -> byte[42] 3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865
deriveKey -> algorithm=AES format=RAW bytes=32
== Shape 2: extract only (returns the pseudorandom key, PRK)
PRK = 077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5
== Shape 3: expand only (you already hold a uniformly random key)
PASS extractOnly + expandOnly equals the one-call result
== The same KDF object can be used again, and gives the same bytes
PASS second call with the same spec is identical
== Several IKM or salt pieces are concatenated in the order added
PASS addIKM(first 10) + addIKM(last 12) == addIKM(all 22)
== deriveKey does not check key sizes; Cipher.init does
deriveKey("AES") length 16 -> ok, 16 bytes
Cipher.init -> ok
deriveKey("AES") length 24 -> ok, 24 bytes
Cipher.init -> ok
deriveKey("AES") length 32 -> ok, 32 bytes
Cipher.init -> ok
deriveKey("AES") length 20 -> ok, 20 bytes
Cipher.init -> InvalidKeyException: Invalid AES key length: 20 bytes
ALL CHECKS PASSED
exit=0
+17
View File
@@ -0,0 +1,17 @@
$ java -cp classes Rfc5869Vectors (25.0.5+8-LTS)
== RFC 5869 A.1 basic (IKM 22 B, salt 13 B, info 10 B, L=42)
PASS extract+expand -> OKM
PASS extractOnly -> PRK
PASS expandOnly(PRK) -> OKM
== RFC 5869 A.2 long inputs (IKM 80 B, salt 80 B, info 80 B, L=82)
PASS extract+expand -> OKM
PASS extractOnly -> PRK
PASS expandOnly(PRK) -> OKM
== RFC 5869 A.3 empty salt and info (IKM 22 B, salt 0 B, info 0 B, L=42)
PASS extract+expand -> OKM
PASS extractOnly -> PRK
PASS expandOnly(PRK) -> OKM
== RFC 5869 cases 4 to 7 use SHA-1, which this provider does not offer
PASS HKDF-SHA1 -> NoSuchAlgorithmException
ALL CHECKS PASSED
exit=0
@@ -0,0 +1,19 @@
$ java -cp classes RefCheck (25.0.5+8-LTS)
== RefHkdf (HmacSHA256 only) on RFC 5869 A.1 basic
PASS PRK
PASS OKM
== RefHkdf (HmacSHA256 only) on RFC 5869 A.2 long inputs
PASS PRK
PASS OKM
== RefHkdf (HmacSHA256 only) on RFC 5869 A.3 empty salt and info
PASS PRK
PASS OKM
ALL CHECKS PASSED
exit=0
$ java -cp classes Interop interop/cases.tsv (25.0.5+8-LTS)
== interop/cases.tsv: 300 cases {SHA256=100, SHA384=100, SHA512=100}, longest output 16320 bytes
PASS JDK KDF API matches Python cryptography on 300 of 300
PASS hand-written RefHkdf matches Python cryptography on 300 of 300
ALL CHECKS PASSED
exit=0
+5
View File
@@ -0,0 +1,5 @@
python cryptography 50.0.1
wrote 300 cases
cases.tsv regenerates byte-identically
OpenSSL 3.0.13 30 Jan 2024 (Library: OpenSSL 3.0.13 30 Jan 2024)
openssl kdf HKDF: 32 of 32 cases match (2 RFC 5869 + every 10th row of cases.tsv)
+18
View File
@@ -0,0 +1,18 @@
$ java -cp classes Tenants (25.0.5+8-LTS)
== Per-tenant, per-purpose, per-version keys (first 8 bytes shown)
tenant-acme aes v1 = 503b6a158de948ef hmac v1 = de11af197467596c aes v2 = 0c80331430e74d8d
tenant-globex aes v1 = 3e7dd7c93250c77e hmac v1 = ab1c9c0b01b4e64b aes v2 = 22859d1db92cf04b
PASS same inputs give the same key every time (nothing to store)
PASS two tenants get different keys
PASS encryption key and MAC key of one tenant differ
PASS key version 2 differs from version 1 (rotation)
== Isolation in practice: tenant A's ciphertext is opaque to tenant B's key
acme key decrypts: invoice #4711 for acme
globex key fails: AEADBadTagException: Tag mismatch
PASS tenant B cannot open tenant A's data
== HMAC key from the same master, used for a signed token
tag = 3cb1fb88afc1c173475dac3a12f68f05...
== The info bytes that separate the contexts (hex) for tenant-acme / aes-gcm / v1
000f61636d652d62696c6c696e672f763100076165732d67636d000b74656e616e742d61636d65000400000001
ALL CHECKS PASSED
exit=0
+18
View File
@@ -0,0 +1,18 @@
$ java -cp classes Mistakes (25.0.5+8-LTS)
== 1. Same info for two purposes: the 'two' keys are one key
PASS encryption key == MAC key when info is the same
PASS distinct info labels give distinct keys
== 2. Gluing fields together without a delimiter: tenant 'ab' + 'c' collides with 'a' + 'bc'
PASS naive concatenation collides
PASS length-prefixed fields (Tenants.info) do not collide
== 3. Salt: leaving it out is the same as HashLen zero bytes, and a salt is not a secret
PASS no salt == 32 zero bytes (SHA-256)
PASS a different salt changes every output, so a salt change is a key change
== 4. Asking for more than 255 * HashLen bytes
length 8160 -> ok (8160 bytes)
length 8161 -> InvalidAlgorithmParameterException: The specified SunJCE provider does not support this input
== 5. Deriving a key from a low-entropy secret (a 4-digit PIN)
HKDF-derived key: attacker finds PIN 4821 after 4822 guesses
PASS PBKDF2 (600000 iterations) is at least 1000 times slower per guess than HKDF
ALL CHECKS PASSED
exit=0
Executable
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# KDF API (JEP 510, final in JDK 25): regenerates every file in output/.
# JDK25=/path/to/jdk-25 ./run.sh needs python3 with `cryptography` and the openssl CLI for steps 05 and 06
set -uo pipefail
unset JAVA_TOOL_OPTIONS
HERE="$(cd "$(dirname "$0")" && pwd)"
JDK25="${JDK25:-/opt/jdks/jdk-25.0.4.1+1}"
OUT="$HERE/output"; mkdir -p "$OUT"; B="$(mktemp -d)"; trap 'rm -rf "$B"' EXIT
cd "$HERE"
J="$JDK25/bin"
echo "javac: $("$J/javac" -version 2>&1)"
"$J/javac" --release 25 -Xlint:all -d "$B" src/*.java || exit 1
run() { echo "\$ java -cp classes $* ($("$J/java" -version 2>&1 | sed -n 2p | sed 's/.*(build \(.*\))/\1/'))"; "$J/java" -Xmx512m -cp "$B" "$@" 2>&1; echo "exit=$?"; }
{ echo "\$ javap javax.crypto.KDF javax.crypto.KDFParameters javax.crypto.KDFSpi javax.crypto.spec.HKDFParameterSpec 'javax.crypto.spec.HKDFParameterSpec\$Builder' ... ($("$J/java" -version 2>&1 | sed -n 2p | sed 's/.*(build \(.*\))/\1/'))"
"$J/javap" javax.crypto.KDF javax.crypto.KDFParameters javax.crypto.KDFSpi javax.crypto.spec.HKDFParameterSpec \
'javax.crypto.spec.HKDFParameterSpec$Builder' 'javax.crypto.spec.HKDFParameterSpec$Extract' \
'javax.crypto.spec.HKDFParameterSpec$Expand' 'javax.crypto.spec.HKDFParameterSpec$ExtractThenExpand' 2>&1 | grep -v '^Compiled from'
echo; echo "\$ javap -v javax.crypto.KDF | grep -ci PreviewFeature (0 = not a preview API)"
"$J/javap" -v javax.crypto.KDF | grep -ci PreviewFeature
echo "\$ unzip -p src.zip java.base/javax/crypto/KDF.java | grep @since"
unzip -p "$JDK25/lib/src.zip" java.base/javax/crypto/KDF.java | grep -m1 '@since'
} > "$OUT/01-api-shape.txt"
run ApiTour > "$OUT/02-api-tour.txt"
run Rfc5869Vectors > "$OUT/03-rfc5869-vectors.txt"
{ run RefCheck; echo; run Interop interop/cases.tsv; } > "$OUT/04-interop-java-vs-python-vs-reference.txt"
{ python3 -I -c 'import cryptography; print("python cryptography", cryptography.__version__)'
python3 -I interop/gen_cases.py "$B/regen.tsv" && cmp "$B/regen.tsv" interop/cases.tsv && echo "cases.tsv regenerates byte-identically"
python3 -I interop/openssl_check.py interop/cases.tsv; } > "$OUT/05-interop-python-openssl.txt" 2>&1
run Tenants > "$OUT/06-tenants.txt"
run Mistakes > "$OUT/07-mistakes.txt"
grep -l 'FAIL' "$OUT"/*.txt && echo "SOME CHECK FAILED" && exit 1
echo "all outputs regenerated, no FAIL lines"
+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();
}
}