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:
@@ -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