From e37c3dfd2635b646df1241a8a711c15061d3ca6f Mon Sep 17 00:00:00 2001 From: Paul Kehrer Date: Mon, 2 Sep 2024 10:15:25 -0700 Subject: [PATCH] argon2id support --- CHANGELOG.rst | 1 + .../primitives/key-derivation-functions.rst | 100 +++++++++++ docs/spelling_wordlist.txt | 3 + .../hazmat/backends/openssl/backend.py | 6 + .../hazmat/bindings/_rust/openssl/kdf.pyi | 10 ++ .../hazmat/primitives/kdf/argon2.py | 87 ++++++++++ src/rust/Cargo.lock | 9 +- src/rust/Cargo.toml | 4 +- src/rust/cryptography-cffi/Cargo.toml | 2 +- src/rust/cryptography-key-parsing/Cargo.toml | 4 +- src/rust/cryptography-openssl/Cargo.toml | 4 +- src/rust/src/backend/kdf.rs | 36 ++++ tests/hazmat/primitives/test_argon2.py | 163 ++++++++++++++++++ 13 files changed, 416 insertions(+), 13 deletions(-) create mode 100644 src/cryptography/hazmat/primitives/kdf/argon2.py create mode 100644 tests/hazmat/primitives/test_argon2.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 224747e3b712..001f2beed457 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,7 @@ Changelog * Relax the Authority Key Identifier requirements on root CA certificates during X.509 verification to allow fields permitted by :rfc:`5280` but forbidden by the CA/Browser BRs. +* Added support for :class:`~cryptography.hazmat.primitives.kdf.argon2.Argon2id`. .. _v43-0-0: diff --git a/docs/hazmat/primitives/key-derivation-functions.rst b/docs/hazmat/primitives/key-derivation-functions.rst index 2715e3e56c5d..34142043777d 100644 --- a/docs/hazmat/primitives/key-derivation-functions.rst +++ b/docs/hazmat/primitives/key-derivation-functions.rst @@ -30,6 +30,105 @@ Different KDFs are suitable for different tasks such as: Variable cost algorithms ~~~~~~~~~~~~~~~~~~~~~~~~ +Argon2id +-------- + +.. currentmodule:: cryptography.hazmat.primitives.kdf.argon2 + +.. class:: Argon2id(*, salt, length, iterations, lanes, memory_cost, ad=None, secret=None) + + .. versionadded:: 44.0.0 + + Argon2id is a KDF designed for password storage. It is designed to be + resistant to hardware attacks and is described in :rfc:`9106`. + + This class conforms to the + :class:`~cryptography.hazmat.primitives.kdf.KeyDerivationFunction` + interface. + + .. doctest:: + + >>> import os + >>> from cryptography.hazmat.primitives.kdf.argon2 import Argon2id + >>> salt = os.urandom(16) + >>> # derive + >>> kdf = Argon2id( + ... salt=salt, + ... length=32, + ... iterations=1, + ... lanes=4, + ... memory_cost=64 * 1024, + ... ad=None, + ... secret=None, + ... ) + >>> key = kdf.derive(b"my great password") + >>> # verify + >>> kdf = Argon2id( + ... salt=salt, + ... length=32, + ... iterations=1, + ... lanes=4, + ... memory_cost=64 * 1024, + ... ad=None, + ... secret=None, + ... ) + >>> kdf.verify(b"my great password", key) + + **All arguments to the constructor are keyword-only.** + + :param bytes salt: A salt. + :param int length: The desired length of the derived key in bytes. + :param int iterations: Also known as passes, this is used to tune + the running time independently of the memory size. + :param int lanes: The number of lanes (parallel threads) to use. Also + known as parallelism. + :param int memory_cost: The amount of memory to use in kibibytes. + 1 kibibyte (KiB) is 1024 bytes. + :param bytes ad: Optional associated data. + :param bytes secret: Optional secret data. + + :rfc:`9106` has recommendations for `parameter choice`_. + + :raises cryptography.exceptions.UnsupportedAlgorithm: If Argon2id is not + supported by the OpenSSL version ``cryptography`` is using. + + .. method:: derive(key_material) + + :param key_material: The input key material. + :type key_material: :term:`bytes-like` + :return bytes: the derived key. + :raises TypeError: This exception is raised if ``key_material`` is not + ``bytes``. + :raises cryptography.exceptions.AlreadyFinalized: This is raised when + :meth:`derive` or + :meth:`verify` is + called more than + once. + + This generates and returns a new key from the supplied password. + + .. method:: verify(key_material, expected_key) + + :param bytes key_material: The input key material. This is the same as + ``key_material`` in :meth:`derive`. + :param bytes expected_key: The expected result of deriving a new key, + this is the same as the return value of + :meth:`derive`. + :raises cryptography.exceptions.InvalidKey: This is raised when the + derived key does not match + the expected key. + :raises cryptography.exceptions.AlreadyFinalized: This is raised when + :meth:`derive` or + :meth:`verify` is + called more than + once. + + This checks whether deriving a new key from the supplied + ``key_material`` generates the same key as the ``expected_key``, and + raises an exception if they do not match. This can be used for + checking whether the password a user provides matches the stored derived + key. + PBKDF2 ------ @@ -1039,3 +1138,4 @@ Interface .. _`recommends`: https://datatracker.ietf.org/doc/html/rfc7914#section-2 .. _`The scrypt paper`: https://www.tarsnap.com/scrypt/scrypt.pdf .. _`understanding HKDF`: https://soatok.blog/2021/11/17/understanding-hkdf/ +.. _`parameter choice`: https://datatracker.ietf.org/doc/html/rfc9106#section-4 diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index 6a0282266821..4ef89ed6283e 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -77,6 +77,9 @@ iOS iterable Kerberos Keychain +KiB +kibibyte +kibibytes Koblitz Lange logins diff --git a/src/cryptography/hazmat/backends/openssl/backend.py b/src/cryptography/hazmat/backends/openssl/backend.py index d31b039add0e..628f6f8d79a7 100644 --- a/src/cryptography/hazmat/backends/openssl/backend.py +++ b/src/cryptography/hazmat/backends/openssl/backend.py @@ -122,6 +122,12 @@ def scrypt_supported(self) -> bool: else: return hasattr(rust_openssl.kdf, "derive_scrypt") + def argon2_supported(self) -> bool: + if self._fips_enabled: + return False + else: + return hasattr(rust_openssl.kdf, "derive_argon2id") + def hmac_supported(self, algorithm: hashes.HashAlgorithm) -> bool: # FIPS mode still allows SHA1 for HMAC if self._fips_enabled and isinstance(algorithm, hashes.SHA1): diff --git a/src/cryptography/hazmat/bindings/_rust/openssl/kdf.pyi b/src/cryptography/hazmat/bindings/_rust/openssl/kdf.pyi index 034a8fed2e78..14f3eaaefa58 100644 --- a/src/cryptography/hazmat/bindings/_rust/openssl/kdf.pyi +++ b/src/cryptography/hazmat/bindings/_rust/openssl/kdf.pyi @@ -20,3 +20,13 @@ def derive_scrypt( max_mem: int, length: int, ) -> bytes: ... +def derive_argon2id( + key_material: bytes, + salt: bytes, + length: int, + iterations: int, + lanes: int, + memory_cost: int, + ad: bytes | None, + secret: bytes | None, +) -> bytes: ... diff --git a/src/cryptography/hazmat/primitives/kdf/argon2.py b/src/cryptography/hazmat/primitives/kdf/argon2.py new file mode 100644 index 000000000000..4b259f94b05b --- /dev/null +++ b/src/cryptography/hazmat/primitives/kdf/argon2.py @@ -0,0 +1,87 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +from __future__ import annotations + +from cryptography import utils +from cryptography.exceptions import ( + AlreadyFinalized, + InvalidKey, + UnsupportedAlgorithm, +) +from cryptography.hazmat.bindings._rust import openssl as rust_openssl +from cryptography.hazmat.primitives import constant_time +from cryptography.hazmat.primitives.kdf import KeyDerivationFunction + + +class Argon2id(KeyDerivationFunction): + def __init__( + self, + *, + salt: bytes, + length: int, + iterations: int, + lanes: int, + memory_cost: int, + ad: bytes | None = None, + secret: bytes | None = None, + ): + from cryptography.hazmat.backends.openssl.backend import ( + backend as ossl, + ) + + if not ossl.argon2_supported(): + raise UnsupportedAlgorithm( + "This version of OpenSSL does not support argon2id" + ) + + utils._check_bytes("salt", salt) + # OpenSSL requires a salt of at least 8 bytes + if len(salt) < 8: + raise ValueError("salt must be at least 8 bytes") + # Minimum length is 4 bytes as specified in RFC 9106 + if not isinstance(length, int) or length < 4: + raise ValueError("length must be an integer greater >= 4") + if not isinstance(iterations, int) or iterations < 1: + raise ValueError("iterations must be an integer greater than 0") + if not isinstance(lanes, int) or lanes < 1: + raise ValueError("lanes must be an integer greater than 0") + # Memory cost must be at least 8 * lanes + if not isinstance(memory_cost, int) or memory_cost < 8 * lanes: + raise ValueError("memory_cost must be an integer >= 8 * lanes") + if ad is not None: + utils._check_bytes("ad", ad) + if secret is not None: + utils._check_bytes("secret", secret) + + self._used = False + self._salt = salt + self._length = length + self._iterations = iterations + self._lanes = lanes + self._memory_cost = memory_cost + self._ad = ad + self._secret = secret + + def derive(self, key_material: bytes) -> bytes: + if self._used: + raise AlreadyFinalized("argon2id instances can only be used once.") + self._used = True + + utils._check_byteslike("key_material", key_material) + + return rust_openssl.kdf.derive_argon2id( + key_material, + self._salt, + self._length, + self._iterations, + self._lanes, + self._memory_cost, + self._ad, + self._secret, + ) + + def verify(self, key_material: bytes, expected_key: bytes) -> None: + if not constant_time.bytes_eq(self.derive(key_material), expected_key): + raise InvalidKey diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock index cd9a9be072aa..d6836dc4f839 100644 --- a/src/rust/Cargo.lock +++ b/src/rust/Cargo.lock @@ -183,8 +183,7 @@ checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "openssl" version = "0.10.66" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9529f4786b70a3e8c61e11179af17ab6188ad8d0ded78c5529441ed39d4bd9c1" +source = "git+https://github.com/sfackler/rust-openssl#73892b527038172eab22556e917b6d6eae3b91bb" dependencies = [ "bitflags", "cfg-if", @@ -198,8 +197,7 @@ dependencies = [ [[package]] name = "openssl-macros" version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +source = "git+https://github.com/sfackler/rust-openssl#73892b527038172eab22556e917b6d6eae3b91bb" dependencies = [ "proc-macro2", "quote", @@ -209,8 +207,7 @@ dependencies = [ [[package]] name = "openssl-sys" version = "0.9.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6" +source = "git+https://github.com/sfackler/rust-openssl#73892b527038172eab22556e917b6d6eae3b91bb" dependencies = [ "cc", "libc", diff --git a/src/rust/Cargo.toml b/src/rust/Cargo.toml index c157ce70e1c0..3d96a9bc21b0 100644 --- a/src/rust/Cargo.toml +++ b/src/rust/Cargo.toml @@ -26,8 +26,8 @@ cryptography-x509 = { path = "cryptography-x509" } cryptography-x509-verification = { path = "cryptography-x509-verification" } cryptography-openssl = { path = "cryptography-openssl" } pem = { version = "3", default-features = false } -openssl = "0.10.66" -openssl-sys = "0.9.103" +openssl = { git = "https://github.com/sfackler/rust-openssl" } +openssl-sys = { git = "https://github.com/sfackler/rust-openssl" } foreign-types-shared = "0.1" self_cell = "1" diff --git a/src/rust/cryptography-cffi/Cargo.toml b/src/rust/cryptography-cffi/Cargo.toml index 3cf116a1af99..327592fe40a8 100644 --- a/src/rust/cryptography-cffi/Cargo.toml +++ b/src/rust/cryptography-cffi/Cargo.toml @@ -8,7 +8,7 @@ rust-version.workspace = true [dependencies] pyo3 = { version = "0.22.2", features = ["abi3"] } -openssl-sys = "0.9.103" +openssl-sys = { git = "https://github.com/sfackler/rust-openssl" } [build-dependencies] cc = "1.1.15" diff --git a/src/rust/cryptography-key-parsing/Cargo.toml b/src/rust/cryptography-key-parsing/Cargo.toml index 1dcaaf4e3f1c..610f5e790286 100644 --- a/src/rust/cryptography-key-parsing/Cargo.toml +++ b/src/rust/cryptography-key-parsing/Cargo.toml @@ -9,6 +9,6 @@ rust-version.workspace = true [dependencies] asn1 = { version = "0.17.0", default-features = false } cfg-if = "1" -openssl = "0.10.66" -openssl-sys = "0.9.103" +openssl = { git = "https://github.com/sfackler/rust-openssl" } +openssl-sys = { git = "https://github.com/sfackler/rust-openssl" } cryptography-x509 = { path = "../cryptography-x509" } diff --git a/src/rust/cryptography-openssl/Cargo.toml b/src/rust/cryptography-openssl/Cargo.toml index f340ed87cf53..589c56689103 100644 --- a/src/rust/cryptography-openssl/Cargo.toml +++ b/src/rust/cryptography-openssl/Cargo.toml @@ -8,7 +8,7 @@ rust-version.workspace = true [dependencies] cfg-if = "1" -openssl = "0.10.66" -ffi = { package = "openssl-sys", version = "0.9.101" } +openssl = { git = "https://github.com/sfackler/rust-openssl" } +ffi = { package = "openssl-sys", git = "https://github.com/sfackler/rust-openssl" } foreign-types = "0.3" foreign-types-shared = "0.1" diff --git a/src/rust/src/backend/kdf.rs b/src/rust/src/backend/kdf.rs index 8c6a151a17d0..39ba6e7eae53 100644 --- a/src/rust/src/backend/kdf.rs +++ b/src/rust/src/backend/kdf.rs @@ -48,8 +48,44 @@ fn derive_scrypt<'p>( })?) } +#[cfg(CRYPTOGRAPHY_OPENSSL_320_OR_GREATER)] +#[pyo3::pyfunction] +#[allow(clippy::too_many_arguments)] +#[pyo3(signature = (key_material, salt, length, iterations, lanes, memory_cost, ad=None, secret=None))] +fn derive_argon2id<'p>( + py: pyo3::Python<'p>, + key_material: CffiBuf<'_>, + salt: &[u8], + length: usize, + iterations: u32, + lanes: u32, + memory_cost: u32, + ad: Option<&[u8]>, + secret: Option<&[u8]>, +) -> CryptographyResult> { + use crate::error::CryptographyError; + + Ok(pyo3::types::PyBytes::new_bound_with(py, length, |b| { + openssl::kdf::argon2id( + key_material.as_bytes(), + salt, + ad, + secret, + iterations, + lanes, + memory_cost, + b, + ) + .map_err(CryptographyError::from)?; + Ok(()) + })?) +} + #[pyo3::pymodule] pub(crate) mod kdf { + #[cfg(CRYPTOGRAPHY_OPENSSL_320_OR_GREATER)] + #[pymodule_export] + use super::derive_argon2id; #[pymodule_export] use super::derive_pbkdf2_hmac; #[cfg(not(CRYPTOGRAPHY_IS_LIBRESSL))] diff --git a/tests/hazmat/primitives/test_argon2.py b/tests/hazmat/primitives/test_argon2.py new file mode 100644 index 000000000000..5b6e6d670f61 --- /dev/null +++ b/tests/hazmat/primitives/test_argon2.py @@ -0,0 +1,163 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + + +import binascii +import os + +import pytest + +from cryptography.exceptions import AlreadyFinalized, InvalidKey +from cryptography.hazmat.primitives.kdf.argon2 import Argon2id +from tests.utils import ( + load_nist_vectors, + load_vectors_from_file, + raises_unsupported_algorithm, +) + +vectors = load_vectors_from_file( + os.path.join("KDF", "argon2id.txt"), load_nist_vectors +) + + +@pytest.mark.supported( + only_if=lambda backend: not backend.argon2_supported(), + skip_message="Supports argon2 so can't test unsupported path", +) +def test_unsupported_backend(backend): + with raises_unsupported_algorithm(None): + Argon2id( + salt=b"salt" * 2, length=32, iterations=1, lanes=1, memory_cost=32 + ) + + +@pytest.mark.supported( + only_if=lambda backend: backend.argon2_supported(), + skip_message="Argon2id not supported by this version of OpenSSL", +) +class TestArgon2id: + @pytest.mark.parametrize("params", vectors) + def test_derive(self, params, backend): + salt = binascii.unhexlify(params["salt"]) + ad = binascii.unhexlify(params["ad"]) if "ad" in params else None + secret = ( + binascii.unhexlify(params["secret"]) + if "secret" in params + else None + ) + length = int(params["length"]) + iterations = int(params["iter"]) + lanes = int(params["lanes"]) + memory_cost = int(params["memcost"]) + password = binascii.unhexlify(params["pass"]) + derived_key = params["output"].lower() + + argon2id = Argon2id( + salt=salt, + length=length, + iterations=iterations, + lanes=lanes, + memory_cost=memory_cost, + ad=ad, + secret=secret, + ) + assert binascii.hexlify(argon2id.derive(password)) == derived_key + + def test_invalid_types(self, backend): + with pytest.raises(TypeError): + Argon2id( + salt="notbytes", # type: ignore[arg-type] + length=32, + iterations=1, + lanes=1, + memory_cost=32, + ad=None, + secret=None, + ) + + with pytest.raises(TypeError): + Argon2id( + salt=b"b" * 8, + length=32, + iterations=1, + lanes=1, + memory_cost=32, + ad="string", # type: ignore[arg-type] + secret=None, + ) + + with pytest.raises(TypeError): + Argon2id( + salt=b"b" * 8, + length=32, + iterations=1, + lanes=1, + memory_cost=32, + ad=None, + secret="string", # type: ignore[arg-type] + ) + + @pytest.mark.parametrize( + "params", + [ + (b"b" * 8, "notanint", 1, 1, 32), # invalid type + (b"b" * 8, 32, "notanint", 1, 32), # invalid type + (b"b" * 8, 32, 1, "notanint", 32), # invalid type + (b"b" * 8, 32, 1, 1, "notanint"), # invalid type + (b"b" * 7, 3, 1, 1, 32), # salt < 8 + (b"b" * 8, 3, 1, 1, 32), # length < 4 + (b"b" * 8, 32, 0, 1, 32), # iterations < 1 + (b"b" * 8, 32, 1, 0, 32), # lanes < 1 + (b"b" * 8, 32, 1, 1, 7), # memory_cost < 8 * lanes + (b"b" * 8, 32, 1, 32, 200), # memory_cost < 8 * lanes + ], + ) + def test_invalid_values(self, params, backend): + (salt, length, iterations, lanes, memory_cost) = params + with pytest.raises(ValueError): + Argon2id( + salt=salt, + length=length, + iterations=iterations, + lanes=lanes, + memory_cost=memory_cost, + ) + + def test_already_finalized(self, backend): + argon2id = Argon2id( + salt=b"salt" * 2, length=32, iterations=1, lanes=1, memory_cost=32 + ) + argon2id.derive(b"password") + with pytest.raises(AlreadyFinalized): + argon2id.derive(b"password") + + def test_already_finalized_verify(self, backend): + argon2id = Argon2id( + salt=b"salt" * 2, length=32, iterations=1, lanes=1, memory_cost=32 + ) + digest = argon2id.derive(b"password") + with pytest.raises(AlreadyFinalized): + argon2id.verify(b"password", digest) + + def test_invalid_verify(self, backend): + argon2id = Argon2id( + salt=b"salt" * 2, length=32, iterations=1, lanes=1, memory_cost=32 + ) + with pytest.raises(InvalidKey): + argon2id.verify(b"password", b"invalidkey") + + def test_verify(self, backend): + argon2id = Argon2id( + salt=b"salt" * 2, + length=32, + iterations=1, + lanes=1, + memory_cost=32, + ad=None, + secret=None, + ) + digest = argon2id.derive(b"password") + Argon2id( + salt=b"salt" * 2, length=32, iterations=1, lanes=1, memory_cost=32 + ).verify(b"password", digest)