diff options
author | Tor Brede Vekterli <vekterli@yahooinc.com> | 2022-10-17 14:34:45 +0200 |
---|---|---|
committer | Tor Brede Vekterli <vekterli@yahooinc.com> | 2022-10-18 16:08:08 +0200 |
commit | 540cae3561cd8823fa1a5e036d5543ca5ba2519e (patch) | |
tree | 623a1de60af7930874187e02e5186a5fba9e16f9 /security-utils/src/main/java/com/yahoo/security/hpke/Hpke.java | |
parent | a7f1520d2a461a3f12240e665f95a84aaaeb7126 (diff) |
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.
Diffstat (limited to 'security-utils/src/main/java/com/yahoo/security/hpke/Hpke.java')
-rw-r--r-- | security-utils/src/main/java/com/yahoo/security/hpke/Hpke.java | 320 |
1 files changed, 320 insertions, 0 deletions
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<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) + * </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<MODE>(pkR, info, aad, pt, ...): + * enc, ctx = Setup<MODE>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<MODE>(enc, skR, info, aad, ct, ...): + * ctx = Setup<MODE>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); + } + +} |