Skip to content

Commit de9dae3

Browse files
alexclaude
andauthored
Remove aad parameter from HPKE one-shot public API (#14203)
Per RFC 9180 Section 8.1, applications using single-shot APIs should use the info parameter for auxiliary authenticated information rather than aad. This change: - Removes aad parameter from public Suite.encrypt/decrypt in Rust - Adds module-level _encrypt_with_aad/_decrypt_with_aad functions in the private Rust hpke module for test vector validation - Updates tests to use the module-level functions for aad - Updates type stubs, documentation, and CHANGELOG Fixes #14073 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2c83d44 commit de9dae3

5 files changed

Lines changed: 136 additions & 68 deletions

File tree

CHANGELOG.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ Changelog
8484
:class:`~cryptography.hazmat.primitives.asymmetric.utils.NoDigestInfo`.
8585
* Added :meth:`~cryptography.hazmat.primitives.hashes.Hash.hash`, a one-shot
8686
method for computing hashes.
87+
* Added :doc:`/hazmat/primitives/hpke` support implementing :rfc:`9180` for
88+
hybrid authenticated encryption.
8789

8890
.. _v46-0-5:
8991

docs/hazmat/primitives/hpke.rst

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ ephemeral key pair, so encrypting the same plaintext twice will produce
1919
different ciphertext.
2020

2121
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.
22+
context (e.g., "MyApp-v1-UserMessages"). Per :rfc:`9180#section-8.1`,
23+
applications using single-shot APIs should use the ``info`` parameter for
24+
specifying auxiliary authenticated information.
2525

2626
.. code-block:: python
2727
@@ -51,27 +51,27 @@ along with the ciphertext.
5151
:param aead: The authenticated encryption algorithm.
5252
:type aead: :class:`AEAD`
5353

54-
.. method:: encrypt(plaintext, public_key, info=b"", aad=b"")
54+
.. method:: encrypt(plaintext, public_key, info=b"")
5555

5656
Encrypt a message using HPKE.
5757

5858
:param bytes plaintext: The message to encrypt.
5959
:param public_key: The recipient's public key.
6060
: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.
61+
:param bytes info: Application-specific context string for binding the
62+
encryption to a specific application or protocol.
6363
:returns: The encapsulated key concatenated with ciphertext (enc || ct).
6464
:rtype: bytes
6565

66-
.. method:: decrypt(ciphertext, private_key, info=b"", aad=b"")
66+
.. method:: decrypt(ciphertext, private_key, info=b"")
6767

6868
Decrypt a message using HPKE.
6969

7070
:param bytes ciphertext: The enc || ct value from :meth:`encrypt`.
7171
:param private_key: The recipient's private key.
7272
: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.
73+
:param bytes info: Application-specific context string (must match the
74+
value used during encryption).
7575
:returns: The decrypted plaintext.
7676
:rtype: bytes
7777
:raises cryptography.exceptions.InvalidTag: If decryption fails.

src/cryptography/hazmat/bindings/_rust/openssl/hpke.pyi

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,25 @@ class Suite:
2121
plaintext: Buffer,
2222
public_key: x25519.X25519PublicKey,
2323
info: Buffer | None = None,
24-
aad: Buffer | None = None,
2524
) -> bytes: ...
2625
def decrypt(
2726
self,
2827
ciphertext: Buffer,
2928
private_key: x25519.X25519PrivateKey,
3029
info: Buffer | None = None,
31-
aad: Buffer | None = None,
3230
) -> bytes: ...
31+
32+
def _encrypt_with_aad(
33+
suite: Suite,
34+
plaintext: Buffer,
35+
public_key: x25519.X25519PublicKey,
36+
info: Buffer | None = None,
37+
aad: Buffer | None = None,
38+
) -> bytes: ...
39+
def _decrypt_with_aad(
40+
suite: Suite,
41+
ciphertext: Buffer,
42+
private_key: x25519.X25519PrivateKey,
43+
info: Buffer | None = None,
44+
aad: Buffer | None = None,
45+
) -> bytes: ...

src/rust/src/backend/hpke.rs

Lines changed: 95 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22
// 2.0, and the BSD License. See the LICENSE file in the root of this repository
33
// for complete details.
44

