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 <[email protected]>
This commit is contained in:
Thomas Waldmann
2026-08-23 17:50:13 +02:00
co-authored by Claude Opus 5
parent 775ec8aac0
commit 8d81cfabb7
13 changed files with 110 additions and 30 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+2 -3
View File
@@ -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
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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
-5
View File
@@ -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",
-2
View File
@@ -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"
+1 -1
View File
@@ -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}
+2 -2
View File
@@ -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)
+2 -11
View File
@@ -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(
+3
View File
@@ -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: ...
+87
View File
@@ -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, &parallelism)
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, <void *>salt_c, len(salt))
p[5] = OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_PASSWORD, <void *>secret_c, len(secret))
p[6] = OSSL_PARAM_construct_end()
result = PyBytes_FromStringAndSize(NULL, hash_len)
if EVP_KDF_derive(kctx, <unsigned char *>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
+8 -1
View File
@@ -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