summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorTor Brede Vekterli <vekterli@yahooinc.com>2022-10-19 12:20:43 +0200
committerGitHub <noreply@github.com>2022-10-19 12:20:43 +0200
commit9bd0a86bba6280aded2ff575ba095a446d6aa4e7 (patch)
tree730823f6bef151648599e5996193a08016088cd1
parent892275bf2b8f3689e44911099602e15d420ed617 (diff)
parent540cae3561cd8823fa1a5e036d5543ca5ba2519e (diff)
Merge pull request #24496 from vespa-engine/vekterli/add-rfc-9180-hybrid-public-key-encryption-impl
Minimal implementation of RFC 9180 Hybrid Public Key Encryption (HPKE)
-rw-r--r--security-utils/src/main/java/com/yahoo/security/ArrayUtils.java50
-rw-r--r--security-utils/src/main/java/com/yahoo/security/HKDF.java16
-rw-r--r--security-utils/src/main/java/com/yahoo/security/KeyUtils.java140
-rw-r--r--security-utils/src/main/java/com/yahoo/security/hpke/Aead.java42
-rw-r--r--security-utils/src/main/java/com/yahoo/security/hpke/AesGcm128.java64
-rw-r--r--security-utils/src/main/java/com/yahoo/security/hpke/Ciphersuite.java28
-rw-r--r--security-utils/src/main/java/com/yahoo/security/hpke/Constants.java38
-rw-r--r--security-utils/src/main/java/com/yahoo/security/hpke/DHKemX25519HkdfSha256.java138
-rw-r--r--security-utils/src/main/java/com/yahoo/security/hpke/HkdfSha256.java32
-rw-r--r--security-utils/src/main/java/com/yahoo/security/hpke/Hpke.java320
-rw-r--r--security-utils/src/main/java/com/yahoo/security/hpke/Kdf.java43
-rw-r--r--security-utils/src/main/java/com/yahoo/security/hpke/Kem.java69
-rw-r--r--security-utils/src/main/java/com/yahoo/security/hpke/LabeledKdfUtils.java61
-rw-r--r--security-utils/src/test/java/com/yahoo/security/HpkeTest.java109
-rw-r--r--security-utils/src/test/java/com/yahoo/security/KeyUtilsTest.java88
15 files changed, 1237 insertions, 1 deletions
diff --git a/security-utils/src/main/java/com/yahoo/security/ArrayUtils.java b/security-utils/src/main/java/com/yahoo/security/ArrayUtils.java
new file mode 100644
index 00000000000..41f19d5c82c
--- /dev/null
+++ b/security-utils/src/main/java/com/yahoo/security/ArrayUtils.java
@@ -0,0 +1,50 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.security;
+
+import org.bouncycastle.util.encoders.Hex;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * A small collection of utils for working on arrays of bytes.
+ *
+ * @author vekterli
+ */
+public class ArrayUtils {
+
+ /**
+ * Returns a new byte array that is the concatenation of all input byte arrays in input order.
+ *
+ * E.g. <code>concat("A", "BC", "DE", "F") => "ABCDEF"</code>
+ */
+ public static byte[] concat(byte[]... bufs) {
+ int len = 0;
+ for (byte[] b : bufs) {
+ len += b.length;
+ }
+ byte[] ret = new byte[len];
+ int offset = 0;
+ for (byte[] b : bufs) {
+ System.arraycopy(b, 0, ret, offset, b.length);
+ offset += b.length;
+ }
+ return ret;
+ }
+
+ public static byte[] unhex(String hexStr) {
+ return Hex.decode(hexStr);
+ }
+
+ public static String hex(byte[] bytes) {
+ return Hex.toHexString(bytes);
+ }
+
+ public static byte[] toUtf8Bytes(String str) {
+ return str.getBytes(StandardCharsets.UTF_8);
+ }
+
+ public static String fromUtf8Bytes(byte[] bytes) {
+ return new String(bytes, StandardCharsets.UTF_8);
+ }
+
+}
diff --git a/security-utils/src/main/java/com/yahoo/security/HKDF.java b/security-utils/src/main/java/com/yahoo/security/HKDF.java
index 3aff89d71c2..ca9a111b5c4 100644
--- a/security-utils/src/main/java/com/yahoo/security/HKDF.java
+++ b/security-utils/src/main/java/com/yahoo/security/HKDF.java
@@ -27,7 +27,7 @@ import java.util.Objects;
*
* @author vekterli
*/
-public class HKDF {
+public final class HKDF {
private static final int HASH_LEN = 32; // Fixed output size of HMAC-SHA256. Corresponds to HashLen in the spec
private static final byte[] EMPTY_BYTES = new byte[0];
@@ -48,6 +48,20 @@ public class HKDF {
}
}
+ /**
+ * @return the computed pseudo-random key (PRK) used as input for each <code>expand()</code> call.
+ */
+ public byte[] pseudoRandomKey() {
+ return this.pseudoRandomKey;
+ }
+
+ /**
+ * @return a new HKDF instance initially keyed with the given PRK
+ */
+ public static HKDF ofPseudoRandomKey(byte[] prk) {
+ return new HKDF(prk);
+ }
+
private static SecretKeySpec hmacKeyFrom(byte[] rawKey) {
return new SecretKeySpec(rawKey, "HmacSHA256");
}
diff --git a/security-utils/src/main/java/com/yahoo/security/KeyUtils.java b/security-utils/src/main/java/com/yahoo/security/KeyUtils.java
index 3f1d1d4ef63..9fe64baa80a 100644
--- a/security-utils/src/main/java/com/yahoo/security/KeyUtils.java
+++ b/security-utils/src/main/java/com/yahoo/security/KeyUtils.java
@@ -16,13 +16,17 @@ import org.bouncycastle.math.ec.FixedPointCombMultiplier;
import org.bouncycastle.openssl.PEMKeyPair;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
+import org.bouncycastle.util.Arrays;
import org.bouncycastle.util.io.pem.PemObject;
+import javax.crypto.KeyAgreement;
import java.io.IOException;
import java.io.StringReader;
import java.io.StringWriter;
import java.io.UncheckedIOException;
+import java.math.BigInteger;
import java.security.GeneralSecurityException;
+import java.security.InvalidKeyException;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
@@ -30,10 +34,17 @@ import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.interfaces.RSAPrivateCrtKey;
+import java.security.interfaces.XECPrivateKey;
+import java.security.interfaces.XECPublicKey;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.NamedParameterSpec;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.RSAPublicKeySpec;
import java.security.spec.X509EncodedKeySpec;
+import java.security.spec.XECPrivateKeySpec;
+import java.security.spec.XECPublicKeySpec;
import java.util.ArrayList;
+import java.util.Base64;
import java.util.List;
import static com.yahoo.security.KeyAlgorithm.EC;
@@ -227,4 +238,133 @@ public class KeyUtils {
return KeyFactory.getInstance(algorithm.getAlgorithmName(), BouncyCastleProviderHolder.getInstance());
}
+ public static XECPublicKey fromRawX25519PublicKey(byte[] rawKeyBytes) {
+ try {
+ NamedParameterSpec paramSpec = new NamedParameterSpec("X25519");
+ KeyFactory keyFactory = KeyFactory.getInstance("XDH");
+ // X25519 public key byte representations are in little-endian (RFC 7748).
+ // Since BigInteger expects byte buffers in big-endian order, we reverse the byte ordering.
+ byte[] asBigEndian = Arrays.reverse(rawKeyBytes);
+ // https://datatracker.ietf.org/doc/html/rfc7748#section-5
+ // "The u-coordinates are elements of the underlying field GF(2^255 - 19)
+ // or GF(2^448 - 2^224 - 1) and are encoded as an array of bytes, u, in
+ // little-endian order such that u[0] + 256*u[1] + 256^2*u[2] + ... +
+ // 256^(n-1)*u[n-1] is congruent to the value modulo p and u[n-1] is
+ // minimal. When receiving such an array, implementations of X25519
+ // (but not X448) MUST mask the most significant bit in the final byte.
+ // This is done to preserve compatibility with point formats that
+ // reserve the sign bit for use in other protocols and to increase
+ // resistance to implementation fingerprinting."
+ asBigEndian[0] &= 0x7f; // MSBit of MSByte clear. TODO do we always want this? Are "we" the "implementation" here?
+ BigInteger pubU = new BigInteger(asBigEndian);
+ return (XECPublicKey) keyFactory.generatePublic(new XECPublicKeySpec(paramSpec, pubU));
+ } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /** Returns the bytes representing the BigInteger of the X25519 public key EC point U coordinate */
+ public static byte[] toRawX25519PublicKeyBytes(XECPublicKey publicKey) {
+ // Raw byte representation is in little-endian, while BigInteger representation is
+ // big-endian. Basically undoes what we do on the input path in fromRawX25519PublicKey().
+ return Arrays.reverse(publicKey.getU().toByteArray());
+ }
+
+ public static XECPublicKey fromBase64EncodedX25519PublicKey(String base64pk) {
+ byte[] rawKeyBytes = Base64.getUrlDecoder().decode(base64pk);
+ return fromRawX25519PublicKey(rawKeyBytes);
+ }
+
+ public static String toBase64EncodedX25519PublicKey(XECPublicKey publicKey) {
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(toRawX25519PublicKeyBytes(publicKey));
+ }
+
+ public static XECPrivateKey fromRawX25519PrivateKey(byte[] rawScalarBytes) {
+ try {
+ NamedParameterSpec paramSpec = new NamedParameterSpec("X25519");
+ KeyFactory keyFactory = KeyFactory.getInstance("XDH");
+ return (XECPrivateKey) keyFactory.generatePrivate(new XECPrivateKeySpec(paramSpec, rawScalarBytes));
+ } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ // TODO ensure output is clamped?
+ public static byte[] toRawX25519PrivateKeyBytes(XECPrivateKey privateKey) {
+ var maybeScalar = privateKey.getScalar();
+ if (maybeScalar.isPresent()) {
+ return maybeScalar.get();
+ }
+ throw new IllegalArgumentException("Could not extract scalar representation of X25519 private key. " +
+ "It might be a hardware-protected private key.");
+ }
+
+ public static XECPrivateKey fromBase64EncodedX25519PrivateKey(String base64pk) {
+ byte[] rawKeyBytes = Base64.getUrlDecoder().decode(base64pk);
+ return fromRawX25519PrivateKey(rawKeyBytes);
+ }
+
+ public static String toBase64EncodedX25519PrivateKey(XECPrivateKey privateKey) {
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(toRawX25519PrivateKeyBytes(privateKey));
+ }
+
+ // TODO unify with generateKeypair()?
+ public static KeyPair generateX25519KeyPair() {
+ try {
+ return KeyPairGenerator.getInstance("X25519").generateKeyPair();
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Computes a shared secret using the Elliptic Curve Diffie-Hellman (ECDH) protocol for X25519 curves.
+ * <p>
+ * Let Bob have private (secret) key <code>skB</code> and public key <code>pkB</code>.
+ * Let Alice have private key <code>skA</code> and public key <code>pkA</code>.
+ * ECDH lets both parties separately compute their own side of:
+ * </p>
+ * <pre>
+ * ecdh(skB, pkA) == ecdh(skA, pkB)
+ * </pre>
+ * <p>
+ * This arrives at the same shared secret without needing to know the secret key of
+ * the other party, but both parties must know their own secret to derive the correct
+ * shared secret. Third party Eve sneaking around in the bushes cannot compute the
+ * shared secret without knowing at least one of the secrets.
+ * </p>
+ * <p>
+ * Performs RFC 7748-recommended (and RFC 9180-mandated) check for "non-contributory"
+ * private keys by checking if the resulting shared secret comprises all zero bytes.
+ * </p>
+ *
+ * @param privateKey X25519 private key
+ * @param publicKey X25519 public key
+ * @return shared Diffie-Hellman secret. Security note: this value should never be
+ * used <em>directly</em> as a key; use a key derivation function (KDF).
+ *
+ * @see <a href="https://www.rfc-editor.org/rfc/rfc7748">RFC 7748 Elliptic Curves for Security</a>
+ * @see <a href="https://www.rfc-editor.org/rfc/rfc9180.html">RFC 9180 Hybrid Public Key Encryption</a>
+ * @see <a href="https://en.wikipedia.org/wiki/Elliptic-curve_Diffie%E2%80%93Hellman">ECDH on wiki</a>
+ */
+ public static byte[] ecdh(XECPrivateKey privateKey, XECPublicKey publicKey) {
+ try {
+ var keyAgreement = KeyAgreement.getInstance("XDH");
+ keyAgreement.init(privateKey);
+ keyAgreement.doPhase(publicKey, true);
+ byte[] sharedSecret = keyAgreement.generateSecret();
+ // RFC 7748 recommends checking that the shared secret is not all zero bytes.
+ // Furthermore, RFC 9180 states "For X25519 and X448, public keys and Diffie-Hellman
+ // outputs MUST be validated as described in [RFC7748]".
+ // Usually we won't get here at all since Java will throw an InvalidKeyException
+ // from detecting a key with a low order point. But in case we _do_ get here, fail fast.
+ if (SideChannelSafe.allZeros(sharedSecret)) {
+ throw new IllegalArgumentException("Computed shared secret is all zeroes");
+ }
+ return sharedSecret;
+ } catch (NoSuchAlgorithmException | InvalidKeyException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
}
diff --git a/security-utils/src/main/java/com/yahoo/security/hpke/Aead.java b/security-utils/src/main/java/com/yahoo/security/hpke/Aead.java
new file mode 100644
index 00000000000..48662d97bb3
--- /dev/null
+++ b/security-utils/src/main/java/com/yahoo/security/hpke/Aead.java
@@ -0,0 +1,42 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.security.hpke;
+
+/**
+ * Authenticated encryption with associated data (AEAD)
+ *
+ * @author vekterli
+ */
+public interface Aead {
+
+ /**
+ * @param key Symmetric key bytes for encryption
+ * @param nonce Nonce to use for the encryption
+ * @param aad Associated authenticated data that will <em>not</em> be encrypted
+ * @param pt Plaintext to seal
+ * @return resulting ciphertext
+ */
+ byte[] seal(byte[] key, byte[] nonce, byte[] aad, byte[] pt);
+
+ /**
+ * @param key Symmetric key bytes for decryption
+ * @param nonce Nonce to use for the decryption
+ * @param aad Associated authenticated data to verify
+ * @param ct ciphertext to decrypt
+ * @return resulting plaintext
+ */
+ byte[] open(byte[] key, byte[] nonce, byte[] aad, byte[] ct);
+
+ /** The length in bytes of a key for this algorithm. */
+ short nK();
+ /** The length in bytes of a nonce for this algorithm. */
+ short nN();
+ /** The length in bytes of the authentication tag for this algorithm. */
+ short nT();
+ /** Predefined AEAD ID, as given in RFC 9180 section 7.3 */
+ short aeadId();
+
+ static Aead aesGcm128() {
+ return AesGcm128.getInstance();
+ }
+
+}
diff --git a/security-utils/src/main/java/com/yahoo/security/hpke/AesGcm128.java b/security-utils/src/main/java/com/yahoo/security/hpke/AesGcm128.java
new file mode 100644
index 00000000000..9ccc7d51de9
--- /dev/null
+++ b/security-utils/src/main/java/com/yahoo/security/hpke/AesGcm128.java
@@ -0,0 +1,64 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.security.hpke;
+
+import javax.crypto.BadPaddingException;
+import javax.crypto.Cipher;
+import javax.crypto.IllegalBlockSizeException;
+import javax.crypto.NoSuchPaddingException;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+
+/**
+ * AES-128 GCM implementation of AEAD
+ *
+ * @author vekterli
+ */
+final class AesGcm128 implements Aead {
+
+ private static final String AEAD_CIPHER_SPEC = "AES/GCM/NoPadding";
+
+ private static final AesGcm128 INSTANCE = new AesGcm128();
+
+ public static AesGcm128 getInstance() { return INSTANCE; }
+
+ /**
+ * @param key Symmetric key bytes for encryption/decryption
+ * @param nonce Nonce to use for the encryption/decrytion
+ * @param aad Associated authenticated data that will <em>not</em> be encrypted
+ * @param text Plaintext to seal or ciphertext to open, depending on cipherMode
+ * @return resulting ciphertext or plaintext, depending on cipherMode
+ */
+ private byte[] aeadImpl(int cipherMode, byte[] key, byte[] nonce, byte[] aad, byte[] text) {
+ try {
+ var cipher = Cipher.getInstance(AEAD_CIPHER_SPEC);
+ var gcmSpec = new GCMParameterSpec(nT() * 8/* in bits */, nonce);
+ var aesKey = new SecretKeySpec(key, "AES");
+ cipher.init(cipherMode, aesKey, gcmSpec);
+ cipher.updateAAD(aad);
+ return cipher.doFinal(text);
+ } catch (NoSuchAlgorithmException | NoSuchPaddingException
+ | InvalidKeyException | InvalidAlgorithmParameterException
+ | IllegalBlockSizeException | BadPaddingException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ public byte[] seal(byte[] key, byte[] nonce, byte[] aad, byte[] pt) {
+ return aeadImpl(Cipher.ENCRYPT_MODE, key, nonce, aad, pt);
+ }
+
+ @Override
+ public byte[] open(byte[] key, byte[] nonce, byte[] aad, byte[] ct) {
+ return aeadImpl(Cipher.DECRYPT_MODE, key, nonce, aad, ct);
+ }
+
+ @Override public short nK() { return 16; } // 128-bit key
+ @Override public short nN() { return 12; } // 96-bit IV
+ @Override public short nT() { return 16; } // 128-bit auth tag
+ @Override public short aeadId() { return 0x0001; } // AES-128-GCM
+
+}
diff --git a/security-utils/src/main/java/com/yahoo/security/hpke/Ciphersuite.java b/security-utils/src/main/java/com/yahoo/security/hpke/Ciphersuite.java
new file mode 100644
index 00000000000..f18753c810e
--- /dev/null
+++ b/security-utils/src/main/java/com/yahoo/security/hpke/Ciphersuite.java
@@ -0,0 +1,28 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.security.hpke;
+
+/**
+ * A Ciphersuite is a 3-tuple that encapsulates the necessary primitives to use HKDF:
+ *
+ * <ul>
+ * <li>A key encapsulation mechanism (KEM)</li>
+ * <li>A key derivation function (KDF)</li>
+ * <li>An "authenticated encryption with associated data" (AEAD) algorithm</li>
+ * </ul>
+ *
+ * @author vekterli
+ */
+public record Ciphersuite(Kem kem, Kdf kdf, Aead aead) {
+
+ public static Ciphersuite of(Kem kem, Kdf kdf, Aead aead) {
+ return new Ciphersuite(kem, kdf, aead);
+ }
+
+ /**
+ * Returns a Ciphersuite of DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, AES-128-GCM
+ */
+ public static Ciphersuite defaultSuite() {
+ return Ciphersuite.of(Kem.dHKemX25519HkdfSha256(), Kdf.hkdfSha256(), Aead.aesGcm128());
+ }
+
+}
diff --git a/security-utils/src/main/java/com/yahoo/security/hpke/Constants.java b/security-utils/src/main/java/com/yahoo/security/hpke/Constants.java
new file mode 100644
index 00000000000..bfd810c10d2
--- /dev/null
+++ b/security-utils/src/main/java/com/yahoo/security/hpke/Constants.java
@@ -0,0 +1,38 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.security.hpke;
+
+import com.yahoo.security.ArrayUtils;
+
+/**
+ * Various internal constants used as part of key derivation etc. in HPKE
+ *
+ * @author vekterli
+ */
+final class Constants {
+
+ private static byte[] toBytes(String str) {
+ return ArrayUtils.toUtf8Bytes(str); // We only expect US-ASCII in practice, so UTF-8 is fine.
+ }
+
+ static final byte[] HPKE_V1_LABEL = toBytes("HPKE-v1");
+ static final byte[] EMPTY_LABEL = new byte[0];
+ static final byte[] EAE_PRK_LABEL = toBytes("eae_prk");
+ static final byte[] SHARED_SECRET_LABEL = toBytes("shared_secret");
+
+ /**
+ * <pre>
+ * default_psk = ""
+ * default_psk_id = ""
+ * </pre>
+ */
+ static final byte[] DEFAULT_PSK = new byte[0];
+ static final byte[] DEFAULT_PSK_ID = new byte[0];
+
+ static final byte[] PSK_ID_HASH_LABEL = toBytes("psk_id_hash");
+ static final byte[] INFO_HASH_LABEL = toBytes("info_hash");
+ static final byte[] SECRET_LABEL = toBytes("secret");
+ static final byte[] KEY_LABEL = toBytes("key");
+ static final byte[] BASE_NONCE_LABEL = toBytes("base_nonce");
+ static final byte[] EXP_LABEL = toBytes("exp");
+
+}
diff --git a/security-utils/src/main/java/com/yahoo/security/hpke/DHKemX25519HkdfSha256.java b/security-utils/src/main/java/com/yahoo/security/hpke/DHKemX25519HkdfSha256.java
new file mode 100644
index 00000000000..430a9d57097
--- /dev/null
+++ b/security-utils/src/main/java/com/yahoo/security/hpke/DHKemX25519HkdfSha256.java
@@ -0,0 +1,138 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.security.hpke;
+
+import com.yahoo.security.KeyUtils;
+
+import java.security.KeyPair;
+import java.security.interfaces.XECPrivateKey;
+import java.security.interfaces.XECPublicKey;
+import java.util.function.Supplier;
+
+import static com.yahoo.security.ArrayUtils.concat;
+import static com.yahoo.security.hpke.Constants.EAE_PRK_LABEL;
+import static com.yahoo.security.hpke.Constants.EMPTY_LABEL;
+import static com.yahoo.security.hpke.Constants.SHARED_SECRET_LABEL;
+import static com.yahoo.security.hpke.LabeledKdfUtils.labeledExpandForSuite;
+import static com.yahoo.security.hpke.LabeledKdfUtils.labeledExtractForSuite;
+
+/**
+ * KEM implementation using Diffie-Hellman over X25519 curves as the shared
+ * secret deriver and HKDF-SHA256 as its key derivation function.
+ *
+ * HPKE KEM spec: DHKEM(X25519, HKDF-SHA256)
+ *
+ * @author vekterli
+ */
+final class DHKemX25519HkdfSha256 implements Kem {
+
+ private static final HkdfSha256 HKDF = HkdfSha256.getInstance();
+
+ private final Supplier<KeyPair> keyPairGen;
+
+ DHKemX25519HkdfSha256(Supplier<KeyPair> keyPairGen) {
+ this.keyPairGen = keyPairGen;
+ }
+
+ // Section 4.1 DH-Based KEM (DHKEM):
+ // "The implicit suite_id value used within LabeledExtract and LabeledExpand
+ // is defined as follows, where kem_id is defined in Section 7.1:"
+ //
+ // suite_id = concat("KEM", I2OSP(kem_id, 2))"
+ //
+ // The ID of our KEM suite, DHKEM(X25519, HKDF-SHA256), is 0x0020, so just hard code this.
+ private static final byte[] DHKEM_SUITE_ID_LABEL = new byte[] { 'K','E','M', 0x00, 0x20 };
+
+ @Override public short nSecret() { return 32; }
+ @Override public short nEnc() { return 32; }
+ @Override public short nPk() { return 32; }
+ @Override public short nSk() { return 32; }
+ @Override public short kemId() { return 0x0020; }
+
+ private static byte[] serializePublicKey(XECPublicKey publicKey) {
+ return KeyUtils.toRawX25519PublicKeyBytes(publicKey);
+ }
+
+ private static XECPublicKey deserializePublicKey(byte[] enc) {
+ return KeyUtils.fromRawX25519PublicKey(enc);
+ }
+
+ /**
+ * Section 4.1 DH-Based KEM (DHKEM):
+ *
+ * <pre>
+ * def ExtractAndExpand(dh, kem_context):
+ * eae_prk = LabeledExtract("", "eae_prk", dh)
+ * shared_secret = LabeledExpand(eae_prk, "shared_secret",
+ * kem_context, Nsecret)
+ * return shared_secret
+ * </pre>
+ */
+ private byte[] extractAndExpand(byte[] dh, byte[] kemContext) {
+ byte[] eaePrk = labeledExtractForSuite(HKDF, DHKEM_SUITE_ID_LABEL, EMPTY_LABEL, EAE_PRK_LABEL, dh);
+ return labeledExpandForSuite(HKDF, eaePrk, DHKEM_SUITE_ID_LABEL, SHARED_SECRET_LABEL, kemContext, nSecret());
+ }
+
+ /**
+ * Section 4.1 DH-Based KEM (DHKEM):
+ *
+ * <pre>
+ * def Encap(pkR):
+ * skE, pkE = GenerateKeyPair()
+ * dh = DH(skE, pkR)
+ * enc = SerializePublicKey(pkE)
+ *
+ * pkRm = SerializePublicKey(pkR)
+ * kem_context = concat(enc, pkRm)
+ *
+ * shared_secret = ExtractAndExpand(dh, kem_context)
+ * return shared_secret, enc
+ * </pre>
+ */
+ @Override
+ public EncapResult encap(XECPublicKey pkR) {
+ var kpE = keyPairGen.get();
+ var skE = (XECPrivateKey)kpE.getPrivate();
+ var pkE = (XECPublicKey)kpE.getPublic();
+
+ byte[] dh = KeyUtils.ecdh(skE, pkR);
+ byte[] enc = serializePublicKey(pkE);
+
+ byte[] pkRm = serializePublicKey(pkR);
+ byte[] kemContext = concat(enc, pkRm);
+
+ byte[] sharedSecret = extractAndExpand(dh, kemContext);
+ return new EncapResult(sharedSecret, enc);
+ }
+
+ /**
+ * Section 4.1 DH-Based KEM (DHKEM):
+ *
+ * <pre>
+ * def Decap(enc, skR):
+ * pkE = DeserializePublicKey(enc)
+ * dh = DH(skR, pkE)
+ *
+ * pkRm = SerializePublicKey(pk(skR))
+ * kem_context = concat(enc, pkRm)
+ *
+ * shared_secret = ExtractAndExpand(dh, kem_context)
+ * return shared_secret
+ * </pre>
+ *
+ * Implementation note: we take in the key pair to avoid needing to compute the public key (TODO!)
+ */
+ @Override
+ public byte[] decap(byte[] enc, KeyPair kpR) {
+ var pkE = deserializePublicKey(enc);
+
+ var skR = (XECPrivateKey)kpR.getPrivate();
+ var pkR = (XECPublicKey)kpR.getPublic();
+ byte[] dh = KeyUtils.ecdh(skR, pkE);
+
+ byte[] pkRm = serializePublicKey(pkR);
+ byte[] kemContext = concat(enc, pkRm);
+
+ return extractAndExpand(dh, kemContext);
+ }
+
+}
diff --git a/security-utils/src/main/java/com/yahoo/security/hpke/HkdfSha256.java b/security-utils/src/main/java/com/yahoo/security/hpke/HkdfSha256.java
new file mode 100644
index 00000000000..a4511a2b804
--- /dev/null
+++ b/security-utils/src/main/java/com/yahoo/security/hpke/HkdfSha256.java
@@ -0,0 +1,32 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.security.hpke;
+
+import com.yahoo.security.HKDF;
+
+/**
+ * KDF implementation using HKDF-SHA256
+ *
+ * @author vekterli
+ */
+final class HkdfSha256 implements Kdf {
+
+ private static final HkdfSha256 INSTANCE = new HkdfSha256();
+
+ public static HkdfSha256 getInstance() { return INSTANCE; }
+
+ @Override
+ public byte[] extract(byte[] salt, byte[] labeledIkm) {
+ return ((salt.length != 0) ? HKDF.extractedFrom(salt, labeledIkm)
+ : HKDF.unsaltedExtractedFrom(labeledIkm))
+ .pseudoRandomKey();
+ }
+
+ @Override
+ public byte[] expand(byte[] prk, byte[] info, int nBytesToExpand) {
+ return HKDF.ofPseudoRandomKey(prk).expand(nBytesToExpand, info);
+ }
+
+ @Override public short nH() { return 32; } // HMAC-SHA256 output size
+ @Override public short kdfId() { return 0x0001; } // HKDF-SHA256
+
+}
diff --git a/security-utils/src/main/java/com/yahoo/security/hpke/Hpke.java b/security-utils/src/main/java/com/yahoo/security/hpke/Hpke.java
new file mode 100644
index 00000000000..e3f233285a8
--- /dev/null
+++ b/security-utils/src/main/java/com/yahoo/security/hpke/Hpke.java
@@ -0,0 +1,320 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.security.hpke;
+
+import java.security.KeyPair;
+import java.security.interfaces.XECPublicKey;
+import java.util.Arrays;
+
+import static com.yahoo.security.ArrayUtils.concat;
+import static com.yahoo.security.hpke.Constants.BASE_NONCE_LABEL;
+import static com.yahoo.security.hpke.Constants.DEFAULT_PSK;
+import static com.yahoo.security.hpke.Constants.DEFAULT_PSK_ID;
+import static com.yahoo.security.hpke.Constants.EMPTY_LABEL;
+import static com.yahoo.security.hpke.Constants.EXP_LABEL;
+import static com.yahoo.security.hpke.Constants.INFO_HASH_LABEL;
+import static com.yahoo.security.hpke.Constants.KEY_LABEL;
+import static com.yahoo.security.hpke.Constants.PSK_ID_HASH_LABEL;
+import static com.yahoo.security.hpke.Constants.SECRET_LABEL;
+import static com.yahoo.security.hpke.LabeledKdfUtils.i2osp2;
+import static com.yahoo.security.hpke.LabeledKdfUtils.labeledExpandForSuite;
+import static com.yahoo.security.hpke.LabeledKdfUtils.labeledExtractForSuite;
+
+/**
+ * Restricted subset implementation of RFC 9180 Hybrid Public Key Encryption (HPKE)
+ * <p>
+ * HPKE is an encryption scheme that builds around three primitives:
+ * </p>
+ * <ul>
+ * <li>A key encapsulation mechanism (KEM)</li>
+ * <li>A key derivation function (KDF)</li>
+ * <li>An "authenticated encryption with associated data" (AEAD) algorithm</li>
+ * </ul>
+ * <p>
+ * The 3-tuple (KEM, KDF, AEAD) is known as the HPKE <em>ciphersuite</em>.
+ * </p>
+ * <p>
+ * This implementation has certain (intentional) limitations:
+ * </p>
+ * <ul>
+ * <li>Only the <code>DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, AES-128-GCM</code> ciphersuite is
+ * implemented. This is expected to be a good default choice for any internal use of this class.</li>
+ * <li>Only the "base mode" (unauthenticated sender) is supported, i.e. no PSK support and no
+ * secret exporting. This implementation is only expected to be used for anonymous one-way
+ * encryption.</li>
+ * <li>The API only offers single-shot encryption to keep anyone from being tempted to
+ * use it to build their own multi-message protocol on top. This entirely avoids the
+ * risk of nonce reuse caused by accidentally repeating sequence numbers.</li>
+ * </ul>
+ * <p>
+ * <em>Deprecation notice:</em> once BouncyCastle (or the Java crypto API) supports HPKE, this
+ * particular implementation can safely be deprecated and sent off to live on a farm.
+ * </p>
+ *
+ * @see <a href="https://www.rfc-editor.org/rfc/rfc9180.html">RFC 9180 Hybrid Public Key Encryption</a>
+ *
+ * @author vekterli
+ *
+ */
+public final class Hpke {
+
+ private final Kem kem;
+ private final Kdf kdf;
+ private final Aead aead;
+ private final byte[] hpkeSuiteId;
+
+ private Hpke(Ciphersuite ciphersuite) {
+ this.kem = ciphersuite.kem();
+ this.kdf = ciphersuite.kdf();
+ this.aead = ciphersuite.aead();
+ this.hpkeSuiteId = makeHpkeSuiteId();
+ }
+
+ public static Hpke of(Ciphersuite ciphersuite) {
+ return new Hpke(ciphersuite);
+ }
+
+ /**
+ * Section 5.1 Creating the Encryption Context:
+ *
+ * HPKE implicit suite_id (this differs from the KEM suite id):
+ *
+ * <pre>
+ * suite_id = concat(
+ * "HPKE",
+ * I2OSP(kem_id, 2),
+ * I2OSP(kdf_id, 2),
+ * I2OSP(aead_id, 2)
+ * )
+ * </pre>
+ */
+ private byte[] makeHpkeSuiteId() {
+ byte[] hpkePrefix = new byte[] { 'H','P','K','E' };
+ return concat(hpkePrefix, i2osp2(kem.kemId()), i2osp2(kdf.kdfId()), i2osp2(aead.aeadId()));
+ }
+
+ byte[] labeledExtractHpke(byte[] salt, byte[] label, byte[] ikm) {
+ return labeledExtractForSuite(kdf, hpkeSuiteId, salt, label, ikm);
+ }
+
+ byte[] labeledExpandHpke(byte[] prk, byte[] label, byte[] info, int nBytesToExpand/*L*/) {
+ return labeledExpandForSuite(kdf, prk, hpkeSuiteId, label, info, nBytesToExpand);
+ }
+
+ /*
+ * HPKE supports several modes, where all but the first one are sender-authenticated:
+ *
+ * Mode Value
+ * mode_base 0x00
+ * mode_psk 0x01
+ * mode_auth 0x02
+ * mode_auth_psk 0x03
+ *
+ * We only support mode_base, as our primary use case is encryption where the sender is
+ * not authenticated.
+ */
+ private static final byte MODE_BASE = 0x00;
+ private static final byte MODE_PSK = 0x01;
+ private static final byte MODE_AUTH = 0x02;
+ private static final byte MODE_AUTH_PSK = 0x03;
+
+ /**
+ * Section 5.1 Creating the Encryption Context:
+ *
+ * <pre>
+ * def VerifyPSKInputs(mode, psk, psk_id):
+ * got_psk = (psk != default_psk)
+ * got_psk_id = (psk_id != default_psk_id)
+ * if got_psk != got_psk_id:
+ * raise Exception("Inconsistent PSK inputs")
+ *
+ * if got_psk and (mode in [mode_base, mode_auth]):
+ * raise Exception("PSK input provided when not needed")
+ * if (not got_psk) and (mode in [mode_psk, mode_auth_psk]):
+ * raise Exception("Missing required PSK input")
+ * </pre>
+ *
+ * Even though we don't support PSK, we implement this method fully for the sake of conformance.
+ */
+ static void verifyPskInputs(byte mode, byte[] psk, byte[] pskId) {
+ boolean gotPsk = !Arrays.equals(psk, DEFAULT_PSK);
+ boolean gotPskId = !Arrays.equals(pskId, DEFAULT_PSK_ID);
+ if (gotPsk != gotPskId) {
+ throw new IllegalArgumentException("Inconsistent PSK inputs");
+ }
+ if (gotPsk && (mode == MODE_BASE || mode == MODE_AUTH)) {
+ throw new IllegalArgumentException("PSK input provided when not needed");
+ }
+ if (!gotPsk && (mode == MODE_PSK || mode == MODE_AUTH_PSK)) {
+ throw new IllegalArgumentException("Missing required PSK input");
+ }
+ }
+
+ /**
+ * Section 7.2.1 Input Length Restrictions:
+ *
+ * "The RECOMMENDED limit for these values(*) is 64 bytes. This would enable interoperability
+ * with implementations that statically allocate memory for these inputs to avoid memory allocations."
+ *
+ * (*) psk, pskId, info in our use case
+ */
+ private static final int MAX_INPUT_LENGTH = 64;
+
+ static void verifyInputLengthRestrictions(byte[] psk, byte[] pskId, byte[] info) {
+ if (psk.length > MAX_INPUT_LENGTH) {
+ throw new IllegalArgumentException("Input PSK length (%d) greater than max length (%d)"
+ .formatted(psk.length, MAX_INPUT_LENGTH));
+ }
+ if (pskId.length > MAX_INPUT_LENGTH) {
+ throw new IllegalArgumentException("Input PSK ID length (%d) greater than max length (%d)"
+ .formatted(pskId.length, MAX_INPUT_LENGTH));
+ }
+ if (info.length > MAX_INPUT_LENGTH) {
+ throw new IllegalArgumentException("Input info length (%d) greater than max length (%d)"
+ .formatted(info.length, MAX_INPUT_LENGTH));
+ }
+ }
+
+ private static record ContextBase(byte[] key, byte[] nonce, long seqNum, byte[] exporterSecret) { }
+
+ /**
+ * Section 5.1 Creating the Encryption Context:
+ *
+ * <pre>
+ * def KeySchedule&lt;ROLE&gt;(mode, shared_secret, info, psk, psk_id):
+ * VerifyPSKInputs(mode, psk, psk_id)
+ *
+ * psk_id_hash = LabeledExtract("", "psk_id_hash", psk_id)
+ * info_hash = LabeledExtract("", "info_hash", info)
+ * key_schedule_context = concat(mode, psk_id_hash, info_hash)
+ *
+ * secret = LabeledExtract(shared_secret, "secret", psk)
+ *
+ * key = LabeledExpand(secret, "key", key_schedule_context, Nk)
+ * base_nonce = LabeledExpand(secret, "base_nonce",
+ * key_schedule_context, Nn)
+ * exporter_secret = LabeledExpand(secret, "exp",
+ * key_schedule_context, Nh)
+ *
+ * return Context&lt;ROLE&gt;(key, base_nonce, 0, exporter_secret)
+ * </pre>
+ *
+ * Note: Labeled*-functions above implicitly include the HPKE suite_id. We do it explicitly.
+ * We also throw in an input length check as recommended in Section 7.2.1.
+ */
+ ContextBase keySchedule(byte mode, byte[] sharedSecret, byte[] info, byte[] psk, byte[] pskId) {
+ verifyPskInputs(mode, psk, pskId);
+ verifyInputLengthRestrictions(psk, pskId, info);
+
+ byte[] pskIdHash = labeledExtractHpke(EMPTY_LABEL, PSK_ID_HASH_LABEL, pskId); // Kdf.nH() bytes returned
+ byte[] infoHash = labeledExtractHpke(EMPTY_LABEL, INFO_HASH_LABEL, info );
+ byte[] keyScheduleContext = concat(new byte[]{mode}, pskIdHash, infoHash);
+
+ byte[] secret = labeledExtractHpke(sharedSecret, SECRET_LABEL, psk);
+
+ byte[] key = labeledExpandHpke(secret, KEY_LABEL, keyScheduleContext, aead.nK());
+ byte[] baseNonce = labeledExpandHpke(secret, BASE_NONCE_LABEL, keyScheduleContext, aead.nN());
+ byte[] exporterSecret = labeledExpandHpke(secret, EXP_LABEL, keyScheduleContext, kdf.nH());
+
+ return new ContextBase(key, baseNonce, 0, exporterSecret);
+ }
+
+ private static record ContextS(byte[] enc, ContextBase base) {}
+ private static record ContextR(ContextBase base) {}
+
+ /**
+ * Section 5.1.1 Encryption to a Public Key:
+ *
+ * <pre>
+ * def SetupBaseS(pkR, info):
+ * shared_secret, enc = Encap(pkR)
+ * return enc, KeyScheduleS(mode_base, shared_secret, info,
+ * default_psk, default_psk_id)
+ * </pre>
+ */
+ ContextS setupBaseS(XECPublicKey pkR, byte[] info) {
+ var encapped = kem.encap(pkR);
+ return new ContextS(encapped.enc(),
+ keySchedule(MODE_BASE, encapped.sharedSecret(), info, DEFAULT_PSK, DEFAULT_PSK_ID));
+ }
+
+ /**
+ * Section 5.1.1 Encryption to a Public Key:
+ *
+ * <pre>
+ * def SetupBaseR(enc, skR, info):
+ * shared_secret = Decap(enc, skR)
+ * return KeyScheduleR(mode_base, shared_secret, info,
+ * default_psk, default_psk_id)
+ * </pre>
+ *
+ * TODO only take private key, not key pair. Need functionality for X25519 priv -> pub extraction first.
+ */
+ ContextR setupBaseR(byte[] enc, KeyPair kpR, byte[] info) {
+ byte[] sharedSecret = kem.decap(enc, kpR);
+ return new ContextR(keySchedule(MODE_BASE, sharedSecret, info, DEFAULT_PSK, DEFAULT_PSK_ID));
+ }
+
+ public static record Sealed(byte[] enc, byte[] ciphertext) {}
+
+ /**
+ * Section 6.1 Encryption and Decryption:
+ *
+ * <pre>
+ * def Seal&lt;MODE&gt;(pkR, info, aad, pt, ...):
+ * enc, ctx = Setup&lt;MODE&gt;S(pkR, info, ...)
+ * ct = ctx.Seal(aad, pt)
+ * return enc, ct
+ * </pre>
+ *
+ * Section 5.2 Encryption and Decryption:
+ *
+ * Since we only support single-shot encryption we collapse ContextS.Seal into the
+ * parent SealBASE, since we don't have to track sequence numbers. This means
+ * ComputeNonce is a no-op since the first sequence number is 0 which will always
+ * XOR to the same nonce.
+ *
+ * <pre>
+ * def ContextS.Seal(aad, pt):
+ * ct = Seal(self.key, self.ComputeNonce(self.seq), aad, pt)
+ * self.IncrementSeq()
+ * return ct
+ * </pre>
+ */
+ public Sealed sealBase(XECPublicKey pkR, byte[] info, byte[] aad, byte[] pt) {
+ var encAndCtx = setupBaseS(pkR, info);
+ var base = encAndCtx.base;
+ byte[] ct = aead.seal(base.key(), base.nonce(), aad, pt);
+ return new Sealed(encAndCtx.enc, ct);
+ }
+
+ /**
+ * Section 6.1 Encryption and Decryption:
+ *
+ * <pre>
+ * def Open&lt;MODE&gt;(enc, skR, info, aad, ct, ...):
+ * ctx = Setup&lt;MODE&gt;R(enc, skR, info, ...)
+ * return ctx.Open(aad, ct)
+ * </pre>
+ *
+ * Section 5.2 Encryption and Decryption:
+ *
+ * Since we only support single-shot decryption we collapse ContextR.Open into the
+ * parent OpenBASE, since we don't have to track sequence numbers. See also: sealBase()
+ *
+ * <pre>
+ * def ContextR.Open(aad, ct):
+ * pt = Open(self.key, self.ComputeNonce(self.seq), aad, ct)
+ * if pt == OpenError:
+ * raise OpenError
+ * self.IncrementSeq()
+ * return pt
+ * </pre>
+ */
+ public byte[] openBase(byte[] enc, KeyPair kpR, byte[] info, byte[] aad, byte[] ct) {
+ var ctx = setupBaseR(enc, kpR, info);
+ var base = ctx.base;
+ // TODO wrap any exceptions in OpenError et al?
+ return aead.open(base.key(), base.nonce(), aad, ct);
+ }
+
+}
diff --git a/security-utils/src/main/java/com/yahoo/security/hpke/Kdf.java b/security-utils/src/main/java/com/yahoo/security/hpke/Kdf.java
new file mode 100644
index 00000000000..7167a5f33ce
--- /dev/null
+++ b/security-utils/src/main/java/com/yahoo/security/hpke/Kdf.java
@@ -0,0 +1,43 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.security.hpke;
+
+/**
+ * Key derivation function (KDF)
+ *
+ * @author vekterli
+ */
+public interface Kdf {
+
+ /**
+ * Extract a pseudorandom key of fixed length {@link #nH()} bytes from input keying material
+ * <code>ikm</code> and an optional byte string <code>salt</code>.
+ *
+ * @param salt non-secret salt used as input to KDF
+ * @param labeledIkm secret input keying material
+ * @return nH bytes of PRK data
+ */
+ byte[] extract(byte[] salt, byte[] labeledIkm);
+
+ /**
+ * Expand a pseudorandom key <code>prk</code> using optional string <code>info</code> into
+ * <code>nBytesToExpand</code> bytes of output keying material.
+ *
+ * @param prk pseudo random key previously gotten from a call to extract.
+ * @param info contextual info for expansion; useful for key domain separation
+ * @param nBytesToExpand number of bytes to return
+ *
+ * @return <code>nBytesToExpand</code> bytes of output keying material.
+ */
+ byte[] expand(byte[] prk, byte[] info, int nBytesToExpand);
+
+ /** Output size of the extract() function in bytes */
+ short nH();
+
+ /** Predefined KDF ID, as given in RFC 9180 section 7.2 */
+ short kdfId();
+
+ static Kdf hkdfSha256() {
+ return HkdfSha256.getInstance();
+ }
+
+}
diff --git a/security-utils/src/main/java/com/yahoo/security/hpke/Kem.java b/security-utils/src/main/java/com/yahoo/security/hpke/Kem.java
new file mode 100644
index 00000000000..7bbb2df0960
--- /dev/null
+++ b/security-utils/src/main/java/com/yahoo/security/hpke/Kem.java
@@ -0,0 +1,69 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.security.hpke;
+
+import com.yahoo.security.KeyUtils;
+
+import java.security.KeyPair;
+import java.security.interfaces.XECPublicKey;
+
+/**
+ * Key encapsulation mechanism (KEM)
+ *
+ * @author vekterli
+ */
+public interface Kem {
+
+ record EncapResult(byte[] sharedSecret, byte[] enc) { }
+
+ /**
+ * Section 4 Cryptographic Dependencies:
+ *
+ * "Randomized algorithm to generate an ephemeral, fixed-length symmetric key
+ * (the KEM shared secret) and a fixed-length encapsulation of that key that can
+ * be decapsulated by the holder of the private key corresponding to <code>pkR</code>"
+ */
+ EncapResult encap(XECPublicKey pkR);
+
+ /**
+ * Section 4 Cryptographic Dependencies:
+ *
+ * "Deterministic algorithm using the private key <code>skR</code> to recover the
+ * ephemeral symmetric key (the KEM shared secret) from its encapsulated
+ * representation <code>enc</code>."
+ *
+ * TODO just take skR instead of entire key pair
+ */
+ byte[] decap(byte[] enc, KeyPair kpR);
+
+ /** The length in bytes of a KEM shared secret produced by this KEM. */
+ short nSecret();
+ /** The length in bytes of an encapsulated key produced by this KEM. */
+ short nEnc();
+ /** The length in bytes of an encoded public key for this KEM. */
+ short nPk();
+ /** The length in bytes of an encoded private key for this KEM. */
+ short nSk();
+ /** Predefined KEM ID, as given in RFC 9180 section 7.1 */
+ short kemId();
+
+ /**
+ * @return a <code>HKEM(X25519, HKDF-SHA256)</code> instance that generates new ephemeral X25519
+ * key pairs from a secure random source per {@link #encap(XECPublicKey)} invocation.
+ */
+ static Kem dHKemX25519HkdfSha256() {
+ return new DHKemX25519HkdfSha256(KeyUtils::generateX25519KeyPair);
+ }
+
+ record UnsafeDeterminsticKeyPairOnlyUsedByTesting(KeyPair keyPair) {}
+
+ /**
+ * Returns an unsafe test KEM that returns a single fixed, deterministic key pair.
+ *
+ * As the name implies, this must only ever be used in the context of testing. If anyone tries
+ * to be clever and use this anywhere else, I will find them and bite them in the ankles!
+ */
+ static Kem dHKemX25519HkdfSha256(UnsafeDeterminsticKeyPairOnlyUsedByTesting testingKP) {
+ return new DHKemX25519HkdfSha256(() -> testingKP.keyPair);
+ }
+
+}
diff --git a/security-utils/src/main/java/com/yahoo/security/hpke/LabeledKdfUtils.java b/security-utils/src/main/java/com/yahoo/security/hpke/LabeledKdfUtils.java
new file mode 100644
index 00000000000..69f465a7314
--- /dev/null
+++ b/security-utils/src/main/java/com/yahoo/security/hpke/LabeledKdfUtils.java
@@ -0,0 +1,61 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.security.hpke;
+
+import static com.yahoo.security.ArrayUtils.concat;
+import static com.yahoo.security.hpke.Constants.HPKE_V1_LABEL;
+
+/**
+ * Utilities for labeled KDF expand/extract used by both DHKEM and HPKE
+ *
+ * @author vekterli
+ */
+class LabeledKdfUtils {
+
+ /**
+ * Section 4 Cryptographic Dependencies:
+ *
+ * <pre>
+ * def LabeledExtract(salt, label, ikm):
+ * labeled_ikm = concat("HPKE-v1", suite_id, label, ikm)
+ * return Extract(salt, labeled_ikm)
+ * </pre>
+ *
+ * We take in the KDF and suite ID explicitly, to allow method reuse between KEM and HPKE.
+ */
+ static byte[] labeledExtractForSuite(Kdf kdf, byte[] suiteId, byte[] salt, byte[] label, byte[] ikm) {
+ byte[] labeledIkm = concat(HPKE_V1_LABEL, suiteId, label, ikm);
+ return kdf.extract(salt, labeledIkm);
+ }
+
+ /**
+ * Section 4 Cryptographic Dependencies:
+ *
+ * <pre>
+ * def LabeledExpand(prk, label, info, L):
+ * labeled_info = concat(I2OSP(L, 2), "HPKE-v1", suite_id,
+ * label, info)
+ * return Expand(prk, labeled_info, L)
+ * </pre>
+ *
+ * We take in the KDF and suite ID explicitly, to allow method reuse between KEM and HPKE.
+ */
+ static byte[] labeledExpandForSuite(Kdf kdf, byte[] prk, byte[] suiteId, byte[] label, byte[] info, int nBytesToExpand/*L*/) {
+ byte[] labeledInfo = concat(i2osp2((short)nBytesToExpand), HPKE_V1_LABEL, suiteId, label, info);
+ return kdf.expand(prk, labeledInfo, nBytesToExpand);
+ }
+
+ /**
+ * <pre>
+ * I2OSP(n, w):
+ * Convert non-negative integer n to a w-length, big-endian byte string,
+ * as described in [RFC8017].
+ * </pre>
+ *
+ * We provide a simple <code>2OSP(n, 2)</code> specialization since we don't need to
+ * encode arbitrary BigIntegers for labels.
+ */
+ static byte[] i2osp2(short v) {
+ return new byte[] { (byte)(v >>> 8), (byte)(v & 0xff) };
+ }
+
+}
diff --git a/security-utils/src/test/java/com/yahoo/security/HpkeTest.java b/security-utils/src/test/java/com/yahoo/security/HpkeTest.java
new file mode 100644
index 00000000000..24944759c5c
--- /dev/null
+++ b/security-utils/src/test/java/com/yahoo/security/HpkeTest.java
@@ -0,0 +1,109 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.security;
+
+import com.yahoo.security.hpke.Aead;
+import com.yahoo.security.hpke.Ciphersuite;
+import com.yahoo.security.hpke.Hpke;
+import com.yahoo.security.hpke.Kdf;
+import com.yahoo.security.hpke.Kem;
+import org.junit.jupiter.api.Test;
+
+import java.security.KeyPair;
+import java.security.interfaces.XECPublicKey;
+
+import static com.yahoo.security.ArrayUtils.hex;
+import static com.yahoo.security.ArrayUtils.toUtf8Bytes;
+import static com.yahoo.security.ArrayUtils.unhex;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * @author vekterli
+ */
+public class HpkeTest {
+
+ static KeyPair ephemeralRrfc9180TestVectorKeyPair() {
+ var priv = KeyUtils.fromRawX25519PrivateKey(unhex("52c4a758a802cd8b936eceea314432798d5baf2d7e9235dc084ab1b9cfa2f736"));
+ var pub = KeyUtils.fromRawX25519PublicKey(unhex("37fda3567bdbd628e88668c3c8d7e97d1d1253b6d4ea6d44c150f741f1bf4431"));
+ return new KeyPair(pub, priv);
+ }
+
+ static KeyPair receiverRrfc9180TestVectorKeyPair() {
+ var priv = KeyUtils.fromRawX25519PrivateKey(unhex("4612c550263fc8ad58375df3f557aac531d26850903e55a9f23f21d8534e8ac8"));
+ var pub = KeyUtils.fromRawX25519PublicKey(unhex("3948cfe0ad1ddb695d780e59077195da6c56506b027329794ab02bca80815c4d"));
+ return new KeyPair(pub, priv);
+ }
+
+ /**
+ * https://www.rfc-editor.org/rfc/rfc9180.html test vector
+ *
+ * Appendix A.1.1
+ *
+ * DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, AES-128-GCM
+ *
+ * Only tests first encryption, i.e. sequence number 0.
+ */
+ @Test
+ void passes_rfc_9180_dhkem_x25519_hkdf_sha256_hkdf_sha256_aes_gcm_128_test_vector() {
+ byte[] info = unhex("4f6465206f6e2061204772656369616e2055726e");
+ byte[] pt = unhex("4265617574792069732074727574682c20747275746820626561757479");
+ byte[] aad = unhex("436f756e742d30");
+ var kpR = receiverRrfc9180TestVectorKeyPair();
+
+ var kem = Kem.dHKemX25519HkdfSha256(new Kem.UnsafeDeterminsticKeyPairOnlyUsedByTesting(ephemeralRrfc9180TestVectorKeyPair()));
+ var ciphersuite = Ciphersuite.of(kem, Kdf.hkdfSha256(), Aead.aesGcm128());
+
+ var hpke = Hpke.of(ciphersuite);
+ var s = hpke.sealBase((XECPublicKey) kpR.getPublic(), info, aad, pt);
+
+ // The "enc" output is the ephemeral public key
+ var expectedEnc = "37fda3567bdbd628e88668c3c8d7e97d1d1253b6d4ea6d44c150f741f1bf4431";
+ assertEquals(expectedEnc, hex(s.enc()));
+
+ var expectedCiphertext = "f938558b5d72f1a23810b4be2ab4f84331acc02fc97babc53a52ae8218a355a9" +
+ "6d8770ac83d07bea87e13c512a";
+ assertEquals(expectedCiphertext, hex(s.ciphertext()));
+
+ byte[] openedPt = hpke.openBase(s.enc(), kpR, info, aad, s.ciphertext());
+ assertEquals(hex(pt), hex(openedPt));
+ }
+
+ @Test
+ void sealing_creates_new_ephemeral_key_pair_per_invocation() {
+ byte[] info = toUtf8Bytes("the finest info");
+ byte[] pt = toUtf8Bytes("seagulls attack at dawn");
+ byte[] aad = toUtf8Bytes("cool AAD");
+ var kpR = receiverRrfc9180TestVectorKeyPair();
+
+ var hpke = Hpke.of(Ciphersuite.defaultSuite());
+
+ var s1 = hpke.sealBase((XECPublicKey) kpR.getPublic(), info, aad, pt);
+ byte[] openedPt = hpke.openBase(s1.enc(), kpR, info, aad, s1.ciphertext());
+ assertEquals(hex(pt), hex(openedPt));
+
+ var s2 = hpke.sealBase((XECPublicKey) kpR.getPublic(), info, aad, pt);
+ openedPt = hpke.openBase(s2.enc(), kpR, info, aad, s2.ciphertext());
+ assertEquals(hex(pt), hex(openedPt));
+
+ assertNotEquals(hex(s1.enc()), hex(s2.enc())); // This is the ephemeral public key
+ }
+
+ @Test
+ void opening_ciphertext_with_different_info_or_aad_fails() {
+ byte[] info = toUtf8Bytes("the finest info");
+ byte[] pt = toUtf8Bytes("seagulls attack at dawn");
+ byte[] aad = toUtf8Bytes("cool AAD");
+ var kpR = receiverRrfc9180TestVectorKeyPair();
+
+ var hpke = Hpke.of(Ciphersuite.defaultSuite());
+ var s = hpke.sealBase((XECPublicKey) kpR.getPublic(), info, aad, pt);
+
+ byte[] badInfo = toUtf8Bytes("lesser info");
+ // TODO better exception classes! Triggers AEAD auth tag mismatch behind the scenes
+ assertThrows(RuntimeException.class, () -> hpke.openBase(s.enc(), kpR, badInfo, aad, s.ciphertext()));
+ byte[] badAad = toUtf8Bytes("non-groovy AAD");
+ assertThrows(RuntimeException.class, () -> hpke.openBase(s.enc(), kpR, info, badAad, s.ciphertext()));
+ }
+
+}
diff --git a/security-utils/src/test/java/com/yahoo/security/KeyUtilsTest.java b/security-utils/src/test/java/com/yahoo/security/KeyUtilsTest.java
index afaa25ce606..fbf27b67f4b 100644
--- a/security-utils/src/test/java/com/yahoo/security/KeyUtilsTest.java
+++ b/security-utils/src/test/java/com/yahoo/security/KeyUtilsTest.java
@@ -1,14 +1,18 @@
// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
package com.yahoo.security;
+import org.bouncycastle.util.encoders.Hex;
import org.junit.jupiter.api.Test;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.security.PublicKey;
+import java.security.interfaces.XECPrivateKey;
+import java.security.interfaces.XECPublicKey;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -82,4 +86,88 @@ public class KeyUtilsTest {
assertEquals(keyAlgorithm.getAlgorithmName(), deserializedKey.getAlgorithm());
}
+ private static XECPrivateKey xecPrivFromHex(String hex) {
+ return KeyUtils.fromRawX25519PrivateKey(Hex.decode(hex));
+ }
+
+ private static String xecHexFromPriv(XECPrivateKey privateKey) {
+ return Hex.toHexString(KeyUtils.toRawX25519PrivateKeyBytes(privateKey));
+ }
+
+ private static XECPublicKey xecPubFromHex(String hex) {
+ return KeyUtils.fromRawX25519PublicKey(Hex.decode(hex));
+ }
+
+ private static String xecHexFromPub(XECPublicKey publicKey) {
+ return Hex.toHexString(KeyUtils.toRawX25519PublicKeyBytes(publicKey));
+ }
+
+ /**
+ * RFC 7748 Section 6.1, Curve25519 Diffie-Hellman test vector
+ */
+ @Test
+ void x25519_ecdh_matches_rfc_7748_test_vector() {
+ var alice_priv = xecPrivFromHex("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a");
+ var alice_pub = xecPubFromHex( "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a");
+ var bob_priv = xecPrivFromHex("5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb");
+ var bob_public = xecPubFromHex( "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f");
+
+ var expectedShared = "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742";
+
+ byte[] sharedAliceToBob = KeyUtils.ecdh(alice_priv, bob_public);
+ assertEquals(expectedShared, Hex.toHexString(sharedAliceToBob));
+
+ byte[] sharedBobToAlice = KeyUtils.ecdh(bob_priv, alice_pub);
+ assertEquals(expectedShared, Hex.toHexString(sharedBobToAlice));
+ }
+
+ // From https://github.com/google/wycheproof/blob/master/testvectors/x25519_test.json (tcId 32)
+ @Test
+ void x25519_ecdh_fails_if_shared_secret_is_all_zeros_case_1() {
+ var alice_priv = xecPrivFromHex("88227494038f2bb811d47805bcdf04a2ac585ada7f2f23389bfd4658f9ddd45e");
+ var bob_public = xecPubFromHex( "0000000000000000000000000000000000000000000000000000000000000000");
+ // This actually internally fails with an InvalidKeyException due to small point order
+ assertThrows(RuntimeException.class, () -> KeyUtils.ecdh(alice_priv, bob_public));
+ }
+
+ // From https://github.com/google/wycheproof/blob/master/testvectors/x25519_test.json (tcId 63)
+ @Test
+ void x25519_ecdh_fails_if_shared_secret_is_all_zeros_case_2() {
+ var alice_priv = xecPrivFromHex("e0f978dfcd3a8f1a5093418de54136a584c20b7b349afdf6c0520886f95b1272");
+ var bob_public = xecPubFromHex( "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800");
+ // This actually internally fails with an InvalidKeyException due to small point order
+ assertThrows(RuntimeException.class, () -> KeyUtils.ecdh(alice_priv, bob_public));
+ }
+
+ @Test
+ void x25519_public_key_deserialization_clears_msb() {
+ var alice_priv = xecPrivFromHex("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a");
+ var bob_public = xecPubFromHex( "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882bcf"); // note msb toggled in last byte
+ var expectedShared = "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742";
+ byte[] sharedAliceToBob = KeyUtils.ecdh(alice_priv, bob_public);
+ assertEquals(expectedShared, Hex.toHexString(sharedAliceToBob));
+ }
+
+ @Test
+ void x25519_private_key_serialization_roundtrip_maintains_original_structure() {
+ var privHex = "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a";
+ var priv = xecPrivFromHex(privHex);
+ assertEquals(privHex, xecHexFromPriv(priv));
+
+ var privB64 = KeyUtils.toBase64EncodedX25519PrivateKey(priv);
+ var priv2 = KeyUtils.fromBase64EncodedX25519PrivateKey(privB64);
+ assertEquals(privHex, xecHexFromPriv(priv2));
+ }
+
+ @Test
+ void x25519_public_key_serialization_roundtrip_maintains_original_structure() {
+ var pubHex = "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a";
+ var pub = xecPubFromHex(pubHex);
+ assertEquals(pubHex, xecHexFromPub(pub));
+
+ var pubB64 = KeyUtils.toBase64EncodedX25519PublicKey(pub);
+ var pub2 = KeyUtils.fromBase64EncodedX25519PublicKey(pubB64);
+ assertEquals(pubHex, xecHexFromPub(pub2));
+ }
+
}