5+
use pyo3::types::{PyAnyMethods, PyBytesMethods};
6+
57
use crate::backend::aead::AesGcm;
68
use crate::backend::kdf::{hkdf_extract, HkdfExpand};
79
use crate::backend::x25519;
810
use crate::buf::CffiBuf;
911
use crate::error::{CryptographyError, CryptographyResult};
10-
use crate::exceptions;
11-
use crate::types;
12-
use pyo3::types::{PyAnyMethods, PyBytesMethods};
12+
use crate::{exceptions, types};
1313

1414
const HPKE_VERSION: &[u8] = b"HPKE-v1";
1515
const HPKE_MODE_BASE: u8 = 0x00;
@@ -235,6 +235,66 @@ impl Suite {
235235
self.hkdf_expand(py, prk, &labeled_info, length)
236236
}
237237

238+
fn encrypt_inner<'p>(
239+
&self,
240+
py: pyo3::Python<'p>,
241+
plaintext: CffiBuf<'_>,
242+
public_key: &pyo3::Bound<'_, pyo3::PyAny>,
243+
info: Option<CffiBuf<'_>>,
244+
aad: Option<CffiBuf<'_>>,
245+
) -> CryptographyResult<pyo3::Bound<'p, pyo3::types::PyBytes>> {
246+
let info_bytes: &[u8] = info.as_ref().map(|b| b.as_bytes()).unwrap_or(b"");
247+
248+
let (shared_secret, enc) = self.encap(py, public_key)?;
249+
let (key, base_nonce) = self.key_schedule(py, shared_secret.as_bytes(), info_bytes)?;
250+
251+
let aesgcm = AesGcm::new(py, pyo3::types::PyBytes::new(py, &key).unbind().into_any())?;
252+
let ct = aesgcm.encrypt(py, CffiBuf::from_bytes(py, &base_nonce), plaintext, aad)?;
253+
254+
let enc_bytes = enc.as_bytes();
255+
let ct_bytes = ct.as_bytes();
256+
Ok(pyo3::types::PyBytes::new_with(
257+
py,
258+
enc_bytes.len() + ct_bytes.len(),
259+
|buf| {
260+
buf[..enc_bytes.len()].copy_from_slice(enc_bytes);
261+
buf[enc_bytes.len()..].copy_from_slice(ct_bytes);
262+
Ok(())
263+
},
264+
)?)
265+
}
266+
267+
fn decrypt_inner<'p>(
268+
&self,
269+
py: pyo3::Python<'p>,
270+
ciphertext: CffiBuf<'_>,
271+
private_key: &pyo3::Bound<'_, pyo3::PyAny>,
272+
info: Option<CffiBuf<'_>>,
273+
aad: Option<CffiBuf<'_>>,
274+
) -> CryptographyResult<pyo3::Bound<'p, pyo3::types::PyBytes>> {
275+
let ct_bytes = ciphertext.as_bytes();
276+
if ct_bytes.len() < kem_params::X25519_NENC + aead_params::AES_128_GCM_NT {
277+
return Err(CryptographyError::from(exceptions::InvalidTag::new_err(())));
278+
}
279+
280+
let info_bytes: &[u8] = info.as_ref().map(|b| b.as_bytes()).unwrap_or(b"");
281+
282+
let (enc, ct) = ct_bytes.split_at(kem_params::X25519_NENC);
283+
284+
let shared_secret = self
285+
.decap(py, enc, private_key)
286+
.map_err(|_| CryptographyError::from(exceptions::InvalidTag::new_err(())))?;
287+
let (key, base_nonce) = self.key_schedule(py, shared_secret.as_bytes(), info_bytes)?;
288+
289+
let aesgcm = AesGcm::new(py, pyo3::types::PyBytes::new(py, &key).unbind().into_any())?;
290+
aesgcm.decrypt(
291+
py,
292+
CffiBuf::from_bytes(py, &base_nonce),
293+
CffiBuf::from_bytes(py, ct),
294+
aad,
295+
)
296+
}
297+
238298
fn key_schedule(
239299
&self,
240300
py: pyo3::Python<'_>,
@@ -297,71 +357,59 @@ impl Suite {
297357
})
298358
}
299359

