From 8d81cfabb77295824f99b247361a70cbaf5b0644 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Mon, 11 May 2026 20:15:17 +0200 Subject: [PATCH] use argon2 from openssl >= 3.2, drop argon2-cffi, fixes #7963 - src/borg/crypto/low_level.pyx: implement `argon2_hash` using OpenSSL's `EVP_KDF` API for ARGON2 (requires OpenSSL >= 3.2.0). - src/borg/crypto/key.py: switch to the native `argon2_hash` implementation, removing `argon2-cffi` dependency. - setup.py: require OpenSSL >= 3.2.0 for the crypto extension to ensure ARGON2 KDF support is available. - pyproject.toml: drop `argon2-cffi` dependency. - docs: update installation requirements and security documentation to reflect the transition to OpenSSL for Argon2. The lanes are computed in parallel via OpenSSL's thread pool: OpenSSL's Argon2 only uses threads if OSSL_set_max_threads() enabled the thread pool and a "threads" parameter > 1 is given. Passing threads=1 would compute the 4 lanes of our default parallelism=4 sequentially, and unlocking would get slower than it was with argon2-cffi, which used real threads for the lanes. The thread count only affects speed, never the derived key (only the lanes parameter does), so this is fully compatible with existing keys. Apple M-series (OpenSSL 3.6.3), borg defaults (t=3, m=64 MiB, p=4): threads=1: 83 ms, threads=4: 29 ms (2.8x faster). Co-Authored-By: Claude Opus 5 --- .github/workflows/bigendian.yml | 2 +- .github/workflows/canary.yml | 2 +- .github/workflows/ci.yml | 5 +- docs/installation.rst | 4 +- docs/internals/security.rst | 2 +- pyproject.toml | 5 -- scripts/build-borg-using-nuitka.sh | 2 - scripts/msys2-install-deps | 2 +- setup.py | 4 +- src/borg/crypto/key.py | 13 +--- src/borg/crypto/low_level.pyi | 3 + src/borg/crypto/low_level.pyx | 87 ++++++++++++++++++++++++ src/borg/testsuite/crypto/crypto_test.py | 9 ++- 13 files changed, 110 insertions(+), 30 deletions(-) diff --git a/.github/workflows/bigendian.yml b/.github/workflows/bigendian.yml index e1186723f..49cd74cc5 100644 --- a/.github/workflows/bigendian.yml +++ b/.github/workflows/bigendian.yml @@ -87,7 +87,7 @@ jobs: python-version: '3.13' # The wheels pip has to build from source inside the emulated container - # (msgpack, argon2-cffi-bindings, borghash, borgstore) are the expensive + # (msgpack, borghash, borgstore) are the expensive # part of the container setup, so keep them from run to run. - name: Cache pip-built wheels (s390x) uses: actions/cache@v6 diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index cec11c4fd..dfa463613 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -154,7 +154,7 @@ jobs: - name: Build python venv run: | - # building cffi / argon2-cffi in the venv fails, so we try to use the system packages + # building some packages in the venv fails, so we try to use the system packages python -m venv --system-site-packages env . env/bin/activate # python -m pip install --upgrade pip diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60e905868..a25326607 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -645,8 +645,7 @@ jobs: pkgman install -y openssl3 pkgman install -y rust_bin pkgman install -y python3.11 - pkgman install -y cffi - pkgman install -y lz4_devel openssl3_devel libffi_devel + pkgman install -y lz4_devel openssl3_devel # there is no pkgman package for tox, so we install it into a venv python3 -m ensurepip --upgrade @@ -750,7 +749,7 @@ jobs: - name: Build python venv run: | - # building cffi / argon2-cffi in the venv fails, so we try to use the system packages + # building native extensions in the venv fails, so we try to use the system packages python -m venv --system-site-packages env . env/bin/activate # python -m pip install --upgrade pip diff --git a/docs/installation.rst b/docs/installation.rst index 2012e8956..ddf89c0a8 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -168,10 +168,10 @@ following dependencies first. For the libraries you will also need their development header files (sometimes in a separate `-dev` or `-devel` package). * `Python 3`_ >= 3.11.0 -* OpenSSL_ >= 1.1.1 (LibreSSL will not work) +* OpenSSL_ >= 3.2.0 (LibreSSL will not work) * libacl_ (which depends on libattr_) * liblz4_ >= 1.7.0 (r129) -* libffi (required for argon2-cffi-bindings) + * pkg-config (cli tool) - Borg uses this to discover header and library locations automatically. Alternatively, you can also point to them via some environment variables, see setup.py. diff --git a/docs/internals/security.rst b/docs/internals/security.rst index ad09641c9..5056b9a3d 100644 --- a/docs/internals/security.rst +++ b/docs/internals/security.rst @@ -335,7 +335,7 @@ on widely used libraries providing them: - BLAKE3 is used via the blake3_ package (new repos, ``--id-hash blake3``). - BLAKE2b from Python's hashlib_ is only used to read legacy (borg < 2.0) repos. - HMAC and a constant-time comparison from Python's hmac_ standard library module are used. -- argon2 is used via argon2-cffi. +- argon2 is used from OpenSSL (>= 3.2). .. _Horton principle: https://en.wikipedia.org/wiki/Horton_Principle .. _length extension: https://en.wikipedia.org/wiki/Length_extension_attack diff --git a/pyproject.toml b/pyproject.toml index 571cbd27b..c5c59b1e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,11 +36,6 @@ dependencies = [ "packaging", "platformdirs >=3.0.0, <5.0.0; sys_platform == 'darwin'", # for macOS: breaking changes in 3.0.0. "platformdirs >=2.6.0, <5.0.0; sys_platform != 'darwin'", # for others: 2.6+ works consistently. - "argon2-cffi", - # 26.x builds via scikit-build-core and thus needs cmake, which has no wheels for - # the BSDs / OmniOS - pip builds it from source there for ~30min, then fails. - # Temporary: argon2 is moving to OpenSSL, which drops argon2-cffi entirely, see #9602. - "argon2-cffi-bindings == 25.1.0", "shtab>=1.9.3", # older versions generate broken zsh (tqdm/shtab#224) or fish (tqdm/shtab#227) completions "backports-zstd; python_version < '3.14'", # for python < 3.14. "jsonargparse>=4.47.0", diff --git a/scripts/build-borg-using-nuitka.sh b/scripts/build-borg-using-nuitka.sh index ab2518135..11c389f3e 100755 --- a/scripts/build-borg-using-nuitka.sh +++ b/scripts/build-borg-using-nuitka.sh @@ -12,7 +12,6 @@ echo "Building single-file binary of borgbackup..." # Run Nuitka compilation # We use --assume-yes-for-downloads to avoid interactive prompts in automated runs. # We set PYTHONPATH=src to ensure the local version of borg is used. -# We include cffi to avoid runtime ModuleNotFoundError in argon2-cffi. mkdir -p $OUTPUT_DIR PYTHONPATH=src python -m nuitka \ --mode=onefile \ @@ -20,7 +19,6 @@ PYTHONPATH=src python -m nuitka \ --include-package=borg \ --include-package=borghash \ --include-package=borgstore \ - --include-package=cffi \ --output-dir="$OUTPUT_DIR" \ --output-filename="$OUTPUT_FILENAME" \ "$SRC_DIR" diff --git a/scripts/msys2-install-deps b/scripts/msys2-install-deps index ae4dc4bd8..0cbf86424 100644 --- a/scripts/msys2-install-deps +++ b/scripts/msys2-install-deps @@ -1,6 +1,6 @@ #!/bin/bash -pacman -S --needed --noconfirm git mingw-w64-ucrt-x86_64-{toolchain,pkgconf,lz4,openssl,rclone,python-msgpack,python-argon2_cffi,python-platformdirs,python,cython,python-setuptools,python-wheel,python-build,python-pkgconfig,python-packaging,python-pip,python-paramiko,rust,python-maturin} +pacman -S --needed --noconfirm git mingw-w64-ucrt-x86_64-{toolchain,pkgconf,lz4,openssl,rclone,python-msgpack,python-platformdirs,python,cython,python-setuptools,python-wheel,python-build,python-pkgconfig,python-packaging,python-pip,python-paramiko,rust,python-maturin} if [ "$1" = "development" ]; then pacman -S --needed --noconfirm mingw-w64-ucrt-x86_64-python-{pytest,pytest-benchmark,pytest-cov,pytest-xdist} diff --git a/setup.py b/setup.py index 4d790e47d..db4061d21 100644 --- a/setup.py +++ b/setup.py @@ -159,7 +159,7 @@ if not on_rtd: ) if is_win32: - crypto_ext_lib = lib_ext_kwargs(pc, "BORG_OPENSSL_PREFIX", "libcrypto", "libcrypto", ">=1.1.1", lib_subdir="") + crypto_ext_lib = lib_ext_kwargs(pc, "BORG_OPENSSL_PREFIX", "libcrypto", "libcrypto", ">=3.2.0", lib_subdir="") elif is_openbsd: # Use OpenSSL (not LibreSSL) because we need AES-OCB via the EVP API. Link # it statically to avoid conflicting with shared libcrypto from the base @@ -171,7 +171,7 @@ if not on_rtd: extra_objects=[os.path.join(openssl_prefix, "lib", openssl_name, "libcrypto.a")], ) else: - crypto_ext_lib = lib_ext_kwargs(pc, "BORG_OPENSSL_PREFIX", "crypto", "libcrypto", ">=1.1.1") + crypto_ext_lib = lib_ext_kwargs(pc, "BORG_OPENSSL_PREFIX", "crypto", "libcrypto", ">=3.2.0") crypto_ext_kwargs = members_appended( dict(sources=[crypto_ll_source]), crypto_ext_lib, dict(extra_compile_args=cflags) diff --git a/src/borg/crypto/key.py b/src/borg/crypto/key.py index 7c805e27e..0f87b8c75 100644 --- a/src/borg/crypto/key.py +++ b/src/borg/crypto/key.py @@ -13,7 +13,6 @@ from ..logger import create_logger logger = create_logger() from blake3 import blake3 -import argon2.low_level from ..constants import * # NOQA from ..helpers import StableDict @@ -577,17 +576,9 @@ class FlexiKey: parallelism = 1 # 8 is the smallest value that avoids the "Memory cost is too small" exception memory_cost = 8 - type_map = {"i": argon2.low_level.Type.I, "d": argon2.low_level.Type.D, "id": argon2.low_level.Type.ID} - key = argon2.low_level.hash_secret_raw( - secret=passphrase.encode("utf-8"), - hash_len=output_len_in_bytes, - salt=salt, - time_cost=time_cost, - memory_cost=memory_cost, - parallelism=parallelism, - type=type_map[type], + return low_level.argon2_hash( + passphrase.encode("utf-8"), salt, time_cost, memory_cost, parallelism, output_len_in_bytes, type ) - return key def decrypt_key_file_argon2(self, encrypted_key, passphrase): key = self.argon2( diff --git a/src/borg/crypto/low_level.pyi b/src/borg/crypto/low_level.pyi index 2db4a9175..a71aa3bdc 100644 --- a/src/borg/crypto/low_level.pyi +++ b/src/borg/crypto/low_level.pyi @@ -12,6 +12,9 @@ def long_to_bytes(x: int) -> bytes: ... def hmac_sha256(key: bytes, data: bytes) -> bytes: ... def blake2b_256(key: bytes, data: bytes) -> bytes: ... def blake2b_128(data: bytes) -> bytes: ... +def argon2_hash( + secret: bytes, salt: bytes, time_cost: int, memory_cost: int, parallelism: int, hash_len: int, type: str +) -> bytes: ... # XXH64: only used to read borg 1.x repo integrity data on `borg transfer`, see #9935. def xxh64(data: bytes, seed: int = 0) -> bytes: ... diff --git a/src/borg/crypto/low_level.pyx b/src/borg/crypto/low_level.pyx index af5c42128..a37ad5edd 100644 --- a/src/borg/crypto/low_level.pyx +++ b/src/borg/crypto/low_level.pyx @@ -39,6 +39,7 @@ the legacy AES-CTR ones a mac_key and an enc_key): import hashlib import hmac +import os from math import ceil from cpython cimport PyMem_Malloc, PyMem_Free @@ -88,6 +89,37 @@ cdef extern from "openssl/evp.h" nogil: int EVP_CTRL_AEAD_SET_TAG int EVP_CTRL_AEAD_SET_IVLEN +cdef extern from "openssl/kdf.h": + ctypedef struct EVP_KDF: + pass + ctypedef struct EVP_KDF_CTX: + pass + EVP_KDF *EVP_KDF_fetch(void *ctx, const char *algorithm, const char *properties) + void EVP_KDF_free(EVP_KDF *kdf) + EVP_KDF_CTX *EVP_KDF_CTX_new(EVP_KDF *kdf) + void EVP_KDF_CTX_free(EVP_KDF_CTX *ctx) + +cdef extern from "openssl/params.h": + ctypedef struct OSSL_PARAM: + pass + OSSL_PARAM OSSL_PARAM_construct_uint32(const char *key, uint32_t *buf) + OSSL_PARAM OSSL_PARAM_construct_octet_string(const char *key, void *buf, size_t bsize) + OSSL_PARAM OSSL_PARAM_construct_end() + +cdef extern from "openssl/core_names.h": + const char *OSSL_KDF_PARAM_THREADS + const char *OSSL_KDF_PARAM_ARGON2_LANES + const char *OSSL_KDF_PARAM_ITER + const char *OSSL_KDF_PARAM_ARGON2_MEMCOST + const char *OSSL_KDF_PARAM_SALT + const char *OSSL_KDF_PARAM_PASSWORD + +cdef extern from "openssl/kdf.h": + int EVP_KDF_derive(EVP_KDF_CTX *ctx, unsigned char *key, size_t keylen, const OSSL_PARAM params[]) + +cdef extern from "openssl/thread.h": + int OSSL_set_max_threads(void *ctx, uint64_t max_threads) + import struct @@ -1027,3 +1059,58 @@ cdef class XXH64: def xxh64(data, seed=0): """One-shot XXH64: return the 8-byte canonical (big-endian) digest of *data*.""" return XXH64(data, seed).digest() + + +def argon2_hash(bytes secret, bytes salt, uint32_t time_cost, uint32_t memory_cost, + uint32_t parallelism, uint32_t hash_len, type): + cdef EVP_KDF *kdf = NULL + cdef EVP_KDF_CTX *kctx = NULL + cdef OSSL_PARAM params[8] + cdef OSSL_PARAM *p = params + cdef uint32_t threads = 1 + cdef bytes result + cdef const char *alg_name + cdef const unsigned char *secret_c = secret + cdef const unsigned char *salt_c = salt + + if type == "i" or type == b"i": + alg_name = b"ARGON2I" + elif type == "d" or type == b"d": + alg_name = b"ARGON2D" + elif type == "id" or type == b"id": + alg_name = b"ARGON2ID" + else: + raise ValueError("Invalid argon2 type") + + # threads only affect speed, not the result; must be <= lanes (parallelism) + threads = min(parallelism, os.cpu_count() or 1) + if threads > 1 and not OSSL_set_max_threads(NULL, threads): + threads = 1 # OpenSSL was built without thread support + + kdf = EVP_KDF_fetch(NULL, alg_name, NULL) + if kdf == NULL: + raise CryptoError("Argon2 KDF not found in OpenSSL (requires >= 3.2)") + + kctx = EVP_KDF_CTX_new(kdf) + if kctx == NULL: + EVP_KDF_free(kdf) + raise MemoryError("Failed to create KDF context") + + p[0] = OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_THREADS, &threads) + p[1] = OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ARGON2_LANES, ¶llelism) + p[2] = OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ITER, &time_cost) + p[3] = OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ARGON2_MEMCOST, &memory_cost) + p[4] = OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_SALT, salt_c, len(salt)) + p[5] = OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_PASSWORD, secret_c, len(secret)) + p[6] = OSSL_PARAM_construct_end() + + result = PyBytes_FromStringAndSize(NULL, hash_len) + + if EVP_KDF_derive(kctx, result, hash_len, params) <= 0: + EVP_KDF_CTX_free(kctx) + EVP_KDF_free(kdf) + raise CryptoError("EVP_KDF_derive failed") + + EVP_KDF_CTX_free(kctx) + EVP_KDF_free(kdf) + return result diff --git a/src/borg/testsuite/crypto/crypto_test.py b/src/borg/testsuite/crypto/crypto_test.py index c548336c0..d071a10f9 100644 --- a/src/borg/testsuite/crypto/crypto_test.py +++ b/src/borg/testsuite/crypto/crypto_test.py @@ -6,7 +6,7 @@ import unittest from ...crypto.low_level import AES256_CTR_HMAC_SHA256, AES256_OCB, CHACHA20_POLY1305, UNENCRYPTED, IntegrityError from ...crypto.low_level import bytes_to_long, bytes_to_int, long_to_bytes -from ...crypto.low_level import hmac_sha256 +from ...crypto.low_level import hmac_sha256, argon2_hash from ...legacy.crypto.low_level import AES from hashlib import sha256 from ...crypto.key import CHPOKey, AESOCBKey, KeyBase, ChecksumKey @@ -224,6 +224,13 @@ class CryptoTestCase(BaseTestCase): self.assert_raises(IntegrityError, lambda: cs.decrypt(hdr_mac_iv_cdata, aad=b"incorrect_chunkid")) +def test_argon2_hash_parallel(): + # parallelism > 1 exercises the multi-threaded lane computation on multi-core machines. + # reference value cross-checked against argon2-cffi; the result must not depend on thread count. + out = argon2_hash(b"pass", b"saltsaltsaltsalt", 3, 65536, 4, 32, "id") + assert out.hex() == "bf1a482d5b69a34f3922bcc5e66747486b0527336f483e13477b0c484c8932ea" + + def test_decrypt_key_file_argon2_chacha20_poly1305(): plain = b"hello" # echo -n "hello, pass phrase" | argon2 saltsaltsaltsalt -id -t 1 -k 8 -p 1 -l 32 -r