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:
@@ -72,6 +72,7 @@ These modules keep only sources, a `run.sh` and their transcripts (`output/`). T
|
||||
| [`compact-strings/`](compact-strings) | Compact Strings in Java: Measured with JOL on JDK 25 (plus Compact Object Headers) | the `String` object and its `byte[]` field by field, Latin-1 vs UTF-16 footprint by length, one million strings with and without `-XX:-CompactStrings`, what flips a string to UTF-16 (and what flips it back), compact object headers on JDK 25 (opt-in) vs 27 (default), a JMH comparison of six operations |
|
||||
| [`pem-api/`](pem-api) | PEM Encodings in Java (JEP 538/542): Read and Write Keys and Certificates Without BouncyCastle | `PEMDecoder`/`PEMEncoder` round trips, JWT signing (RS256/ES256/EdDSA) and a TLS server built from PEM files, encrypted PKCS#8 keys, the failure catalogue (wrong target class, SEC1 `EC PRIVATE KEY`, CRLF output), BouncyCastle and Spring Security `RsaKeyConverters` side by side, preview flag on JDK 27 vs the JDK 28 EA build |
|
||||
| [`structured-concurrency-final/`](structured-concurrency-final) | Structured Concurrency Is Final in JDK 28 (JEP 543): From Preview to Production | the thread leak of `ExecutorService` and its fix, every built-in `Joiner`, structure violations, timeouts, a custom joiner, JSON thread dumps, scoped values, `javap` diffs of the API on JDK 25, 26, 27 and 28-ea, the compiler output for six pieces of 25/26-era code, and the preview class-file trap between 27 and 28 |
|
||||
| [`kdf/`](kdf) | Key Derivation Function API in Java (JEP 510): HKDF Explained with Real Examples | `javax.crypto.KDF` final in JDK 25, `HKDF-SHA256/384/512`, extract vs expand, RFC 5869 vectors asserted, a hand-written HKDF and 300 generated cases compared with Python `cryptography` and `openssl kdf`, per-tenant keys with length-prefixed `info`, and the mistakes reproduced (same `info`, ambiguous concatenation, PIN through HKDF vs PBKDF2) |
|
||||
|
||||
## Captured output (`docs/output/`)
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user