300-
#[pyo3(signature = (plaintext, public_key, info=None, aad=None))]
360+
#[pyo3(signature = (plaintext, public_key, info=None))]
301361
fn encrypt<'p>(
302362
&self,
303363
py: pyo3::Python<'p>,
304364
plaintext: CffiBuf<'_>,
305365
public_key: &pyo3::Bound<'_, pyo3::PyAny>,
306366
info: Option<CffiBuf<'_>>,
307-
aad: Option<CffiBuf<'_>>,
308367
) -> CryptographyResult<pyo3::Bound<'p, pyo3::types::PyBytes>> {
309-
let info_bytes: &[u8] = info.as_ref().map(|b| b.as_bytes()).unwrap_or(b"");
310-
311-
let (shared_secret, enc) = self.encap(py, public_key)?;
312-
let (key, base_nonce) = self.key_schedule(py, shared_secret.as_bytes(), info_bytes)?;
313-
314-
let aesgcm = AesGcm::new(py, pyo3::types::PyBytes::new(py, &key).unbind().into_any())?;
315-
let ct = aesgcm.encrypt(py, CffiBuf::from_bytes(py, &base_nonce), plaintext, aad)?;
316-
317-
let enc_bytes = enc.as_bytes();
318-
let ct_bytes = ct.as_bytes();
319-
Ok(pyo3::types::PyBytes::new_with(
320-
py,
321-
enc_bytes.len() + ct_bytes.len(),
322-
|buf| {
323-
buf[..enc_bytes.len()].copy_from_slice(enc_bytes);
324-
buf[enc_bytes.len()..].copy_from_slice(ct_bytes);
325-
Ok(())
326-
},
327-
)?)
368+
self.encrypt_inner(py, plaintext, public_key, info, None)
328369
}
329370

330-
#[pyo3(signature = (ciphertext, private_key, info=None, aad=None))]
371+
#[pyo3(signature = (ciphertext, private_key, info=None))]
331372
fn decrypt<'p>(
332373
&self,
333374
py: pyo3::Python<'p>,
334375
ciphertext: CffiBuf<'_>,
335376
private_key: &pyo3::Bound<'_, pyo3::PyAny>,
336377
info: Option<CffiBuf<'_>>,
337-
aad: Option<CffiBuf<'_>>,
338378
) -> CryptographyResult<pyo3::Bound<'p, pyo3::types::PyBytes>> {
339-
let ct_bytes = ciphertext.as_bytes();
340-
if ct_bytes.len() < kem_params::X25519_NENC + aead_params::AES_128_GCM_NT {
341-
return Err(CryptographyError::from(exceptions::InvalidTag::new_err(())));
342-
}
343-
344-
let info_bytes: &[u8] = info.as_ref().map(|b| b.as_bytes()).unwrap_or(b"");
345-
346-
let (enc, ct) = ct_bytes.split_at(kem_params::X25519_NENC);
379+
self.decrypt_inner(py, ciphertext, private_key, info, None)
380+
}
381+
}
347382

348-
let shared_secret = self
349-
.decap(py, enc, private_key)
350-
.map_err(|_| CryptographyError::from(exceptions::InvalidTag::new_err(())))?;
351-
let (key, base_nonce) = self.key_schedule(py, shared_secret.as_bytes(), info_bytes)?;
383+
#[pyo3::pyfunction]
384+
#[pyo3(signature = (suite, plaintext, public_key, info=None, aad=None))]
385+
fn _encrypt_with_aad<'p>(
386+
py: pyo3::Python<'p>,
387+
suite: &Suite,
388+
plaintext: CffiBuf<'_>,
389+
public_key: &pyo3::Bound<'_, pyo3::PyAny>,
390+
info: Option<CffiBuf<'_>>,
391+
aad: Option<CffiBuf<'_>>,
392+
) -> CryptographyResult<pyo3::Bound<'p, pyo3::types::PyBytes>> {
393+
suite.encrypt_inner(py, plaintext, public_key, info, aad)
394+
}
352395

