From 540cae3561cd8823fa1a5e036d5543ca5ba2519e Mon Sep 17 00:00:00 2001 From: Tor Brede Vekterli Date: Mon, 17 Oct 2022 14:34:45 +0200 Subject: Minimal implementation of RFC 9180 Hybrid Public Key Encryption (HPKE) HPKE is a hybrid encryption scheme that builds around three primitives: * A key encapsulation mechanism (KEM) * A key derivation function (KDF) * An "authenticated encryption with associated data" (AEAD) algorithm The 3-tuple (KEM, KDF, AEAD) is known as the HPKE _ciphersuite_. This implementation has certain (intentional) limitations: * Only the `DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, AES-128-GCM` ciphersuite is implemented. This is expected to be a good default choice for any internal use of this class. * 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. * 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. **Deprecation notice:** once BouncyCastle (or the Java crypto API) supports HPKE, this particular implementation can safely be deprecated and sent off to live on a farm. --- .../main/java/com/yahoo/security/ArrayUtils.java | 50 ++++ .../src/main/java/com/yahoo/security/HKDF.java | 16 +- .../src/main/java/com/yahoo/security/KeyUtils.java | 140 +++++++++ .../main/java/com/yahoo/security/hpke/Aead.java | 42 +++ .../java/com/yahoo/security/hpke/AesGcm128.java | 64 +++++ .../java/com/yahoo/security/hpke/Ciphersuite.java | 28 ++ .../java/com/yahoo/security/hpke/Constants.java | 38 +++ .../yahoo/security/hpke/DHKemX25519HkdfSha256.java | 138 +++++++++ .../java/com/yahoo/security/hpke/HkdfSha256.java | 32 +++ .../main/java/com/yahoo/security/hpke/Hpke.java | 320 +++++++++++++++++++++ .../src/main/java/com/yahoo/security/hpke/Kdf.java | 43 +++ .../src/main/java/com/yahoo/security/hpke/Kem.java | 69 +++++ .../com/yahoo/security/hpke/LabeledKdfUtils.java | 61 ++++ .../src/test/java/com/yahoo/security/HpkeTest.java | 109 +++++++ .../test/java/com/yahoo/security/KeyUtilsTest.java | 88 ++++++ 15 files changed, 1237 insertions(+), 1 deletion(-) create mode 100644 security-utils/src/main/java/com/yahoo/security/ArrayUtils.java create mode 100644 security-utils/src/main/java/com/yahoo/security/hpke/Aead.java create mode 100644 security-utils/src/main/java/com/yahoo/security/hpke/AesGcm128.java create mode 100644 security-utils/src/main/java/com/yahoo/security/hpke/Ciphersuite.java create mode 100644 security-utils/src/main/java/com/yahoo/security/hpke/Constants.java create mode 100644 security-utils/src/main/java/com/yahoo/security/hpke/DHKemX25519HkdfSha256.java create mode 100644 security-utils/src/main/java/com/yahoo/security/hpke/HkdfSha256.java create mode 100644 security-utils/src/main/java/com/yahoo/security/hpke/Hpke.java create mode 100644 security-utils/src/main/java/com/yahoo/security/hpke/Kdf.java create mode 100644 security-utils/src/main/java/com/yahoo/security/hpke/Kem.java create mode 100644 security-utils/src/main/java/com/yahoo/security/hpke/LabeledKdfUtils.java create mode 100644 security-utils/src/test/java/com/yahoo/security/HpkeTest.java 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. concat("A", "BC", "DE", "F") => "ABCDEF" + */ + 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 expand() 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. + *

+ * Let Bob have private (secret) key skB and public key pkB. + * Let Alice have private key skA and public key pkA. + * ECDH lets both parties separately compute their own side of: + *

+ *
+     *   ecdh(skB, pkA) == ecdh(skA, pkB)
+     * 
+ *

+ * 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. + *

+ *

+ * 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. + *

+ * + * @param privateKey X25519 private key + * @param publicKey X25519 public key + * @return shared Diffie-Hellman secret. Security note: this value should never be + * used directly as a key; use a key derivation function (KDF). + * + * @see RFC 7748 Elliptic Curves for Security + * @see RFC 9180 Hybrid Public Key Encryption + * @see ECDH on wiki + */ + 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 not 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 not 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: + * + * + * + * @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"); + + /** + *
+     * default_psk = ""
+     * default_psk_id = ""
+     * 
+ */ + 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 keyPairGen; + + DHKemX25519HkdfSha256(Supplier 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): + * + *
+     * def ExtractAndExpand(dh, kem_context):
+     *   eae_prk = LabeledExtract("", "eae_prk", dh)
+     *   shared_secret = LabeledExpand(eae_prk, "shared_secret",
+     *                                 kem_context, Nsecret)
+     *   return shared_secret
+     * 
+ */ + 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): + * + *
+     * 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
+     * 
+ */ + @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): + * + *
+     * 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
+     * 
+ * + * 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) + *

