Skip to content

Commit d59470e

Browse files
authored
Add HPKE (Hybrid Public Key Encryption) support per RFC 9180 (#14126)
* Add HPKE support per RFC 9180 * Trigger CI re-run * Refactor HPKE API to match agreed design from issue discussion API changes per maintainer feedback: - Add Suite(KEM, KDF, AEAD) class with RFC parameter order - Add KEM, KDF, AEAD enum types for algorithm selection - suite.sender(public_key, info) returns SenderContext - suite.recipient(enc, private_key, info) returns RecipientContext - First encrypt() returns enc || ciphertext concatenated - Subsequent encrypt() calls return just ciphertext - Use encrypt/decrypt method names - Simpler KEM names (X25519 instead of DHKEM_X25519_HKDF_SHA256) This matches the API discussed in issue #14073 and addresses reviewer feedback about the API mismatch. * Fix ruff lint errors: unused import, line length, sorting * Fix ruff format * Fix mypy type errors with TypedDict for parameter dicts * Address reviewer feedback: dataclass, HKDF.extract, remove HPKEError - Replace TypedDict with frozen dataclasses for parameter types - Use HKDF.extract() static method instead of hmac.digest - Remove base HPKEError class, keep only MessageLimitReachedError - Use if/elif instead of match for Python 3.8 compatibility * Fix coverage: use assert instead of unreachable raise statements The type system guarantees only valid enum values can be passed to these internal functions, so use assert which is excluded from coverage. * Address reviewer feedback: remove first_message behavior, add test vectors - Remove first_message special behavior from SenderContext and RecipientContext - encrypt() now always returns just ciphertext, enc accessed via property - Remove try/except Exception - AESGCM.decrypt raises InvalidTag directly - Update tests to use new API (enc via sender.enc property) - Add test vector loading from RFC 9180 vectors (when available) - Update docstring example to show correct usage * Add test for load_vectors missing file branch * Simplify to single-shot API per maintainer feedback - Replace sender()/recipient() context methods with encrypt()/decrypt() - encrypt() returns enc || ciphertext (like Go's single-shot API) - decrypt() takes enc || ciphertext and returns plaintext - Make enum values opaque strings instead of RFC numeric values - Remove SenderContext, RecipientContext, MessageLimitReachedError - Update tests for new single-shot API * Format test file with ruff * Fix coverage: remove conditional branch in vector test * Address Alex's review feedback - Remove docstrings (prose docs only) - Use load_vectors_from_file helper - Use [] instead of .get() for vector keys - Parameterize roundtrip tests by KEM/KDF/AEAD - Merge error test class into main test class - Use subtest fixture instead of parametrize for vectors - Remove test_load_vectors_missing_file test - Load vectors inside test function, not at module level * Fix Alex's review comments - Remove comment from _key_schedule (mode = 0x00) - Remove parameterization from error case tests (only roundtrip tests) - Remove FileNotFoundError handling (vectors always exist) - Update docs to match actual Suite API implementation - Add HPKE test vectors from RFC 9180 * - Add _HPKE_MODE_BASE module-level constant (third request) - Add type checking in Suite.__init__ for kem/kdf/aead parameters - Rename NENC to X25519_ENC_LENGTH for clarity - Expand docs with security guidance on authenticated encryption, ephemeral keys, info/aad usage - Remove unnecessary 'Example' and 'Classes' section headers from docs * Fix spelling error in docs: ciphertexts -> ciphertext * Add tests for TypeError paths in Suite.__init__ for coverage * Fix line length in test_invalid_aead_type * Add type: ignore comments for intentional TypeError tests --------- Co-authored-by: mkdev11 <mkdev11@users.noreply.github.com>
1 parent 5225a78 commit d59470e

File tree

4 files changed

+564
-0
lines changed

4 files changed

+564
-0
lines changed

docs/hazmat/primitives/hpke.rst

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
.. hazmat::
2+
3+
HPKE (Hybrid Public Key Encryption)
4+
===================================
5+
6+
.. module:: cryptography.hazmat.primitives.hpke
7+
8+
HPKE is a standard for public key encryption that combines a Key Encapsulation
9+
Mechanism (KEM), a Key Derivation Function (KDF), and an Authenticated
10+
Encryption with Associated Data (AEAD) scheme. It is defined in :rfc:`9180`.
11+
12+
This implementation supports Base mode with DHKEM(X25519, HKDF-SHA256),
13+
HKDF-SHA256, and AES-128-GCM.
14+
15+
HPKE provides authenticated encryption: the recipient can be certain that the
16+
message was encrypted by someone who knows the recipient's public key, but
17+
the sender is anonymous. Each call to :meth:`Suite.encrypt` generates a fresh
18+
ephemeral key pair, so encrypting the same plaintext twice will produce
19+
different ciphertext.
20+
21+
The ``info`` parameter should be used to bind the encryption to a specific
22+
context (e.g., "MyApp-v1-UserMessages"). The ``aad`` parameter provides
23+
additional authenticated data that is not encrypted but is authenticated
24+
along with the ciphertext.
25+
26+
.. code-block:: python
27+
28+
from cryptography.hazmat.primitives.hpke import Suite, KEM, KDF, AEAD
29+
from cryptography.hazmat.primitives.asymmetric import x25519
30+
31+
suite = Suite(KEM.X25519, KDF.HKDF_SHA256, AEAD.AES_128_GCM)
32+
33+
# Generate recipient key pair
34+
private_key = x25519.X25519PrivateKey.generate()
35+
public_key = private_key.public_key()
36+
37+
# Encrypt
38+
ciphertext = suite.encrypt(b"secret message", public_key, info=b"app info")
39+
40+
# Decrypt
41+
plaintext = suite.decrypt(ciphertext, private_key, info=b"app info")
42+
43+
.. class:: Suite(kem, kdf, aead)
44+
45+
An HPKE cipher suite combining a KEM, KDF, and AEAD.
46+
47+
:param kem: The key encapsulation mechanism.
48+
:type kem: :class:`KEM`
49+
:param kdf: The key derivation function.
50+
:type kdf: :class:`KDF`
51+
:param aead: The authenticated encryption algorithm.
52+
:type aead: :class:`AEAD`
53+
54+
.. method:: encrypt(plaintext, public_key, info=b"", aad=b"")
55+
56+
Encrypt a message using HPKE.
57+
58+
:param bytes plaintext: The message to encrypt.
59+
:param public_key: The recipient's public key.
60+
:type public_key: :class:`~cryptography.hazmat.primitives.asymmetric.x25519.X25519PublicKey`
61+
:param bytes info: Application-specific info string.
62+
:param bytes aad: Additional authenticated data.
63+
:returns: The encapsulated key concatenated with ciphertext (enc || ct).
64+
:rtype: bytes
65+
66+
.. method:: decrypt(ciphertext, private_key, info=b"", aad=b"")
67+
68+
Decrypt a message using HPKE.
69+
70+
:param bytes ciphertext: The enc || ct value from :meth:`encrypt`.
71+
:param private_key: The recipient's private key.
72+
:type private_key: :class:`~cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey`
73+
:param bytes info: Application-specific info string.
74+
:param bytes aad: Additional authenticated data.
75+
:returns: The decrypted plaintext.
76+
:rtype: bytes
77+
:raises cryptography.exceptions.InvalidTag: If decryption fails.
78+
79+
.. class:: KEM
80+
81+
An enumeration of key encapsulation mechanisms.
82+
83+
.. attribute:: X25519
84+
85+
DHKEM(X25519, HKDF-SHA256)
86+
87+
.. class:: KDF
88+
89+
An enumeration of key derivation functions.
90+
91+
.. attribute:: HKDF_SHA256
92+
93+
HKDF-SHA256
94+
95+
.. class:: AEAD
96+
97+
An enumeration of authenticated encryption algorithms.
98+
99+
.. attribute:: AES_128_GCM
100+
101+
AES-128-GCM

docs/hazmat/primitives/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Primitives
88

99
aead
1010
asymmetric/index
11+
hpke
1112
constant-time
1213
key-derivation-functions
1314
keywrap
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
# This file is dual licensed under the terms of the Apache License, Version
2+
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
3+
# for complete details.
4+
5+
from __future__ import annotations
6+
7+
import dataclasses
8+
import enum
9+
10+
from cryptography.hazmat.primitives import hashes
11+
from cryptography.hazmat.primitives.asymmetric import x25519
12+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
13+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF, HKDFExpand
14+
from cryptography.utils import int_to_bytes
15+
16+
_HPKE_VERSION = b"HPKE-v1"
17+
_HPKE_MODE_BASE = 0x00
18+
19+
20+
class KEM(enum.Enum):
21+
X25519 = "X25519"
22+
23+
24+
class KDF(enum.Enum):
25+
HKDF_SHA256 = "HKDF_SHA256"
26+
27+
28+
class AEAD(enum.Enum):
29+
AES_128_GCM = "AES_128_GCM"
30+
31+
32+
@dataclasses.dataclass(frozen=True)
33+
class _KEMParams:
34+
id: int
35+
nsecret: int
36+
nenc: int
37+
npk: int
38+
nsk: int
39+
hash: hashes.HashAlgorithm
40+
41+
42+
@dataclasses.dataclass(frozen=True)
43+
class _KDFParams:
44+
id: int
45+
nh: int
46+
hash: hashes.HashAlgorithm
47+
48+
49+
@dataclasses.dataclass(frozen=True)
50+
class _AEADParams:
51+
id: int
52+
nk: int
53+
nn: int
54+
nt: int
55+
56+
57+
def _get_kem_params(kem: KEM) -> _KEMParams:
58+
assert kem == KEM.X25519
59+
return _KEMParams(
60+
id=0x0020,
61+
nsecret=32,
62+
nenc=32,
63+
npk=32,
64+
nsk=32,
65+
hash=hashes.SHA256(),
66+
)
67+
68+
69+
def _get_kdf_params(kdf: KDF) -> _KDFParams:
70+
assert kdf == KDF.HKDF_SHA256
71+
return _KDFParams(
72+
id=0x0001,
73+
nh=32,
74+
hash=hashes.SHA256(),
75+
)
76+
77+
78+
def _get_aead_params(aead: AEAD) -> _AEADParams:
79+
assert aead == AEAD.AES_128_GCM
80+
return _AEADParams(
81+
id=0x0001,
82+
nk=16,
83+
nn=12,
84+
nt=16,
85+
)
86+
87+
88+
class Suite:
89+
def __init__(self, kem: KEM, kdf: KDF, aead: AEAD) -> None:
90+
if not isinstance(kem, KEM):
91+
raise TypeError("kem must be an instance of KEM")
92+
if not isinstance(kdf, KDF):
93+
raise TypeError("kdf must be an instance of KDF")
94+
if not isinstance(aead, AEAD):
95+
raise TypeError("aead must be an instance of AEAD")
96+
97+
self._kem = kem
98+
self._kdf = kdf
99+
self._aead = aead
100+
101+
self._kem_params = _get_kem_params(kem)
102+
self._kdf_params = _get_kdf_params(kdf)
103+
self._aead_params = _get_aead_params(aead)
104+
105+
# Build suite IDs
106+
self._kem_suite_id = b"KEM" + int_to_bytes(self._kem_params.id, 2)
107+
self._hpke_suite_id = (
108+
b"HPKE"
109+
+ int_to_bytes(self._kem_params.id, 2)
110+
+ int_to_bytes(self._kdf_params.id, 2)
111+
+ int_to_bytes(self._aead_params.id, 2)
112+
)
113+
114+
def _kem_labeled_extract(
115+
self, salt: bytes, label: bytes, ikm: bytes
116+
) -> bytes:
117+
labeled_ikm = _HPKE_VERSION + self._kem_suite_id + label + ikm
118+
return HKDF.extract(
119+
self._kdf_params.hash,
120+
salt if salt else None,
121+
labeled_ikm,
122+
)
123+
124+
def _kem_labeled_expand(
125+
self, prk: bytes, label: bytes, info: bytes, length: int
126+
) -> bytes:
127+
labeled_info = (
128+
int_to_bytes(length, 2)
129+
+ _HPKE_VERSION
130+
+ self._kem_suite_id
131+
+ label
132+
+ info
133+
)
134+
hkdf_expand = HKDFExpand(
135+
algorithm=self._kdf_params.hash,
136+
length=length,
137+
info=labeled_info,
138+
)
139+
return hkdf_expand.derive(prk)
140+
141+
def _extract_and_expand(self, dh: bytes, kem_context: bytes) -> bytes:
142+
eae_prk = self._kem_labeled_extract(b"", b"eae_prk", dh)
143+
shared_secret = self._kem_labeled_expand(
144+
eae_prk,
145+
b"shared_secret",
146+
kem_context,
147+
self._kem_params.nsecret,
148+
)
149+
return shared_secret
150+
151+
def _encap(self, pk_r: x25519.X25519PublicKey) -> tuple[bytes, bytes]:
152+
sk_e = x25519.X25519PrivateKey.generate()
153+
pk_e = sk_e.public_key()
154+
dh = sk_e.exchange(pk_r)
155+
enc = pk_e.public_bytes_raw()
156+
pk_rm = pk_r.public_bytes_raw()
157+
kem_context = enc + pk_rm
158+
shared_secret = self._extract_and_expand(dh, kem_context)
159+
return shared_secret, enc
160+
161+
def _decap(self, enc: bytes, sk_r: x25519.X25519PrivateKey) -> bytes:
162+
pk_e = x25519.X25519PublicKey.from_public_bytes(enc)
163+
dh = sk_r.exchange(pk_e)
164+
pk_rm = sk_r.public_key().public_bytes_raw()
165+
kem_context = enc + pk_rm
166+
shared_secret = self._extract_and_expand(dh, kem_context)
167+
return shared_secret
168+
169+
def _hpke_labeled_extract(
170+
self, salt: bytes, label: bytes, ikm: bytes
171+
) -> bytes:
172+
labeled_ikm = _HPKE_VERSION + self._hpke_suite_id + label + ikm
173+
return HKDF.extract(
174+
self._kdf_params.hash,
175+
salt if salt else None,
176+
labeled_ikm,
177+
)
178+
179+
def _hpke_labeled_expand(
180+
self, prk: bytes, label: bytes, info: bytes, length: int
181+
) -> bytes:
182+
labeled_info = (
183+
int_to_bytes(length, 2)
184+
+ _HPKE_VERSION
185+
+ self._hpke_suite_id
186+
+ label
187+
+ info
188+
)
189+
hkdf_expand = HKDFExpand(
190+
algorithm=self._kdf_params.hash,
191+
length=length,
192+
info=labeled_info,
193+
)
194+
return hkdf_expand.derive(prk)
195+
196+
def _key_schedule(
197+
self, shared_secret: bytes, info: bytes
198+
) -> tuple[bytes, bytes]:
199+
mode = _HPKE_MODE_BASE
200+
201+
psk_id_hash = self._hpke_labeled_extract(b"", b"psk_id_hash", b"")
202+
info_hash = self._hpke_labeled_extract(b"", b"info_hash", info)
203+
key_schedule_context = bytes([mode]) + psk_id_hash + info_hash
204+
205+
secret = self._hpke_labeled_extract(shared_secret, b"secret", b"")
206+
207+
key = self._hpke_labeled_expand(
208+
secret, b"key", key_schedule_context, self._aead_params.nk
209+
)
210+
base_nonce = self._hpke_labeled_expand(
211+
secret,
212+
b"base_nonce",
213+
key_schedule_context,
214+
self._aead_params.nn,
215+
)
216+
217+
return key, base_nonce
218+
219+
def encrypt(
220+
self,
221+
plaintext: bytes,
222+
public_key: x25519.X25519PublicKey,
223+
info: bytes = b"",
224+
aad: bytes = b"",
225+
) -> bytes:
226+
shared_secret, enc = self._encap(public_key)
227+
key, base_nonce = self._key_schedule(shared_secret, info)
228+
aead_impl = AESGCM(key)
229+
ct = aead_impl.encrypt(base_nonce, plaintext, aad)
230+
return enc + ct
231+
232+
def decrypt(
233+
self,
234+
ciphertext: bytes,
235+
private_key: x25519.X25519PrivateKey,
236+
info: bytes = b"",
237+
aad: bytes = b"",
238+
) -> bytes:
239+
nenc = self._kem_params.nenc
240+
enc = ciphertext[:nenc]
241+
ct = ciphertext[nenc:]
242+
shared_secret = self._decap(enc, private_key)
243+
key, base_nonce = self._key_schedule(shared_secret, info)
244+
aead_impl = AESGCM(key)
245+
return aead_impl.decrypt(base_nonce, ct, aad)
246+
247+
248+
__all__ = [
249+
"AEAD",
250+
"KDF",
251+
"KEM",
252+
"Suite",
253+
]

0 commit comments

Comments
 (0)