353-
let aesgcm = AesGcm::new(py, pyo3::types::PyBytes::new(py, &key).unbind().into_any())?;
354-
aesgcm.decrypt(
355-
py,
356-
CffiBuf::from_bytes(py, &base_nonce),
357-
CffiBuf::from_bytes(py, ct),
358-
aad,
359-
)
360-
}
396+
#[pyo3::pyfunction]
397+
#[pyo3(signature = (suite, ciphertext, private_key, info=None, aad=None))]
398+
fn _decrypt_with_aad<'p>(
399+
py: pyo3::Python<'p>,
400+
suite: &Suite,
401+
ciphertext: CffiBuf<'_>,
402+
private_key: &pyo3::Bound<'_, pyo3::PyAny>,
403+
info: Option<CffiBuf<'_>>,
404+
aad: Option<CffiBuf<'_>>,
405+
) -> CryptographyResult<pyo3::Bound<'p, pyo3::types::PyBytes>> {
406+
suite.decrypt_inner(py, ciphertext, private_key, info, aad)
361407
}
362408

363409
#[pyo3::pymodule(gil_used = false)]
364410
pub(crate) mod hpke {
411+
// stable and nightly rustfmt disagree on import ordering
412+
#[rustfmt::skip]
365413
#[pymodule_export]
366-
use super::{Suite, AEAD, KDF, KEM};
414+
use super::{_decrypt_with_aad, _encrypt_with_aad, Suite, AEAD, KDF, KEM};
367415
}

tests/hazmat/primitives/test_hpke.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import pytest
99

1010
from cryptography.exceptions import InvalidTag
11+
from cryptography.hazmat.bindings._rust import openssl as rust_openssl
1112
from cryptography.hazmat.primitives.asymmetric import x25519
1213
from cryptography.hazmat.primitives.hpke import (
1314
AEAD,
@@ -49,17 +50,13 @@ def test_roundtrip(self, kem, kdf, aead):
4950
sk_r = x25519.X25519PrivateKey.generate()
5051
pk_r = sk_r.public_key()
5152

52-
ciphertext = suite.encrypt(
53-
b"Hello, HPKE!", pk_r, info=b"test", aad=b"additional data"
54-
)
55-
plaintext = suite.decrypt(
56-
ciphertext, sk_r, info=b"test", aad=b"additional data"
57-
)
53+
ciphertext = suite.encrypt(b"Hello, HPKE!", pk_r, info=b"test")
54+
plaintext = suite.decrypt(ciphertext, sk_r, info=b"test")
5855

5956
assert plaintext == b"Hello, HPKE!"
6057

6158
@pytest.mark.parametrize("kem,kdf,aead", SUPPORTED_SUITES)
62-
def test_roundtrip_no_aad(self, kem, kdf, aead):
59+
def test_roundtrip_no_info(self, kem, kdf, aead):
6360
suite = Suite(kem, kdf, aead)
6461

6562
sk_r = x25519.X25519PrivateKey.generate()
@@ -88,10 +85,14 @@ def test_wrong_aad_fails(self):
8885
sk_r = x25519.X25519PrivateKey.generate()
8986
pk_r = sk_r.public_key()
9087

91-
ciphertext = suite.encrypt(b"Secret message", pk_r, aad=b"correct aad")
88+
ciphertext = rust_openssl.hpke._encrypt_with_aad(
89+
suite, b"Secret message", pk_r, aad=b"correct aad"
90+
)
9291

9392
with pytest.raises(InvalidTag):
94-
suite.decrypt(ciphertext, sk_r, aad=b"wrong aad")
93+
rust_openssl.hpke._decrypt_with_aad(
94+
suite, ciphertext, sk_r, aad=b"wrong aad"
95+
)
9596

9697
def test_info_mismatch_fails(self):
9798
suite = Suite(KEM.X25519, KDF.HKDF_SHA256, AEAD.AES_128_GCM)
@@ -248,6 +249,10 @@ def test_vector_decryption(self, subtests):
248249
pt_expected = bytes.fromhex(encryption["pt"])
249250

250251
# Combine enc || ct for single-shot decrypt
252+
# Use internal function with AAD for test vector
253+
# validation
251254
ciphertext = enc + ct
252-
pt = suite.decrypt(ciphertext, sk_r, info=info, aad=aad)
255+
pt = rust_openssl.hpke._decrypt_with_aad(
256+
suite, ciphertext, sk_r, info=info, aad=aad
257+
)
253258
assert pt == pt_expected

0 commit comments

Comments
 (0)