+ * HPKE is an encryption scheme that builds around three primitives: + *

+ *
    + *
  • A key encapsulation mechanism (KEM)
  • + *
  • A key derivation function (KDF)
  • + *
  • An "authenticated encryption with associated data" (AEAD) algorithm
  • + *
+ *

+ * The 3-tuple (KEM, KDF, AEAD) is known as the HPKE ciphersuite. + *

+ *

+ * This implementation has certain (intentional) limitations: + *

+ *
    + *
  • Only the DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, AES-128-GCM ciphersuite is + * implemented. This is expected to be a good default choice for any internal use of this class.
  • + *
  • 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.
  • + *
  • 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.
  • + *
+ *

+ * Deprecation notice: once BouncyCastle (or the Java crypto API) supports HPKE, this + * particular implementation can safely be deprecated and sent off to live on a farm. + *

+ * + * @see RFC 9180 Hybrid Public Key Encryption + * + * @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): + * + *
+     * suite_id = concat(
+     *   "HPKE",
+     *   I2OSP(kem_id, 2),
+     *   I2OSP(kdf_id, 2),
+     *   I2OSP(aead_id, 2)
+     * )
+     * 
+ */ + 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: + * + *
+     * 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")
+     * 
+ * + * 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: + * + *
+     * def KeySchedule<ROLE>(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<ROLE>(key, base_nonce, 0, exporter_secret)
+     * 
+ * + * 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: + * + *
+     * def SetupBaseS(pkR, info):
+     *   shared_secret, enc = Encap(pkR)
+     *   return enc, KeyScheduleS(mode_base, shared_secret, info,
+     *                            default_psk, default_psk_id)
+     * 
+ */ + 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: + * + *
+     * def SetupBaseR(enc, skR, info):
+     *   shared_secret = Decap(enc, skR)
+     *   return KeyScheduleR(mode_base, shared_secret, info,
+     *                       default_psk, default_psk_id)
+     * 
+ * + * 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: + * + *
+     * def Seal<MODE>(pkR, info, aad, pt, ...):
+     *   enc, ctx = Setup<MODE>S(pkR, info, ...)
+     *   ct = ctx.Seal(aad, pt)
+     *   return enc, ct
+     * 
+ * + * 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. + * + *
+     * def ContextS.Seal(aad, pt):
+     *   ct = Seal(self.key, self.ComputeNonce(self.seq), aad, pt)
+     *   self.IncrementSeq()
+     *   return ct
+     * 
+ */ + 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: + * + *
+     * def Open<MODE>(enc, skR, info, aad, ct, ...):
+     *   ctx = Setup<MODE>R(enc, skR, info, ...)
+     *   return ctx.Open(aad, ct)
+     * 
+ * + * 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() + * + *
+     * def ContextR.Open(aad, ct):
+     *   pt = Open(self.key, self.ComputeNonce(self.seq), aad, ct)
+     *   if pt == OpenError:
+     *     raise OpenError
+     *   self.IncrementSeq()
+     *   return pt
+     * 
+ */ + 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 + * ikm and an optional byte string salt. + * + * @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 prk using optional string info into + * nBytesToExpand 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 nBytesToExpand 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 pkR" + */ + EncapResult encap(XECPublicKey pkR); + + /** + * Section 4 Cryptographic Dependencies: + * + * "Deterministic algorithm using the private key skR to recover the + * ephemeral symmetric key (the KEM shared secret) from its encapsulated + * representation enc." + * + * 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 HKEM(X25519, HKDF-SHA256) 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: + * + *
+     * def LabeledExtract(salt, label, ikm):
+     *   labeled_ikm = concat("HPKE-v1", suite_id, label, ikm)
+     *   return Extract(salt, labeled_ikm)
+     * 
+ * + * 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: + * + *
+     * def LabeledExpand(prk, label, info, L):
+     *   labeled_info = concat(I2OSP(L, 2), "HPKE-v1", suite_id,
+     *                         label, info)
+     *   return Expand(prk, labeled_info, L)
+     * 
+ * + * 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); + } + + /** + *
+     * I2OSP(n, w):
+     * Convert non-negative integer n to a w-length, big-endian byte string,
+     * as described in [RFC8017].
+     * 
+ * + * We provide a simple 2OSP(n, 2) 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)); + } + } -- cgit v1.2.3