From 1eff22de91a7e37607af6b1109ea33e5a98a55c3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 23:03:22 -0800 Subject: [PATCH 01/10] chore(quest): claim quest/m2/adoption/pyrind.md Quest-Claim: 00ff78d095c98fad From 69d98294daad8e6b466c0669b7ccda238acf421f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 23:28:31 -0800 Subject: [PATCH 02/10] feat(python): pyrind, rind's API for Python (checkpoint) Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 77 +++ Cargo.toml | 1 + admin/scan-license | 6 + flake.nix | 7 +- rind-python/Cargo.toml | 28 + rind-python/LICENSE | 523 ++++++++++++++++++ rind-python/README.md | 1 + rind-python/pyproject.toml | 30 + rind-python/python/pyrind/__init__.py | 52 ++ rind-python/python/pyrind/_rind.pyi | 34 ++ rind-python/python/pyrind/aead.pyi | 52 ++ rind-python/python/pyrind/cpu.pyi | 4 + rind-python/python/pyrind/digest/__init__.pyi | 37 ++ rind-python/python/pyrind/digest/legacy.pyi | 6 + rind-python/python/pyrind/py.typed | 0 rind-python/src/aead.rs | 196 +++++++ rind-python/src/agreement.rs | 126 +++++ rind-python/src/digest.rs | 73 +++ rind-python/src/hkdf.rs | 89 +++ rind-python/src/hmac.rs | 92 +++ rind-python/src/kem.rs | 83 +++ rind-python/src/lib.rs | 218 ++++++++ rind-python/src/pbkdf2.rs | 79 +++ rind-python/src/rsa.rs | 186 +++++++ rind-python/src/signature.rs | 396 +++++++++++++ .../__pycache__/test_api.cpython-314.pyc | Bin 0 -> 49558 bytes .../test_wycheproof.cpython-314.pyc | Bin 0 -> 14364 bytes rind-python/tests/test_api.py | 498 +++++++++++++++++ rind-python/tests/test_wycheproof.py | 155 ++++++ sh/dispatch.sh | 2 +- sh/python.sh | 30 + 31 files changed, 3078 insertions(+), 3 deletions(-) create mode 100644 rind-python/Cargo.toml create mode 100644 rind-python/LICENSE create mode 100644 rind-python/README.md create mode 100644 rind-python/pyproject.toml create mode 100644 rind-python/python/pyrind/__init__.py create mode 100644 rind-python/python/pyrind/_rind.pyi create mode 100644 rind-python/python/pyrind/aead.pyi create mode 100644 rind-python/python/pyrind/cpu.pyi create mode 100644 rind-python/python/pyrind/digest/__init__.pyi create mode 100644 rind-python/python/pyrind/digest/legacy.pyi create mode 100644 rind-python/python/pyrind/py.typed create mode 100644 rind-python/src/aead.rs create mode 100644 rind-python/src/agreement.rs create mode 100644 rind-python/src/digest.rs create mode 100644 rind-python/src/hkdf.rs create mode 100644 rind-python/src/hmac.rs create mode 100644 rind-python/src/kem.rs create mode 100644 rind-python/src/lib.rs create mode 100644 rind-python/src/pbkdf2.rs create mode 100644 rind-python/src/rsa.rs create mode 100644 rind-python/src/signature.rs create mode 100644 rind-python/tests/__pycache__/test_api.cpython-314.pyc create mode 100644 rind-python/tests/__pycache__/test_wycheproof.cpython-314.pyc create mode 100644 rind-python/tests/test_api.py create mode 100644 rind-python/tests/test_wycheproof.py create mode 100755 sh/python.sh diff --git a/Cargo.lock b/Cargo.lock index 2b0a0e81d..c7f970eaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1480,6 +1480,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.3" @@ -2862,6 +2868,63 @@ dependencies = [ "unarray", ] +[[package]] +name = "pyo3" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229ca13b8a15bb0d1487b81ab975bfcd97513416499a8312011b6036e42e0de4" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70130e2b6dd1ee589a8788026a429561ca5c40fa7a2fa7914c2282eddae24700" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e7ff73c555c286d212d4ce7e5fbda845a973c4f61bd5f160f5f2c3c2a04d1c4" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c46d2e5862c7bb8207e3b639f713dda3c499933444784154c11d013c5a691b60" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b89f066f2ae72a2aef52d1537ca2fcd104440ebfadee06e97aa90738a25b9b" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -3159,6 +3222,14 @@ dependencies = [ "tokio", ] +[[package]] +name = "rind-python" +version = "0.1.0" +dependencies = [ + "pyo3", + "rind", +] + [[package]] name = "rind-rcgen" version = "0.14.0" @@ -3752,6 +3823,12 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.27.0" diff --git a/Cargo.toml b/Cargo.toml index b0e1587a9..9c5640619 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "rind-ring", "rind-rustls", "rind-jsonwebtoken", + "rind-python", "fuzz" ] diff --git a/admin/scan-license b/admin/scan-license index 89e1cb6bf..fcdc8437a 100755 --- a/admin/scan-license +++ b/admin/scan-license @@ -8,4 +8,10 @@ for f in $(find rind/src/ -name *.rs -exec grep -L "SPDX-License-Identifier:" {} bad=1 done +# pyrind's wheels ship rind's code, so they carry rind's whole license. +if ! cmp -s rind/LICENSE rind-python/LICENSE; then + echo "rind-python/LICENSE must be a copy of rind/LICENSE" + bad=1 +fi + exit $bad diff --git a/flake.nix b/flake.nix index b648dd3b0..cf09840e9 100644 --- a/flake.nix +++ b/flake.nix @@ -256,8 +256,11 @@ # sh/alert.sh reads the workflows. yq-go nixfmt - # admin/ scripts. - python3 + # admin/ scripts, and pyrind's tests and benchmarks, which + # check it against pyca/cryptography. + (python3.withPackages (ps: [ ps.cryptography ])) + # Builds pyrind (rind-python). + maturin # admin/bench-report-test runs the script that holds a # leased host. flock diff --git a/rind-python/Cargo.toml b/rind-python/Cargo.toml new file mode 100644 index 000000000..209d29309 --- /dev/null +++ b/rind-python/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "rind-python" +version = "0.1.0" +edition = "2024" +repository = "https://github.com/moq-dev/rind" +license = "Apache-2.0 OR ISC OR MIT-0" +description = "pyrind, rind's API for Python" +rust-version.workspace = true +readme = "README.md" +# Released to PyPI as `pyrind` by .github/workflows/release.yml, never to +# crates.io. +publish = false + +[lib] +name = "_rind" +crate-type = ["cdylib"] +# The tests are Python's, under tests/, run against the built extension +# (sh/python.sh). A Rust test binary would link libpython, which the hosts +# that run `cargo test` need not have. +test = false +doctest = false + +[dependencies] +rind = { version = "0.2.1", path = "../rind" } +# The stable ABI from 3.11, the oldest CPython still supported and the first +# whose stable ABI has the buffer protocol, so one wheel per platform covers +# every CPython since. +pyo3 = { version = "0.30", features = ["abi3-py311"] } diff --git a/rind-python/LICENSE b/rind-python/LICENSE new file mode 100644 index 000000000..8c42cfdd1 --- /dev/null +++ b/rind-python/LICENSE @@ -0,0 +1,523 @@ +rind, a fork of Graviola, is distributed under the following three licenses: + +- Apache License version 2.0. +- ISC license. +- MIT-0 license. + +You may use this software under the terms of any of these licenses, +at your option, except for the third-party code listed below, which +keeps the license given for it. + +Contributions are made under these licenses. + +-- + +Third-party code + +rind copies code from the projects below. Each copied file keeps its +source's copyright lines and names its license in an +SPDX-License-Identifier comment. The license texts follow this list. +ALGORITHMS.md's Sources table names the same projects: a change that copies +code from a new project updates both. + +Graviola + + What: rind began as a copy of Graviola, and rind-rustls as a copy of + its rustls-graviola. Files Graviola wrote carry a "Written for + Graviola by ..." header. Its aarch64 ChaCha20 and its Poly1305 began + as cifra , by the same author under + CC0-1.0. + Copyright 2024 Joe Birr-Pixton + License: Apache-2.0 OR ISC OR MIT-0, the same choice as rind's own code. + Graviola has no NOTICE file. + +s2n-bignum + + What: the files under rind/src/low/x86_64 and rind/src/low/aarch64 + that begin "generated source. do not edit.", which admin/parse-asm + transliterates from s2n-bignum's assembly. + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + License: Apache-2.0 OR ISC OR MIT-0, except for: + + - The ML-KEM routines (mlkem_*.rs) and the aarch64 Keccak-f routines + (sha3_keccak_f1600.rs, sha3_keccak_f1600_alt.rs, + sha3_keccak2_f1600.rs), which s2n-bignum takes from mlkem-native + . + License: Apache-2.0 OR ISC OR MIT. Further copyright lines: + Copyright (c) 2024 The mlkem-native project authors + Copyright (c) 2021-2022 Arm Limited + Copyright (c) 2022 Arm Limited + Copyright (c) 2022 Hanno Becker + Copyright (c) 2022 Matthias Kannwischer + Copyright (c) 2023 Amin Abdulrahman, Matthias Kannwischer + + - The x86_64 Keccak-f routine (x86_64/sha3_keccak_f1600.rs), which + s2n-bignum takes from OpenSSL , + written by Andy Polyakov. + License: Apache-2.0. Further copyright line: + Copyright (c) 2017-2024 The OpenSSL Project Authors + + s2n-bignum's NOTICE file reads: + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +libcrux + + What: rind/src/low/arm/aes.rs, the bitsliced AES block cipher and key + expansion from libcrux-aes 0.0.10. + Copyright 2023 Cryspen Sarl + License: Apache-2.0. libcrux has no NOTICE file. + +HACL* , through libcrux + + What: rind/src/low/arm/chacha20.rs, the ChaCha20 block function that + libcrux-chacha20poly1305 0.0.10 compiles from HACL*'s Hacl_Chacha20. + Copyright (c) 2016-2022 INRIA, CMU and Microsoft Corporation + Copyright (c) 2022-2023 HACL* Contributors + Copyright 2023 Cryspen Sarl + License: Apache-2.0. Neither project has a NOTICE file. + +HACL* , Vale + + What: the files under rind/src/low/x86_64/hacl, Vale's x86_64 AES-GCM, + which admin/import-hacl takes from HACL*'s dist/vale at commit + f557259007db9933442dde10aa995c1b00bd3446. Its gcm*_opt routines port + OpenSSL's aesni-gcm-x86_64, which OpenSSL releases under Apache-2.0. + Copyright (c) 2016-2022 INRIA, CMU and Microsoft Corporation + Copyright (c) 2022-2023 HACL* Contributors + Copyright 2013-2016 The OpenSSL Project Authors + License: Apache-2.0. HACL* has no NOTICE file. + +aws-lc , from OpenSSL and BoringSSL + + What: the files under rind/src/low/{arm,aarch64,x86_64}/aws_lc but + the mlkem_* ones (see mlkem-native), which + admin/import-aws-lc takes from aws-lc's assembly at commit + 91c93a8eb860e51482a871a8772cb9b1b310baea, + rind/src/low/arm/poly1305_arm.rs, + rind/src/low/x86_64/ghash_ssse3_x86_64.rs, + rind/src/low/x86_64/rsaz.rs, rind/src/low/x86_64/aes_nohw.rs, and + rind/src/low/generic/ghash_nohw.rs: + + - For 32-bit ARM, aesv8-armx.s (AES), ghashv8-armx.s (GHASH), + sha1-armv4-large.s (SHA1), sha256-armv4.s (SHA256), sha512-armv4.s + (SHA512), and chacha-armv4.s (ChaCha20); for aarch64 and x86_64, the + sha512_block_data_order_* routines and the K512 tables of + sha512-armv8 and sha512-x86_64 (SHA512); for x86_64, the + sha256_block_data_order_avx, _ssse3, and _nohw routines and the K256 + table of sha256-x86_64 (SHA256), the sha1_block_data_order_avx, + _ssse3, and _nohw routines and the K_XX_XX table of sha1-x86_64 + (SHA1), and the + ChaCha20_ctr32_nohw and _ssse3_4x routines and tables of chacha-x86_64 + (ChaCha20); the bn_mul_mont_nohw, bn_mul4x_mont, bn_mulx4x_mont, and + bn_sqr8x_mont routines of x86_64-mont and bn_sqr8x_internal and + bn_sqrx8x_internal of x86_64-mont5, and the rsaz_1024_* + routines and tables of rsaz-avx2 (RSA); the + aes_hw_ctr32_encrypt_blocks and aes_hw_cbc_encrypt routines of + aesni-x86_64 (AES-CTR and AES-CBC), the + vpaes_* routines and table of vpaes-x86_64 (AES), and the + ecp_nistz256_* routines and tables of p256-x86_64-asm (P-256); for + aarch64, the bn_mul_mont routine of armv8-mont (RSA), the vpaes_* + routines and table of vpaes-armv8 (AES), the gcm_*_neon routines and + table of ghash-neon-armv8 (GHASH), and the *_block_data_order_nohw + routines and table of sha1-armv8 (SHA1) and sha256-armv8 (SHA256). All + are pregenerated from Perl scripts written for OpenSSL by Andy Polyakov, + or for rsaz-avx2 and p256-x86_64-asm by Shay Gueron and Vlad Krasnov + of Intel, which Andy Polyakov extended for p256-x86_64-asm; + vpaes-armv8 and vpaes-x86_64 adapt the public domain vector permutation + AES by Mike Hamburg. ghash_ssse3_x86_64.rs ports aws-lc's + gcm_init_ssse3, from crypto/fipsmodule/modes/gcm.c, to Rust, and + rsaz.rs its RSAZ_1024_mod_exp_avx2, from crypto/fipsmodule/bn/rsaz_exp.c. + Copyright (c) 2008 The OpenSSL Project. All rights reserved. + Copyright 2005-2016 The OpenSSL Project Authors. All Rights Reserved. + Copyright 2006-2016 The OpenSSL Project Authors. All Rights Reserved. + Copyright 2007-2016 The OpenSSL Project Authors. All Rights Reserved. + Copyright 2009-2016 The OpenSSL Project Authors. All Rights Reserved. + Copyright 2010-2016 The OpenSSL Project Authors. All Rights Reserved. + Copyright 2011-2016 The OpenSSL Project Authors. All Rights Reserved. + Copyright 2013-2016 The OpenSSL Project Authors. All Rights Reserved. + Copyright 2014-2016 The OpenSSL Project Authors. All Rights Reserved. + Copyright 2014-2020 The OpenSSL Project Authors. All Rights Reserved. + Copyright 2015-2016 The OpenSSL Project Authors. All Rights Reserved. + Copyright 2016 The OpenSSL Project Authors. All Rights Reserved. + Copyright (c) 2012, Intel Corporation. All Rights Reserved. + Copyright (c) 2014, Intel Corporation. All Rights Reserved. + Copyright (c) 2015 CloudFlare, Inc. + License: Apache-2.0. + + - poly1305_arm_asm.s (Poly1305 with NEON), and poly1305_arm.rs, which + ports aws-lc's crypto/poly1305/poly1305_arm.c to Rust. BoringSSL took + both from the public domain neon2 version in SUPERCOP by D. J. + Bernstein and Peter Schwabe. + Copyright (c) 2014, Google Inc. + License: ISC. + + - For x86_64 and aarch64, the chacha20_poly1305_open and _seal + routines, the poly_hash_ad_internal helper, and the tables of + chacha20_poly1305_x86_64 and chacha20_poly1305_armv8 + (ChaCha20-Poly1305), which aws-lc takes from BoringSSL. Both are + pregenerated from Perl scripts written by Vlad Krasnov. + Copyright (c) 2015, CloudFlare Ltd. + Copyright (c) 2020, CloudFlare Ltd. + License: ISC. + + - For x86_64, the gcm_ghash_ssse3 routine and tables of + ghash-ssse3-x86_64 (GHASH), which aws-lc takes from BoringSSL; + aes_nohw.rs, which ports aws-lc's crypto/fipsmodule/aes/aes_nohw.c + (AES), in its SSE2 configuration, to Rust; and, for x86_64 and 32-bit ARM, ghash_nohw.rs, which ports aws-lc's + crypto/fipsmodule/modes/gcm_nohw.c (GHASH) to Rust. aws-lc takes both + C files from BoringSSL. + Copyright (c) 2019, Google Inc. + License: ISC. + + aws-lc's NOTICE file reads: + AWS-LC + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +mlkem-native + + What: rind/src/low/generic/mlkem_poly.rs, which ports mlkem-native's + portable C polynomial arithmetic (mlkem/poly.c, poly_k.c, compress.c, + sampling.c, and zetas.inc, as aws-lc vendors them at commit + 91c93a8eb860e51482a871a8772cb9b1b310baea) to Rust; and + rind/src/low/x86_64/aws_lc/mlkem_*, which admin/import-aws-lc takes from + mlkem-native's x86_64 AVX2 assembly as aws-lc vendors it at that commit, + with the tables of its compress_consts.c in + rind/src/low/x86_64/mlkem_compress.rs. + Copyright (c) The mlkem-native project authors + License: Apache-2.0 OR ISC OR MIT. + +BoringSSL + + What: the files under rind/src/low/{x86_64,arm}/boringssl, which + admin/import-boringssl takes from BoringSSL at commit + ed9209cff8b5a2fbc6c10050f29b3355048bda85: for x86_64, AES-GCM on VAES + from its pregenerated gen/bcm/aes-gcm-avx2-x86_64 and + aes-gcm-avx512-x86_64, the AVX-512 one based on aes-gcm-avx10-x86_64.S + from Linux; for 32-bit ARM, X25519 on NEON from + crypto/curve25519/asm/x25519-asm-arm.S, which BoringSSL took from + crypto_scalarmult/curve25519/neon2/scalarmult.s in SUPERCOP 20141124 + , which is public domain. + Copyright 2015 The BoringSSL Authors + Copyright 2024 The BoringSSL Authors + License: Apache-2.0. BoringSSL has no NOTICE file. + +OpenSSL + + What: the files under rind/src/low/{x86_64,arm}/openssl, which + admin/import-openssl takes from OpenSSL at commit + 2f4e3a5fec05394068cfa7379890ab552483a8a6: for x86_64, SHA-256 and + SHA-512 on AVX2 and SHA-512 on the SHA512 instructions, generated from + crypto/sha/asm/sha512-x86_64.pl, written by Andy Polyakov; + for 32-bit ARM, Keccak-f[1600] in integer code and on NEON, generated from + crypto/sha/asm/keccak1600-armv4.pl, written by Andy Polyakov. + Copyright 2005-2026 The OpenSSL Project Authors. All Rights Reserved. + Copyright 2017-2026 The OpenSSL Project Authors. All Rights Reserved. + License: Apache-2.0. OpenSSL has no NOTICE file. + +curve25519-dalek + + What: rind/src/low/x86_64/edwards25519_scalarmuldouble_avx2.rs, a port + of curve25519-dalek 5.0.0's AVX2 backend (its field arithmetic, point + formulas, and table of the base point's odd multiples) and its scalars' + non-adjacent forms, for Ed25519 verification on x86_64 CPUs with AVX2 but + without ADX. + Copyright (c) 2016-2021 isis agora lovecruft. All rights reserved. + Copyright (c) 2016-2021 Henry de Valence. All rights reserved. + License: BSD-3-Clause. + +-- + +SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 + +Apache 2.0 license +------------------------------------- + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + +ISC license +------------------------------------- + +Copyright 2024 Joe Birr-Pixton +Copyright Amazon.com, Inc. or its affiliates. +Copyright (c) 2024 The mlkem-native project authors +Copyright (c) 2021-2022 Arm Limited +Copyright (c) 2022 Arm Limited +Copyright (c) 2022 Hanno Becker +Copyright (c) 2022 Matthias Kannwischer +Copyright (c) 2023 Amin Abdulrahman, Matthias Kannwischer +Copyright (c) 2014, Google Inc. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +MIT-0 license +------------------------------------- + +Copyright 2024 Joe Birr-Pixton +Copyright 2021-2024 Amazon.com, Inc. or its affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +MIT license +------------------------------------- + +Copyright Amazon.com, Inc. or its affiliates. +Copyright (c) 2024 The mlkem-native project authors +Copyright (c) 2021-2022 Arm Limited +Copyright (c) 2022 Arm Limited +Copyright (c) 2022 Hanno Becker +Copyright (c) 2022 Matthias Kannwischer +Copyright (c) 2023 Amin Abdulrahman, Matthias Kannwischer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +BSD 3-Clause license +------------------------------------- + +Copyright (c) 2016-2021 isis agora lovecruft. All rights reserved. +Copyright (c) 2016-2021 Henry de Valence. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/rind-python/README.md b/rind-python/README.md new file mode 100644 index 000000000..39e031481 --- /dev/null +++ b/rind-python/README.md @@ -0,0 +1 @@ +# pyrind diff --git a/rind-python/pyproject.toml b/rind-python/pyproject.toml new file mode 100644 index 000000000..ec184514f --- /dev/null +++ b/rind-python/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["maturin>=1.15,<2"] +build-backend = "maturin" + +[project] +name = "pyrind" +description = "Fast cryptography for Python from rind, a pure-Rust library: AEADs, hashes, HMAC, HKDF, PBKDF2, X25519, Ed25519, ECDSA, ECDH, RSA, and ML-KEM" +readme = "README.md" +requires-python = ">=3.11" +# rind's own licenses, and those of the code it copies (rind/LICENSE). +license = "(Apache-2.0 OR ISC OR MIT-0) AND (Apache-2.0 OR ISC OR MIT) AND Apache-2.0 AND ISC AND BSD-3-Clause" +license-files = ["LICENSE"] +keywords = ["cryptography", "aead", "ed25519", "ecdsa", "ml-kem"] +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Security :: Cryptography", + "Typing :: Typed", +] +# maturin takes the version from rind-python's Cargo.toml, which release-plz +# bumps. +dynamic = ["version"] + +[project.urls] +Repository = "https://github.com/moq-dev/rind" +Documentation = "https://github.com/moq-dev/rind/tree/main/rind-python" + +[tool.maturin] +python-source = "python" +module-name = "pyrind._rind" diff --git a/rind-python/python/pyrind/__init__.py b/rind-python/python/pyrind/__init__.py new file mode 100644 index 000000000..528f529c0 --- /dev/null +++ b/rind-python/python/pyrind/__init__.py @@ -0,0 +1,52 @@ +"""rind's cryptography for Python. + +Each rind module is a submodule: `pyrind.aead`, `pyrind.agreement`, +`pyrind.cpu`, `pyrind.digest`, `pyrind.hkdf`, `pyrind.hmac`, `pyrind.kem`, +`pyrind.pbkdf2`, `pyrind.rsa`, and `pyrind.signature`. Every failure raises +a subclass of `pyrind.Error`. + +Importing pyrind raises `ImportError` on a CPU missing a feature rind +requires, naming it. +""" + +from pyrind._rind import ( + BadSignature, + DecryptFailed, + Error, + Fault, + KeyRejected, + RngFailed, + Unsupported, + WrongLength, + aead, + agreement, + cpu, + digest, + hkdf, + hmac, + kem, + pbkdf2, + rsa, + signature, +) + +__all__ = [ + "BadSignature", + "DecryptFailed", + "Error", + "Fault", + "KeyRejected", + "RngFailed", + "Unsupported", + "WrongLength", + "aead", + "agreement", + "cpu", + "digest", + "hkdf", + "hmac", + "kem", + "pbkdf2", + "rsa", + "signature", +] diff --git a/rind-python/python/pyrind/_rind.pyi b/rind-python/python/pyrind/_rind.pyi new file mode 100644 index 000000000..b4365e883 --- /dev/null +++ b/rind-python/python/pyrind/_rind.pyi @@ -0,0 +1,34 @@ +from pyrind import aead as aead +from pyrind import agreement as agreement +from pyrind import cpu as cpu +from pyrind import digest as digest +from pyrind import hkdf as hkdf +from pyrind import hmac as hmac +from pyrind import kem as kem +from pyrind import pbkdf2 as pbkdf2 +from pyrind import rsa as rsa +from pyrind import signature as signature + +class Error(Exception): + """A rind call failed.""" + +class WrongLength(Error): + """An input or output of fixed length, such as a key, a nonce, or a derived key, was another length.""" + +class KeyRejected(Error): + """A private, public, or peer's key was refused; the message says why.""" + +class BadSignature(Error): + """A signature, MAC tag, or PBKDF2 output did not verify.""" + +class DecryptFailed(Error): + """An AEAD ciphertext, its tag, or its associated data did not authenticate, or an RSA-OAEP ciphertext did not decrypt.""" + +class RngFailed(Error): + """The system random source failed.""" + +class Unsupported(Error): + """A call was given an algorithm it does not take.""" + +class Fault(Error): + """An internal consistency check failed, from a hardware fault or a rind bug.""" diff --git a/rind-python/python/pyrind/aead.pyi b/rind-python/python/pyrind/aead.pyi new file mode 100644 index 000000000..d626896be --- /dev/null +++ b/rind-python/python/pyrind/aead.pyi @@ -0,0 +1,52 @@ +"""Authenticated encryption: AES-GCM and ChaCha20-Poly1305 with the caller's +nonces, and XChaCha20-Poly1305 with random ones.""" + +from typing import final + +from typing_extensions import Buffer + +@final +class Algorithm: + """An AEAD algorithm.""" + + @property + def key_len(self) -> int: + """The key's length in bytes.""" + @property + def nonce_len(self) -> int: + """The nonce's length in bytes.""" + @property + def tag_len(self) -> int: + """The tag's length in bytes.""" + +AES_128_GCM: Algorithm +AES_192_GCM: Algorithm +AES_256_GCM: Algorithm +CHACHA20_POLY1305: Algorithm + +@final +class Key: + """An AEAD key, which seals with the caller's nonces. + + Sealing two messages with one nonce under one key loses confidentiality + and lets anyone forge messages, so derive nonces from state that never + repeats, such as a sequence number, or use `RandomizedNonceKey`. + """ + + def __init__(self, algorithm: Algorithm, key: Buffer) -> None: + """Expands `key` for `algorithm`; raises `WrongLength` unless it is `algorithm.key_len` bytes.""" + def seal(self, nonce: Buffer, plaintext: Buffer, *, aad: Buffer) -> bytes: + """Encrypts `plaintext` under the 12-byte `nonce`, authenticating `aad` with it, and returns the ciphertext with the tag appended.""" + def open(self, nonce: Buffer, ciphertext: Buffer, *, aad: Buffer) -> bytes: + """Decrypts and verifies `ciphertext`, sealed under `nonce` with `aad`, and returns the plaintext; raises `DecryptFailed` if it does not verify.""" + +@final +class RandomizedNonceKey: + """An XChaCha20-Poly1305 key that picks a random 192-bit nonce for every seal, so the caller tracks no nonce state.""" + + def __init__(self, key: Buffer) -> None: + """Makes a key from 32 bytes of key material; raises `WrongLength` otherwise.""" + def seal(self, plaintext: Buffer, *, aad: Buffer) -> tuple[bytes, bytes]: + """Encrypts `plaintext` under a fresh random nonce, authenticating `aad` with it, and returns the nonce, which the opener needs, and the ciphertext with the tag appended.""" + def open(self, nonce: Buffer, ciphertext: Buffer, *, aad: Buffer) -> bytes: + """Decrypts and verifies `ciphertext`, sealed under the 24-byte `nonce` with `aad`, and returns the plaintext; raises `DecryptFailed` if it does not verify.""" diff --git a/rind-python/python/pyrind/cpu.pyi b/rind-python/python/pyrind/cpu.pyi new file mode 100644 index 000000000..11ea8cfb6 --- /dev/null +++ b/rind-python/python/pyrind/cpu.pyi @@ -0,0 +1,4 @@ +"""CPU support.""" + +def has_aes_hardware() -> bool: + """Whether this CPU runs AES and GHASH in hardware; where it does not, prefer ChaCha20-Poly1305.""" diff --git a/rind-python/python/pyrind/digest/__init__.pyi b/rind-python/python/pyrind/digest/__init__.pyi new file mode 100644 index 000000000..d4ee00d08 --- /dev/null +++ b/rind-python/python/pyrind/digest/__init__.pyi @@ -0,0 +1,37 @@ +"""Hash functions.""" + +from typing import final + +from typing_extensions import Buffer + +from pyrind.digest import legacy as legacy + +@final +class Algorithm: + """A hash function.""" + + @property + def output_len(self) -> int: + """The digest's length in bytes.""" + +SHA256: Algorithm +SHA384: Algorithm +SHA512: Algorithm +SHA512_256: Algorithm +SHA3_256: Algorithm +SHA3_384: Algorithm +SHA3_512: Algorithm + +def digest(algorithm: Algorithm, data: Buffer) -> bytes: + """Returns the digest of `data`.""" + +@final +class Context: + """A hash of a message given in pieces.""" + + def __init__(self, algorithm: Algorithm) -> None: + """Starts hashing with `algorithm`.""" + def update(self, data: Buffer) -> None: + """Adds `data` to the message.""" + def finish(self) -> bytes: + """Returns the digest of the message, and starts a new one with the same algorithm.""" diff --git a/rind-python/python/pyrind/digest/legacy.pyi b/rind-python/python/pyrind/digest/legacy.pyi new file mode 100644 index 000000000..23f4443db --- /dev/null +++ b/rind-python/python/pyrind/digest/legacy.pyi @@ -0,0 +1,6 @@ +"""Hash functions only legacy protocols should use.""" + +from pyrind.digest import Algorithm + +MD5: Algorithm +SHA1: Algorithm diff --git a/rind-python/python/pyrind/py.typed b/rind-python/python/pyrind/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/rind-python/src/aead.rs b/rind-python/src/aead.rs new file mode 100644 index 000000000..f3cca5372 --- /dev/null +++ b/rind-python/src/aead.rs @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 +use pyo3::prelude::*; +use pyo3::types::PyBytes; +use rind::aead; + +use crate::{Bytes, WrongLength, algorithm, bytes, constants, error}; + +const TAG_LEN: usize = 16; + +algorithm!( + /// An AEAD algorithm. + Algorithm(aead::Algorithm) as "pyrind.aead"."Algorithm" { + /// The key's length in bytes. + #[getter] + fn key_len(&self) -> usize { + self.inner.key_len() + } + + /// The nonce's length in bytes. + #[getter] + fn nonce_len(&self) -> usize { + self.inner.nonce_len() + } + + /// The tag's length in bytes. + #[getter] + fn tag_len(&self) -> usize { + self.inner.tag_len() + } + } +); + +/// Copies `plaintext` into a new `bytes` one tag longer, and seals it there +/// with `seal`, which returns the tag. +fn seal_into<'py, T: AsRef<[u8]>>( + py: Python<'py>, + plaintext: &[u8], + seal: impl FnOnce(&mut [u8]) -> Result, +) -> PyResult> { + PyBytes::new_with(py, plaintext.len() + TAG_LEN, |out| { + let (body, tag) = out.split_at_mut(plaintext.len()); + body.copy_from_slice(plaintext); + tag.copy_from_slice(seal(body).map_err(error)?.as_ref()); + Ok(()) + }) +} + +/// Copies `ciphertext`, less its tag, into a new `bytes`, and opens it there +/// with `open`, which takes the tag. +fn open_into<'py>( + py: Python<'py>, + ciphertext: &[u8], + open: impl FnOnce(&mut [u8], [u8; TAG_LEN]) -> Result<(), rind::Error>, +) -> PyResult> { + let (body, tag) = ciphertext + .split_last_chunk::() + .ok_or_else(|| error(rind::Error::DecryptFailed))?; + PyBytes::new_with(py, body.len(), |out| { + out.copy_from_slice(body); + open(out, *tag).map_err(error) + }) +} + +/// An AEAD key, which seals with the caller's nonces. +/// +/// Sealing two messages with one nonce under one key loses confidentiality +/// and lets anyone forge messages, so derive nonces from state that never +/// repeats, such as a sequence number, or use `RandomizedNonceKey`. +#[pyclass(frozen, module = "pyrind.aead")] +pub(crate) struct Key(pub(crate) Box); + +#[pymethods] +impl Key { + /// Expands `key` for `algorithm`; raises `WrongLength` unless it is + /// `algorithm.key_len` bytes. + #[new] + fn new(algorithm: &Bound<'_, Algorithm>, key: Bytes<'_>) -> PyResult { + aead::Key::new(algorithm.get().inner, &key) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Encrypts `plaintext` under `nonce`, authenticating `aad` with it, and + /// returns the ciphertext with the tag appended. + #[pyo3(signature = (nonce, plaintext, *, aad))] + fn seal<'py>( + &self, + py: Python<'py>, + nonce: Bytes<'_>, + plaintext: Bytes<'_>, + aad: Bytes<'_>, + ) -> PyResult> { + let nonce = nonce_of(&nonce)?; + seal_into(py, &plaintext, |body| { + self.0.seal_in_place_separate_tag(nonce, &aad, body) + }) + } + + /// Decrypts and verifies `ciphertext`, sealed under `nonce` with `aad`, + /// and returns the plaintext; raises `DecryptFailed` if it does not + /// verify. + #[pyo3(signature = (nonce, ciphertext, *, aad))] + fn open<'py>( + &self, + py: Python<'py>, + nonce: Bytes<'_>, + ciphertext: Bytes<'_>, + aad: Bytes<'_>, + ) -> PyResult> { + let nonce = nonce_of(&nonce)?; + open_into(py, &ciphertext, |body, tag| { + self.0 + .open_in_place_separate_tag(nonce, &aad, aead::Tag::try_from(&tag[..])?, body) + .map(drop) + }) + } +} + +fn nonce_of(nonce: &[u8]) -> PyResult { + let nonce = nonce + .try_into() + .map_err(|_| WrongLength::new_err("a nonce is 12 bytes"))?; + Ok(aead::Nonce::assume_unique_for_key(nonce)) +} + +/// An XChaCha20-Poly1305 key that picks a random 192-bit nonce for every +/// seal, so the caller tracks no nonce state. +#[pyclass(frozen, module = "pyrind.aead")] +struct RandomizedNonceKey(Box); + +#[pymethods] +impl RandomizedNonceKey { + /// Makes a key from 32 bytes of key material; raises `WrongLength` + /// otherwise. + #[new] + fn new(key: Bytes<'_>) -> PyResult { + let key = (*key) + .try_into() + .map_err(|_| error(rind::Error::WrongLength))?; + Ok(Self(Box::new(aead::RandomizedNonceKey::new(key)))) + } + + /// Encrypts `plaintext` under a fresh random nonce, authenticating `aad` + /// with it, and returns the nonce, which the opener needs, and the + /// ciphertext with the tag appended. + #[pyo3(signature = (plaintext, *, aad))] + fn seal<'py>( + &self, + py: Python<'py>, + plaintext: Bytes<'_>, + aad: Bytes<'_>, + ) -> PyResult<(Bound<'py, PyBytes>, Bound<'py, PyBytes>)> { + let mut sealed = plaintext.to_vec(); + let nonce = self + .0 + .seal_in_place_append_tag(&aad, &mut sealed) + .map_err(error)?; + Ok((bytes(py, nonce), bytes(py, sealed))) + } + + /// Decrypts and verifies `ciphertext`, sealed under `nonce` with `aad`, + /// and returns the plaintext; raises `DecryptFailed` if it does not + /// verify. + #[pyo3(signature = (nonce, ciphertext, *, aad))] + fn open<'py>( + &self, + py: Python<'py>, + nonce: Bytes<'_>, + ciphertext: Bytes<'_>, + aad: Bytes<'_>, + ) -> PyResult> { + let nonce: [u8; 24] = (*nonce) + .try_into() + .map_err(|_| WrongLength::new_err("a nonce is 24 bytes"))?; + let mut sealed = ciphertext.to_vec(); + let plaintext = self + .0 + .open_in_place(nonce.into(), &aad, &mut sealed) + .map_err(error)?; + Ok(bytes(py, plaintext)) + } +} + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + constants!(m, Algorithm, "pyrind.aead", { + AES_128_GCM => &aead::AES_128_GCM, + AES_192_GCM => &aead::AES_192_GCM, + AES_256_GCM => &aead::AES_256_GCM, + CHACHA20_POLY1305 => &aead::CHACHA20_POLY1305, + }); + Ok(()) +} diff --git a/rind-python/src/agreement.rs b/rind-python/src/agreement.rs new file mode 100644 index 000000000..9a7c7dbfb --- /dev/null +++ b/rind-python/src/agreement.rs @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 +use pyo3::prelude::*; +use pyo3::types::PyBytes; +use rind::agreement; + +use crate::{Bytes, algorithm, bytes, constants, error}; + +algorithm!( + /// A key agreement algorithm. + Algorithm(agreement::Algorithm) as "pyrind.agreement"."Algorithm" {} +); + +/// A private key for key agreement. +#[pyclass(frozen, module = "pyrind.agreement")] +struct PrivateKey(Box); + +#[pymethods] +impl PrivateKey { + /// Generates a key for `algorithm`. + #[staticmethod] + fn generate(algorithm: &Bound<'_, Algorithm>) -> PyResult { + agreement::PrivateKey::generate(algorithm.get().inner) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a key for `algorithm` from its big-endian scalar, or for X25519 + /// its 32 bytes. + #[staticmethod] + fn from_scalar(algorithm: &Bound<'_, Algorithm>, scalar: Bytes<'_>) -> PyResult { + agreement::PrivateKey::from_scalar(algorithm.get().inner, &scalar) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a key for `algorithm` from PKCS#8 DER. + #[staticmethod] + fn from_pkcs8(algorithm: &Bound<'_, Algorithm>, der: Bytes<'_>) -> PyResult { + agreement::PrivateKey::from_pkcs8(algorithm.get().inner, &der) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads an ECDH key for `algorithm` from SEC1 `ECPrivateKey` DER. + #[staticmethod] + fn from_sec1(algorithm: &Bound<'_, Algorithm>, der: Bytes<'_>) -> PyResult { + agreement::PrivateKey::from_sec1(algorithm.get().inner, &der) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// The public key to send the peer. + fn public_key(&self) -> PublicKey { + PublicKey(Box::new(self.0.public_key())) + } + + /// Agrees a shared secret with the peer's public key `peer`, as + /// `bytes(PublicKey)` encodes it; raises `KeyRejected` for an invalid + /// one. + fn agree<'py>(&self, py: Python<'py>, peer: Bytes<'_>) -> PyResult> { + let secret = self.0.agree(&peer).map_err(error)?; + Ok(bytes(py, secret)) + } + + /// Encodes the key as PKCS#8 DER. + fn to_pkcs8<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_pkcs8()) + } + + /// Returns the key's scalar, as `from_scalar` takes it. + fn to_scalar<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_scalar()) + } +} + +/// A public key for key agreement, whose `bytes` are what `agree` takes: +/// 32 bytes for X25519, and an uncompressed SEC1 point for ECDH. +#[pyclass(frozen, module = "pyrind.agreement")] +struct PublicKey(Box); + +#[pymethods] +impl PublicKey { + /// Parses a public key for `algorithm` from its `bytes` encoding. + #[new] + fn new(algorithm: &Bound<'_, Algorithm>, data: Bytes<'_>) -> PyResult { + agreement::PublicKey::new(algorithm.get().inner, &data) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Parses a public key for `algorithm` from `SubjectPublicKeyInfo` DER. + #[staticmethod] + fn from_spki(algorithm: &Bound<'_, Algorithm>, der: Bytes<'_>) -> PyResult { + agreement::PublicKey::from_spki(algorithm.get().inner, &der) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Encodes the key as `SubjectPublicKeyInfo` DER. + fn to_spki<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_spki()) + } + + fn __bytes__<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, &*self.0) + } +} + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + constants!(m, Algorithm, "pyrind.agreement", { + X25519 => &agreement::X25519, + ECDH_P256 => &agreement::ECDH_P256, + ECDH_P384 => &agreement::ECDH_P384, + ECDH_P521 => &agreement::ECDH_P521, + }); + Ok(()) +} diff --git a/rind-python/src/digest.rs b/rind-python/src/digest.rs new file mode 100644 index 000000000..5256aafa1 --- /dev/null +++ b/rind-python/src/digest.rs @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 +use pyo3::prelude::*; +use pyo3::types::PyBytes; +use rind::digest; + +use crate::{Bytes, algorithm, bytes, constants, submodule}; + +algorithm!( + /// A hash function. + Algorithm(digest::Algorithm) as "pyrind.digest"."Algorithm" { + /// The digest's length in bytes. + #[getter] + fn output_len(&self) -> usize { + self.inner.output_len() + } + } +); + +/// Returns the digest of `data`. +#[pyfunction(name = "digest")] +fn digest_of<'py>( + py: Python<'py>, + algorithm: &Bound<'_, Algorithm>, + data: Bytes<'_>, +) -> Bound<'py, PyBytes> { + bytes(py, digest::digest(algorithm.get().inner, &data)) +} + +/// A hash of a message given in pieces. +#[pyclass(module = "pyrind.digest")] +struct Context(Box); + +#[pymethods] +impl Context { + /// Starts hashing with `algorithm`. + #[new] + fn new(algorithm: &Bound<'_, Algorithm>) -> Self { + Self(Box::new(digest::Context::new(algorithm.get().inner))) + } + + /// Adds `data` to the message. + fn update(&mut self, data: Bytes<'_>) { + self.0.update(&data); + } + + /// Returns the digest of the message, and starts a new one with the same + /// algorithm. + fn finish<'py>(&mut self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.finish()) + } +} + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(digest_of, m)?)?; + constants!(m, Algorithm, "pyrind.digest", { + SHA256 => &digest::SHA256, + SHA384 => &digest::SHA384, + SHA512 => &digest::SHA512, + SHA512_256 => &digest::SHA512_256, + SHA3_256 => &digest::SHA3_256, + SHA3_384 => &digest::SHA3_384, + SHA3_512 => &digest::SHA3_512, + }); + submodule(m, "pyrind.digest.legacy", |legacy| { + constants!(legacy, Algorithm, "pyrind.digest.legacy", { + MD5 => &digest::legacy::MD5, + SHA1 => &digest::legacy::SHA1, + }); + Ok(()) + }) +} diff --git a/rind-python/src/hkdf.rs b/rind-python/src/hkdf.rs new file mode 100644 index 000000000..a0816dc2c --- /dev/null +++ b/rind-python/src/hkdf.rs @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 +use pyo3::prelude::*; +use pyo3::types::PyBytes; +use rind::hkdf; + +use crate::{Bytes, aead, algorithm, constants, error, hmac, submodule}; + +algorithm!( + /// An HKDF algorithm. + Algorithm(hkdf::Algorithm) as "pyrind.hkdf"."Algorithm" {} +); + +/// An HKDF salt, which extracts pseudorandom keys from secrets. +#[pyclass(frozen, module = "pyrind.hkdf")] +struct Salt(Box); + +#[pymethods] +impl Salt { + /// Makes a salt for `algorithm` from `value`, of any length. + #[new] + fn new(algorithm: &Bound<'_, Algorithm>, value: Bytes<'_>) -> Self { + Self(Box::new(hkdf::Salt::new(algorithm.get().inner, &value))) + } + + /// Extracts a pseudorandom key from `secret`. + fn extract(&self, secret: Bytes<'_>) -> Prk { + Prk(Box::new(self.0.extract(&secret))) + } +} + +/// What `Prk.expand` derives: bytes of a length, or a key for an algorithm. +#[derive(FromPyObject)] +enum KeyType<'py> { + Len(usize), + Aead(Bound<'py, aead::Algorithm>), + Hmac(Bound<'py, hmac::Algorithm>), +} + +/// A pseudorandom key, which expands into any number of keys. +#[pyclass(frozen, module = "pyrind.hkdf")] +struct Prk(Box); + +#[pymethods] +impl Prk { + /// Expands the key with `info` into `key_type`: that many bytes for an + /// `int`, or an `aead.Key` or `hmac.Key` for their algorithms, whose key + /// bytes never leave rind. + /// + /// Raises `WrongLength` for more than 255 times the hash's length. + fn expand<'py>( + &self, + py: Python<'py>, + info: Bytes<'_>, + key_type: KeyType<'py>, + ) -> PyResult> { + let info: [&[u8]; 1] = [&info]; + Ok(match key_type { + KeyType::Len(len) => PyBytes::new_with(py, len, |out| { + self.0.expand(&info, out.len()).fill(out).map_err(error) + })? + .into_any(), + KeyType::Aead(algorithm) => { + let key = rind::aead::Key::from(self.0.expand(&info, algorithm.get().inner)); + Bound::new(py, aead::Key(Box::new(key)))?.into_any() + } + KeyType::Hmac(algorithm) => { + let key = rind::hmac::Key::from(self.0.expand(&info, algorithm.get().inner)); + Bound::new(py, hmac::Key(Box::new(key)))?.into_any() + } + }) + } +} + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + constants!(m, Algorithm, "pyrind.hkdf", { + HKDF_SHA256 => &hkdf::HKDF_SHA256, + HKDF_SHA384 => &hkdf::HKDF_SHA384, + HKDF_SHA512 => &hkdf::HKDF_SHA512, + }); + submodule(m, "pyrind.hkdf.legacy", |legacy| { + constants!(legacy, Algorithm, "pyrind.hkdf.legacy", { + HKDF_SHA1 => &hkdf::legacy::HKDF_SHA1, + }); + Ok(()) + }) +} diff --git a/rind-python/src/hmac.rs b/rind-python/src/hmac.rs new file mode 100644 index 000000000..1e50e4c93 --- /dev/null +++ b/rind-python/src/hmac.rs @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::PyBytes; +use rind::hmac; + +use crate::{Bytes, algorithm, bytes, constants, error, submodule}; + +algorithm!( + /// An HMAC algorithm. + Algorithm(hmac::Algorithm) as "pyrind.hmac"."Algorithm" { + /// The tag's length in bytes. + #[getter] + fn tag_len(&self) -> usize { + self.inner.tag_len() + } + } +); + +/// An HMAC key, whose keyed hash states are computed once. +#[pyclass(frozen, module = "pyrind.hmac")] +pub(crate) struct Key(pub(crate) Box); + +#[pymethods] +impl Key { + /// Makes a key for `algorithm` from `key`, of any length. + #[new] + fn new(algorithm: &Bound<'_, Algorithm>, key: Bytes<'_>) -> Self { + Self(Box::new(hmac::Key::new(algorithm.get().inner, &key))) + } +} + +/// Returns the tag of `data` under `key`. +#[pyfunction] +fn sign<'py>(py: Python<'py>, key: &Bound<'_, Key>, data: Bytes<'_>) -> Bound<'py, PyBytes> { + bytes(py, hmac::sign(&key.get().0, &data)) +} + +/// Checks in constant time that `tag` is the whole tag of `data` under `key`; +/// raises `BadSignature` otherwise, including for a truncated tag. +#[pyfunction] +fn verify(key: &Bound<'_, Key>, data: Bytes<'_>, tag: Bytes<'_>) -> PyResult<()> { + hmac::verify(&key.get().0, &data, &tag).map_err(error) +} + +/// A tag of a message given in pieces, which signs once. +#[pyclass(module = "pyrind.hmac")] +struct Context(Option>); + +fn signed() -> PyErr { + PyValueError::new_err("the context has already signed") +} + +#[pymethods] +impl Context { + /// Starts a tag under `key`. + #[new] + fn new(key: &Bound<'_, Key>) -> Self { + Self(Some(Box::new(hmac::Context::with_key(&key.get().0)))) + } + + /// Adds `data` to the message. + fn update(&mut self, data: Bytes<'_>) -> PyResult<()> { + self.0.as_mut().ok_or_else(signed)?.update(&data); + Ok(()) + } + + /// Returns the tag of the message; the context takes no more data. + fn sign<'py>(&mut self, py: Python<'py>) -> PyResult> { + let context = self.0.take().ok_or_else(signed)?; + Ok(bytes(py, context.sign())) + } +} + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(sign, m)?)?; + m.add_function(wrap_pyfunction!(verify, m)?)?; + constants!(m, Algorithm, "pyrind.hmac", { + HMAC_SHA256 => &hmac::HMAC_SHA256, + HMAC_SHA384 => &hmac::HMAC_SHA384, + HMAC_SHA512 => &hmac::HMAC_SHA512, + }); + submodule(m, "pyrind.hmac.legacy", |legacy| { + constants!(legacy, Algorithm, "pyrind.hmac.legacy", { + HMAC_SHA1 => &hmac::legacy::HMAC_SHA1, + }); + Ok(()) + }) +} diff --git a/rind-python/src/kem.rs b/rind-python/src/kem.rs new file mode 100644 index 000000000..f9b84ec1f --- /dev/null +++ b/rind-python/src/kem.rs @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 +use pyo3::prelude::*; +use pyo3::types::PyBytes; +use rind::kem; + +use crate::{Bytes, algorithm, bytes, constants, error}; + +algorithm!( + /// A key encapsulation mechanism. + Algorithm(kem::Algorithm) as "pyrind.kem"."Algorithm" {} +); + +/// The recipient's private key, which decapsulates. +#[pyclass(frozen, module = "pyrind.kem")] +struct DecapsulationKey(Box); + +#[pymethods] +impl DecapsulationKey { + /// Generates a key for `algorithm`. + #[staticmethod] + fn generate(algorithm: &Bound<'_, Algorithm>) -> PyResult { + kem::DecapsulationKey::generate(algorithm.get().inner) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// The public key to send the sender. + fn public_key(&self) -> EncapsulationKey { + EncapsulationKey(Box::new(self.0.public_key())) + } + + /// Recovers the shared secret from the sender's `ciphertext`. + fn decapsulate<'py>( + &self, + py: Python<'py>, + ciphertext: Bytes<'_>, + ) -> PyResult> { + let secret = self.0.decapsulate(&ciphertext).map_err(error)?; + Ok(bytes(py, secret)) + } +} + +/// The recipient's public key, which encapsulates, and whose `bytes` are its +/// encoding. +#[pyclass(frozen, module = "pyrind.kem")] +struct EncapsulationKey(Box); + +#[pymethods] +impl EncapsulationKey { + /// Parses a public key for `algorithm`. + #[new] + fn new(algorithm: &Bound<'_, Algorithm>, data: Bytes<'_>) -> PyResult { + kem::EncapsulationKey::new(algorithm.get().inner, &data) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Encapsulates a fresh shared secret, returning the ciphertext to send + /// the recipient and the secret. + fn encapsulate<'py>( + &self, + py: Python<'py>, + ) -> PyResult<(Bound<'py, PyBytes>, Bound<'py, PyBytes>)> { + let (ciphertext, secret) = self.0.encapsulate().map_err(error)?; + Ok((bytes(py, ciphertext), bytes(py, secret))) + } + + fn __bytes__<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, &*self.0) + } +} + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + constants!(m, Algorithm, "pyrind.kem", { + ML_KEM_768 => &kem::ML_KEM_768, + }); + Ok(()) +} diff --git a/rind-python/src/lib.rs b/rind-python/src/lib.rs new file mode 100644 index 000000000..9d3035ccd --- /dev/null +++ b/rind-python/src/lib.rs @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 +//! pyrind: rind's API for Python. +//! +//! Each rind module becomes a Python submodule of the same name, importable +//! as `pyrind.`. Keys and contexts are classes; algorithms are module +//! constants of a class per module, so passing one module's algorithm to +//! another's key is a `TypeError`. Every rind error raises a subclass of +//! `pyrind.Error`. +//! +//! Every class holds its rind value in a `Box`. pyo3 places a class's +//! contents inside the Python object, which CPython's allocator aligns to +//! only 16 bytes (8 on 32-bit targets), while some rind types need 32, such +//! as ML-KEM's coefficients that AVX2 loads aligned +//! (). + +use std::borrow::Cow; + +use pyo3::buffer::PyBuffer; +use pyo3::create_exception; +use pyo3::exceptions::{PyException, PyImportError}; +use pyo3::prelude::*; +use pyo3::types::{PyBytes, PyDict}; + +mod aead; +mod agreement; +mod digest; +mod hkdf; +mod hmac; +mod kem; +mod pbkdf2; +mod rsa; +mod signature; + +create_exception!(pyrind, Error, PyException, "A rind call failed."); +create_exception!( + pyrind, + WrongLength, + Error, + "An input or output of fixed length, such as a key, a nonce, or a derived key, was another length." +); +create_exception!( + pyrind, + KeyRejected, + Error, + "A private, public, or peer's key was refused; the message says why." +); +create_exception!( + pyrind, + BadSignature, + Error, + "A signature, MAC tag, or PBKDF2 output did not verify." +); +create_exception!( + pyrind, + DecryptFailed, + Error, + "An AEAD ciphertext, its tag, or its associated data did not authenticate, or an RSA-OAEP ciphertext did not decrypt." +); +create_exception!( + pyrind, + RngFailed, + Error, + "The system random source failed." +); +create_exception!( + pyrind, + Unsupported, + Error, + "A call was given an algorithm it does not take." +); +create_exception!( + pyrind, + Fault, + Error, + "An internal consistency check failed, from a hardware fault or a rind bug." +); + +/// Raises the `pyrind` exception for a rind error. +pub(crate) fn error(e: rind::Error) -> PyErr { + match e { + rind::Error::WrongLength => WrongLength::new_err(()), + rind::Error::KeyRejected(why) => KeyRejected::new_err(why.to_string()), + rind::Error::BadSignature => BadSignature::new_err(()), + rind::Error::DecryptFailed => DecryptFailed::new_err(()), + rind::Error::RngFailed => RngFailed::new_err(()), + rind::Error::Unsupported => Unsupported::new_err(()), + rind::Error::Fault => Fault::new_err(()), + // `NotEqual` comes only from `constant_time`, which pyrind leaves to + // `hmac.compare_digest`; the rest are variants rind adds later. + other => Error::new_err(format!("{other:?}")), + } +} + +/// A bytes-like argument: borrowed from `bytes`, which is immutable, and +/// copied from any other buffer, such as a `bytearray` or `memoryview`, so +/// no other thread can change it while rind reads it, even with the GIL +/// released. +pub(crate) struct Bytes<'a>(Cow<'a, [u8]>); + +impl<'a, 'py> FromPyObject<'a, 'py> for Bytes<'a> { + type Error = PyErr; + + fn extract(obj: Borrowed<'a, 'py, PyAny>) -> PyResult { + if let Ok(bytes) = <&'a [u8]>::extract(obj) { + return Ok(Self(Cow::Borrowed(bytes))); + } + let buffer = PyBuffer::::get(&obj)?; + Ok(Self(Cow::Owned(buffer.to_vec(obj.py())?))) + } +} + +impl core::ops::Deref for Bytes<'_> { + type Target = [u8]; + + fn deref(&self) -> &[u8] { + &self.0 + } +} + +/// Declares the Python class of a module's algorithm constants, each +/// wrapping a rind algorithm, with any further methods. +macro_rules! algorithm { + ($(#[$doc:meta])* $ty:ident($inner:ty) as $module:literal . $name:literal { $($methods:tt)* }) => { + $(#[$doc])* + #[pyclass(frozen, module = $module, name = $name)] + pub(crate) struct $ty { + pub(crate) inner: &'static $inner, + pub(crate) path: &'static str, + } + + #[pymethods] + impl $ty { + fn __repr__(&self) -> &'static str { + self.path + } + + $($methods)* + } + }; +} +pub(crate) use algorithm; + +/// Adds each algorithm constant to the module `m`, whose path is `$module`. +macro_rules! constants { + ($m:expr, $ty:ident, $module:literal, { $($constant:ident => $value:expr),* $(,)? }) => {{ + $($m.add( + stringify!($constant), + $ty { + inner: $value, + path: concat!($module, ".", stringify!($constant)), + }, + )?;)* + }}; +} +pub(crate) use constants; + +/// Returns `bytes` holding `value`. +pub(crate) fn bytes<'py>(py: Python<'py>, value: impl AsRef<[u8]>) -> Bound<'py, PyBytes> { + PyBytes::new(py, value.as_ref()) +} + +/// Adds the submodule `path`, such as `pyrind.signature.verify`, to +/// `parent` under its last component, and to `sys.modules`, so that +/// `import pyrind.signature.verify` finds it. +pub(crate) fn submodule<'py>( + parent: &Bound<'py, PyModule>, + path: &str, + build: impl FnOnce(&Bound<'py, PyModule>) -> PyResult<()>, +) -> PyResult<()> { + let py = parent.py(); + let module = PyModule::new(py, path)?; + build(&module)?; + let name = path.rsplit('.').next().unwrap_or(path); + parent.add(name, &module)?; + py.import("sys")? + .getattr("modules")? + .cast_into::()? + .set_item(path, module) +} + +/// Whether this CPU runs AES and GHASH (or ARM's PMULL) in hardware; where it +/// does not, prefer ChaCha20-Poly1305. +#[pyfunction] +fn has_aes_hardware() -> bool { + rind::cpu::has_aes_hardware() +} + +#[pymodule] +#[pyo3(name = "_rind")] +fn pyrind(m: &Bound<'_, PyModule>) -> PyResult<()> { + // rind panics on a CPU missing a feature it requires; refuse the import + // instead, naming the feature. + rind::cpu::check().map_err(|missing| PyImportError::new_err(missing.to_string()))?; + + let py = m.py(); + m.add("Error", py.get_type::())?; + m.add("WrongLength", py.get_type::())?; + m.add("KeyRejected", py.get_type::())?; + m.add("BadSignature", py.get_type::())?; + m.add("DecryptFailed", py.get_type::())?; + m.add("RngFailed", py.get_type::())?; + m.add("Unsupported", py.get_type::())?; + m.add("Fault", py.get_type::())?; + + submodule(m, "pyrind.aead", aead::register)?; + submodule(m, "pyrind.agreement", agreement::register)?; + submodule(m, "pyrind.cpu", |cpu| { + cpu.add_function(wrap_pyfunction!(has_aes_hardware, cpu)?) + })?; + submodule(m, "pyrind.digest", digest::register)?; + submodule(m, "pyrind.hkdf", hkdf::register)?; + submodule(m, "pyrind.hmac", hmac::register)?; + submodule(m, "pyrind.kem", kem::register)?; + submodule(m, "pyrind.pbkdf2", pbkdf2::register)?; + submodule(m, "pyrind.rsa", rsa::register)?; + submodule(m, "pyrind.signature", signature::register)?; + Ok(()) +} diff --git a/rind-python/src/pbkdf2.rs b/rind-python/src/pbkdf2.rs new file mode 100644 index 000000000..d79c14ed9 --- /dev/null +++ b/rind-python/src/pbkdf2.rs @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 +use std::num::NonZeroU32; + +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::PyBytes; +use rind::pbkdf2; + +use crate::{Bytes, algorithm, constants, error, submodule}; + +algorithm!( + /// A PBKDF2 algorithm. + Algorithm(pbkdf2::Algorithm) as "pyrind.pbkdf2"."Algorithm" {} +); + +fn iterations_of(iterations: u32) -> PyResult { + NonZeroU32::new(iterations).ok_or_else(|| PyValueError::new_err("iterations must be at least 1")) +} + +/// A password keyed into HMAC once, which derives keys for any salt and +/// iteration count. +/// +/// Both calls release the GIL while they run. +#[pyclass(frozen, module = "pyrind.pbkdf2")] +struct Secret(Box); + +#[pymethods] +impl Secret { + /// Keys `algorithm` with `secret`, a password of any length. + #[new] + fn new(algorithm: &Bound<'_, Algorithm>, secret: Bytes<'_>) -> Self { + Self(Box::new(pbkdf2::Secret::new(algorithm.get().inner, &secret))) + } + + /// Derives `length` bytes for `salt` with `iterations`. + fn derive<'py>( + &self, + py: Python<'py>, + iterations: u32, + salt: Bytes<'_>, + length: usize, + ) -> PyResult> { + let iterations = iterations_of(iterations)?; + PyBytes::new_with(py, length, |out| { + py.detach(|| self.0.derive(iterations, &salt, out)) + .map_err(error) + }) + } + + /// Checks in constant time that `previously_derived` is what `derive` + /// returns for `salt` and `iterations`; raises `BadSignature` otherwise. + fn verify( + &self, + py: Python<'_>, + iterations: u32, + salt: Bytes<'_>, + previously_derived: Bytes<'_>, + ) -> PyResult<()> { + let iterations = iterations_of(iterations)?; + py.detach(|| self.0.verify(iterations, &salt, &previously_derived)) + .map_err(error) + } +} + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + constants!(m, Algorithm, "pyrind.pbkdf2", { + PBKDF2_HMAC_SHA256 => &pbkdf2::PBKDF2_HMAC_SHA256, + PBKDF2_HMAC_SHA384 => &pbkdf2::PBKDF2_HMAC_SHA384, + PBKDF2_HMAC_SHA512 => &pbkdf2::PBKDF2_HMAC_SHA512, + }); + submodule(m, "pyrind.pbkdf2.legacy", |legacy| { + constants!(legacy, Algorithm, "pyrind.pbkdf2.legacy", { + PBKDF2_HMAC_SHA1 => &pbkdf2::legacy::PBKDF2_HMAC_SHA1, + }); + Ok(()) + }) +} diff --git a/rind-python/src/rsa.rs b/rind-python/src/rsa.rs new file mode 100644 index 000000000..f0943305d --- /dev/null +++ b/rind-python/src/rsa.rs @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 +use pyo3::prelude::*; +use pyo3::types::PyBytes; +use rind::rsa; + +use crate::{Bytes, algorithm, bytes, constants, error, submodule}; + +/// An RSA modulus size for key generation. +#[pyclass(frozen, eq, eq_int, from_py_object, module = "pyrind.rsa")] +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum KeySize { + #[pyo3(name = "RSA_2048")] + Rsa2048, + #[pyo3(name = "RSA_3072")] + Rsa3072, + #[pyo3(name = "RSA_4096")] + Rsa4096, + #[pyo3(name = "RSA_6144")] + Rsa6144, + #[pyo3(name = "RSA_8192")] + Rsa8192, +} + +impl From for rsa::KeySize { + fn from(size: KeySize) -> Self { + match size { + KeySize::Rsa2048 => Self::Rsa2048, + KeySize::Rsa3072 => Self::Rsa3072, + KeySize::Rsa4096 => Self::Rsa4096, + KeySize::Rsa6144 => Self::Rsa6144, + KeySize::Rsa8192 => Self::Rsa8192, + } + } +} + +algorithm!( + /// An RSA-OAEP algorithm. + OaepAlgorithm(rsa::OaepAlgorithm) as "pyrind.rsa"."OaepAlgorithm" {} +); + +/// An RSA-OAEP private key, which decrypts. +#[pyclass(frozen, module = "pyrind.rsa")] +struct DecryptingKey(Box); + +#[pymethods] +impl DecryptingKey { + /// Generates a key of `size` for `algorithm`, releasing the GIL: about + /// 30 milliseconds for 2048 bits, and seconds for the largest sizes. + #[staticmethod] + fn generate( + py: Python<'_>, + algorithm: &Bound<'_, OaepAlgorithm>, + size: KeySize, + ) -> PyResult { + let algorithm = algorithm.get().inner; + py.detach(|| rsa::DecryptingKey::generate(algorithm, size.into())) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a PKCS#8 RSA private key for `algorithm`. + #[staticmethod] + fn from_pkcs8(algorithm: &Bound<'_, OaepAlgorithm>, der: Bytes<'_>) -> PyResult { + rsa::DecryptingKey::from_pkcs8(algorithm.get().inner, &der) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a PKCS#1 `RSAPrivateKey` for `algorithm`. + #[staticmethod] + fn from_pkcs1(algorithm: &Bound<'_, OaepAlgorithm>, der: Bytes<'_>) -> PyResult { + rsa::DecryptingKey::from_pkcs1(algorithm.get().inner, &der) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// The public key, which encrypts to this key with its algorithm. + fn public_key(&self) -> EncryptingKey { + EncryptingKey(Box::new(self.0.public_key())) + } + + /// Decrypts `ciphertext`, encrypted with `label`, releasing the GIL. + /// + /// Every malformed ciphertext raises the same `DecryptFailed`, and bad + /// padding takes as long to find as good, so revealing the failure is no + /// padding oracle. + #[pyo3(signature = (ciphertext, *, label))] + fn decrypt<'py>( + &self, + py: Python<'py>, + ciphertext: Bytes<'_>, + label: Bytes<'_>, + ) -> PyResult> { + let plaintext = py + .detach(|| self.0.decrypt(&ciphertext, &label)) + .map_err(error)?; + Ok(bytes(py, plaintext)) + } + + /// Encodes the private key as PKCS#8 DER. + fn to_pkcs8<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_pkcs8()) + } + + /// Encodes the public key as `SubjectPublicKeyInfo` DER. + fn to_spki<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_spki()) + } +} + +/// An RSA-OAEP public key, which encrypts. +#[pyclass(frozen, module = "pyrind.rsa")] +struct EncryptingKey(Box); + +#[pymethods] +impl EncryptingKey { + /// Parses a PKCS#1 `RSAPublicKey` or a `SubjectPublicKeyInfo` for + /// `algorithm`. + #[new] + fn new(algorithm: &Bound<'_, OaepAlgorithm>, der: Bytes<'_>) -> PyResult { + rsa::EncryptingKey::new(algorithm.get().inner, &der) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a key for `algorithm` from its big-endian modulus `n` and + /// exponent `e`, as a JWK carries them. + #[staticmethod] + fn from_components( + algorithm: &Bound<'_, OaepAlgorithm>, + n: Bytes<'_>, + e: Bytes<'_>, + ) -> PyResult { + let components = rsa::PublicKeyComponents { n: &*n, e: &*e }; + rsa::EncryptingKey::from_components(algorithm.get().inner, &components) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Encrypts `message` with `label`, returning a ciphertext as long as the + /// modulus; raises `WrongLength` past `max_message_len`. + #[pyo3(signature = (message, *, label))] + fn encrypt<'py>( + &self, + py: Python<'py>, + message: Bytes<'_>, + label: Bytes<'_>, + ) -> PyResult> { + let ciphertext = self.0.encrypt(&message, &label).map_err(error)?; + Ok(bytes(py, ciphertext)) + } + + /// The longest message this key encrypts. + #[getter] + fn max_message_len(&self) -> usize { + self.0.max_message_len() + } + + /// Encodes the key as `SubjectPublicKeyInfo` DER. + fn to_spki<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_spki()) + } +} + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + constants!(m, OaepAlgorithm, "pyrind.rsa", { + OAEP_SHA256 => &rsa::OAEP_SHA256, + OAEP_SHA384 => &rsa::OAEP_SHA384, + OAEP_SHA512 => &rsa::OAEP_SHA512, + }); + submodule(m, "pyrind.rsa.legacy", |legacy| { + constants!(legacy, OaepAlgorithm, "pyrind.rsa.legacy", { + OAEP_SHA1 => &rsa::legacy::OAEP_SHA1, + }); + Ok(()) + }) +} diff --git a/rind-python/src/signature.rs b/rind-python/src/signature.rs new file mode 100644 index 000000000..68c2b86ee --- /dev/null +++ b/rind-python/src/signature.rs @@ -0,0 +1,396 @@ +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 +use pyo3::prelude::*; +use pyo3::types::PyBytes; +use rind::signature::{self, sign, verify}; + +use crate::rsa::KeySize; +use crate::{Bytes, algorithm, bytes, constants, error, submodule}; + +algorithm!( + /// An algorithm an `EcdsaKeyPair` signs with. + EcdsaSigningAlgorithm(signature::EcdsaSigningAlgorithm) as "pyrind.signature"."EcdsaSigningAlgorithm" {} +); + +algorithm!( + /// An algorithm an `RsaKeyPair` signs with. + RsaSigningAlgorithm(signature::RsaSigningAlgorithm) as "pyrind.signature"."RsaSigningAlgorithm" {} +); + +algorithm!( + /// An algorithm a `PublicKey` verifies with. + VerificationAlgorithm(signature::VerificationAlgorithm) as "pyrind.signature"."VerificationAlgorithm" {} +); + +algorithm!( + /// An RSA algorithm a `PublicKey` verifies with, which also loads a key + /// from its components. + RsaVerificationAlgorithm(signature::RsaVerificationAlgorithm) as "pyrind.signature"."RsaVerificationAlgorithm" {} +); + +/// Either kind of verification algorithm. +#[derive(FromPyObject)] +enum AnyVerificationAlgorithm<'py> { + Plain(Bound<'py, VerificationAlgorithm>), + Rsa(Bound<'py, RsaVerificationAlgorithm>), +} + +impl AnyVerificationAlgorithm<'_> { + fn get(&self) -> &'static signature::VerificationAlgorithm { + match self { + Self::Plain(algorithm) => algorithm.get().inner, + Self::Rsa(algorithm) => algorithm.get().inner.as_ref(), + } + } +} + +/// A public key that verifies signatures for one algorithm, whose `bytes` +/// are its encoding: an uncompressed SEC1 point for ECDSA, 32 bytes for +/// Ed25519, and a PKCS#1 `RSAPublicKey` for RSA. +#[pyclass(frozen, module = "pyrind.signature")] +struct PublicKey(Box); + +#[pymethods] +impl PublicKey { + /// Parses a public key for `algorithm` from its `bytes` encoding. + #[new] + fn new(algorithm: AnyVerificationAlgorithm<'_>, data: Bytes<'_>) -> PyResult { + signature::PublicKey::new(algorithm.get(), &data) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Parses a public key for `algorithm` from `SubjectPublicKeyInfo` DER, + /// refusing one that names another algorithm or curve. + #[staticmethod] + fn from_spki(algorithm: AnyVerificationAlgorithm<'_>, der: Bytes<'_>) -> PyResult { + signature::PublicKey::from_spki(algorithm.get(), &der) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads an RSA key for `algorithm` from its big-endian modulus `n` and + /// exponent `e`, as a JWK carries them. + #[staticmethod] + fn from_components( + algorithm: &Bound<'_, RsaVerificationAlgorithm>, + n: Bytes<'_>, + e: Bytes<'_>, + ) -> PyResult { + let components = rind::rsa::PublicKeyComponents { n: &*n, e: &*e }; + signature::PublicKey::from_components(algorithm.get().inner, &components) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Checks `signature` over `message`; raises `BadSignature` if it does + /// not verify. + fn verify(&self, message: Bytes<'_>, signature: Bytes<'_>) -> PyResult<()> { + self.0.verify(&message, &signature).map_err(error) + } + + /// Encodes the key as `SubjectPublicKeyInfo` DER. + fn to_spki<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_spki()) + } + + fn __bytes__<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, &*self.0) + } +} + +/// An ECDSA key pair, which signs with one algorithm. +#[pyclass(frozen, module = "pyrind.signature")] +struct EcdsaKeyPair(Box); + +#[pymethods] +impl EcdsaKeyPair { + /// Generates a key pair for `algorithm`. + #[staticmethod] + fn generate(algorithm: &Bound<'_, EcdsaSigningAlgorithm>) -> PyResult { + signature::EcdsaKeyPair::generate(algorithm.get().inner) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a key pair for `algorithm` from PKCS#8 DER. + #[staticmethod] + fn from_pkcs8(algorithm: &Bound<'_, EcdsaSigningAlgorithm>, der: Bytes<'_>) -> PyResult { + signature::EcdsaKeyPair::from_pkcs8(algorithm.get().inner, &der) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a key pair for `algorithm` from SEC1 `ECPrivateKey` DER. + #[staticmethod] + fn from_sec1(algorithm: &Bound<'_, EcdsaSigningAlgorithm>, der: Bytes<'_>) -> PyResult { + signature::EcdsaKeyPair::from_sec1(algorithm.get().inner, &der) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a key pair for `algorithm` from its big-endian scalar and its + /// uncompressed public key, as a JWK's `d`, `x`, and `y` carry them, + /// refused unless they match. + #[staticmethod] + fn from_scalar_and_public_key( + algorithm: &Bound<'_, EcdsaSigningAlgorithm>, + scalar: Bytes<'_>, + public_key: Bytes<'_>, + ) -> PyResult { + signature::EcdsaKeyPair::from_scalar_and_public_key( + algorithm.get().inner, + &scalar, + &public_key, + ) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// The public key, which verifies with the verification algorithm of the + /// same name. + fn public_key(&self) -> PublicKey { + PublicKey(Box::new(self.0.public_key())) + } + + /// Signs `message`. + fn sign<'py>(&self, py: Python<'py>, message: Bytes<'_>) -> PyResult> { + let signature = self.0.sign(&message).map_err(error)?; + Ok(bytes(py, signature)) + } + + /// Encodes the key pair as PKCS#8 DER. + fn to_pkcs8<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_pkcs8()) + } + + /// Encodes the key pair as SEC1 `ECPrivateKey` DER. + fn to_sec1<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_sec1()) + } + + /// Returns the private key's big-endian scalar. + fn to_scalar<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_scalar()) + } + + /// Encodes the public key as `SubjectPublicKeyInfo` DER. + fn to_spki<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_spki()) + } +} + +/// An Ed25519 key pair. +#[pyclass(frozen, module = "pyrind.signature")] +struct Ed25519KeyPair(Box); + +#[pymethods] +impl Ed25519KeyPair { + /// Generates a key pair. + #[staticmethod] + fn generate() -> PyResult { + signature::Ed25519KeyPair::generate() + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a key pair from PKCS#8 v1 or v2 DER, refused if a v2 key's + /// public key does not match. + #[staticmethod] + fn from_pkcs8(der: Bytes<'_>) -> PyResult { + signature::Ed25519KeyPair::from_pkcs8(&der) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a 32-byte seed, as a JWK's `d` holds it, deriving its public + /// key. + #[staticmethod] + fn from_seed(seed: Bytes<'_>) -> PyResult { + signature::Ed25519KeyPair::from_seed(&seed) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a 32-byte seed, refused unless `public_key` is its public key. + #[staticmethod] + fn from_seed_and_public_key(seed: Bytes<'_>, public_key: Bytes<'_>) -> PyResult { + signature::Ed25519KeyPair::from_seed_and_public_key(&seed, &public_key) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// The public key, which verifies with `verify.ED25519`. + fn public_key(&self) -> PublicKey { + PublicKey(Box::new(self.0.public_key())) + } + + /// Signs `message`. + fn sign<'py>(&self, py: Python<'py>, message: Bytes<'_>) -> Bound<'py, PyBytes> { + bytes(py, self.0.sign(&message)) + } + + /// Encodes the key pair as PKCS#8 v2 DER (RFC 5958), with its public key. + fn to_pkcs8<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_pkcs8()) + } + + /// Encodes the private key as PKCS#8 v1 DER (RFC 8410), as OpenSSL + /// writes it. + fn to_pkcs8v1<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_pkcs8v1()) + } + + /// Returns the private key's 32-byte seed. + fn to_seed<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_seed()) + } + + /// Encodes the public key as `SubjectPublicKeyInfo` DER. + fn to_spki<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_spki()) + } +} + +/// An RSA key pair, which signs with any RSA signing algorithm. +#[pyclass(frozen, module = "pyrind.signature")] +struct RsaKeyPair(Box); + +#[pymethods] +impl RsaKeyPair { + /// Generates a key pair of `size`, releasing the GIL: about 30 + /// milliseconds for 2048 bits, and seconds for the largest sizes. + #[staticmethod] + fn generate(py: Python<'_>, size: KeySize) -> PyResult { + py.detach(|| signature::RsaKeyPair::generate(size.into())) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a PKCS#8 RSA private key. + #[staticmethod] + fn from_pkcs8(der: Bytes<'_>) -> PyResult { + signature::RsaKeyPair::from_pkcs8(&der) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a PKCS#1 `RSAPrivateKey`. + #[staticmethod] + fn from_pkcs1(der: Bytes<'_>) -> PyResult { + signature::RsaKeyPair::from_pkcs1(&der) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// The public key, which verifies what this key pair signs with + /// `algorithm`. + fn public_key(&self, algorithm: &Bound<'_, RsaSigningAlgorithm>) -> PublicKey { + PublicKey(Box::new(self.0.public_key(algorithm.get().inner))) + } + + /// Signs `message` with `algorithm`, releasing the GIL. + fn sign<'py>( + &self, + py: Python<'py>, + algorithm: &Bound<'_, RsaSigningAlgorithm>, + message: Bytes<'_>, + ) -> PyResult> { + let algorithm = algorithm.get().inner; + let signature = py + .detach(|| self.0.sign(algorithm, &message)) + .map_err(error)?; + Ok(bytes(py, signature)) + } + + /// Encodes the private key as PKCS#8 DER. + fn to_pkcs8<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_pkcs8()) + } + + /// Encodes the public key as `SubjectPublicKeyInfo` DER. + fn to_spki<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_spki()) + } +} + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + submodule(m, "pyrind.signature.sign", |m| { + constants!(m, EcdsaSigningAlgorithm, "pyrind.signature.sign", { + ECDSA_P256_SHA256_ASN1 => &sign::ECDSA_P256_SHA256_ASN1, + ECDSA_P256_SHA256_FIXED => &sign::ECDSA_P256_SHA256_FIXED, + ECDSA_P384_SHA384_ASN1 => &sign::ECDSA_P384_SHA384_ASN1, + ECDSA_P384_SHA384_FIXED => &sign::ECDSA_P384_SHA384_FIXED, + ECDSA_P521_SHA512_ASN1 => &sign::ECDSA_P521_SHA512_ASN1, + ECDSA_P521_SHA512_FIXED => &sign::ECDSA_P521_SHA512_FIXED, + }); + constants!(m, RsaSigningAlgorithm, "pyrind.signature.sign", { + RSA_PKCS1_SHA256 => &sign::RSA_PKCS1_SHA256, + RSA_PKCS1_SHA384 => &sign::RSA_PKCS1_SHA384, + RSA_PKCS1_SHA512 => &sign::RSA_PKCS1_SHA512, + RSA_PSS_SHA256 => &sign::RSA_PSS_SHA256, + RSA_PSS_SHA384 => &sign::RSA_PSS_SHA384, + RSA_PSS_SHA512 => &sign::RSA_PSS_SHA512, + }); + Ok(()) + })?; + submodule(m, "pyrind.signature.verify", |m| { + constants!(m, VerificationAlgorithm, "pyrind.signature.verify", { + ECDSA_P256_SHA256_ASN1 => &verify::ECDSA_P256_SHA256_ASN1, + ECDSA_P256_SHA256_FIXED => &verify::ECDSA_P256_SHA256_FIXED, + ECDSA_P256_SHA384_ASN1 => &verify::ECDSA_P256_SHA384_ASN1, + ECDSA_P256_SHA512_ASN1 => &verify::ECDSA_P256_SHA512_ASN1, + ECDSA_P384_SHA256_ASN1 => &verify::ECDSA_P384_SHA256_ASN1, + ECDSA_P384_SHA384_ASN1 => &verify::ECDSA_P384_SHA384_ASN1, + ECDSA_P384_SHA384_FIXED => &verify::ECDSA_P384_SHA384_FIXED, + ECDSA_P384_SHA512_ASN1 => &verify::ECDSA_P384_SHA512_ASN1, + ECDSA_P521_SHA256_ASN1 => &verify::ECDSA_P521_SHA256_ASN1, + ECDSA_P521_SHA384_ASN1 => &verify::ECDSA_P521_SHA384_ASN1, + ECDSA_P521_SHA512_ASN1 => &verify::ECDSA_P521_SHA512_ASN1, + ECDSA_P521_SHA512_FIXED => &verify::ECDSA_P521_SHA512_FIXED, + ED25519 => &verify::ED25519, + }); + constants!(m, RsaVerificationAlgorithm, "pyrind.signature.verify", { + RSA_PKCS1_SHA256 => &verify::RSA_PKCS1_SHA256, + RSA_PKCS1_SHA384 => &verify::RSA_PKCS1_SHA384, + RSA_PKCS1_SHA512 => &verify::RSA_PKCS1_SHA512, + RSA_PSS_SHA256 => &verify::RSA_PSS_SHA256, + RSA_PSS_SHA256_ANY_SALT => &verify::RSA_PSS_SHA256_ANY_SALT, + RSA_PSS_SHA384 => &verify::RSA_PSS_SHA384, + RSA_PSS_SHA384_ANY_SALT => &verify::RSA_PSS_SHA384_ANY_SALT, + RSA_PSS_SHA512 => &verify::RSA_PSS_SHA512, + RSA_PSS_SHA512_ANY_SALT => &verify::RSA_PSS_SHA512_ANY_SALT, + }); + submodule(m, "pyrind.signature.verify.legacy", |legacy| { + constants!(legacy, RsaVerificationAlgorithm, "pyrind.signature.verify.legacy", { + RSA_PKCS1_1024_SHA1 => &verify::legacy::RSA_PKCS1_1024_SHA1, + RSA_PKCS1_1024_SHA256 => &verify::legacy::RSA_PKCS1_1024_SHA256, + RSA_PKCS1_1024_SHA512 => &verify::legacy::RSA_PKCS1_1024_SHA512, + RSA_PKCS1_3072_SHA384 => &verify::legacy::RSA_PKCS1_3072_SHA384, + RSA_PKCS1_SHA1 => &verify::legacy::RSA_PKCS1_SHA1, + }); + Ok(()) + }) + }) +} diff --git a/rind-python/tests/__pycache__/test_api.cpython-314.pyc b/rind-python/tests/__pycache__/test_api.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b01ea4357a581099305ff3ab42daf6c2c9386207 GIT binary patch literal 49558 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 C_p9ju literal 0 HcmV?d00001 diff --git a/rind-python/tests/__pycache__/test_wycheproof.cpython-314.pyc b/rind-python/tests/__pycache__/test_wycheproof.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c9779a62b1b0e5e451b32702f9c24093ca52698f GIT binary patch literal 14364 zcmd5@Yj70TmF}MROF|5ikOYDO@z7WgkdSx@FrX0#1Y$fRhDRhdt(hKata+uoMG%QM z+Rbi^ovk%?l0{Ux7P;0bi!EFzyQU&n*=4b@*HrDMYQ}>!&|x1{`ISFg7@RnpUwh8& z>6spBU~rO3azUqG_i^sM=X~efbMG{imRKlA?|t^aFLqQ>)GzQsN&3vquBf2aQ6AGu%3~hTy6bagmAf{rd?!{6RC+8sPg7K&oT64!kh@vQle?~? z6kgZD$kmZQr3ej`N&fjPZg)9N}YQ!y}IPI3J1z8XZCmUa_Ks zJJ0dsjtCnL#`p*qaD<}qG10-ZA%PPdtmT}XzqyO&WBfUIFOG0LmW(?5JS&WZqQi|2 zHX6v(bDj-{0*+XebHrHzYG}4tusH`m7DW~~M{q10b_io35o!;K0v8T;;Cq2{0MGtW zl4l4?K)t|xgpJ4H&JhdBdvb;nb~2U<04#ClyoDbfgsQb>zA~nTtw3H0xOw?&~TI$$9T?3 zO9mLKP;gwbh$A6B5NCN2?!S>8Y^fAFC1%E(Ip5;X$QhY49Z;O#$8$g?sD7Yw@=-RB z|5lA!V&b%qc4wqP$%j{E#y4kP4lhIb=vTCf^3hNi-KtR}e1z^dDCONI8Gi$W}VvC|QPsR{`KOjQR?^=`*-NQ7A)io^nAFb8At z04s8irlwF7-seVdi)!#JSl?lg(YW9=NJd{*XWt2rS2A!HLV_qr%MXRZTyIP~6dQ{M zh%ibzY+TZZV{AZ>%sk8|@N_|RY9w8hjc}4K4#FyEpi`S1(upl2u?V+iG<1Or122Is z--z*}B22!p1qF1AxUDG=2(-4dEv@85Q+!+;iAA>{R|26-j@jn;xMUb6yz&5T)C%|$ zjscmV9$4*@y36M$&n7DB=dJYib@%1KmFVlyROP0Wb@Q}s-rAnfwLgaXC_fSWPx#+0PK(J`m-A6 zqZQdLqYho7sJPyz1!>nSZ?hT%HLRw3U;=%553R;`C^rJ-3_hxmCZ4Ly*9nhyM`m_9 zXoeosjA@yywyvdCQ9jK&>Xff7lV>djwPv+e=@9T>0IQ<5F?yfDZOnaF?mPyiABxQY z8(_kJxo4klkZvqdN8Ko#4iS(6UM@#WDG@#xsr_jScKRBJ$VN6K(tyi0)q8{ z{!683%Xkssr|auuY*^9`bE0IBEu*BD(Wpeua~N<)# z05OqYi=T9aO`PLRkQ@$&tQl57Q6^(bq)L@?sNNI(xDO>KsC#A0FXnU3>Nt$XV+o#qom^S?83oEsH{k1(;_k2Oo6=&&t zRjczVKRnm>>FL|2?}Soarx&WuJk(;Q-+l>|?Wcjg-wIoycehLF&Q|m7GSk6k)9orJ z-2ZFyzRoSypEhdX{-@0}-fz+3eXG@_DanH)99RMp8h{3bOdkRy1psM^(+HPw&EQ1N z7`rnH3Z=4Xa8C7I4M3S39*ydI*4$=`Q(Lrgt(S6}b8nP8kJd*6C}|aC_GxnJ`-BoY zERok@0fpR>1wVC-9z8&@A#ZfBw`jW_qY>Lf%j4tIY||(s*n==mRsLhBftle|AI*0i6%#NS#|5DFvY7nxf_NTon8D19uBDz>O99hx zsy(k3L#`Gy6Hc>a9OcHnp^F?k(BqOObY9Z3;ALwg!my+P;M0oi@O7Hs2RtCiA`_Cf z2eRsi#sLQK_4Ehht(w23akDskBuEu8749E)2j2iNQ)j*w;*uJNF1B zz}8X1KLhy!>Z?wVh_`-85Jj=z8W&W1ZcDi{jPBjtsoL!y=szlXzho|Or~Q-B)K1T3;X&o9E0NbDQ?BWXAN8avw?dKX zHP>pd)=r7jt{+{Tam`lT>Y3mCe5!i?Wy8I)@^qOaS>~8p@lMrSRnr@1y;D`GbqD6Q z-7P!3^kW@<+&5=fC_7A?D83eWcWU__xbWz-%HxBw5p>8S!?(hNRQC)cgQ6*v0r|fP z>em`WNjLqlj4EGw>8R|lW&JV8fwN73;~?B15a)x!;JKaM6xBg17Crz1h&~16%Y%Ty z3CsuLrjZPx^F-Q=AXa!odd9DT>IH0AaWVxF7L6<)kTyNNLrT7#SV0|O^4^N7$*Lbd zKQ)wGQJ-GnN~~~ApPbn>-?VpbMY742u*q)eFDYnFGP0b&4ErO}a{mYm|68{*@mP4g zW!u*FUZ+78cI$yIFVoVxlR4aZT#_GlwdNjL+qdHbza1KvmUng^fd5d9`^2#`SdCLh zri#oC@4-A;Xpgwa!K~+C&I++qUZ}qf*t;kY5Z_;E%;uGks%Uc;{m?*_t@wc}UA`e% zzG2FLqb=Rgk!-9H|kG2J!o%Dzoh zH(uvvydP{x?{+75yAy3Mq?$d6zWxQ%z*2cY|Cg9%)Z@jb8O)zrb|42P53GxEb(1rb z9xX_MuCRf68*11?7dKEv!8y-(DFCcopTPv>$uIVxD7b3@#tS$MML8{KwtU*6y(B@bK z)IHFP&=2N=cVVtBB!`h40s7 z?it<8$y?U>x{g%Eo=b=BRa9L${rc%QxoeTD5pcZA-#waIvtzdM`VDB?EB$* zvj7|`FiZ0>dOj+CG6bTT$f|lnt-Z9SuzWC03cJK`0y%&50guv?> z8SijjNE|1aSskq;7`L>8()!}Oppzh=Xb}9dTEtR84sME~AEwHj)82AG0Y<>b~Wce$%7&{qIB0CWn27#d|r0AlhD&_YXmSfyx+ zwvY#NimDeal?R4Osl@;<0|ZVT0SE*GG#`8?5Xsto{6Ht;1yk9+T?GX=4B+PEqkw>J z4(L2_0Ezb_ty?TJ7y`~y@gq=1I01xc_?7xM>fdZjuWCrHYPfOo=D_uV*|s_ThaIV= z!zp_==((-zwVqddCbe&#%s74ecRNzGJ3grRsP_HZIq^={C*Mr%@-Eo=WJR@NdyD`M zL;LoxqM;sIafD2@94QBeAeNS^^y-_8^f3d z1t&9%lI(Jj$oUeN1K)zbMhWP3PEe0D8uLl|p(U$vleN>@WRqvXAw<8_{-AD%w^-$UWRJ6NPq$sdMpIzw4u?6qzgrZF?d0il(ErBFSrUQ6q3%1d92+%u0u-{ zUoyfb0MGhG;s@XcLL58>@SU?rAZg?LsO$n@Z2$*q2Vf~4z{XG>9-u)Kz5_%y`B$88 zINx-oS2reCH{RfGMy^L@U2~Nm9!oVJN!gF4?VhCFbJu%1?LC+Do=dP?A~-tFhEv{1 z${xMcnKR|yYiF;Xy`jHZa=m0GFuVO$I91;{*LP>rC;h2f&)vQ=={`2u$0mk?iQ(}4 zP$bnCU9iOz3;`Z97}C2)OBJJ;(Nf&yTB@b+){VWwm?ANZ8grb6Xdnjkh`$eNx?pPj zBkSnFXVTF^k#vW6#QIZjs2M-z)3N4~!amyJn7-PjK3?o4NgXJZ|>DKo4mR%~limsca zCmW~NRZ1nBXcB5Epj1m@2^UDUd1`eUDi+YV5VcyBwr@(>H>K^ZNqg&iyVBb_liNB~ z4lz6L9ZGfiQ}#f<4xPDr=Iya}UV7`LnZDWjTZ5_1u2gN;$77$qbo-^m$uo(wL-Qy7 zsl$N<8z-xdLFVL1T4R-6O~P$nAYS$U6f&;ToM4{jR84JLFm3)LYYsZ4#n-;SgXUzN z%f(tUSf)r_Avqs(cxdIja{Y?73ZqOUxiN~gC;>NxH37x}Q-872`4%lxO5qfC*L2`X z==r~aswC}DXqf*l-0?31>2+F&3h|fl%`2F}+|}voX58SdHkr>V*JG8`M}+iJ6WKDxr|wNvG)sm47IO`g+e zgo;S$@@=?5wesR-GL>_>AY79X8OV@L{Tasy)>OD!S(FwlkWyRGQiTmhrY#Uv;`GYF za4j5P>4ilQYHd#B)V>fAWXS;WdL|YuCWi9)7iC{EPYiKoAmG9n#PB|BM0S0D0K*ja z*N(q({LPKmnyxlY>!we>YfaU(y~q6|^5e*y>rTZdJ*n;P1=|Z`4rO@(B|{rR_Dy*b zqW%YbBq0DCMA#%%9GcR6cJc%%{}TrV1w23i%%LE8@&LMg!Bn>tp0P0^MHn~2_(-7{ z_E4(XQp+He!Gt@EsS`!|as?@#bRG6IFl zAem*i0lfoFd=ysS=+sDNH*qE~0*3-78M|DIorOigcsXL^T%z&vfaC;9S&hb_TH!e| zB5yj=HBFz@G~Ec^j9!m^zbd> zK1h~z0dlM^-po<3OhMH;JpW_M3XzNBuj`2n@$Wz-l3DgRjZ0>Wj6=S4RHO*|{kR?os6P>-WKC1LW=FDS$E^9|_D}cT-kW&AmpH}EzYt7y z4&SXA$;uEB>q;=^-UGy*XBL~x0dH>$5ili0nd5T5ytlYa7OpH;Qq7Y&M(ZtXl=*v5 zUXw0wNtU-IlcZy2F5@VF<-cmUr z4*pU(C?#^>^*&t+WMh-5d#SO>eE7~{32-LMop}=Ae5wSXS3r!;PPq94MNG2_{TE<6 ze>d~d58TM<_xDy7o0hl+obLjMRfJgNGt5YqRT{5q4hB= z2A(ZCx-1jP0jJ9iaJ7@)4|T#JXm*GTgOBs?@RZpvHSsueQi+T6^W$+5&Ssi;3N%M`Cf$lV3!}*E7JD5q`fX}Z%*2qZwNQPef`@X>pv~IU2=y@^l|gO z!PNfYlzjx&s#UA5S+82(?t16=TgPWM%!s$1OKsSz3Jo#y-gBugcG8-v8oG2O7hpY; z4X(D|8co$7T(ET#NSD^4`XPHuFim!+p0t!L$>KK_ma3lA>+eAya|>Cm*`?kANmTEi z?OZU)+qQWCn$_m5P`>zvqvC8SOTfZQ*uN%qsWdXXNQDoXv`+Q=1UGWAC>4$LKXtPa z_#;bfNyClyf_$N;ER}Ud{cIf0<+5TZ7A2B;{20^Ib)4C;eP_-qbY{FlJOCzJS=pJF zxAL|xo`6W0fuC|PoVsp!GcM%G;SaE~Iv^m1wqEK6@d>zSOB6$tTRU}hL{t3w}l8=!58hJn*mV#v?1qR5Aa#$Xtr z?d1&P55v*EK*%rpxp0_a;8!&zs+Uj<7ihBQ#A9y>!6OR7DImXj8M=SJdH)wwsea=l zhh0BNKdLwCzeYc@8}xnjqcXj|=~1a(Uyo!bk_|{Kk4g-};aOB99w3 zhl3UYcmvL=BqPHFVtyDDJAN_Z&q>0eA;~lbzhc38<(uI>IoKsf08vxqeQ6YF0>AP? zxh(S|s;u`cua|Xhs1aMlv*|EAMmU%r3!mq(DIR{4B=ERrC!a+(^+AK3EF428qw-(y zLDC_o@|G`prt-qWv`3!mef&dshodQ=;{s=^Y5H@Gp4L4srD)sFsnVZQ%YH$X{5RE^ zraF^U=YLXl|6^JHTH~vYmoKC&jWSI575;&Uyf It06=E|Ib?8$p8QV literal 0 HcmV?d00001 diff --git a/rind-python/tests/test_api.py b/rind-python/tests/test_api.py new file mode 100644 index 000000000..04f3d58c3 --- /dev/null +++ b/rind-python/tests/test_api.py @@ -0,0 +1,498 @@ +"""pyrind's API, checked against known answers and against pyca/cryptography, +hashlib, and hmac.""" + +import hashlib +import hmac as std_hmac +import os +import threading +import unittest + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, ed25519, padding +from cryptography.hazmat.primitives.asymmetric import rsa as pyca_rsa +from cryptography.hazmat.primitives.asymmetric import x25519 +from cryptography.hazmat.primitives.ciphers import aead as pyca_aead +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + +import pyrind +from pyrind import aead, agreement, cpu, digest, hkdf, hmac, kem, pbkdf2, rsa, signature +from pyrind.signature import sign, verify + + +class Package(unittest.TestCase): + def test_submodules_import(self): + import pyrind.digest.legacy + import pyrind.signature.verify.legacy + + self.assertIs(pyrind.signature.verify.legacy, verify.legacy) + self.assertIs(pyrind.digest.legacy.SHA1, digest.legacy.SHA1) + + def test_constants_name_themselves(self): + self.assertEqual(repr(aead.AES_128_GCM), "pyrind.aead.AES_128_GCM") + self.assertEqual(repr(verify.legacy.RSA_PKCS1_SHA1), "pyrind.signature.verify.legacy.RSA_PKCS1_SHA1") + self.assertEqual(aead.Key.__module__, "pyrind.aead") + + def test_errors_share_a_base(self): + for error in ( + pyrind.WrongLength, + pyrind.KeyRejected, + pyrind.BadSignature, + pyrind.DecryptFailed, + pyrind.RngFailed, + pyrind.Unsupported, + pyrind.Fault, + ): + self.assertTrue(issubclass(error, pyrind.Error)) + + def test_algorithms_are_not_constructible(self): + with self.assertRaises(TypeError): + aead.Algorithm() + + def test_another_modules_algorithm_is_a_type_error(self): + with self.assertRaises(TypeError): + aead.Key(digest.SHA256, bytes(16)) + with self.assertRaises(TypeError): + signature.EcdsaKeyPair.generate(verify.ECDSA_P256_SHA256_ASN1) + + def test_keys_do_not_pickle(self): + import pickle + + with self.assertRaises(TypeError): + pickle.dumps(aead.Key(aead.AES_128_GCM, bytes(16))) + + def test_cpu(self): + self.assertIsInstance(cpu.has_aes_hardware(), bool) + + +class BytesLike(unittest.TestCase): + def test_any_buffer_is_accepted(self): + expected = hashlib.sha256(b"abc").digest() + for data in (b"abc", bytearray(b"abc"), memoryview(b"xabcx")[1:4]): + self.assertEqual(digest.digest(digest.SHA256, data), expected) + + def test_str_is_refused(self): + with self.assertRaises(TypeError): + digest.digest(digest.SHA256, "abc") + + def test_strided_buffer_is_copied(self): + expected = hashlib.sha256(b"ace").digest() + self.assertEqual(digest.digest(digest.SHA256, memoryview(b"abcdef")[::2]), expected) + + +class Digest(unittest.TestCase): + ALGORITHMS = { + "sha256": digest.SHA256, + "sha384": digest.SHA384, + "sha512": digest.SHA512, + "sha512_256": digest.SHA512_256, + "sha3_256": digest.SHA3_256, + "sha3_384": digest.SHA3_384, + "sha3_512": digest.SHA3_512, + "md5": digest.legacy.MD5, + "sha1": digest.legacy.SHA1, + } + + def test_matches_hashlib(self): + for name, algorithm in self.ALGORITHMS.items(): + for length in (0, 1, 55, 56, 64, 127, 128, 200, 1000): + data = os.urandom(length) + expected = hashlib.new(name, data).digest() + self.assertEqual(digest.digest(algorithm, data), expected, name) + self.assertEqual(algorithm.output_len, len(expected)) + + def test_context_finishes_and_restarts(self): + context = digest.Context(digest.SHA256) + context.update(b"hello, ") + context.update(b"world") + self.assertEqual(context.finish(), hashlib.sha256(b"hello, world").digest()) + self.assertEqual(context.finish(), hashlib.sha256(b"").digest()) + + +class Hmac(unittest.TestCase): + def test_rfc4231_case_2(self): + key = hmac.Key(hmac.HMAC_SHA256, b"Jefe") + tag = hmac.sign(key, b"what do ya want for nothing?") + self.assertEqual(tag.hex(), "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843") + hmac.verify(key, b"what do ya want for nothing?", tag) + + def test_matches_stdlib(self): + for algorithm, name in ( + (hmac.HMAC_SHA256, "sha256"), + (hmac.HMAC_SHA384, "sha384"), + (hmac.HMAC_SHA512, "sha512"), + (hmac.legacy.HMAC_SHA1, "sha1"), + ): + for key_len in (0, 16, 64, 200): + raw = os.urandom(key_len) + data = os.urandom(100) + expected = std_hmac.digest(raw, data, name) + self.assertEqual(hmac.sign(hmac.Key(algorithm, raw), data), expected) + self.assertEqual(algorithm.tag_len, len(expected)) + + def test_verify_refuses_wrong_and_truncated_tags(self): + key = hmac.Key(hmac.HMAC_SHA256, b"key") + tag = hmac.sign(key, b"data") + with self.assertRaises(pyrind.BadSignature): + hmac.verify(key, b"data", tag[:16]) + with self.assertRaises(pyrind.BadSignature): + hmac.verify(key, b"Data", tag) + + def test_context_signs_once(self): + key = hmac.Key(hmac.HMAC_SHA256, b"key") + context = hmac.Context(key) + context.update(b"da") + context.update(b"ta") + self.assertEqual(context.sign(), hmac.sign(key, b"data")) + with self.assertRaises(ValueError): + context.sign() + with self.assertRaises(ValueError): + context.update(b"more") + + +class Hkdf(unittest.TestCase): + def test_rfc5869_case_1(self): + prk = hkdf.Salt(hkdf.HKDF_SHA256, bytes.fromhex("000102030405060708090a0b0c")).extract(b"\x0b" * 22) + okm = prk.expand(bytes.fromhex("f0f1f2f3f4f5f6f7f8f9"), 42) + self.assertEqual( + okm.hex(), + "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865", + ) + + def test_matches_pyca(self): + salt, secret, info = os.urandom(16), os.urandom(32), os.urandom(10) + expected = HKDF(hashes.SHA384(), 100, salt, info).derive(secret) + self.assertEqual(hkdf.Salt(hkdf.HKDF_SHA384, salt).extract(secret).expand(info, 100), expected) + + def test_expands_into_keys(self): + prk = hkdf.Salt(hkdf.HKDF_SHA256, b"salt").extract(b"secret") + key = prk.expand(b"aead", aead.AES_256_GCM) + self.assertIsInstance(key, aead.Key) + raw = prk.expand(b"aead", 32) + nonce = bytes(12) + self.assertEqual( + key.seal(nonce, b"m", aad=b""), + aead.Key(aead.AES_256_GCM, raw).seal(nonce, b"m", aad=b""), + ) + mac = prk.expand(b"hmac", hmac.HMAC_SHA256) + self.assertEqual(hmac.sign(mac, b"m"), hmac.sign(hmac.Key(hmac.HMAC_SHA256, prk.expand(b"hmac", 32)), b"m")) + + def test_too_long_is_wrong_length(self): + prk = hkdf.Salt(hkdf.HKDF_SHA256, b"").extract(b"secret") + with self.assertRaises(pyrind.WrongLength): + prk.expand(b"", 255 * 32 + 1) + + +class Pbkdf2(unittest.TestCase): + def test_matches_pyca(self): + secret = pbkdf2.Secret(pbkdf2.PBKDF2_HMAC_SHA256, b"password") + expected = PBKDF2HMAC(hashes.SHA256(), 40, b"salt", 1000).derive(b"password") + self.assertEqual(secret.derive(1000, b"salt", 40), expected) + secret.verify(1000, b"salt", expected) + with self.assertRaises(pyrind.BadSignature): + secret.verify(1001, b"salt", expected) + + def test_rfc6070_sha1(self): + secret = pbkdf2.Secret(pbkdf2.legacy.PBKDF2_HMAC_SHA1, b"password") + self.assertEqual(secret.derive(2, b"salt", 20).hex(), "ea6c014dc72d6f8ccd1ed92ace1d41f0d8de8957") + + def test_zero_iterations_is_refused(self): + secret = pbkdf2.Secret(pbkdf2.PBKDF2_HMAC_SHA256, b"password") + with self.assertRaises(ValueError): + secret.derive(0, b"salt", 32) + with self.assertRaises(OverflowError): + secret.derive(-1, b"salt", 32) + + def test_releases_the_gil(self): + secret = pbkdf2.Secret(pbkdf2.PBKDF2_HMAC_SHA256, b"password") + results = [] + threads = [threading.Thread(target=lambda: results.append(secret.derive(20000, b"salt", 32))) for _ in range(4)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + self.assertEqual(len(set(results)), 1) + + +class Aead(unittest.TestCase): + PYCA = { + id(aead.AES_128_GCM): pyca_aead.AESGCM, + id(aead.AES_192_GCM): pyca_aead.AESGCM, + id(aead.AES_256_GCM): pyca_aead.AESGCM, + id(aead.CHACHA20_POLY1305): pyca_aead.ChaCha20Poly1305, + } + + def test_matches_pyca(self): + for algorithm in (aead.AES_128_GCM, aead.AES_192_GCM, aead.AES_256_GCM, aead.CHACHA20_POLY1305): + raw = os.urandom(algorithm.key_len) + key = aead.Key(algorithm, raw) + other = self.PYCA[id(algorithm)](raw) + for length in (0, 1, 15, 16, 17, 255, 1350, 4096): + nonce = os.urandom(algorithm.nonce_len) + plaintext, aad = os.urandom(length), os.urandom(length % 40) + sealed = key.seal(nonce, plaintext, aad=aad) + self.assertEqual(sealed, other.encrypt(nonce, plaintext, aad)) + self.assertEqual(len(sealed), length + algorithm.tag_len) + self.assertEqual(key.open(nonce, sealed, aad=aad), plaintext) + + def test_tampering_fails_to_open(self): + key = aead.Key(aead.AES_128_GCM, bytes(16)) + nonce = bytes(12) + sealed = key.seal(nonce, b"message", aad=b"header") + with self.assertRaises(pyrind.DecryptFailed): + key.open(nonce, sealed, aad=b"Header") + with self.assertRaises(pyrind.DecryptFailed): + key.open(nonce, sealed[:-1] + bytes([sealed[-1] ^ 1]), aad=b"header") + with self.assertRaises(pyrind.DecryptFailed): + key.open(nonce, sealed[:15], aad=b"header") + + def test_lengths_are_checked(self): + with self.assertRaises(pyrind.WrongLength): + aead.Key(aead.AES_128_GCM, bytes(32)) + key = aead.Key(aead.CHACHA20_POLY1305, bytes(32)) + with self.assertRaises(pyrind.WrongLength): + key.seal(bytes(8), b"", aad=b"") + + def test_aad_is_keyword_only_and_required(self): + key = aead.Key(aead.AES_128_GCM, bytes(16)) + with self.assertRaises(TypeError): + key.seal(bytes(12), b"message", b"header") + with self.assertRaises(TypeError): + key.seal(bytes(12), b"message") + + def test_randomized_nonce_key(self): + raw = os.urandom(32) + key = aead.RandomizedNonceKey(raw) + nonce, sealed = key.seal(b"message", aad=b"header") + _, again = key.seal(b"message", aad=b"header") + self.assertEqual(len(nonce), 24) + self.assertNotEqual(sealed, again) + self.assertEqual(key.open(nonce, sealed, aad=b"header"), b"message") + with self.assertRaises(pyrind.DecryptFailed): + key.open(nonce, sealed, aad=b"") + with self.assertRaises(pyrind.WrongLength): + key.open(nonce[:12], sealed, aad=b"header") + with self.assertRaises(pyrind.WrongLength): + aead.RandomizedNonceKey(bytes(16)) + + +class Agreement(unittest.TestCase): + def test_x25519_rfc7748(self): + alice = agreement.PrivateKey.from_scalar( + agreement.X25519, + bytes.fromhex("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"), + ) + self.assertEqual( + bytes(alice.public_key()).hex(), + "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a", + ) + bob = bytes.fromhex("de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f") + self.assertEqual( + alice.agree(bob).hex(), + "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742", + ) + + def test_x25519_matches_pyca(self): + ours = agreement.PrivateKey.generate(agreement.X25519) + theirs = x25519.X25519PrivateKey.generate() + their_public = theirs.public_key().public_bytes_raw() + self.assertEqual( + ours.agree(their_public), + theirs.exchange(x25519.X25519PublicKey.from_public_bytes(bytes(ours.public_key()))), + ) + + def test_ecdh_matches_pyca(self): + for algorithm, curve in ( + (agreement.ECDH_P256, ec.SECP256R1()), + (agreement.ECDH_P384, ec.SECP384R1()), + (agreement.ECDH_P521, ec.SECP521R1()), + ): + ours = agreement.PrivateKey.generate(algorithm) + theirs = ec.generate_private_key(curve) + their_public = theirs.public_key().public_bytes( + serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint + ) + our_public = ec.EllipticCurvePublicKey.from_encoded_point(curve, bytes(ours.public_key())) + self.assertEqual(ours.agree(their_public), theirs.exchange(ec.ECDH(), our_public)) + + def test_keys_round_trip(self): + for algorithm in (agreement.X25519, agreement.ECDH_P256): + key = agreement.PrivateKey.generate(algorithm) + public = bytes(key.public_key()) + for loaded in ( + agreement.PrivateKey.from_pkcs8(algorithm, key.to_pkcs8()), + agreement.PrivateKey.from_scalar(algorithm, key.to_scalar()), + ): + self.assertEqual(bytes(loaded.public_key()), public) + spki = key.public_key().to_spki() + self.assertEqual(bytes(agreement.PublicKey.from_spki(algorithm, spki)), public) + self.assertEqual(bytes(agreement.PublicKey(algorithm, public)), public) + + def test_invalid_peer_is_key_rejected(self): + key = agreement.PrivateKey.generate(agreement.ECDH_P256) + with self.assertRaises(pyrind.KeyRejected) as raised: + key.agree(b"\x04" + bytes(64)) + self.assertTrue(str(raised.exception)) + + +class Signature(unittest.TestCase): + def test_ed25519_rfc8032_test_1(self): + key = signature.Ed25519KeyPair.from_seed( + bytes.fromhex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60") + ) + self.assertEqual( + bytes(key.public_key()).hex(), + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", + ) + signed = key.sign(b"") + self.assertEqual( + signed.hex(), + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155" + "5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b", + ) + key.public_key().verify(b"", signed) + with self.assertRaises(pyrind.BadSignature): + key.public_key().verify(b"x", signed) + + def test_ed25519_matches_pyca(self): + ours = signature.Ed25519KeyPair.generate() + theirs = ed25519.Ed25519PrivateKey.from_private_bytes(ours.to_seed()) + self.assertEqual(ours.sign(b"message"), theirs.sign(b"message")) + loaded = serialization.load_der_private_key(ours.to_pkcs8v1(), None) + self.assertEqual(loaded.sign(b"m"), ours.sign(b"m")) + self.assertEqual(signature.Ed25519KeyPair.from_pkcs8(ours.to_pkcs8()).to_seed(), ours.to_seed()) + + def test_ecdsa_against_pyca(self): + for algorithm, check, curve, hash_ in ( + (sign.ECDSA_P256_SHA256_ASN1, verify.ECDSA_P256_SHA256_ASN1, ec.SECP256R1(), hashes.SHA256()), + (sign.ECDSA_P384_SHA384_ASN1, verify.ECDSA_P384_SHA384_ASN1, ec.SECP384R1(), hashes.SHA384()), + (sign.ECDSA_P521_SHA512_ASN1, verify.ECDSA_P521_SHA512_ASN1, ec.SECP521R1(), hashes.SHA512()), + ): + ours = signature.EcdsaKeyPair.generate(algorithm) + their_view = serialization.load_der_public_key(ours.to_spki()) + their_view.verify(ours.sign(b"message"), b"message", ec.ECDSA(hash_)) + + theirs = ec.generate_private_key(curve) + der = theirs.private_bytes( + serialization.Encoding.DER, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() + ) + loaded = signature.EcdsaKeyPair.from_pkcs8(algorithm, der) + spki = theirs.public_key().public_bytes( + serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo + ) + public = signature.PublicKey.from_spki(check, spki) + public.verify(b"message", theirs.sign(b"message", ec.ECDSA(hash_))) + public.verify(b"message", loaded.sign(b"message")) + with self.assertRaises(pyrind.BadSignature): + public.verify(b"massage", loaded.sign(b"message")) + + def test_ecdsa_fixed_and_encodings(self): + key = signature.EcdsaKeyPair.generate(sign.ECDSA_P256_SHA256_FIXED) + signed = key.sign(b"m") + self.assertEqual(len(signed), 64) + public = signature.PublicKey(verify.ECDSA_P256_SHA256_FIXED, bytes(key.public_key())) + public.verify(b"m", signed) + for loaded in ( + signature.EcdsaKeyPair.from_sec1(sign.ECDSA_P256_SHA256_FIXED, key.to_sec1()), + signature.EcdsaKeyPair.from_scalar_and_public_key( + sign.ECDSA_P256_SHA256_FIXED, key.to_scalar(), bytes(key.public_key()) + ), + ): + public.verify(b"m", loaded.sign(b"m")) + with self.assertRaises(pyrind.KeyRejected): + signature.EcdsaKeyPair.from_pkcs8(sign.ECDSA_P384_SHA384_ASN1, key.to_pkcs8()) + + def test_rsa_against_pyca(self): + theirs = pyca_rsa.generate_private_key(65537, 2048) + der = theirs.private_bytes( + serialization.Encoding.DER, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() + ) + ours = signature.RsaKeyPair.from_pkcs8(der) + their_public = theirs.public_key() + for algorithm, check, pad in ( + (sign.RSA_PKCS1_SHA256, verify.RSA_PKCS1_SHA256, padding.PKCS1v15()), + (sign.RSA_PSS_SHA256, verify.RSA_PSS_SHA256, padding.PSS(padding.MGF1(hashes.SHA256()), 32)), + ): + their_public.verify(ours.sign(algorithm, b"message"), b"message", pad, hashes.SHA256()) + public = signature.PublicKey.from_spki(check, ours.to_spki()) + public.verify(b"message", theirs.sign(b"message", pad, hashes.SHA256())) + self.assertEqual(bytes(ours.public_key(algorithm)), bytes(public)) + numbers = their_public.public_numbers() + n = numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, "big") + e = numbers.e.to_bytes(3, "big") + public = signature.PublicKey.from_components(verify.RSA_PKCS1_SHA256, n, e) + public.verify(b"m", ours.sign(sign.RSA_PKCS1_SHA256, b"m")) + with self.assertRaises(TypeError): + signature.PublicKey.from_components(verify.ED25519, n, e) + + def test_rsa_generate(self): + key = signature.RsaKeyPair.generate(rsa.KeySize.RSA_2048) + public = signature.PublicKey.from_spki(verify.RSA_PSS_SHA256, key.to_spki()) + public.verify(b"m", key.sign(sign.RSA_PSS_SHA256, b"m")) + loaded = serialization.load_der_private_key(key.to_pkcs8(), None) + self.assertEqual(loaded.key_size, 2048) + + def test_garbage_keys_are_rejected(self): + for call in ( + lambda: signature.Ed25519KeyPair.from_pkcs8(b"\x30\x00"), + lambda: signature.RsaKeyPair.from_pkcs1(os.urandom(100)), + lambda: signature.PublicKey(verify.ECDSA_P256_SHA256_ASN1, os.urandom(65)), + lambda: signature.PublicKey.from_spki(verify.ED25519, b""), + ): + with self.assertRaises(pyrind.KeyRejected): + call() + + +class Rsa(unittest.TestCase): + def test_oaep_against_pyca(self): + theirs = pyca_rsa.generate_private_key(65537, 2048) + der = theirs.private_bytes( + serialization.Encoding.DER, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() + ) + ours = rsa.DecryptingKey.from_pkcs8(rsa.OAEP_SHA256, der) + pad = padding.OAEP(padding.MGF1(hashes.SHA256()), hashes.SHA256(), b"label") + self.assertEqual(ours.decrypt(theirs.public_key().encrypt(b"secret", pad), label=b"label"), b"secret") + encrypting = rsa.EncryptingKey(rsa.OAEP_SHA256, ours.to_spki()) + self.assertEqual(theirs.decrypt(encrypting.encrypt(b"secret", label=b"label"), pad), b"secret") + self.assertEqual(encrypting.max_message_len, 256 - 2 * 32 - 2) + with self.assertRaises(pyrind.WrongLength): + encrypting.encrypt(bytes(encrypting.max_message_len + 1), label=b"") + with self.assertRaises(pyrind.DecryptFailed): + ours.decrypt(encrypting.encrypt(b"secret", label=b"label"), label=b"other") + + def test_generate(self): + key = rsa.DecryptingKey.generate(rsa.OAEP_SHA512, rsa.KeySize.RSA_2048) + ciphertext = key.public_key().encrypt(b"m", label=b"") + self.assertEqual(key.decrypt(ciphertext, label=b""), b"m") + self.assertEqual(rsa.KeySize.RSA_2048, rsa.KeySize.RSA_2048) + self.assertNotEqual(rsa.KeySize.RSA_2048, rsa.KeySize.RSA_3072) + + +class Kem(unittest.TestCase): + def test_keys_at_any_address(self): + # rind's AVX2 ML-KEM loads key data aligned to 32 bytes, which an + # object's own storage only reaches by chance; holding many keys at + # once places some of them off it. + keys = [kem.DecapsulationKey.generate(kem.ML_KEM_768) for _ in range(32)] + public = [key.public_key() for key in keys] + for key, encapsulation_key in zip(keys, public): + ciphertext, secret = encapsulation_key.encapsulate() + self.assertEqual(key.decapsulate(ciphertext), secret) + + def test_round_trip(self): + recipient = kem.DecapsulationKey.generate(kem.ML_KEM_768) + public = kem.EncapsulationKey(kem.ML_KEM_768, bytes(recipient.public_key())) + ciphertext, secret = public.encapsulate() + self.assertEqual(len(secret), 32) + self.assertEqual(recipient.decapsulate(ciphertext), secret) + # A tampered ciphertext yields an unrelated secret, not an error. + tampered = bytes([ciphertext[0] ^ 1]) + ciphertext[1:] + self.assertNotEqual(recipient.decapsulate(tampered), secret) + with self.assertRaises(pyrind.Error): + recipient.decapsulate(ciphertext[:-1]) + with self.assertRaises(pyrind.Error): + kem.EncapsulationKey(kem.ML_KEM_768, bytes(100)) + + +if __name__ == "__main__": + unittest.main() diff --git a/rind-python/tests/test_wycheproof.py b/rind-python/tests/test_wycheproof.py new file mode 100644 index 000000000..e901faf9d --- /dev/null +++ b/rind-python/tests/test_wycheproof.py @@ -0,0 +1,155 @@ +"""Wycheproof's vectors through pyrind, so that every malformed input raises a +`pyrind.Error` rather than crashing, and every valid one passes. + +rind runs the full suites itself; these check the binding's mapping of inputs +and errors. A vector Wycheproof calls acceptable may go either way. +""" + +import json +import pathlib +import unittest + +import pyrind +from pyrind import aead, agreement, hkdf, hmac, kem, rsa, signature +from pyrind.signature import verify + +VECTORS = pathlib.Path(__file__).resolve().parents[2] / "thirdparty" / "wycheproof" / "testvectors_v1" + + +def groups(name): + path = VECTORS / f"{name}_test.json" + if not path.exists(): + raise FileNotFoundError(f"{path} is missing; run: git submodule update --init thirdparty/wycheproof") + return json.loads(path.read_text())["testGroups"] + + +class Wycheproof(unittest.TestCase): + def run_vectors(self, name, check, select=lambda group: True): + count = 0 + for group in groups(name): + if not select(group): + continue + for test in group["tests"]: + with self.subTest(file=name, tcId=test["tcId"]): + try: + check(group, test) + except pyrind.Error: + self.assertNotEqual(test["result"], "valid", test.get("comment")) + else: + self.assertNotEqual(test["result"], "invalid", test.get("comment")) + count += 1 + self.assertGreater(count, 0) + + def test_aead(self): + def check(algorithm): + def run(group, test): + key = aead.Key(algorithm[group["keySize"]], bytes.fromhex(test["key"])) + nonce, aad = bytes.fromhex(test["iv"]), bytes.fromhex(test["aad"]) + msg = bytes.fromhex(test["msg"]) + sealed = bytes.fromhex(test["ct"] + test["tag"]) + self.assertEqual(key.open(nonce, sealed, aad=aad), msg) + self.assertEqual(key.seal(nonce, msg, aad=aad), sealed) + + return run + + # rind takes only 96-bit nonces, which these files' other groups vary. + def standard(group): + return group["ivSize"] == 96 and group["tagSize"] == 128 + + gcm = {128: aead.AES_128_GCM, 192: aead.AES_192_GCM, 256: aead.AES_256_GCM} + self.run_vectors("aes_gcm", check(gcm), standard) + self.run_vectors("chacha20_poly1305", check({256: aead.CHACHA20_POLY1305}), standard) + + def test_xchacha20_poly1305(self): + def run(group, test): + key = aead.RandomizedNonceKey(bytes.fromhex(test["key"])) + sealed = bytes.fromhex(test["ct"] + test["tag"]) + opened = key.open(bytes.fromhex(test["iv"]), sealed, aad=bytes.fromhex(test["aad"])) + self.assertEqual(opened, bytes.fromhex(test["msg"])) + + self.run_vectors("xchacha20_poly1305", run, lambda group: group["tagSize"] == 128) + + def test_hmac(self): + def run(group, test): + key = hmac.Key(hmac.HMAC_SHA256, bytes.fromhex(test["key"])) + hmac.verify(key, bytes.fromhex(test["msg"]), bytes.fromhex(test["tag"])) + + # rind verifies whole tags only. + self.run_vectors("hmac_sha256", run, lambda group: group["tagSize"] == 256) + + def test_hkdf(self): + def run(group, test): + prk = hkdf.Salt(hkdf.HKDF_SHA256, bytes.fromhex(test["salt"])).extract(bytes.fromhex(test["ikm"])) + self.assertEqual(prk.expand(bytes.fromhex(test["info"]), test["size"]), bytes.fromhex(test["okm"])) + + self.run_vectors("hkdf_sha256", run) + + def test_x25519(self): + def run(group, test): + key = agreement.PrivateKey.from_scalar(agreement.X25519, bytes.fromhex(test["private"])) + self.assertEqual(key.agree(bytes.fromhex(test["public"])), bytes.fromhex(test["shared"])) + + self.run_vectors("x25519", run) + + def test_ecdh_p256(self): + def run(group, test): + key = agreement.PrivateKey.from_scalar(agreement.ECDH_P256, int(test["private"], 16).to_bytes(32, "big")) + self.assertEqual(key.agree(bytes.fromhex(test["public"])), bytes.fromhex(test["shared"])) + + self.run_vectors("ecdh_secp256r1_ecpoint", run) + + def verify_vectors(self, name, algorithm, key_of): + def run(group, test): + public = key_of(group) + public.verify(bytes.fromhex(test["msg"]), bytes.fromhex(test["sig"])) + + self.run_vectors(name, run) + + def test_ed25519(self): + self.verify_vectors( + "ed25519", + verify.ED25519, + lambda group: signature.PublicKey(verify.ED25519, bytes.fromhex(group["publicKey"]["pk"])), + ) + + def test_ecdsa_p256(self): + algorithm = verify.ECDSA_P256_SHA256_ASN1 + self.verify_vectors( + "ecdsa_secp256r1_sha256", + algorithm, + lambda group: signature.PublicKey.from_spki(algorithm, bytes.fromhex(group["publicKeyDer"])), + ) + + def test_rsa_pkcs1(self): + algorithm = verify.RSA_PKCS1_SHA256 + self.verify_vectors( + "rsa_signature_2048_sha256", + algorithm, + lambda group: signature.PublicKey.from_spki(algorithm, bytes.fromhex(group["publicKeyDer"])), + ) + + def test_rsa_pss(self): + algorithm = verify.RSA_PSS_SHA256 + self.verify_vectors( + "rsa_pss_2048_sha256_mgf1_32", + algorithm, + lambda group: signature.PublicKey.from_spki(algorithm, bytes.fromhex(group["publicKeyDer"])), + ) + + def test_rsa_oaep(self): + def run(group, test): + key = rsa.DecryptingKey.from_pkcs8(rsa.OAEP_SHA256, bytes.fromhex(group["privateKeyPkcs8"])) + plaintext = key.decrypt(bytes.fromhex(test["ct"]), label=bytes.fromhex(test["label"])) + self.assertEqual(plaintext, bytes.fromhex(test["msg"])) + + self.run_vectors("rsa_oaep_2048_sha256_mgf1sha256", run) + + def test_ml_kem_encapsulation_keys(self): + def run(group, test): + kem.EncapsulationKey(kem.ML_KEM_768, bytes.fromhex(test["ek"])) + + self.run_vectors("mlkem_768_encaps", run) + + +if __name__ == "__main__": + unittest.main() diff --git a/sh/dispatch.sh b/sh/dispatch.sh index 4dcb58147..7a5135fc2 100755 --- a/sh/dispatch.sh +++ b/sh/dispatch.sh @@ -55,7 +55,7 @@ declare -A scope=( [deny]='(^|/)Cargo\.(toml|lock)$|^deny\.toml$' # An adapter's manifest holds both its version and its upstream requirement. [versions]='^(rind-[^/]+/Cargo\.toml$|sh/adapter-versions\.sh$)' - [license]='^(rind/src/|admin/scan-license$)' + [license]='^(rind/src/|rind/LICENSE$|rind-python/LICENSE$|admin/scan-license$)' # Dependents may build with the workspace's declared rust-version. [msrv]='^(rind[^/]*/|fuzz/|Cargo\.(toml|lock)$|flake\.(nix|lock)$)' # rind's aarch64 and armv7 backends, which the host's own build skips. diff --git a/sh/python.sh b/sh/python.sh new file mode 100755 index 000000000..f1b7d5ec4 --- /dev/null +++ b/sh/python.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Build pyrind's wheel and run its tests, or its benchmarks, against it. +# +# Usage: sh/python.sh test|bench [ARGS...] +# The tests need pyca/cryptography, the differential oracle, and the +# Wycheproof submodule; the benchmarks compare against cryptography, +# hashlib, and hmac. ARGS go to unittest or the benchmark script. +set -euo pipefail + +usage="usage: sh/python.sh test|bench [ARGS...]" +action=${1:?$usage} +shift + +cd "$(git rev-parse --show-toplevel)" + +out=$(cargo metadata --format-version 1 --no-deps | jq -r .target_directory)/pyrind +rm -rf "$out" +maturin build --release --quiet --manifest-path rind-python/Cargo.toml --out "$out/wheel" +# A wheel is a zip of the installed package, so unpacking it is installing it +# without pip. +python3 -m zipfile -e "$out"/wheel/pyrind-*.whl "$out/site" + +case "$action" in + test) PYTHONPATH="$out/site" python3 -m unittest discover --start-directory rind-python/tests "$@" ;; + bench) PYTHONPATH="$out/site" python3 rind-python/bench/bench.py "$@" ;; + *) + echo "$usage" >&2 + exit 2 + ;; +esac From eb7d36109c659cdf4933d8f67649bdcc5e756d98 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 23:28:35 -0800 Subject: [PATCH 03/10] chore: ignore Python bytecode Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + .../tests/__pycache__/test_api.cpython-314.pyc | Bin 49558 -> 0 bytes .../__pycache__/test_wycheproof.cpython-314.pyc | Bin 14364 -> 0 bytes 3 files changed, 1 insertion(+) delete mode 100644 rind-python/tests/__pycache__/test_api.cpython-314.pyc delete mode 100644 rind-python/tests/__pycache__/test_wycheproof.cpython-314.pyc diff --git a/.gitignore b/.gitignore index 2b8f2af17..eee319960 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ proofs/Rind/Aarch64/HashAxioms.lean proofs/Rind/Aarch64/SignatureDispatch.lean proofs/Rind/Aarch64/Ed25519.lean patch/*/Cargo.lock +__pycache__/ diff --git a/rind-python/tests/__pycache__/test_api.cpython-314.pyc b/rind-python/tests/__pycache__/test_api.cpython-314.pyc deleted file mode 100644 index b01ea4357a581099305ff3ab42daf6c2c9386207..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 49558 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 C_p9ju diff --git a/rind-python/tests/__pycache__/test_wycheproof.cpython-314.pyc b/rind-python/tests/__pycache__/test_wycheproof.cpython-314.pyc deleted file mode 100644 index c9779a62b1b0e5e451b32702f9c24093ca52698f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14364 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 It06=E|Ib?8$p8QV From be1ed996f5acf69f84e8934e6f4adff1b3104418 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 23:38:57 -0800 Subject: [PATCH 04/10] wip: pyrind release config Co-Authored-By: Claude Opus 5.5 --- .release-plz.toml | 6 + .schema/latest.json | 843 ++++++++++++++++++ flake.nix | 10 +- rind-python/bench/bench.py | 206 +++++ rind-python/python/pyrind/_rind.pyi | 2 + rind-python/python/pyrind/aead.pyi | 6 +- rind-python/python/pyrind/agreement.pyi | 54 ++ rind-python/python/pyrind/cpu.pyi | 2 + rind-python/python/pyrind/digest/__init__.pyi | 4 +- rind-python/python/pyrind/digest/legacy.pyi | 2 + rind-python/python/pyrind/hkdf/__init__.pyi | 38 + rind-python/python/pyrind/hkdf/legacy.pyi | 7 + rind-python/python/pyrind/hmac/__init__.pyi | 45 + rind-python/python/pyrind/hmac/legacy.pyi | 7 + rind-python/python/pyrind/kem.pyi | 35 + rind-python/python/pyrind/pbkdf2/__init__.pyi | 31 + rind-python/python/pyrind/pbkdf2/legacy.pyi | 7 + rind-python/python/pyrind/rsa/__init__.pyi | 69 ++ rind-python/python/pyrind/rsa/legacy.pyi | 7 + .../python/pyrind/signature/__init__.pyi | 129 +++ rind-python/python/pyrind/signature/sign.pyi | 18 + .../pyrind/signature/verify/__init__.pyi | 29 + .../python/pyrind/signature/verify/legacy.pyi | 11 + rind-python/src/aead.rs | 10 +- rind-python/src/lib.rs | 32 +- rind-python/tests/test_api.py | 12 +- sh/dispatch.sh | 7 +- sh/python.sh | 8 +- 28 files changed, 1619 insertions(+), 18 deletions(-) create mode 100644 .schema/latest.json create mode 100644 rind-python/bench/bench.py create mode 100644 rind-python/python/pyrind/agreement.pyi create mode 100644 rind-python/python/pyrind/hkdf/__init__.pyi create mode 100644 rind-python/python/pyrind/hkdf/legacy.pyi create mode 100644 rind-python/python/pyrind/hmac/__init__.pyi create mode 100644 rind-python/python/pyrind/hmac/legacy.pyi create mode 100644 rind-python/python/pyrind/kem.pyi create mode 100644 rind-python/python/pyrind/pbkdf2/__init__.pyi create mode 100644 rind-python/python/pyrind/pbkdf2/legacy.pyi create mode 100644 rind-python/python/pyrind/rsa/__init__.pyi create mode 100644 rind-python/python/pyrind/rsa/legacy.pyi create mode 100644 rind-python/python/pyrind/signature/__init__.pyi create mode 100644 rind-python/python/pyrind/signature/sign.pyi create mode 100644 rind-python/python/pyrind/signature/verify/__init__.pyi create mode 100644 rind-python/python/pyrind/signature/verify/legacy.pyi diff --git a/.release-plz.toml b/.release-plz.toml index 74fdcf273..50bd50afe 100644 --- a/.release-plz.toml +++ b/.release-plz.toml @@ -4,3 +4,9 @@ git_release_enable = false # Publish only when the commit is a merged release PR, so an ordinary merge to # `main` never publishes. release_always = false + +# pyrind, which .github/workflows/release.yml publishes to PyPI from the tag +# this creates. It never goes to crates.io, so its versions come from tags. +[[package]] +name = "rind-python" +git_only = true diff --git a/.schema/latest.json b/.schema/latest.json new file mode 100644 index 000000000..effcf4ab3 --- /dev/null +++ b/.schema/latest.json @@ -0,0 +1,843 @@ +{ + "$id": "https://raw.githubusercontent.com/release-plz/release-plz/main/.schema/latest.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Config", + "description": "You can find the documentation of the configuration file\n[here](https://release-plz.dev/docs/config).", + "type": "object", + "properties": { + "changelog": { + "$ref": "#/$defs/ChangelogCfg", + "default": { + "body": null, + "commit_parsers": null, + "commit_preprocessors": null, + "header": null, + "link_parsers": null, + "postprocessors": null, + "protect_breaking_commits": null, + "sort_commits": null, + "tag_pattern": null, + "trim": null + } + }, + "package": { + "title": "Package", + "description": "Package-specific configuration. This overrides `workspace`.\nNot all settings of `workspace` can be overridden.", + "type": "array", + "default": [], + "items": { + "$ref": "#/$defs/PackageSpecificConfigWithName" + } + }, + "workspace": { + "title": "Workspace", + "description": "Global configuration. Applied to all packages by default.", + "$ref": "#/$defs/Workspace", + "default": { + "allow_dirty": null, + "changelog_config": null, + "changelog_path": null, + "changelog_update": null, + "custom_major_increment_regex": null, + "custom_minor_increment_regex": null, + "dependencies_update": null, + "features_always_increment_minor": null, + "git_only": null, + "git_release_body": null, + "git_release_draft": null, + "git_release_enable": null, + "git_release_generate_notes": null, + "git_release_latest": null, + "git_release_name": null, + "git_release_type": null, + "git_tag_enable": null, + "git_tag_name": null, + "max_analyze_commits": 1000, + "pr_body": null, + "pr_branch_prefix": null, + "pr_draft": false, + "pr_labels": [], + "pr_name": null, + "publish": null, + "publish_all_features": null, + "publish_allow_dirty": null, + "publish_features": null, + "publish_no_verify": null, + "publish_timeout": null, + "release": null, + "release_always": null, + "release_commits": null, + "repo_url": null, + "semver_check": null + } + } + }, + "additionalProperties": false, + "$defs": { + "ChangelogCfg": { + "type": "object", + "properties": { + "body": { + "description": "Template that represents a single release in the changelog.\nIt contains the commit messages.\nThis is a [tera](https://keats.github.io/tera/) template.", + "type": [ + "string", + "null" + ] + }, + "commit_parsers": { + "description": "Commits that don't match any of the commit parsers are skipped.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/CommitParser" + } + }, + "commit_preprocessors": { + "description": "An array of commit preprocessors for manipulating the commit messages before parsing/grouping them.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/TextProcessor" + } + }, + "header": { + "description": "Text at the beginning of the changelog.", + "type": [ + "string", + "null" + ] + }, + "link_parsers": { + "description": "An array of link parsers for extracting external references, and turning them into URLs, using regex.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/LinkParser" + } + }, + "postprocessors": { + "description": "An array of postprocessors for manipulating the rendered changelog.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/TextProcessor" + } + }, + "protect_breaking_commits": { + "description": "Whether to protect all breaking changes from being skipped by a commit parser.", + "type": [ + "boolean", + "null" + ] + }, + "sort_commits": { + "description": "How to sort the commits inside the various sections.", + "anyOf": [ + { + "$ref": "#/$defs/Sorting" + }, + { + "type": "null" + } + ] + }, + "tag_pattern": { + "description": "A regular expression for matching the git tags to add to the changelog.", + "type": [ + "string", + "null" + ] + }, + "trim": { + "description": "If set to `true`, leading and trailing whitespace are removed from [`Self::body`].", + "type": [ + "boolean", + "null" + ] + } + }, + "additionalProperties": false + }, + "CommitParser": { + "description": "Parser for grouping commits.", + "type": "object", + "properties": { + "body": { + "description": "Regex for matching the commit body.", + "type": [ + "string", + "null" + ] + }, + "continue": { + "description": "Whether to continue applying subsequent parsers after this one matches.\nDefaults to `false`.", + "type": [ + "boolean", + "null" + ] + }, + "default_scope": { + "description": "Default scope of the commit.", + "type": [ + "string", + "null" + ] + }, + "field": { + "description": "Field name of the commit to match the regex against.", + "type": [ + "string", + "null" + ] + }, + "footer": { + "description": "Regex for matching the commit footer.", + "type": [ + "string", + "null" + ] + }, + "group": { + "description": "Group of the commit.", + "type": [ + "string", + "null" + ] + }, + "message": { + "description": "Regex for matching the commit message.", + "type": [ + "string", + "null" + ] + }, + "pattern": { + "description": "Regex for matching the field value.", + "type": [ + "string", + "null" + ] + }, + "scope": { + "description": "Commit scope for overriding the default scope.", + "type": [ + "string", + "null" + ] + }, + "sha": { + "description": "SHA1 of the commit.", + "type": [ + "string", + "null" + ] + }, + "skip": { + "description": "Whether to skip this commit group.", + "type": [ + "boolean", + "null" + ] + } + } + }, + "LinkParser": { + "type": "object", + "properties": { + "href": { + "description": "The string used to generate the link URL.", + "type": "string" + }, + "pattern": { + "description": "Regex for finding links in the commit message.", + "type": "string" + }, + "text": { + "description": "The string used to generate the link text.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "pattern", + "href" + ] + }, + "PackageSpecificConfigWithName": { + "description": "Config at the `[[package]]` level.", + "type": "object", + "properties": { + "changelog_include": { + "title": "Changelog Include", + "description": "List of package names.\nInclude the changelogs of these packages in the changelog of the current package.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "changelog_path": { + "title": "Changelog Path", + "description": "Normally the changelog is placed in the same directory of the Cargo.toml file.\nThe user can provide a custom path here.\n`changelog_path` is propagated to the commands:\n`update`, `release-pr` and `release`.", + "type": [ + "string", + "null" + ] + }, + "changelog_update": { + "title": "Changelog Update", + "description": "Whether to create/update changelog or not.\nIf unspecified, the changelog is updated.", + "type": [ + "boolean", + "null" + ] + }, + "custom_major_increment_regex": { + "title": "Custom Major Increment Regex", + "description": "Custom regex to match commit types that should trigger a major version increment.\nUseful when using non-conventional commit prefixes.", + "type": [ + "string", + "null" + ] + }, + "custom_minor_increment_regex": { + "title": "Custom Minor Increment Regex", + "description": "Custom regex to match commit types that should trigger a minor version increment.\nUseful when using non-conventional commit prefixes.", + "type": [ + "string", + "null" + ] + }, + "features_always_increment_minor": { + "title": "Features Always Increment Minor Version", + "description": "- If `true`, feature commits will always bump the minor version, even in 0.x releases.\n- If `false` (default), feature commits will only bump the minor version starting with 1.x releases.", + "type": [ + "boolean", + "null" + ] + }, + "git_only": { + "title": "Git Only", + "description": "Use git tags for release information.\nIf true, release-plz will use git tags to determine what the latest version of the package\nis (i.e newest version is v0.1.3 and is associated with commit ac83762).\nIf false (default), release-plz will use the cargo registry (e.g. crates.io) to get the latest version.", + "type": [ + "boolean", + "null" + ] + }, + "git_release_body": { + "title": "Git Release Body", + "description": "Tera template of the git release body created by release-plz.", + "type": [ + "string", + "null" + ] + }, + "git_release_draft": { + "title": "Git Release Draft", + "description": "If true, will not auto-publish the release.", + "type": [ + "boolean", + "null" + ] + }, + "git_release_enable": { + "title": "Git Release Enable", + "description": "Publish the GitHub/Gitea/GitLab release for the created git tag.\nEnabled by default.", + "type": [ + "boolean", + "null" + ] + }, + "git_release_generate_notes": { + "title": "Git Release Generate Notes", + "description": "Generate release notes on GitHub. Defaults to `false`.\nGenerated notes are appended to the configured release body.", + "type": [ + "boolean", + "null" + ] + }, + "git_release_latest": { + "title": "Git Release Latest", + "description": "If true, will set the git release as latest.", + "type": [ + "boolean", + "null" + ] + }, + "git_release_name": { + "title": "Git Release Name", + "description": "Tera template of the git release name created by release-plz.", + "type": [ + "string", + "null" + ] + }, + "git_release_type": { + "title": "Git Release Type", + "description": "Whether to mark the created release as not ready for production.", + "anyOf": [ + { + "$ref": "#/$defs/ReleaseType" + }, + { + "type": "null" + } + ] + }, + "git_tag_enable": { + "title": "Git Tag Enable", + "description": "Publish the git tag for the new package version.\nEnabled by default.", + "type": [ + "boolean", + "null" + ] + }, + "git_tag_name": { + "title": "Git Tag Name", + "description": "Tera template of the git tag name created by release-plz.", + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "publish": { + "title": "Publish", + "description": "If `false`, don't run `cargo publish`.", + "type": [ + "boolean", + "null" + ] + }, + "publish_all_features": { + "title": "Publish All Features", + "description": "If `true`, add the `--all-features` flag to the `cargo publish` command.", + "type": [ + "boolean", + "null" + ] + }, + "publish_allow_dirty": { + "title": "Publish Allow Dirty", + "description": "If `true`, add the `--allow-dirty` flag to the `cargo publish` command.", + "type": [ + "boolean", + "null" + ] + }, + "publish_features": { + "title": "Publish Features", + "description": "If `[\"a\", \"b\", \"c\"]`, add the `--features=a,b,c` flag to the `cargo publish` command.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "publish_no_verify": { + "title": "Publish No Verify", + "description": "If `true`, add the `--no-verify` flag to the `cargo publish` command.", + "type": [ + "boolean", + "null" + ] + }, + "release": { + "title": "Release", + "description": "Used to toggle off the update/release process for a workspace or package.", + "type": [ + "boolean", + "null" + ] + }, + "semver_check": { + "title": "Semver Check", + "description": "Controls when to run cargo-semver-checks.\nIf unspecified, run cargo-semver-checks if the package is a library.", + "type": [ + "boolean", + "null" + ] + }, + "version_group": { + "title": "Version group", + "description": "The name of a group of packages that needs to have the same version.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name" + ] + }, + "ReleaseType": { + "oneOf": [ + { + "title": "Prod", + "description": "Will mark the release as ready for production.", + "type": "string", + "const": "prod" + }, + { + "title": "Pre", + "description": "Will mark the release as not ready for production.\nI.e. as pre-release.", + "type": "string", + "const": "pre" + }, + { + "title": "Auto", + "description": "Will mark the release as not ready for production\nin case there is a semver pre-release in the tag e.g. v1.0.0-rc1.\nOtherwise, will mark the release as ready for production.", + "type": "string", + "const": "auto" + } + ] + }, + "Sorting": { + "type": "string", + "enum": [ + "oldest", + "newest" + ] + }, + "TextProcessor": { + "description": "Used for modifying commit messages.", + "type": "object", + "properties": { + "pattern": { + "description": "Regex for matching a text to replace.", + "type": "string" + }, + "replace": { + "description": "Replacement text.", + "type": [ + "string", + "null" + ] + }, + "replace_command": { + "description": "Command that will be run for replacing the commit message.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "pattern" + ] + }, + "Workspace": { + "description": "Config at the `[workspace]` level.", + "type": "object", + "properties": { + "allow_dirty": { + "title": "Allow Dirty", + "description": "- If `true`, allow dirty working directories to be updated. The uncommitted changes will be part of the update.\n- If `false` or [`Option::None`], the command will fail if the working directory is dirty.", + "type": [ + "boolean", + "null" + ] + }, + "changelog_config": { + "title": "Changelog Config", + "description": "Path to the git cliff configuration file. Defaults to the `keep a changelog` configuration.", + "type": [ + "string", + "null" + ] + }, + "changelog_path": { + "title": "Changelog Path", + "description": "Normally the changelog is placed in the same directory of the Cargo.toml file.\nThe user can provide a custom path here.\n`changelog_path` is propagated to the commands:\n`update`, `release-pr` and `release`.", + "type": [ + "string", + "null" + ] + }, + "changelog_update": { + "title": "Changelog Update", + "description": "Whether to create/update changelog or not.\nIf unspecified, the changelog is updated.", + "type": [ + "boolean", + "null" + ] + }, + "custom_major_increment_regex": { + "title": "Custom Major Increment Regex", + "description": "Custom regex to match commit types that should trigger a major version increment.\nUseful when using non-conventional commit prefixes.", + "type": [ + "string", + "null" + ] + }, + "custom_minor_increment_regex": { + "title": "Custom Minor Increment Regex", + "description": "Custom regex to match commit types that should trigger a minor version increment.\nUseful when using non-conventional commit prefixes.", + "type": [ + "string", + "null" + ] + }, + "dependencies_update": { + "title": "Dependencies Update", + "description": "- If `true`, update all the dependencies in the Cargo.lock file by running `cargo update`.\n- If `false` or [`Option::None`], only update the workspace packages by running `cargo update --workspace`.", + "type": [ + "boolean", + "null" + ] + }, + "features_always_increment_minor": { + "title": "Features Always Increment Minor Version", + "description": "- If `true`, feature commits will always bump the minor version, even in 0.x releases.\n- If `false` (default), feature commits will only bump the minor version starting with 1.x releases.", + "type": [ + "boolean", + "null" + ] + }, + "git_only": { + "title": "Git Only", + "description": "Use git tags for release information.\nIf true, release-plz will use git tags to determine what the latest version of the package\nis (i.e newest version is v0.1.3 and is associated with commit ac83762).\nIf false (default), release-plz will use the cargo registry (e.g. crates.io) to get the latest version.", + "type": [ + "boolean", + "null" + ] + }, + "git_release_body": { + "title": "Git Release Body", + "description": "Tera template of the git release body created by release-plz.", + "type": [ + "string", + "null" + ] + }, + "git_release_draft": { + "title": "Git Release Draft", + "description": "If true, will not auto-publish the release.", + "type": [ + "boolean", + "null" + ] + }, + "git_release_enable": { + "title": "Git Release Enable", + "description": "Publish the GitHub/Gitea/GitLab release for the created git tag.\nEnabled by default.", + "type": [ + "boolean", + "null" + ] + }, + "git_release_generate_notes": { + "title": "Git Release Generate Notes", + "description": "Generate release notes on GitHub. Defaults to `false`.\nGenerated notes are appended to the configured release body.", + "type": [ + "boolean", + "null" + ] + }, + "git_release_latest": { + "title": "Git Release Latest", + "description": "If true, will set the git release as latest.", + "type": [ + "boolean", + "null" + ] + }, + "git_release_name": { + "title": "Git Release Name", + "description": "Tera template of the git release name created by release-plz.", + "type": [ + "string", + "null" + ] + }, + "git_release_type": { + "title": "Git Release Type", + "description": "Whether to mark the created release as not ready for production.", + "anyOf": [ + { + "$ref": "#/$defs/ReleaseType" + }, + { + "type": "null" + } + ] + }, + "git_tag_enable": { + "title": "Git Tag Enable", + "description": "Publish the git tag for the new package version.\nEnabled by default.", + "type": [ + "boolean", + "null" + ] + }, + "git_tag_name": { + "title": "Git Tag Name", + "description": "Tera template of the git tag name created by release-plz.", + "type": [ + "string", + "null" + ] + }, + "max_analyze_commits": { + "description": "Maximum number of commits to analyze when the package hasn't been published yet.\nDefault: 1000.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "default": 1000, + "minimum": 0 + }, + "pr_body": { + "title": "PR Body", + "description": "Tera template of the pull request's body created by release-plz.", + "type": [ + "string", + "null" + ] + }, + "pr_branch_prefix": { + "title": "PR Branch Prefix", + "description": "Prefix for the PR Branch", + "type": [ + "string", + "null" + ] + }, + "pr_draft": { + "title": "PR Draft", + "description": "If `true`, the created release PR will be marked as a draft.", + "type": "boolean", + "default": false + }, + "pr_labels": { + "title": "PR Labels", + "description": "Labels to add to the release PR.", + "type": "array", + "default": [], + "items": { + "type": "string" + } + }, + "pr_name": { + "title": "PR Name", + "description": "Tera template of the pull request's name created by release-plz.", + "type": [ + "string", + "null" + ] + }, + "publish": { + "title": "Publish", + "description": "If `false`, don't run `cargo publish`.", + "type": [ + "boolean", + "null" + ] + }, + "publish_all_features": { + "title": "Publish All Features", + "description": "If `true`, add the `--all-features` flag to the `cargo publish` command.", + "type": [ + "boolean", + "null" + ] + }, + "publish_allow_dirty": { + "title": "Publish Allow Dirty", + "description": "If `true`, add the `--allow-dirty` flag to the `cargo publish` command.", + "type": [ + "boolean", + "null" + ] + }, + "publish_features": { + "title": "Publish Features", + "description": "If `[\"a\", \"b\", \"c\"]`, add the `--features=a,b,c` flag to the `cargo publish` command.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "publish_no_verify": { + "title": "Publish No Verify", + "description": "If `true`, add the `--no-verify` flag to the `cargo publish` command.", + "type": [ + "boolean", + "null" + ] + }, + "publish_timeout": { + "title": "Publish Timeout", + "description": "Timeout for the publishing process", + "type": [ + "string", + "null" + ] + }, + "release": { + "title": "Release", + "description": "Used to toggle off the update/release process for a workspace or package.", + "type": [ + "boolean", + "null" + ] + }, + "release_always": { + "title": "Release always", + "description": "- If true, release-plz release will try to release your packages every time you run it\n (e.g. on every commit in the main branch). *(Default)*.\n- If false, `release-plz release` will try release your packages only when you merge the\n release pr.\n Use this if you want to commit your packages and publish them later.\n To determine if a pr is a release-pr, release-plz will check if the branch of the PR starts with\n `release-plz-`. So if you want to create a PR that should trigger a release\n (e.g. when you fix the CI), use this branch name format (e.g. `release-plz-fix-ci`).", + "type": [ + "boolean", + "null" + ] + }, + "release_commits": { + "title": "Release Commits", + "description": "Prepare release only if at least one commit respects this regex.", + "type": [ + "string", + "null" + ] + }, + "repo_url": { + "title": "Repo URL", + "description": "GitHub/Gitea/GitLab repository url where your project is hosted.\nIt is used to generate the changelog release link.\nIt defaults to the url of the default remote.", + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "semver_check": { + "title": "Semver Check", + "description": "Controls when to run cargo-semver-checks.\nIf unspecified, run cargo-semver-checks if the package is a library.", + "type": [ + "boolean", + "null" + ] + } + }, + "additionalProperties": false + } + } +} \ No newline at end of file diff --git a/flake.nix b/flake.nix index cf09840e9..913018bf7 100644 --- a/flake.nix +++ b/flake.nix @@ -256,9 +256,13 @@ # sh/alert.sh reads the workflows. yq-go nixfmt - # admin/ scripts, and pyrind's tests and benchmarks, which - # check it against pyca/cryptography. - (python3.withPackages (ps: [ ps.cryptography ])) + # admin/ scripts, and pyrind's tests and benchmarks: they + # check it against pyca/cryptography, and its type stubs + # with mypy's stubtest. + (python3.withPackages (ps: [ + ps.cryptography + ps.mypy + ])) # Builds pyrind (rind-python). maturin # admin/bench-report-test runs the script that holds a diff --git a/rind-python/bench/bench.py b/rind-python/bench/bench.py new file mode 100644 index 000000000..f1f7d673c --- /dev/null +++ b/rind-python/bench/bench.py @@ -0,0 +1,206 @@ +"""Per-call cost of pyrind against pyca/cryptography, hashlib, and hmac. + +Each operation runs as a Python program calls it, inputs already in hand, so +the times include each library's per-call overhead, which dominates small +messages. Prints a Markdown table; `sh/python.sh bench` runs it. +""" + +import argparse +import hashlib +import hmac as std_hmac +import os +import platform +import timeit + +import cryptography +from cryptography.hazmat.backends.openssl import backend +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import hmac as pyca_hmac +from cryptography.hazmat.primitives.asymmetric import ec, ed25519, mlkem, padding, x25519 +from cryptography.hazmat.primitives.asymmetric import rsa as pyca_rsa +from cryptography.hazmat.primitives.ciphers import aead as pyca_aead +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +from pyrind import aead, agreement, digest, hkdf, hmac, kem, signature +from pyrind.signature import sign, verify + + +def cases(): + """Yields (operation, {library: callable}).""" + message = os.urandom(21) + yield "SHA-256, 21 B", { + "pyrind": lambda: digest.digest(digest.SHA256, message), + "cryptography": lambda: _pyca_hash(message), + "hashlib": lambda: hashlib.sha256(message).digest(), + } + + raw = os.urandom(32) + key = hmac.Key(hmac.HMAC_SHA256, raw) + yield "HMAC-SHA256, 21 B", { + "pyrind": lambda: hmac.sign(key, message), + "cryptography": lambda: _pyca_hmac(raw, message), + "hashlib": lambda: std_hmac.digest(raw, message, "sha256"), + } + + yield "HKDF-SHA256, 32 B key", { + "pyrind": lambda: hkdf.Salt(hkdf.HKDF_SHA256, b"salt").extract(raw).expand(b"info", 32), + "cryptography": lambda: HKDF(hashes.SHA256(), 32, b"salt", b"info").derive(raw), + } + + nonce = os.urandom(12) + for name, algorithm, other in ( + ("AES-128-GCM", aead.AES_128_GCM, pyca_aead.AESGCM), + ("ChaCha20-Poly1305", aead.CHACHA20_POLY1305, pyca_aead.ChaCha20Poly1305), + ): + raw_key = os.urandom(algorithm.key_len) + ours, theirs = aead.Key(algorithm, raw_key), other(raw_key) + for size in (32, 1350, 16384): + plaintext = os.urandom(size) + sealed = ours.seal(nonce, plaintext, aad=b"") + yield f"{name} seal, {size} B", { + "pyrind": lambda ours=ours, plaintext=plaintext: ours.seal(nonce, plaintext, aad=b""), + "cryptography": lambda theirs=theirs, plaintext=plaintext: theirs.encrypt(nonce, plaintext, b""), + } + yield f"{name} open, {size} B", { + "pyrind": lambda ours=ours, sealed=sealed: ours.open(nonce, sealed, aad=b""), + "cryptography": lambda theirs=theirs, sealed=sealed: theirs.decrypt(nonce, sealed, b""), + } + + # Key agreement takes the peer's key as bytes, as it arrives. + ours = agreement.PrivateKey.generate(agreement.X25519) + theirs = x25519.X25519PrivateKey.generate() + peer = theirs.public_key().public_bytes_raw() + yield "X25519 agree", { + "pyrind": lambda: ours.agree(peer), + "cryptography": lambda: theirs.exchange(x25519.X25519PublicKey.from_public_bytes(peer)), + } + + ours_p256 = agreement.PrivateKey.generate(agreement.ECDH_P256) + theirs_p256 = ec.generate_private_key(ec.SECP256R1()) + peer_p256 = bytes(ours_p256.public_key()) + yield "ECDH P-256 agree", { + "pyrind": lambda: ours_p256.agree(peer_p256), + "cryptography": lambda: theirs_p256.exchange( + ec.ECDH(), ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), peer_p256) + ), + } + + ours_ed = signature.Ed25519KeyPair.generate() + theirs_ed = ed25519.Ed25519PrivateKey.from_private_bytes(ours_ed.to_seed()) + signed_ed = ours_ed.sign(message) + public_ed, their_public_ed = ours_ed.public_key(), theirs_ed.public_key() + yield "Ed25519 sign, 21 B", { + "pyrind": lambda: ours_ed.sign(message), + "cryptography": lambda: theirs_ed.sign(message), + } + yield "Ed25519 verify, 21 B", { + "pyrind": lambda: public_ed.verify(message, signed_ed), + "cryptography": lambda: their_public_ed.verify(signed_ed, message), + } + + ours_ec = signature.EcdsaKeyPair.generate(sign.ECDSA_P256_SHA256_ASN1) + public_ec = signature.PublicKey(verify.ECDSA_P256_SHA256_ASN1, bytes(ours_ec.public_key())) + theirs_ec = ec.generate_private_key(ec.SECP256R1()) + their_public_ec = theirs_ec.public_key() + signed_ec, their_signed_ec = ours_ec.sign(message), theirs_ec.sign(message, ec.ECDSA(hashes.SHA256())) + yield "ECDSA P-256 sign, 21 B", { + "pyrind": lambda: ours_ec.sign(message), + "cryptography": lambda: theirs_ec.sign(message, ec.ECDSA(hashes.SHA256())), + } + yield "ECDSA P-256 verify, 21 B", { + "pyrind": lambda: public_ec.verify(message, signed_ec), + "cryptography": lambda: their_public_ec.verify(their_signed_ec, message, ec.ECDSA(hashes.SHA256())), + } + + theirs_rsa = pyca_rsa.generate_private_key(65537, 2048) + from cryptography.hazmat.primitives import serialization + + der = theirs_rsa.private_bytes( + serialization.Encoding.DER, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() + ) + ours_rsa = signature.RsaKeyPair.from_pkcs8(der) + public_rsa = ours_rsa.public_key(sign.RSA_PKCS1_SHA256) + their_public_rsa = theirs_rsa.public_key() + signed_rsa = ours_rsa.sign(sign.RSA_PKCS1_SHA256, message) + yield "RSA-2048 PKCS#1 sign, 21 B", { + "pyrind": lambda: ours_rsa.sign(sign.RSA_PKCS1_SHA256, message), + "cryptography": lambda: theirs_rsa.sign(message, padding.PKCS1v15(), hashes.SHA256()), + } + yield "RSA-2048 PKCS#1 verify, 21 B", { + "pyrind": lambda: public_rsa.verify(message, signed_rsa), + "cryptography": lambda: their_public_rsa.verify(signed_rsa, message, padding.PKCS1v15(), hashes.SHA256()), + } + + ours_kem = kem.DecapsulationKey.generate(kem.ML_KEM_768) + public_kem = ours_kem.public_key() + ciphertext, _ = public_kem.encapsulate() + theirs_kem = mlkem.MLKEM768PrivateKey.generate() + their_public_kem = theirs_kem.public_key() + _, their_ciphertext = their_public_kem.encapsulate() + yield "ML-KEM-768 encapsulate", { + "pyrind": public_kem.encapsulate, + "cryptography": their_public_kem.encapsulate, + } + yield "ML-KEM-768 decapsulate", { + "pyrind": lambda: ours_kem.decapsulate(ciphertext), + "cryptography": lambda: theirs_kem.decapsulate(their_ciphertext), + } + + +def _pyca_hash(message): + context = hashes.Hash(hashes.SHA256()) + context.update(message) + return context.finalize() + + +def _pyca_hmac(key, message): + context = pyca_hmac.HMAC(key, hashes.SHA256()) + context.update(message) + return context.finalize() + + +def seconds_per_call(function, repeat): + """The fastest of `repeat` runs, each long enough to time reliably.""" + timer = timeit.Timer(function) + number, _ = timer.autorange() + return min(timer.repeat(repeat, number)) / number + + +def human(seconds): + if seconds < 1e-6: + return f"{seconds * 1e9:.0f} ns" + if seconds < 1e-3: + return f"{seconds * 1e6:.2f} µs" + return f"{seconds * 1e3:.2f} ms" + + +def cpu_model(): + try: + with open("/proc/cpuinfo") as cpuinfo: + for line in cpuinfo: + if line.startswith("model name"): + return line.split(":", 1)[1].strip() + except OSError: + pass + return platform.processor() or platform.machine() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--repeat", type=int, default=7, help="timed runs per operation, of which the fastest counts") + args = parser.parse_args() + + print(f"{cpu_model()}, {platform.system()} {platform.release()}, Python {platform.python_version()}, ") + print(f"cryptography {cryptography.__version__} on {backend.openssl_version_text()}.\n") + libraries = ["pyrind", "cryptography", "hashlib"] + print("| Operation | pyrind | cryptography | hashlib, hmac | cryptography ÷ pyrind |") + print("|---|" + "---:|" * (len(libraries) + 1)) + for operation, functions in cases(): + times = {name: seconds_per_call(function, args.repeat) for name, function in functions.items()} + cells = [human(times[name]) if name in times else "-" for name in libraries] + ratio = times["cryptography"] / times["pyrind"] + print(f"| {operation} | " + " | ".join(cells) + f" | {ratio:.1f}x |", flush=True) + + +if __name__ == "__main__": + main() diff --git a/rind-python/python/pyrind/_rind.pyi b/rind-python/python/pyrind/_rind.pyi index b4365e883..a8941affb 100644 --- a/rind-python/python/pyrind/_rind.pyi +++ b/rind-python/python/pyrind/_rind.pyi @@ -32,3 +32,5 @@ class Unsupported(Error): class Fault(Error): """An internal consistency check failed, from a hardware fault or a rind bug.""" + +__all__ = ["Error", "WrongLength", "KeyRejected", "BadSignature", "DecryptFailed", "RngFailed", "Unsupported", "Fault", "aead", "agreement", "cpu", "digest", "hkdf", "hmac", "kem", "pbkdf2", "rsa", "signature"] diff --git a/rind-python/python/pyrind/aead.pyi b/rind-python/python/pyrind/aead.pyi index d626896be..0736299bb 100644 --- a/rind-python/python/pyrind/aead.pyi +++ b/rind-python/python/pyrind/aead.pyi @@ -33,7 +33,7 @@ class Key: repeats, such as a sequence number, or use `RandomizedNonceKey`. """ - def __init__(self, algorithm: Algorithm, key: Buffer) -> None: + def __new__(cls, algorithm: Algorithm, key: Buffer) -> Key: """Expands `key` for `algorithm`; raises `WrongLength` unless it is `algorithm.key_len` bytes.""" def seal(self, nonce: Buffer, plaintext: Buffer, *, aad: Buffer) -> bytes: """Encrypts `plaintext` under the 12-byte `nonce`, authenticating `aad` with it, and returns the ciphertext with the tag appended.""" @@ -44,9 +44,11 @@ class Key: class RandomizedNonceKey: """An XChaCha20-Poly1305 key that picks a random 192-bit nonce for every seal, so the caller tracks no nonce state.""" - def __init__(self, key: Buffer) -> None: + def __new__(cls, key: Buffer) -> RandomizedNonceKey: """Makes a key from 32 bytes of key material; raises `WrongLength` otherwise.""" def seal(self, plaintext: Buffer, *, aad: Buffer) -> tuple[bytes, bytes]: """Encrypts `plaintext` under a fresh random nonce, authenticating `aad` with it, and returns the nonce, which the opener needs, and the ciphertext with the tag appended.""" def open(self, nonce: Buffer, ciphertext: Buffer, *, aad: Buffer) -> bytes: """Decrypts and verifies `ciphertext`, sealed under the 24-byte `nonce` with `aad`, and returns the plaintext; raises `DecryptFailed` if it does not verify.""" + +__all__ = ["Algorithm", "Key", "RandomizedNonceKey", "AES_128_GCM", "AES_192_GCM", "AES_256_GCM", "CHACHA20_POLY1305"] diff --git a/rind-python/python/pyrind/agreement.pyi b/rind-python/python/pyrind/agreement.pyi new file mode 100644 index 000000000..0888aa9a7 --- /dev/null +++ b/rind-python/python/pyrind/agreement.pyi @@ -0,0 +1,54 @@ +"""Key agreement: X25519, and ECDH on P-256, P-384, and P-521.""" + +from typing import final + +from typing_extensions import Buffer + +@final +class Algorithm: + """A key agreement algorithm.""" + +X25519: Algorithm +ECDH_P256: Algorithm +ECDH_P384: Algorithm +ECDH_P521: Algorithm + +@final +class PrivateKey: + """A private key for key agreement.""" + + @staticmethod + def generate(algorithm: Algorithm) -> PrivateKey: + """Generates a key for `algorithm`.""" + @staticmethod + def from_scalar(algorithm: Algorithm, scalar: Buffer) -> PrivateKey: + """Loads a key for `algorithm` from its big-endian scalar, or for X25519 its 32 bytes.""" + @staticmethod + def from_pkcs8(algorithm: Algorithm, der: Buffer) -> PrivateKey: + """Loads a key for `algorithm` from PKCS#8 DER.""" + @staticmethod + def from_sec1(algorithm: Algorithm, der: Buffer) -> PrivateKey: + """Loads an ECDH key for `algorithm` from SEC1 `ECPrivateKey` DER.""" + def public_key(self) -> PublicKey: + """The public key to send the peer.""" + def agree(self, peer: Buffer) -> bytes: + """Agrees a shared secret with the peer's public key `peer`, as `bytes(PublicKey)` encodes it; raises `KeyRejected` for an invalid one.""" + def to_pkcs8(self) -> bytes: + """Encodes the key as PKCS#8 DER.""" + def to_scalar(self) -> bytes: + """Returns the key's scalar, as `from_scalar` takes it.""" + +@final +class PublicKey: + """A public key for key agreement, whose `bytes` are what `agree` takes: 32 bytes for X25519, and an uncompressed SEC1 point for ECDH.""" + + def __new__(cls, algorithm: Algorithm, data: Buffer) -> PublicKey: + """Parses a public key for `algorithm` from its `bytes` encoding.""" + @staticmethod + def from_spki(algorithm: Algorithm, der: Buffer) -> PublicKey: + """Parses a public key for `algorithm` from `SubjectPublicKeyInfo` DER.""" + def to_spki(self) -> bytes: + """Encodes the key as `SubjectPublicKeyInfo` DER.""" + def __bytes__(self) -> bytes: ... + +__all__ = ["Algorithm", "PrivateKey", "PublicKey", "X25519", "ECDH_P256", "ECDH_P384", "ECDH_P521"] diff --git a/rind-python/python/pyrind/cpu.pyi b/rind-python/python/pyrind/cpu.pyi index 11ea8cfb6..7b28c5edd 100644 --- a/rind-python/python/pyrind/cpu.pyi +++ b/rind-python/python/pyrind/cpu.pyi @@ -2,3 +2,5 @@ def has_aes_hardware() -> bool: """Whether this CPU runs AES and GHASH in hardware; where it does not, prefer ChaCha20-Poly1305.""" + +__all__ = ["has_aes_hardware"] diff --git a/rind-python/python/pyrind/digest/__init__.pyi b/rind-python/python/pyrind/digest/__init__.pyi index d4ee00d08..d375dbac9 100644 --- a/rind-python/python/pyrind/digest/__init__.pyi +++ b/rind-python/python/pyrind/digest/__init__.pyi @@ -29,9 +29,11 @@ def digest(algorithm: Algorithm, data: Buffer) -> bytes: class Context: """A hash of a message given in pieces.""" - def __init__(self, algorithm: Algorithm) -> None: + def __new__(cls, algorithm: Algorithm) -> Context: """Starts hashing with `algorithm`.""" def update(self, data: Buffer) -> None: """Adds `data` to the message.""" def finish(self) -> bytes: """Returns the digest of the message, and starts a new one with the same algorithm.""" + +__all__ = ["Algorithm", "Context", "digest", "SHA256", "SHA384", "SHA512", "SHA512_256", "SHA3_256", "SHA3_384", "SHA3_512", "legacy"] diff --git a/rind-python/python/pyrind/digest/legacy.pyi b/rind-python/python/pyrind/digest/legacy.pyi index 23f4443db..ccdf4673a 100644 --- a/rind-python/python/pyrind/digest/legacy.pyi +++ b/rind-python/python/pyrind/digest/legacy.pyi @@ -4,3 +4,5 @@ from pyrind.digest import Algorithm MD5: Algorithm SHA1: Algorithm + +__all__ = ["MD5", "SHA1"] diff --git a/rind-python/python/pyrind/hkdf/__init__.pyi b/rind-python/python/pyrind/hkdf/__init__.pyi new file mode 100644 index 000000000..4bda19596 --- /dev/null +++ b/rind-python/python/pyrind/hkdf/__init__.pyi @@ -0,0 +1,38 @@ +"""Key derivation with HKDF.""" + +from typing import final, overload + +from typing_extensions import Buffer + +from pyrind import aead, hmac +from pyrind.hkdf import legacy as legacy + +@final +class Algorithm: + """An HKDF algorithm.""" + +HKDF_SHA256: Algorithm +HKDF_SHA384: Algorithm +HKDF_SHA512: Algorithm + +@final +class Salt: + """An HKDF salt, which extracts pseudorandom keys from secrets.""" + + def __new__(cls, algorithm: Algorithm, value: Buffer) -> Salt: + """Makes a salt for `algorithm` from `value`, of any length.""" + def extract(self, secret: Buffer) -> Prk: + """Extracts a pseudorandom key from `secret`.""" + +@final +class Prk: + """A pseudorandom key, which expands into any number of keys.""" + + @overload + def expand(self, info: Buffer, key_type: int) -> bytes: ... + @overload + def expand(self, info: Buffer, key_type: aead.Algorithm) -> aead.Key: ... + @overload + def expand(self, info: Buffer, key_type: hmac.Algorithm) -> hmac.Key: ... + +__all__ = ["Algorithm", "Salt", "Prk", "HKDF_SHA256", "HKDF_SHA384", "HKDF_SHA512", "legacy"] diff --git a/rind-python/python/pyrind/hkdf/legacy.pyi b/rind-python/python/pyrind/hkdf/legacy.pyi new file mode 100644 index 000000000..b2e70cf1c --- /dev/null +++ b/rind-python/python/pyrind/hkdf/legacy.pyi @@ -0,0 +1,7 @@ +"""HKDF algorithms only legacy protocols should use.""" + +from pyrind.hkdf import Algorithm + +HKDF_SHA1: Algorithm + +__all__ = ["HKDF_SHA1"] diff --git a/rind-python/python/pyrind/hmac/__init__.pyi b/rind-python/python/pyrind/hmac/__init__.pyi new file mode 100644 index 000000000..b410cd832 --- /dev/null +++ b/rind-python/python/pyrind/hmac/__init__.pyi @@ -0,0 +1,45 @@ +"""Message authentication with HMAC.""" + +from typing import final + +from typing_extensions import Buffer + +from pyrind.hmac import legacy as legacy + +@final +class Algorithm: + """An HMAC algorithm.""" + + @property + def tag_len(self) -> int: + """The tag's length in bytes.""" + +HMAC_SHA256: Algorithm +HMAC_SHA384: Algorithm +HMAC_SHA512: Algorithm + +@final +class Key: + """An HMAC key, whose keyed hash states are computed once.""" + + def __new__(cls, algorithm: Algorithm, key: Buffer) -> Key: + """Makes a key for `algorithm` from `key`, of any length.""" + +def sign(key: Key, data: Buffer) -> bytes: + """Returns the tag of `data` under `key`.""" + +def verify(key: Key, data: Buffer, tag: Buffer) -> None: + """Checks in constant time that `tag` is the whole tag of `data` under `key`; raises `BadSignature` otherwise, including for a truncated tag.""" + +@final +class Context: + """A tag of a message given in pieces, which signs once.""" + + def __new__(cls, key: Key) -> Context: + """Starts a tag under `key`.""" + def update(self, data: Buffer) -> None: + """Adds `data` to the message.""" + def sign(self) -> bytes: + """Returns the tag of the message; the context takes no more data.""" + +__all__ = ["Algorithm", "Key", "Context", "sign", "verify", "HMAC_SHA256", "HMAC_SHA384", "HMAC_SHA512", "legacy"] diff --git a/rind-python/python/pyrind/hmac/legacy.pyi b/rind-python/python/pyrind/hmac/legacy.pyi new file mode 100644 index 000000000..63c4ef12f --- /dev/null +++ b/rind-python/python/pyrind/hmac/legacy.pyi @@ -0,0 +1,7 @@ +"""HMAC algorithms only legacy protocols should use.""" + +from pyrind.hmac import Algorithm + +HMAC_SHA1: Algorithm + +__all__ = ["HMAC_SHA1"] diff --git a/rind-python/python/pyrind/kem.pyi b/rind-python/python/pyrind/kem.pyi new file mode 100644 index 000000000..e8a74e9ac --- /dev/null +++ b/rind-python/python/pyrind/kem.pyi @@ -0,0 +1,35 @@ +"""Key encapsulation: ML-KEM-768.""" + +from typing import final + +from typing_extensions import Buffer + +@final +class Algorithm: + """A key encapsulation mechanism.""" + +ML_KEM_768: Algorithm + +@final +class DecapsulationKey: + """The recipient's private key, which decapsulates.""" + + @staticmethod + def generate(algorithm: Algorithm) -> DecapsulationKey: + """Generates a key for `algorithm`.""" + def public_key(self) -> EncapsulationKey: + """The public key to send the sender.""" + def decapsulate(self, ciphertext: Buffer) -> bytes: + """Recovers the shared secret from the sender's `ciphertext`.""" + +@final +class EncapsulationKey: + """The recipient's public key, which encapsulates, and whose `bytes` are its encoding.""" + + def __new__(cls, algorithm: Algorithm, data: Buffer) -> EncapsulationKey: + """Parses a public key for `algorithm`.""" + def encapsulate(self) -> tuple[bytes, bytes]: + """Encapsulates a fresh shared secret, returning the ciphertext to send the recipient and the secret.""" + def __bytes__(self) -> bytes: ... + +__all__ = ["Algorithm", "DecapsulationKey", "EncapsulationKey", "ML_KEM_768"] diff --git a/rind-python/python/pyrind/pbkdf2/__init__.pyi b/rind-python/python/pyrind/pbkdf2/__init__.pyi new file mode 100644 index 000000000..77a8547b3 --- /dev/null +++ b/rind-python/python/pyrind/pbkdf2/__init__.pyi @@ -0,0 +1,31 @@ +"""Password-based key derivation with PBKDF2.""" + +from typing import final + +from typing_extensions import Buffer + +from pyrind.pbkdf2 import legacy as legacy + +@final +class Algorithm: + """A PBKDF2 algorithm.""" + +PBKDF2_HMAC_SHA256: Algorithm +PBKDF2_HMAC_SHA384: Algorithm +PBKDF2_HMAC_SHA512: Algorithm + +@final +class Secret: + """A password keyed into HMAC once, which derives keys for any salt and iteration count. + + Both calls release the GIL while they run. + """ + + def __new__(cls, algorithm: Algorithm, secret: Buffer) -> Secret: + """Keys `algorithm` with `secret`, a password of any length.""" + def derive(self, iterations: int, salt: Buffer, length: int) -> bytes: + """Derives `length` bytes for `salt` with `iterations`.""" + def verify(self, iterations: int, salt: Buffer, previously_derived: Buffer) -> None: + """Checks in constant time that `previously_derived` is what `derive` returns for `salt` and `iterations`; raises `BadSignature` otherwise.""" + +__all__ = ["Algorithm", "Secret", "PBKDF2_HMAC_SHA256", "PBKDF2_HMAC_SHA384", "PBKDF2_HMAC_SHA512", "legacy"] diff --git a/rind-python/python/pyrind/pbkdf2/legacy.pyi b/rind-python/python/pyrind/pbkdf2/legacy.pyi new file mode 100644 index 000000000..bc53091ad --- /dev/null +++ b/rind-python/python/pyrind/pbkdf2/legacy.pyi @@ -0,0 +1,7 @@ +"""PBKDF2 algorithms only legacy formats should use.""" + +from pyrind.pbkdf2 import Algorithm + +PBKDF2_HMAC_SHA1: Algorithm + +__all__ = ["PBKDF2_HMAC_SHA1"] diff --git a/rind-python/python/pyrind/rsa/__init__.pyi b/rind-python/python/pyrind/rsa/__init__.pyi new file mode 100644 index 000000000..143cf579e --- /dev/null +++ b/rind-python/python/pyrind/rsa/__init__.pyi @@ -0,0 +1,69 @@ +"""RSA encryption: RSA-OAEP with SHA-256, SHA-384, or SHA-512, and SHA-1 under `rsa.legacy`. + +RSA signatures are under `pyrind.signature`, which takes this module's `KeySize` too. +""" + +from typing import ClassVar, final + +from typing_extensions import Buffer + +from pyrind.rsa import legacy as legacy + +@final +class KeySize: + """An RSA modulus size for key generation.""" + + RSA_2048: ClassVar[KeySize] + RSA_3072: ClassVar[KeySize] + RSA_4096: ClassVar[KeySize] + RSA_6144: ClassVar[KeySize] + RSA_8192: ClassVar[KeySize] + +@final +class OaepAlgorithm: + """An RSA-OAEP algorithm.""" + +OAEP_SHA256: OaepAlgorithm +OAEP_SHA384: OaepAlgorithm +OAEP_SHA512: OaepAlgorithm + +@final +class DecryptingKey: + """An RSA-OAEP private key, which decrypts.""" + + @staticmethod + def generate(algorithm: OaepAlgorithm, size: KeySize) -> DecryptingKey: + """Generates a key of `size` for `algorithm`, releasing the GIL: about 30 milliseconds for 2048 bits, and seconds for the largest sizes.""" + @staticmethod + def from_pkcs8(algorithm: OaepAlgorithm, der: Buffer) -> DecryptingKey: + """Loads a PKCS#8 RSA private key for `algorithm`.""" + @staticmethod + def from_pkcs1(algorithm: OaepAlgorithm, der: Buffer) -> DecryptingKey: + """Loads a PKCS#1 `RSAPrivateKey` for `algorithm`.""" + def public_key(self) -> EncryptingKey: + """The public key, which encrypts to this key with its algorithm.""" + def decrypt(self, ciphertext: Buffer, *, label: Buffer) -> bytes: + """Decrypts `ciphertext`, encrypted with `label`, releasing the GIL; every malformed ciphertext raises the same `DecryptFailed`.""" + def to_pkcs8(self) -> bytes: + """Encodes the private key as PKCS#8 DER.""" + def to_spki(self) -> bytes: + """Encodes the public key as `SubjectPublicKeyInfo` DER.""" + +@final +class EncryptingKey: + """An RSA-OAEP public key, which encrypts.""" + + def __new__(cls, algorithm: OaepAlgorithm, der: Buffer) -> EncryptingKey: + """Parses a PKCS#1 `RSAPublicKey` or a `SubjectPublicKeyInfo` for `algorithm`.""" + @staticmethod + def from_components(algorithm: OaepAlgorithm, n: Buffer, e: Buffer) -> EncryptingKey: + """Loads a key for `algorithm` from its big-endian modulus `n` and exponent `e`, as a JWK carries them.""" + def encrypt(self, message: Buffer, *, label: Buffer) -> bytes: + """Encrypts `message` with `label`, returning a ciphertext as long as the modulus; raises `WrongLength` past `max_message_len`.""" + @property + def max_message_len(self) -> int: + """The longest message this key encrypts.""" + def to_spki(self) -> bytes: + """Encodes the key as `SubjectPublicKeyInfo` DER.""" + +__all__ = ["KeySize", "OaepAlgorithm", "DecryptingKey", "EncryptingKey", "OAEP_SHA256", "OAEP_SHA384", "OAEP_SHA512", "legacy"] diff --git a/rind-python/python/pyrind/rsa/legacy.pyi b/rind-python/python/pyrind/rsa/legacy.pyi new file mode 100644 index 000000000..c99afe381 --- /dev/null +++ b/rind-python/python/pyrind/rsa/legacy.pyi @@ -0,0 +1,7 @@ +"""RSA-OAEP with SHA-1, for protocols that still require it.""" + +from pyrind.rsa import OaepAlgorithm + +OAEP_SHA1: OaepAlgorithm + +__all__ = ["OAEP_SHA1"] diff --git a/rind-python/python/pyrind/signature/__init__.pyi b/rind-python/python/pyrind/signature/__init__.pyi new file mode 100644 index 000000000..ed6456a0f --- /dev/null +++ b/rind-python/python/pyrind/signature/__init__.pyi @@ -0,0 +1,129 @@ +"""Public key signatures: ECDSA on P-256, P-384, and P-521, RSA, and Ed25519. + +A key pair signs with a `signature.sign` algorithm, and a peer's public key +is parsed for a `signature.verify` algorithm. +""" + +from typing import final + +from typing_extensions import Buffer + +from pyrind.rsa import KeySize +from pyrind.signature import sign as sign +from pyrind.signature import verify as verify + +@final +class EcdsaSigningAlgorithm: + """An algorithm an `EcdsaKeyPair` signs with.""" + +@final +class RsaSigningAlgorithm: + """An algorithm an `RsaKeyPair` signs with.""" + +@final +class VerificationAlgorithm: + """An algorithm a `PublicKey` verifies with.""" + +@final +class RsaVerificationAlgorithm: + """An RSA algorithm a `PublicKey` verifies with, which also loads a key from its components.""" + +@final +class PublicKey: + """A public key that verifies signatures for one algorithm, whose `bytes` are its encoding: an uncompressed SEC1 point for ECDSA, 32 bytes for Ed25519, and a PKCS#1 `RSAPublicKey` for RSA.""" + + def __new__(cls, algorithm: VerificationAlgorithm | RsaVerificationAlgorithm, data: Buffer) -> PublicKey: + """Parses a public key for `algorithm` from its `bytes` encoding.""" + @staticmethod + def from_spki(algorithm: VerificationAlgorithm | RsaVerificationAlgorithm, der: Buffer) -> PublicKey: + """Parses a public key for `algorithm` from `SubjectPublicKeyInfo` DER, refusing one that names another algorithm or curve.""" + @staticmethod + def from_components(algorithm: RsaVerificationAlgorithm, n: Buffer, e: Buffer) -> PublicKey: + """Loads an RSA key for `algorithm` from its big-endian modulus `n` and exponent `e`, as a JWK carries them.""" + def verify(self, message: Buffer, signature: Buffer) -> None: + """Checks `signature` over `message`; raises `BadSignature` if it does not verify.""" + def to_spki(self) -> bytes: + """Encodes the key as `SubjectPublicKeyInfo` DER.""" + def __bytes__(self) -> bytes: ... + +@final +class EcdsaKeyPair: + """An ECDSA key pair, which signs with one algorithm.""" + + @staticmethod + def generate(algorithm: EcdsaSigningAlgorithm) -> EcdsaKeyPair: + """Generates a key pair for `algorithm`.""" + @staticmethod + def from_pkcs8(algorithm: EcdsaSigningAlgorithm, der: Buffer) -> EcdsaKeyPair: + """Loads a key pair for `algorithm` from PKCS#8 DER.""" + @staticmethod + def from_sec1(algorithm: EcdsaSigningAlgorithm, der: Buffer) -> EcdsaKeyPair: + """Loads a key pair for `algorithm` from SEC1 `ECPrivateKey` DER.""" + @staticmethod + def from_scalar_and_public_key(algorithm: EcdsaSigningAlgorithm, scalar: Buffer, public_key: Buffer) -> EcdsaKeyPair: + """Loads a key pair for `algorithm` from its big-endian scalar and its uncompressed public key, as a JWK's `d`, `x`, and `y` carry them, refused unless they match.""" + def public_key(self) -> PublicKey: + """The public key, which verifies with the verification algorithm of the same name.""" + def sign(self, message: Buffer) -> bytes: + """Signs `message`.""" + def to_pkcs8(self) -> bytes: + """Encodes the key pair as PKCS#8 DER.""" + def to_sec1(self) -> bytes: + """Encodes the key pair as SEC1 `ECPrivateKey` DER.""" + def to_scalar(self) -> bytes: + """Returns the private key's big-endian scalar.""" + def to_spki(self) -> bytes: + """Encodes the public key as `SubjectPublicKeyInfo` DER.""" + +@final +class Ed25519KeyPair: + """An Ed25519 key pair.""" + + @staticmethod + def generate() -> Ed25519KeyPair: + """Generates a key pair.""" + @staticmethod + def from_pkcs8(der: Buffer) -> Ed25519KeyPair: + """Loads a key pair from PKCS#8 v1 or v2 DER, refused if a v2 key's public key does not match.""" + @staticmethod + def from_seed(seed: Buffer) -> Ed25519KeyPair: + """Loads a 32-byte seed, as a JWK's `d` holds it, deriving its public key.""" + @staticmethod + def from_seed_and_public_key(seed: Buffer, public_key: Buffer) -> Ed25519KeyPair: + """Loads a 32-byte seed, refused unless `public_key` is its public key.""" + def public_key(self) -> PublicKey: + """The public key, which verifies with `verify.ED25519`.""" + def sign(self, message: Buffer) -> bytes: + """Signs `message`.""" + def to_pkcs8(self) -> bytes: + """Encodes the key pair as PKCS#8 v2 DER (RFC 5958), with its public key.""" + def to_pkcs8v1(self) -> bytes: + """Encodes the private key as PKCS#8 v1 DER (RFC 8410), as OpenSSL writes it.""" + def to_seed(self) -> bytes: + """Returns the private key's 32-byte seed.""" + def to_spki(self) -> bytes: + """Encodes the public key as `SubjectPublicKeyInfo` DER.""" + +@final +class RsaKeyPair: + """An RSA key pair, which signs with any RSA signing algorithm.""" + + @staticmethod + def generate(size: KeySize) -> RsaKeyPair: + """Generates a key pair of `size`, releasing the GIL: about 30 milliseconds for 2048 bits, and seconds for the largest sizes.""" + @staticmethod + def from_pkcs8(der: Buffer) -> RsaKeyPair: + """Loads a PKCS#8 RSA private key.""" + @staticmethod + def from_pkcs1(der: Buffer) -> RsaKeyPair: + """Loads a PKCS#1 `RSAPrivateKey`.""" + def public_key(self, algorithm: RsaSigningAlgorithm) -> PublicKey: + """The public key, which verifies what this key pair signs with `algorithm`.""" + def sign(self, algorithm: RsaSigningAlgorithm, message: Buffer) -> bytes: + """Signs `message` with `algorithm`, releasing the GIL.""" + def to_pkcs8(self) -> bytes: + """Encodes the private key as PKCS#8 DER.""" + def to_spki(self) -> bytes: + """Encodes the public key as `SubjectPublicKeyInfo` DER.""" + +__all__ = ["EcdsaSigningAlgorithm", "RsaSigningAlgorithm", "VerificationAlgorithm", "RsaVerificationAlgorithm", "PublicKey", "EcdsaKeyPair", "Ed25519KeyPair", "RsaKeyPair", "sign", "verify"] diff --git a/rind-python/python/pyrind/signature/sign.pyi b/rind-python/python/pyrind/signature/sign.pyi new file mode 100644 index 000000000..db64820ec --- /dev/null +++ b/rind-python/python/pyrind/signature/sign.pyi @@ -0,0 +1,18 @@ +"""Algorithms a key pair signs with; each signs as the `verify` algorithm of the same name verifies.""" + +from pyrind.signature import EcdsaSigningAlgorithm, RsaSigningAlgorithm + +ECDSA_P256_SHA256_ASN1: EcdsaSigningAlgorithm +ECDSA_P256_SHA256_FIXED: EcdsaSigningAlgorithm +ECDSA_P384_SHA384_ASN1: EcdsaSigningAlgorithm +ECDSA_P384_SHA384_FIXED: EcdsaSigningAlgorithm +ECDSA_P521_SHA512_ASN1: EcdsaSigningAlgorithm +ECDSA_P521_SHA512_FIXED: EcdsaSigningAlgorithm +RSA_PKCS1_SHA256: RsaSigningAlgorithm +RSA_PKCS1_SHA384: RsaSigningAlgorithm +RSA_PKCS1_SHA512: RsaSigningAlgorithm +RSA_PSS_SHA256: RsaSigningAlgorithm +RSA_PSS_SHA384: RsaSigningAlgorithm +RSA_PSS_SHA512: RsaSigningAlgorithm + +__all__ = ["ECDSA_P256_SHA256_ASN1", "ECDSA_P256_SHA256_FIXED", "ECDSA_P384_SHA384_ASN1", "ECDSA_P384_SHA384_FIXED", "ECDSA_P521_SHA512_ASN1", "ECDSA_P521_SHA512_FIXED", "RSA_PKCS1_SHA256", "RSA_PKCS1_SHA384", "RSA_PKCS1_SHA512", "RSA_PSS_SHA256", "RSA_PSS_SHA384", "RSA_PSS_SHA512"] diff --git a/rind-python/python/pyrind/signature/verify/__init__.pyi b/rind-python/python/pyrind/signature/verify/__init__.pyi new file mode 100644 index 000000000..4ebfbc5ae --- /dev/null +++ b/rind-python/python/pyrind/signature/verify/__init__.pyi @@ -0,0 +1,29 @@ +"""Algorithms a public key verifies with; RSA algorithms accept moduli of 2048 to 8192 bits.""" + +from pyrind.signature import RsaVerificationAlgorithm, VerificationAlgorithm +from pyrind.signature.verify import legacy as legacy + +ECDSA_P256_SHA256_ASN1: VerificationAlgorithm +ECDSA_P256_SHA256_FIXED: VerificationAlgorithm +ECDSA_P256_SHA384_ASN1: VerificationAlgorithm +ECDSA_P256_SHA512_ASN1: VerificationAlgorithm +ECDSA_P384_SHA256_ASN1: VerificationAlgorithm +ECDSA_P384_SHA384_ASN1: VerificationAlgorithm +ECDSA_P384_SHA384_FIXED: VerificationAlgorithm +ECDSA_P384_SHA512_ASN1: VerificationAlgorithm +ECDSA_P521_SHA256_ASN1: VerificationAlgorithm +ECDSA_P521_SHA384_ASN1: VerificationAlgorithm +ECDSA_P521_SHA512_ASN1: VerificationAlgorithm +ECDSA_P521_SHA512_FIXED: VerificationAlgorithm +ED25519: VerificationAlgorithm +RSA_PKCS1_SHA256: RsaVerificationAlgorithm +RSA_PKCS1_SHA384: RsaVerificationAlgorithm +RSA_PKCS1_SHA512: RsaVerificationAlgorithm +RSA_PSS_SHA256: RsaVerificationAlgorithm +RSA_PSS_SHA256_ANY_SALT: RsaVerificationAlgorithm +RSA_PSS_SHA384: RsaVerificationAlgorithm +RSA_PSS_SHA384_ANY_SALT: RsaVerificationAlgorithm +RSA_PSS_SHA512: RsaVerificationAlgorithm +RSA_PSS_SHA512_ANY_SALT: RsaVerificationAlgorithm + +__all__ = ["ECDSA_P256_SHA256_ASN1", "ECDSA_P256_SHA256_FIXED", "ECDSA_P256_SHA384_ASN1", "ECDSA_P256_SHA512_ASN1", "ECDSA_P384_SHA256_ASN1", "ECDSA_P384_SHA384_ASN1", "ECDSA_P384_SHA384_FIXED", "ECDSA_P384_SHA512_ASN1", "ECDSA_P521_SHA256_ASN1", "ECDSA_P521_SHA384_ASN1", "ECDSA_P521_SHA512_ASN1", "ECDSA_P521_SHA512_FIXED", "ED25519", "RSA_PKCS1_SHA256", "RSA_PKCS1_SHA384", "RSA_PKCS1_SHA512", "RSA_PSS_SHA256", "RSA_PSS_SHA256_ANY_SALT", "RSA_PSS_SHA384", "RSA_PSS_SHA384_ANY_SALT", "RSA_PSS_SHA512", "RSA_PSS_SHA512_ANY_SALT", "legacy"] diff --git a/rind-python/python/pyrind/signature/verify/legacy.pyi b/rind-python/python/pyrind/signature/verify/legacy.pyi new file mode 100644 index 000000000..2744e3496 --- /dev/null +++ b/rind-python/python/pyrind/signature/verify/legacy.pyi @@ -0,0 +1,11 @@ +"""RSA algorithms with another modulus floor, or SHA-1, for old keys and signatures.""" + +from pyrind.signature import RsaVerificationAlgorithm + +RSA_PKCS1_1024_SHA1: RsaVerificationAlgorithm +RSA_PKCS1_1024_SHA256: RsaVerificationAlgorithm +RSA_PKCS1_1024_SHA512: RsaVerificationAlgorithm +RSA_PKCS1_3072_SHA384: RsaVerificationAlgorithm +RSA_PKCS1_SHA1: RsaVerificationAlgorithm + +__all__ = ["RSA_PKCS1_1024_SHA1", "RSA_PKCS1_1024_SHA256", "RSA_PKCS1_1024_SHA512", "RSA_PKCS1_3072_SHA384", "RSA_PKCS1_SHA1"] diff --git a/rind-python/src/aead.rs b/rind-python/src/aead.rs index f3cca5372..ef2de6ff9 100644 --- a/rind-python/src/aead.rs +++ b/rind-python/src/aead.rs @@ -3,7 +3,7 @@ use pyo3::prelude::*; use pyo3::types::PyBytes; use rind::aead; -use crate::{Bytes, WrongLength, algorithm, bytes, constants, error}; +use crate::{Bytes, WrongLength, algorithm, bytes, bytes_with, constants, error}; const TAG_LEN: usize = 16; @@ -37,9 +37,8 @@ fn seal_into<'py, T: AsRef<[u8]>>( plaintext: &[u8], seal: impl FnOnce(&mut [u8]) -> Result, ) -> PyResult> { - PyBytes::new_with(py, plaintext.len() + TAG_LEN, |out| { + bytes_with(py, plaintext, plaintext.len() + TAG_LEN, |out| { let (body, tag) = out.split_at_mut(plaintext.len()); - body.copy_from_slice(plaintext); tag.copy_from_slice(seal(body).map_err(error)?.as_ref()); Ok(()) }) @@ -55,10 +54,7 @@ fn open_into<'py>( let (body, tag) = ciphertext .split_last_chunk::() .ok_or_else(|| error(rind::Error::DecryptFailed))?; - PyBytes::new_with(py, body.len(), |out| { - out.copy_from_slice(body); - open(out, *tag).map_err(error) - }) + bytes_with(py, body, body.len(), |out| open(out, *tag).map_err(error)) } /// An AEAD key, which seals with the caller's nonces. diff --git a/rind-python/src/lib.rs b/rind-python/src/lib.rs index 9d3035ccd..44d435289 100644 --- a/rind-python/src/lib.rs +++ b/rind-python/src/lib.rs @@ -17,7 +17,7 @@ use std::borrow::Cow; use pyo3::buffer::PyBuffer; use pyo3::create_exception; -use pyo3::exceptions::{PyException, PyImportError}; +use pyo3::exceptions::{PyException, PyImportError, PyMemoryError}; use pyo3::prelude::*; use pyo3::types::{PyBytes, PyDict}; @@ -154,6 +154,36 @@ macro_rules! constants { } pub(crate) use constants; +/// Returns a new `bytes` of `len` bytes that begins with `prefix`, the rest +/// zeroed, after `fill` has written it in place. +/// +/// Unlike `PyBytes::new_with`, this writes each byte once before `fill` +/// rather than zeroing the whole buffer and then copying `prefix` over it, +/// which for an AEAD's plaintext is a pass over every byte saved. +pub(crate) fn bytes_with<'py>( + py: Python<'py>, + prefix: &[u8], + len: usize, + fill: impl FnOnce(&mut [u8]) -> PyResult<()>, +) -> PyResult> { + assert!(prefix.len() <= len); + let size = isize::try_from(len).map_err(|_| PyMemoryError::new_err(()))?; + // SAFETY: `PyBytes_FromStringAndSize` with a null pointer returns a new + // object, owned here and referenced nowhere else, whose `len` bytes are + // uninitialized, or null with an exception set. Its buffer is copied and + // zeroed into whole before any slice of it exists, and Python sees the + // object only once it is returned. + unsafe { + let object = pyo3::ffi::PyBytes_FromStringAndSize(core::ptr::null(), size); + let bytes = Bound::from_owned_ptr_or_err(py, object)?.cast_into_unchecked::(); + let buffer = pyo3::ffi::PyBytes_AsString(object).cast::(); + core::ptr::copy_nonoverlapping(prefix.as_ptr(), buffer, prefix.len()); + core::ptr::write_bytes(buffer.add(prefix.len()), 0, len - prefix.len()); + fill(core::slice::from_raw_parts_mut(buffer, len))?; + Ok(bytes) + } +} + /// Returns `bytes` holding `value`. pub(crate) fn bytes<'py>(py: Python<'py>, value: impl AsRef<[u8]>) -> Bound<'py, PyBytes> { PyBytes::new(py, value.as_ref()) diff --git a/rind-python/tests/test_api.py b/rind-python/tests/test_api.py index 04f3d58c3..23d925d63 100644 --- a/rind-python/tests/test_api.py +++ b/rind-python/tests/test_api.py @@ -8,7 +8,7 @@ import unittest from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import ec, ed25519, padding +from cryptography.hazmat.primitives.asymmetric import ec, ed25519, mlkem, padding from cryptography.hazmat.primitives.asymmetric import rsa as pyca_rsa from cryptography.hazmat.primitives.asymmetric import x25519 from cryptography.hazmat.primitives.ciphers import aead as pyca_aead @@ -479,6 +479,16 @@ def test_keys_at_any_address(self): ciphertext, secret = encapsulation_key.encapsulate() self.assertEqual(key.decapsulate(ciphertext), secret) + def test_against_pyca(self): + ours = kem.DecapsulationKey.generate(kem.ML_KEM_768) + their_view = mlkem.MLKEM768PublicKey.from_public_bytes(bytes(ours.public_key())) + secret, ciphertext = their_view.encapsulate() + self.assertEqual(ours.decapsulate(ciphertext), secret) + + theirs = mlkem.MLKEM768PrivateKey.generate() + ciphertext, secret = kem.EncapsulationKey(kem.ML_KEM_768, theirs.public_key().public_bytes_raw()).encapsulate() + self.assertEqual(theirs.decapsulate(ciphertext), secret) + def test_round_trip(self): recipient = kem.DecapsulationKey.generate(kem.ML_KEM_768) public = kem.EncapsulationKey(kem.ML_KEM_768, bytes(recipient.public_key())) diff --git a/sh/dispatch.sh b/sh/dispatch.sh index 7a5135fc2..ca5c8781a 100755 --- a/sh/dispatch.sh +++ b/sh/dispatch.sh @@ -72,6 +72,9 @@ declare -A scope=( [fixtures]='^(rind/|rind-rustls/|Cargo\.toml$|admin/build-cost-fixtures/(rind|rustls)/)' # The fixture builds this checkout's rind-ring under the rind-patch shim. [patch]='^(patch/|rind-ring/|(rind/)?Cargo\.toml$|sh/patch\.sh$)' + # pyrind's Python tests and type stubs, against its built wheel. rind's + # own changes reach them in the nightly wheels workflow instead. + [py]='^(rind-python/|sh/python\.sh$)' # Quest documents form one graph, so any change validates the whole tree, # as does a bump of the flake-pinned validator. [quest]='^(quest/|flake\.lock$)' @@ -100,6 +103,7 @@ declare -A tools=( [bench]='python3' [fixtures]='cargo' [patch]='cargo jq git' + [py]='cargo jq maturin python3' [quest]='quest' [gh]='actionlint yq jq' [hooks]='shellcheck' @@ -111,7 +115,7 @@ verb=$action [[ "$verb" != ci ]] || verb=check case "$verb" in - check) modules=(rs msrv cross deny versions license inplace compat bench fixtures patch quest gh hooks shell nix) ;; + check) modules=(rs msrv cross deny versions license inplace compat bench fixtures patch py quest gh hooks shell nix) ;; fix) modules=(rs compat patch nix) ;; esac @@ -225,6 +229,7 @@ for module in "${selected[@]}"; do cargo check --manifest-path admin/build-cost-fixtures/rustls/Cargo.toml --features rind-lean --target-dir target ;; check:patch) sh/patch.sh ;; + check:py) sh/python.sh test ;; fix:patch) cargo fmt --manifest-path patch/fixture/Cargo.toml ;; check:gh) actionlint diff --git a/sh/python.sh b/sh/python.sh index f1b7d5ec4..a50b47474 100755 --- a/sh/python.sh +++ b/sh/python.sh @@ -2,7 +2,7 @@ # Build pyrind's wheel and run its tests, or its benchmarks, against it. # # Usage: sh/python.sh test|bench [ARGS...] -# The tests need pyca/cryptography, the differential oracle, and the +# The tests need pyca/cryptography, the differential oracle, mypy, and the # Wycheproof submodule; the benchmarks compare against cryptography, # hashlib, and hmac. ARGS go to unittest or the benchmark script. set -euo pipefail @@ -21,7 +21,11 @@ maturin build --release --quiet --manifest-path rind-python/Cargo.toml --out "$o python3 -m zipfile -e "$out"/wheel/pyrind-*.whl "$out/site" case "$action" in - test) PYTHONPATH="$out/site" python3 -m unittest discover --start-directory rind-python/tests "$@" ;; + test) + PYTHONPATH="$out/site" python3 -m unittest discover --start-directory rind-python/tests "$@" + # Checks the type stubs against the built module. + (cd "$out/site" && python3 -m mypy.stubtest pyrind) + ;; bench) PYTHONPATH="$out/site" python3 rind-python/bench/bench.py "$@" ;; *) echo "$usage" >&2 From 4f8f31c91cbc23385049f620eb61798e6df56c42 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 23:43:48 -0800 Subject: [PATCH 05/10] feat(python): wheels, PyPI release, stubs, tests, and docs for pyrind Co-Authored-By: Claude Opus 5.5 --- .github/workflows/alert.yml | 1 + .github/workflows/python.yml | 138 +++++++++++++++++++++++++++++++ .github/workflows/release.yml | 37 +++++++++ README.md | 2 + quest/m2/adoption/pyrind.md | 38 ++++----- rind-python/README.md | 95 +++++++++++++++++++++ rind-python/tests/test_readme.py | 19 +++++ sh/pyrind-unpublished.sh | 34 ++++++++ 8 files changed, 342 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/python.yml create mode 100644 rind-python/tests/test_readme.py create mode 100755 sh/pyrind-unpublished.sh diff --git a/.github/workflows/alert.yml b/.github/workflows/alert.yml index b79bc5dcb..ebe4ae755 100644 --- a/.github/workflows/alert.yml +++ b/.github/workflows/alert.yml @@ -21,6 +21,7 @@ on: - fuzz - prove - Pullfrog + - python - quality lints - Queue - regenerate diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 000000000..34f32d65e --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,138 @@ +name: python + +# Builds pyrind's wheels for every platform rind supports that PyPI takes, +# and its sdist, and tests each wheel its runner can load. Nightly, on +# demand, and from the release workflow, which publishes what this uploads. +# Read-only: publishing is release.yml's. + +on: + schedule: + - cron: "30 18 * * *" + workflow_dispatch: + workflow_call: + inputs: + ref: + description: The commit or tag to build. + type: string + required: true + +permissions: + contents: read + +concurrency: + group: python-${{ inputs.ref || github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + # The dev shell's, which the tests and `just check` use. + MATURIN_VERSION: "1.15.0" + +jobs: + wheel: + name: wheel (${{ matrix.target }}${{ matrix.manylinux && format(', {0}', matrix.manylinux) || '' }}) + strategy: + fail-fast: false + matrix: + include: + # Rust's own glibc floor, 2.17, is manylinux2014's. + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + manylinux: "2014" + test: true + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + manylinux: "2014" + test: true + - os: ubuntu-latest + target: armv7-unknown-linux-gnueabihf + manylinux: "2014" + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + manylinux: musllinux_1_2 + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + manylinux: musllinux_1_2 + - os: macos-15-intel + target: x86_64-apple-darwin + test: true + - os: macos-latest + target: aarch64-apple-darwin + test: true + - os: windows-latest + target: x86_64-pc-windows-msvc + test: true + - os: windows-11-arm + target: aarch64-pc-windows-msvc + test: true + + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + + # The oldest CPython the stable ABI wheel targets, which both builds + # it on Windows and tests it. + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.11" + + - name: Build + uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 + with: + maturin-version: ${{ env.MATURIN_VERSION }} + target: ${{ matrix.target }} + manylinux: ${{ matrix.manylinux || 'auto' }} + args: --release --locked --out dist --manifest-path rind-python/Cargo.toml + + - if: matrix.test + uses: ./.github/actions/vectors + + - name: Test + if: matrix.test + shell: bash + run: | + python -m pip install --only-binary :all: cryptography mypy + python -m pip install --no-index --find-links dist pyrind + python -m unittest discover --start-directory rind-python/tests + python -m mypy.stubtest pyrind + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: pyrind-${{ matrix.target }} + path: dist/*.whl + if-no-files-found: error + + # PyPy and free-threaded CPython cannot load the stable ABI wheels, so they + # build this. + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.11" + + - name: Build + uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 + with: + maturin-version: ${{ env.MATURIN_VERSION }} + command: sdist + args: --out dist --manifest-path rind-python/Cargo.toml + + - name: Build a wheel from it + run: | + python -m pip install --no-binary pyrind dist/pyrind-*.tar.gz + python -c "from pyrind import digest; print(digest.digest(digest.SHA256, b'').hex())" + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: pyrind-sdist + path: dist/*.tar.gz + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c403099b..bcf8888da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,6 +34,8 @@ jobs: # The environment, which only `main` can deploy to, holds the App key and # the crates.io token, so a workflow on another branch cannot read them. environment: publish + outputs: + pyrind: ${{ steps.pyrind.outputs.tag }} steps: # A GitHub App token, since the repository does not let GITHUB_TOKEN open @@ -61,3 +63,38 @@ jobs: BEFORE: ${{ github.event.before }} GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + # release-plz tags pyrind's version rather than publishing it. + - name: Find an unpublished pyrind release + id: pyrind + run: | + tag=$(nix develop --command sh/pyrind-unpublished.sh) + echo "tag=$tag" >>"$GITHUB_OUTPUT" + + pyrind-wheels: + name: pyrind wheels + needs: release + if: needs.release.outputs.pyrind != '' + permissions: + contents: read + uses: ./.github/workflows/python.yml + with: + ref: ${{ needs.release.outputs.pyrind }} + + # Publishes to PyPI by trusted publishing, which trusts this workflow in the + # `publish` environment, so no PyPI token is stored anywhere. + pyrind-publish: + name: pyrind to PyPI + needs: [release, pyrind-wheels] + runs-on: ubuntu-latest + environment: publish + permissions: + id-token: write + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: pyrind-* + path: dist + merge-multiple: true + + - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 diff --git a/README.md b/README.md index b205d67f2..77da70295 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ Each crate's README shows its setup. | [rind-object-store][] | S3, Azure, and GCS request signing through [object_store](https://github.com/apache/arrow-rs-object-store) | | [rind-rcgen][] | certificates through [rcgen](https://github.com/rustls/rcgen) | | [rind-instant-acme][] | ACME through [instant-acme](https://github.com/djc/instant-acme), unpublished until instant-acme releases the interface it targets | +| [rind-python][] | Python, as the `pyrind` package on PyPI, with rind's own API | ### Platforms @@ -197,6 +198,7 @@ from other projects keeps its source's license, which [LICENSE] lists. [rind-object-store]: https://github.com/moq-dev/rind/tree/main/rind-object-store [rind-rcgen]: https://github.com/moq-dev/rind/tree/main/rind-rcgen [rind-instant-acme]: https://github.com/moq-dev/rind/tree/main/rind-instant-acme +[rind-python]: https://github.com/moq-dev/rind/tree/main/rind-python [quests]: https://github.com/moq-dev/rind/blob/main/quest/README.md [contributing]: https://github.com/moq-dev/rind/blob/main/CONTRIBUTING.md [migrating]: https://github.com/moq-dev/rind/blob/main/MIGRATING.md diff --git a/quest/m2/adoption/pyrind.md b/quest/m2/adoption/pyrind.md index b98e42cf0..8e29983d5 100644 --- a/quest/m2/adoption/pyrind.md +++ b/quest/m2/adoption/pyrind.md @@ -1,33 +1,27 @@ -# [L] pyrind on PyPI +# [XS] pyrind on PyPI ## Goal `pip install pyrind` installs rind for Python, with rind's own API: AEADs, hashes, HMAC, HKDF, PBKDF2, X25519, Ed25519, ECDSA and ECDH, RSA, and ML-KEM, from wheels for every platform rind supports. A small-message AEAD or HMAC -call costs a fraction of pyca/cryptography's (about 4 µs for HMAC-SHA256 of -21 B on an i7-8700, almost all of it OpenSSL 3's per-call overhead). +call costs a fraction of pyca/cryptography's. ## Plan -Decided with the maintainer on 2026-11-04, in a planning interview: +[rind-python](/rind-python/README.md) builds the package, with its numbers +against pyca/cryptography in its README. release-plz versions it from git +tags (`git_only`), so the next release PR carries rind-python 0.1.0; when +that PR lands, release-plz tags `rind-python-v0.1.0`, and `release.yml` +builds the wheels with `python.yml` and publishes them by PyPI trusted +publishing from the `publish` environment. A failed publish retries on the +next push to `main` that runs the release job. -- Name: `pyrind` ✅ (PyPI's `rind` is an unrelated active project) / - `rind-crypto`. -- Two packages, as rind and rind-ring are: this one with rind's own - misuse-resistant API, and [a pyca-compatible one](/quest/m2/adoption/pyrind-cryptography.md) - on top ✅ / one package with both APIs. -- It lives in this workspace as a `rind-python` crate (pyo3, maturin), - released by this repository's CI ✅ / a separate repository. +## Waiting -Guidance: - -- Use the stable ABI (abi3) so one wheel per platform covers every CPython - from the oldest still supported; PyPy cannot load abi3 wheels, so decide - when starting whether it gets its own wheels or the sdist; - publish with PyPI trusted publishing, keeping release credentials as the - [m0 credentials quest](/quest/m0/confine-release-credentials.md) decides. -- Measure per-call cost from Python against `cryptography`, `hashlib`, and - `hmac`, and publish the numbers with the package; the pitch is these - numbers. -- Platforms rind refuses get no wheel; there is no pure-Python fallback. +- Maintainer: on PyPI, add a pending trusted publisher for the project + `pyrind` (owner `moq-dev`, repository `rind`, workflow `release.yml`, + environment `publish`) before merging the release PR that carries + rind-python 0.1.0, then merge it. An agent cannot create the PyPI account + or publisher. Check: `pip install pyrind==0.1.0` installs, and + `python -c "import pyrind"` runs. diff --git a/rind-python/README.md b/rind-python/README.md index 39e031481..dcbafe344 100644 --- a/rind-python/README.md +++ b/rind-python/README.md @@ -1 +1,96 @@ # pyrind + +Cryptography for Python from [rind](https://github.com/moq-dev/rind), a +pure-Rust library: AEADs, hashes, HMAC, HKDF, PBKDF2, X25519, Ed25519, ECDSA +and ECDH, RSA, and ML-KEM, with rind's own misuse-resistant API. + +```shell +pip install pyrind +``` + +## Why + +A Python call into pyca/cryptography pays OpenSSL 3's per-call setup, which +dwarfs the work on a small message. pyrind's calls go straight to rind's +assembly: + + + +Run `sh/python.sh bench` in the repository for your own machine's numbers. + +## Use + +Each rind module is a submodule. Algorithms are constants, and a key is +made for one algorithm, so the wrong combination fails before any data +moves: passing a hash where a key wants an AEAD algorithm raises `TypeError`. + +```python +import os +from pyrind import aead, digest, hkdf, hmac, signature +from pyrind.signature import sign, verify + +# Hashes and MACs. +digest.digest(digest.SHA256, b"hello") +key = hmac.Key(hmac.HMAC_SHA256, b"secret") +tag = hmac.sign(key, b"message") +hmac.verify(key, b"message", tag) # raises pyrind.BadSignature on a mismatch + +# AEAD with the caller's nonces, which must never repeat under one key. The +# associated data is keyword-only, so it can't be swapped with the plaintext. +key = aead.Key(aead.AES_256_GCM, os.urandom(32)) +sealed = key.seal(bytes(12), b"plaintext", aad=b"header") +assert key.open(bytes(12), sealed, aad=b"header") == b"plaintext" + +# Or random 192-bit nonces, with no nonce state. +key = aead.RandomizedNonceKey(os.urandom(32)) +nonce, sealed = key.seal(b"plaintext", aad=b"") +assert key.open(nonce, sealed, aad=b"") == b"plaintext" + +# HKDF expands straight into keys, whose bytes never reach Python. +prk = hkdf.Salt(hkdf.HKDF_SHA256, b"salt").extract(b"input key material") +aead_key = prk.expand(b"aead", aead.AES_256_GCM) + +# Signatures: a key pair signs with a `sign` algorithm, and a peer's public +# key is parsed for a `verify` algorithm. +key_pair = signature.EcdsaKeyPair.generate(sign.ECDSA_P256_SHA256_ASN1) +signed = key_pair.sign(b"message") +public_key = signature.PublicKey(verify.ECDSA_P256_SHA256_ASN1, bytes(key_pair.public_key())) +public_key.verify(b"message", signed) +``` + +The modules are `aead`, `agreement` (X25519 and ECDH), `cpu`, `digest`, +`hkdf`, `hmac`, `kem` (ML-KEM-768), `pbkdf2`, `rsa` (RSA-OAEP), and +`signature` (ECDSA, Ed25519, and RSA). Algorithms only legacy protocols +should use, such as SHA-1 and MD5, live in each module's `legacy` submodule. +Keys load from and encode to PKCS#8, SEC1, PKCS#1, and +`SubjectPublicKeyInfo` DER. The package ships type stubs, and +[rind's documentation](https://docs.rs/rind/) describes each operation in +depth. + +Every failure raises a subclass of `pyrind.Error`: `WrongLength`, +`KeyRejected` (whose message says why), `BadSignature`, `DecryptFailed`, +`RngFailed`, `Unsupported`, or `Fault`. Arguments take any bytes-like object; +anything but `bytes` is copied first, so no other thread can change it +mid-call. RSA key generation, RSA signing and decryption, and PBKDF2 release +the GIL while they run. + +rind wipes keys and keyed state when Python frees them. It cannot wipe the +`bytes` a key was loaded from or exported to; Python owns those. + +## Platforms + +Wheels cover CPython 3.11 and later through the stable ABI, on Linux (glibc +2.17 and later on x86_64, aarch64, and armv7; musl on x86_64 and aarch64), +macOS on x86_64 and Apple silicon, and Windows on x86_64 and aarch64. PyPy, +free-threaded CPython, and other platforms build the sdist, which needs a +Rust toolchain. rind runs only on x86_64, aarch64, and 32-bit arm, so other +architectures fail to build; there is no pure-Python fallback. + +Importing pyrind raises `ImportError` on a CPU missing a feature rind +requires, naming it, rather than failing later. + +## License + +pyrind carries rind's licenses: rind's own code is Apache-2.0, ISC, or +MIT-0, at your option, and the code it copies keeps its source's license, as +[LICENSE](LICENSE) lists. diff --git a/rind-python/tests/test_readme.py b/rind-python/tests/test_readme.py new file mode 100644 index 000000000..4b5ffb880 --- /dev/null +++ b/rind-python/tests/test_readme.py @@ -0,0 +1,19 @@ +"""The README's example runs as written.""" + +import pathlib +import re +import unittest + +README = pathlib.Path(__file__).resolve().parents[1] / "README.md" + + +class Readme(unittest.TestCase): + def test_example_runs(self): + blocks = re.findall(r"```python\n(.*?)```", README.read_text(), re.S) + self.assertTrue(blocks) + for block in blocks: + exec(compile(block, str(README), "exec"), {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/sh/pyrind-unpublished.sh b/sh/pyrind-unpublished.sh new file mode 100755 index 000000000..ec4135eab --- /dev/null +++ b/sh/pyrind-unpublished.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Print the tag of pyrind's current version if release-plz has released it +# and PyPI does not have it yet, or nothing. +# +# Usage: sh/pyrind-unpublished.sh +# release-plz tags rind-python when a release PR lands, since it never goes +# to crates.io; this lets release.yml publish that tag to PyPI, and retry on a +# later push if publishing failed. +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +version=$(cargo metadata --format-version 1 --no-deps --locked | + jq -r '.packages[] | select(.name == "rind-python") | .version') +tag="rind-python-v$version" + +# Exit status 2 means no such tag: this version is unreleased. +status=0 +git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null || status=$? +case "$status" in + 0) ;; + 2) exit 0 ;; + *) exit "$status" ;; +esac + +status=$(curl --silent --output /dev/null --write-out '%{http_code}' "https://pypi.org/pypi/pyrind/$version/json") +case "$status" in + 200) ;; + 404) echo "$tag" ;; + *) + echo "error: PyPI answered $status for pyrind $version" >&2 + exit 1 + ;; +esac From 5676c8a283a05bf68de6e07d820828af21b10a92 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 23:44:26 -0800 Subject: [PATCH 06/10] fix(python): the crate carries rind's whole license Co-Authored-By: Claude Opus 5.5 --- rind-python/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rind-python/Cargo.toml b/rind-python/Cargo.toml index 209d29309..46f4a3bd2 100644 --- a/rind-python/Cargo.toml +++ b/rind-python/Cargo.toml @@ -3,7 +3,7 @@ name = "rind-python" version = "0.1.0" edition = "2024" repository = "https://github.com/moq-dev/rind" -license = "Apache-2.0 OR ISC OR MIT-0" +license = "(Apache-2.0 OR ISC OR MIT-0) AND (Apache-2.0 OR ISC OR MIT) AND Apache-2.0 AND ISC AND BSD-3-Clause" description = "pyrind, rind's API for Python" rust-version.workspace = true readme = "README.md" From cef4e7bc1f8a90dd3fe74088f80bd59d4d152e28 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 00:51:56 -0800 Subject: [PATCH 07/10] feat(python): publish per-call numbers and test Windows on Arm The README carries the benchmark against pyca/cryptography, hashlib, and hmac, run on a held host. pyca/cryptography's last Windows on Arm wheel lacks ML-KEM, so that runner tests pyrind against Wycheproof, the README, and its stubs only. rustfmt applied. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/python.yml | 7 ++++++- rind-python/README.md | 40 +++++++++++++++++++++++++++++++++--- rind-python/bench/bench.py | 2 +- rind-python/src/lib.rs | 7 +------ rind-python/src/pbkdf2.rs | 8 ++++++-- rind-python/src/signature.rs | 2 +- 6 files changed, 52 insertions(+), 14 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 34f32d65e..a52e0648c 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -65,6 +65,9 @@ jobs: - os: windows-11-arm target: aarch64-pc-windows-msvc test: true + # pyca/cryptography's last Windows on Arm wheel, 46.0.3, lacks + # ML-KEM, so the comparisons against it run on the other runners. + tests: "test_[rw]*.py" runs-on: ${{ matrix.os }} steps: @@ -93,10 +96,12 @@ jobs: - name: Test if: matrix.test shell: bash + env: + TESTS: ${{ matrix.tests || 'test*.py' }} run: | python -m pip install --only-binary :all: cryptography mypy python -m pip install --no-index --find-links dist pyrind - python -m unittest discover --start-directory rind-python/tests + python -m unittest discover --start-directory rind-python/tests --pattern "$TESTS" python -m mypy.stubtest pyrind - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/rind-python/README.md b/rind-python/README.md index dcbafe344..a0d1492b4 100644 --- a/rind-python/README.md +++ b/rind-python/README.md @@ -12,9 +12,43 @@ pip install pyrind A Python call into pyca/cryptography pays OpenSSL 3's per-call setup, which dwarfs the work on a small message. pyrind's calls go straight to rind's -assembly: - - +assembly. + +On an Intel Core i7-8700 (Coffee Lake, 2017), Linux 6.18, CPython 3.14.8, +against cryptography 50.0.2 on OpenSSL 3.5.9, one call at a time with its +inputs in hand, fastest of seven runs: + +| Operation | pyrind | cryptography | hashlib, hmac | cryptography ÷ pyrind | +|---|---:|---:|---:|---:| +| SHA-256, 21 B | 262 ns | 1.79 µs | 526 ns | 6.8x | +| HMAC-SHA256, 21 B | 401 ns | 2.54 µs | 1.87 µs | 6.3x | +| HKDF-SHA256, 32 B key | 1.68 µs | 5.07 µs | - | 3.0x | +| AES-128-GCM seal, 32 B | 237 ns | 667 ns | - | 2.8x | +| AES-128-GCM open, 32 B | 218 ns | 690 ns | - | 3.2x | +| AES-128-GCM seal, 1350 B | 507 ns | 924 ns | - | 1.8x | +| AES-128-GCM open, 1350 B | 472 ns | 926 ns | - | 2.0x | +| AES-128-GCM seal, 16384 B | 3.04 µs | 3.63 µs | - | 1.2x | +| AES-128-GCM open, 16384 B | 3.02 µs | 3.59 µs | - | 1.2x | +| ChaCha20-Poly1305 seal, 32 B | 315 ns | 858 ns | - | 2.7x | +| ChaCha20-Poly1305 open, 32 B | 293 ns | 894 ns | - | 3.0x | +| ChaCha20-Poly1305 seal, 1350 B | 930 ns | 1.54 µs | - | 1.7x | +| ChaCha20-Poly1305 open, 1350 B | 901 ns | 1.59 µs | - | 1.8x | +| ChaCha20-Poly1305 seal, 16384 B | 7.13 µs | 7.66 µs | - | 1.1x | +| ChaCha20-Poly1305 open, 16384 B | 7.03 µs | 7.72 µs | - | 1.1x | +| X25519 agree | 19.93 µs | 30.44 µs | - | 1.5x | +| ECDH P-256 agree | 37.44 µs | 62.57 µs | - | 1.7x | +| Ed25519 sign, 21 B | 7.10 µs | 28.96 µs | - | 4.1x | +| Ed25519 verify, 21 B | 29.07 µs | 93.03 µs | - | 3.2x | +| ECDSA P-256 sign, 21 B | 14.41 µs | 26.14 µs | - | 1.8x | +| ECDSA P-256 verify, 21 B | 39.42 µs | 60.53 µs | - | 1.5x | +| RSA-2048 PKCS#1 sign, 21 B | 470.21 µs | 483.67 µs | - | 1.0x | +| RSA-2048 PKCS#1 verify, 21 B | 11.00 µs | 20.14 µs | - | 1.8x | +| ML-KEM-768 encapsulate | 3.22 µs | 22.16 µs | - | 6.9x | +| ML-KEM-768 decapsulate | 7.78 µs | 33.81 µs | - | 4.3x | + +The gap is widest on small messages and short-lived keys, which is where +protocols spend their calls. On 16 KiB the assembly dominates both and the +two converge; RSA signing is the same modular exponentiation in each. Run `sh/python.sh bench` in the repository for your own machine's numbers. diff --git a/rind-python/bench/bench.py b/rind-python/bench/bench.py index f1f7d673c..fcfa40231 100644 --- a/rind-python/bench/bench.py +++ b/rind-python/bench/bench.py @@ -190,7 +190,7 @@ def main(): parser.add_argument("--repeat", type=int, default=7, help="timed runs per operation, of which the fastest counts") args = parser.parse_args() - print(f"{cpu_model()}, {platform.system()} {platform.release()}, Python {platform.python_version()}, ") + print(f"{cpu_model()}, {platform.system()} {platform.release()}, Python {platform.python_version()},") print(f"cryptography {cryptography.__version__} on {backend.openssl_version_text()}.\n") libraries = ["pyrind", "cryptography", "hashlib"] print("| Operation | pyrind | cryptography | hashlib, hmac | cryptography ÷ pyrind |") diff --git a/rind-python/src/lib.rs b/rind-python/src/lib.rs index 44d435289..d0e8b03c8 100644 --- a/rind-python/src/lib.rs +++ b/rind-python/src/lib.rs @@ -56,12 +56,7 @@ create_exception!( Error, "An AEAD ciphertext, its tag, or its associated data did not authenticate, or an RSA-OAEP ciphertext did not decrypt." ); -create_exception!( - pyrind, - RngFailed, - Error, - "The system random source failed." -); +create_exception!(pyrind, RngFailed, Error, "The system random source failed."); create_exception!( pyrind, Unsupported, diff --git a/rind-python/src/pbkdf2.rs b/rind-python/src/pbkdf2.rs index d79c14ed9..750d89f42 100644 --- a/rind-python/src/pbkdf2.rs +++ b/rind-python/src/pbkdf2.rs @@ -14,7 +14,8 @@ algorithm!( ); fn iterations_of(iterations: u32) -> PyResult { - NonZeroU32::new(iterations).ok_or_else(|| PyValueError::new_err("iterations must be at least 1")) + NonZeroU32::new(iterations) + .ok_or_else(|| PyValueError::new_err("iterations must be at least 1")) } /// A password keyed into HMAC once, which derives keys for any salt and @@ -29,7 +30,10 @@ impl Secret { /// Keys `algorithm` with `secret`, a password of any length. #[new] fn new(algorithm: &Bound<'_, Algorithm>, secret: Bytes<'_>) -> Self { - Self(Box::new(pbkdf2::Secret::new(algorithm.get().inner, &secret))) + Self(Box::new(pbkdf2::Secret::new( + algorithm.get().inner, + &secret, + ))) } /// Derives `length` bytes for `salt` with `iterations`. diff --git a/rind-python/src/signature.rs b/rind-python/src/signature.rs index 68c2b86ee..76979f8b5 100644 --- a/rind-python/src/signature.rs +++ b/rind-python/src/signature.rs @@ -149,7 +149,7 @@ impl EcdsaKeyPair { &public_key, ) .map(Box::new) - .map(Self) + .map(Self) .map_err(error) } From b9d7d74e0b1c3cc17acb0fb772ed7cac1d1275f2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 01:05:53 -0800 Subject: [PATCH 08/10] fix(python): wipe a signed HMAC context, take any buffer, bound lengths - hmac.Context.sign signs a copy, so dropping the box wipes the heap state in place rather than freeing it unwiped after a move. - Arguments take the raw bytes of any buffer, whatever its item format, as hashlib does. - HKDF and PBKDF2 refuse a length no hash allows before allocating it. - A partly uploaded release stays unpublished until its sdist, which twine uploads last, is on PyPI, and the retry skips what is already there. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 3 +++ rind-python/src/hkdf.rs | 3 +++ rind-python/src/hmac.rs | 4 +++- rind-python/src/lib.rs | 30 +++++++++++++++++++++++------- rind-python/src/pbkdf2.rs | 6 ++++++ rind-python/tests/test_api.py | 15 +++++++++++++++ sh/pyrind-unpublished.sh | 15 ++++++++++++--- 7 files changed, 65 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bcf8888da..d40df6799 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,3 +98,6 @@ jobs: merge-multiple: true - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 + with: + # A retry after a partial upload sends only what PyPI lacks. + skip-existing: true diff --git a/rind-python/src/hkdf.rs b/rind-python/src/hkdf.rs index a0816dc2c..be85e3ca8 100644 --- a/rind-python/src/hkdf.rs +++ b/rind-python/src/hkdf.rs @@ -55,6 +55,9 @@ impl Prk { ) -> PyResult> { let info: [&[u8]; 1] = [&info]; Ok(match key_type { + // The longest any hash expands to, so a longer request fails + // before allocating; rind refuses the rest by the hash's length. + KeyType::Len(len) if len > 255 * 64 => return Err(error(rind::Error::WrongLength)), KeyType::Len(len) => PyBytes::new_with(py, len, |out| { self.0.expand(&info, out.len()).fill(out).map_err(error) })? diff --git a/rind-python/src/hmac.rs b/rind-python/src/hmac.rs index 1e50e4c93..1d5d03184 100644 --- a/rind-python/src/hmac.rs +++ b/rind-python/src/hmac.rs @@ -67,8 +67,10 @@ impl Context { /// Returns the tag of the message; the context takes no more data. fn sign<'py>(&mut self, py: Python<'py>) -> PyResult> { + // Signs a stack copy: moving the context out of its box would free + // the heap copy unwiped, while dropping the box wipes it in place. let context = self.0.take().ok_or_else(signed)?; - Ok(bytes(py, context.sign())) + Ok(bytes(py, (*context).clone().sign())) } } diff --git a/rind-python/src/lib.rs b/rind-python/src/lib.rs index d0e8b03c8..c77537b89 100644 --- a/rind-python/src/lib.rs +++ b/rind-python/src/lib.rs @@ -15,11 +15,11 @@ use std::borrow::Cow; -use pyo3::buffer::PyBuffer; +use pyo3::buffer::PyUntypedBuffer; use pyo3::create_exception; use pyo3::exceptions::{PyException, PyImportError, PyMemoryError}; use pyo3::prelude::*; -use pyo3::types::{PyBytes, PyDict}; +use pyo3::types::{PyBytes, PyDict, PyMemoryView}; mod aead; mod agreement; @@ -87,9 +87,10 @@ pub(crate) fn error(e: rind::Error) -> PyErr { } /// A bytes-like argument: borrowed from `bytes`, which is immutable, and -/// copied from any other buffer, such as a `bytearray` or `memoryview`, so -/// no other thread can change it while rind reads it, even with the GIL -/// released. +/// copied from any other buffer, such as a `bytearray`, `memoryview`, or +/// `array`, so no other thread can change it while rind reads it, even with +/// the GIL released. Like `hashlib`, it takes a buffer's raw bytes whatever +/// its item format. pub(crate) struct Bytes<'a>(Cow<'a, [u8]>); impl<'a, 'py> FromPyObject<'a, 'py> for Bytes<'a> { @@ -99,8 +100,23 @@ impl<'a, 'py> FromPyObject<'a, 'py> for Bytes<'a> { if let Ok(bytes) = <&'a [u8]>::extract(obj) { return Ok(Self(Cow::Borrowed(bytes))); } - let buffer = PyBuffer::::get(&obj)?; - Ok(Self(Cow::Owned(buffer.to_vec(obj.py())?))) + let buffer = PyUntypedBuffer::get(&obj)?; + if buffer.is_c_contiguous() { + let len = buffer.len_bytes(); + if len == 0 { + return Ok(Self(Cow::Borrowed(&[]))); + } + // SAFETY: a C-contiguous buffer is `len_bytes()` readable bytes + // at `buf_ptr()`, non-null when nonempty, for as long as `buffer` + // holds it, and the GIL keeps Python code from resizing it. + let raw = unsafe { std::slice::from_raw_parts(buffer.buf_ptr().cast::(), len) }; + return Ok(Self(Cow::Owned(raw.to_vec()))); + } + // Any other layout, such as a strided `memoryview`, in C order. + let bytes = PyMemoryView::from(&obj)?.call_method0("tobytes")?; + Ok(Self(Cow::Owned( + bytes.cast::()?.as_bytes().to_vec(), + ))) } } diff --git a/rind-python/src/pbkdf2.rs b/rind-python/src/pbkdf2.rs index 750d89f42..2d8a89ce1 100644 --- a/rind-python/src/pbkdf2.rs +++ b/rind-python/src/pbkdf2.rs @@ -45,6 +45,12 @@ impl Secret { length: usize, ) -> PyResult> { let iterations = iterations_of(iterations)?; + // The longest any hash derives, (2**32 - 1) blocks of 64 bytes, so a + // longer request fails before allocating; rind refuses the rest by + // the hash's length. + if length as u64 > u64::from(u32::MAX) * 64 { + return Err(error(rind::Error::WrongLength)); + } PyBytes::new_with(py, length, |out| { py.detach(|| self.0.derive(iterations, &salt, out)) .map_err(error) diff --git a/rind-python/tests/test_api.py b/rind-python/tests/test_api.py index 23d925d63..f6d8b1851 100644 --- a/rind-python/tests/test_api.py +++ b/rind-python/tests/test_api.py @@ -1,6 +1,7 @@ """pyrind's API, checked against known answers and against pyca/cryptography, hashlib, and hmac.""" +import array import hashlib import hmac as std_hmac import os @@ -79,6 +80,11 @@ def test_strided_buffer_is_copied(self): expected = hashlib.sha256(b"ace").digest() self.assertEqual(digest.digest(digest.SHA256, memoryview(b"abcdef")[::2]), expected) + def test_any_item_format_is_its_raw_bytes(self): + for data in (memoryview(b"abc").cast("b"), array.array("H", [1, 2]), memoryview(bytearray(8)).cast("I")[::2]): + self.assertEqual(digest.digest(digest.SHA256, data), hashlib.sha256(bytes(data)).digest()) + self.assertEqual(digest.digest(digest.SHA256, bytearray()), hashlib.sha256(b"").digest()) + class Digest(unittest.TestCase): ALGORITHMS = { @@ -181,6 +187,9 @@ def test_too_long_is_wrong_length(self): prk = hkdf.Salt(hkdf.HKDF_SHA256, b"").extract(b"secret") with self.assertRaises(pyrind.WrongLength): prk.expand(b"", 255 * 32 + 1) + # Refused before allocating, rather than raising MemoryError. + with self.assertRaises(pyrind.WrongLength): + prk.expand(b"", 1 << 62) class Pbkdf2(unittest.TestCase): @@ -203,6 +212,12 @@ def test_zero_iterations_is_refused(self): with self.assertRaises(OverflowError): secret.derive(-1, b"salt", 32) + def test_too_long_is_wrong_length(self): + # Refused before allocating, rather than raising MemoryError. + secret = pbkdf2.Secret(pbkdf2.PBKDF2_HMAC_SHA256, b"password") + with self.assertRaises(pyrind.WrongLength): + secret.derive(1, b"salt", 1 << 62) + def test_releases_the_gil(self): secret = pbkdf2.Secret(pbkdf2.PBKDF2_HMAC_SHA256, b"password") results = [] diff --git a/sh/pyrind-unpublished.sh b/sh/pyrind-unpublished.sh index ec4135eab..358c79cbd 100755 --- a/sh/pyrind-unpublished.sh +++ b/sh/pyrind-unpublished.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Print the tag of pyrind's current version if release-plz has released it -# and PyPI does not have it yet, or nothing. +# and PyPI does not have all of it yet, or nothing. # # Usage: sh/pyrind-unpublished.sh # release-plz tags rind-python when a release PR lands, since it never goes @@ -23,9 +23,18 @@ case "$status" in *) exit "$status" ;; esac -status=$(curl --silent --output /dev/null --write-out '%{http_code}' "https://pypi.org/pypi/pyrind/$version/json") +# twine uploads the wheels before the sdist, so a version whose sdist is on +# PyPI is complete; one without it may be partly uploaded, and release.yml +# skips the files already there. +json=$(mktemp) +trap 'rm -f "$json"' EXIT +status=$(curl --silent --output "$json" --write-out '%{http_code}' "https://pypi.org/pypi/pyrind/$version/json") case "$status" in - 200) ;; + 200) + if ! jq -e '.urls | any(.packagetype == "sdist")' "$json" >/dev/null; then + echo "$tag" + fi + ;; 404) echo "$tag" ;; *) echo "error: PyPI answered $status for pyrind $version" >&2 From 99929da8be6db8b2dce60a15011294238fe1a93d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 01:14:32 -0800 Subject: [PATCH 09/10] fix(python): copy buffers through CPython, bound PBKDF2 by its hash - Arguments other than bytes are copied by one buffer export and PyBuffer_ToContiguous, so no Rust slice covers memory another thread may write on free-threaded CPython, and an exporter allowing one export works. - PBKDF2 refuses a length past its own hash's limit before allocating. Co-Authored-By: Claude Opus 5.5 --- rind-python/src/lib.rs | 56 ++++++++++++++++++++++------------- rind-python/src/pbkdf2.rs | 40 ++++++++++++++++++------- rind-python/tests/test_api.py | 17 +++++++---- 3 files changed, 77 insertions(+), 36 deletions(-) diff --git a/rind-python/src/lib.rs b/rind-python/src/lib.rs index c77537b89..ce6afadb0 100644 --- a/rind-python/src/lib.rs +++ b/rind-python/src/lib.rs @@ -14,12 +14,13 @@ //! (). use std::borrow::Cow; +use std::mem::MaybeUninit; -use pyo3::buffer::PyUntypedBuffer; use pyo3::create_exception; use pyo3::exceptions::{PyException, PyImportError, PyMemoryError}; +use pyo3::ffi; use pyo3::prelude::*; -use pyo3::types::{PyBytes, PyDict, PyMemoryView}; +use pyo3::types::{PyBytes, PyDict}; mod aead; mod agreement; @@ -90,7 +91,7 @@ pub(crate) fn error(e: rind::Error) -> PyErr { /// copied from any other buffer, such as a `bytearray`, `memoryview`, or /// `array`, so no other thread can change it while rind reads it, even with /// the GIL released. Like `hashlib`, it takes a buffer's raw bytes whatever -/// its item format. +/// its item format or layout. pub(crate) struct Bytes<'a>(Cow<'a, [u8]>); impl<'a, 'py> FromPyObject<'a, 'py> for Bytes<'a> { @@ -100,24 +101,39 @@ impl<'a, 'py> FromPyObject<'a, 'py> for Bytes<'a> { if let Ok(bytes) = <&'a [u8]>::extract(obj) { return Ok(Self(Cow::Borrowed(bytes))); } - let buffer = PyUntypedBuffer::get(&obj)?; - if buffer.is_c_contiguous() { - let len = buffer.len_bytes(); - if len == 0 { - return Ok(Self(Cow::Borrowed(&[]))); - } - // SAFETY: a C-contiguous buffer is `len_bytes()` readable bytes - // at `buf_ptr()`, non-null when nonempty, for as long as `buffer` - // holds it, and the GIL keeps Python code from resizing it. - let raw = unsafe { std::slice::from_raw_parts(buffer.buf_ptr().cast::(), len) }; - return Ok(Self(Cow::Owned(raw.to_vec()))); - } - // Any other layout, such as a strided `memoryview`, in C order. - let bytes = PyMemoryView::from(&obj)?.call_method0("tobytes")?; - Ok(Self(Cow::Owned( - bytes.cast::()?.as_bytes().to_vec(), - ))) + Ok(Self(Cow::Owned(copy_buffer(&obj)?))) + } +} + +/// Copies the raw bytes of any buffer, in C order, through CPython's own +/// copy, so no Rust reference ever covers memory another thread may write, +/// as a `bytearray` can be on free-threaded CPython. +fn copy_buffer(obj: &Bound<'_, PyAny>) -> PyResult> { + let py = obj.py(); + let mut view = MaybeUninit::::uninit(); + // SAFETY: `obj` is a live object and `view` has room for a `Py_buffer`, + // which this initializes on success. + if unsafe { ffi::PyObject_GetBuffer(obj.as_ptr(), view.as_mut_ptr(), ffi::PyBUF_FULL_RO) } == -1 + { + return Err(PyErr::fetch(py)); + } + // SAFETY: `PyObject_GetBuffer` succeeded, so `view` is initialized and + // must be released, which happens below in place, never moved. + let view = unsafe { view.assume_init_mut() }; + let len = usize::try_from(view.len).expect("a buffer's length is non-negative"); + let mut out = Vec::::with_capacity(len); + // SAFETY: `out` has room for `view.len` bytes, which this copies; the + // view describes its own layout. + let copied = + unsafe { ffi::PyBuffer_ToContiguous(out.as_mut_ptr().cast(), view, view.len, b'C' as _) }; + // SAFETY: `view` came from `PyObject_GetBuffer` and is released once. + unsafe { ffi::PyBuffer_Release(view) }; + if copied == -1 { + return Err(PyErr::fetch(py)); } + // SAFETY: `PyBuffer_ToContiguous` wrote all `len` bytes. + unsafe { out.set_len(len) }; + Ok(out) } impl core::ops::Deref for Bytes<'_> { diff --git a/rind-python/src/pbkdf2.rs b/rind-python/src/pbkdf2.rs index 2d8a89ce1..26ba9cead 100644 --- a/rind-python/src/pbkdf2.rs +++ b/rind-python/src/pbkdf2.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 use std::num::NonZeroU32; +use std::ptr; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; @@ -23,17 +24,35 @@ fn iterations_of(iterations: u32) -> PyResult { /// /// Both calls release the GIL while they run. #[pyclass(frozen, module = "pyrind.pbkdf2")] -struct Secret(Box); +struct Secret { + secret: Box, + hash_len: usize, +} + +/// The output length of `algorithm`'s hash, which rind keeps private; 64, +/// the longest, for any other. +fn hash_len(algorithm: &'static pbkdf2::Algorithm) -> usize { + [ + (&pbkdf2::PBKDF2_HMAC_SHA256, 32), + (&pbkdf2::PBKDF2_HMAC_SHA384, 48), + (&pbkdf2::PBKDF2_HMAC_SHA512, 64), + (&pbkdf2::legacy::PBKDF2_HMAC_SHA1, 20), + ] + .into_iter() + .find_map(|(known, len)| ptr::eq(known, algorithm).then_some(len)) + .unwrap_or(64) +} #[pymethods] impl Secret { /// Keys `algorithm` with `secret`, a password of any length. #[new] fn new(algorithm: &Bound<'_, Algorithm>, secret: Bytes<'_>) -> Self { - Self(Box::new(pbkdf2::Secret::new( - algorithm.get().inner, - &secret, - ))) + let algorithm = algorithm.get().inner; + Self { + secret: Box::new(pbkdf2::Secret::new(algorithm, &secret)), + hash_len: hash_len(algorithm), + } } /// Derives `length` bytes for `salt` with `iterations`. @@ -45,14 +64,13 @@ impl Secret { length: usize, ) -> PyResult> { let iterations = iterations_of(iterations)?; - // The longest any hash derives, (2**32 - 1) blocks of 64 bytes, so a - // longer request fails before allocating; rind refuses the rest by - // the hash's length. - if length as u64 > u64::from(u32::MAX) * 64 { + // PBKDF2 derives at most 2**32 - 1 hash outputs, refused here + // before allocating them. + if length as u64 > u64::from(u32::MAX) * self.hash_len as u64 { return Err(error(rind::Error::WrongLength)); } PyBytes::new_with(py, length, |out| { - py.detach(|| self.0.derive(iterations, &salt, out)) + py.detach(|| self.secret.derive(iterations, &salt, out)) .map_err(error) }) } @@ -67,7 +85,7 @@ impl Secret { previously_derived: Bytes<'_>, ) -> PyResult<()> { let iterations = iterations_of(iterations)?; - py.detach(|| self.0.verify(iterations, &salt, &previously_derived)) + py.detach(|| self.secret.verify(iterations, &salt, &previously_derived)) .map_err(error) } } diff --git a/rind-python/tests/test_api.py b/rind-python/tests/test_api.py index f6d8b1851..93e2c2197 100644 --- a/rind-python/tests/test_api.py +++ b/rind-python/tests/test_api.py @@ -81,7 +81,8 @@ def test_strided_buffer_is_copied(self): self.assertEqual(digest.digest(digest.SHA256, memoryview(b"abcdef")[::2]), expected) def test_any_item_format_is_its_raw_bytes(self): - for data in (memoryview(b"abc").cast("b"), array.array("H", [1, 2]), memoryview(bytearray(8)).cast("I")[::2]): + strided = memoryview(bytearray(range(8))).cast("I")[::2] + for data in (memoryview(b"abc").cast("b"), array.array("H", [1, 2]), strided): self.assertEqual(digest.digest(digest.SHA256, data), hashlib.sha256(bytes(data)).digest()) self.assertEqual(digest.digest(digest.SHA256, bytearray()), hashlib.sha256(b"").digest()) @@ -213,10 +214,16 @@ def test_zero_iterations_is_refused(self): secret.derive(-1, b"salt", 32) def test_too_long_is_wrong_length(self): - # Refused before allocating, rather than raising MemoryError. - secret = pbkdf2.Secret(pbkdf2.PBKDF2_HMAC_SHA256, b"password") - with self.assertRaises(pyrind.WrongLength): - secret.derive(1, b"salt", 1 << 62) + # One byte past each hash's limit, refused before allocating it. + for algorithm, hash_len in ( + (pbkdf2.PBKDF2_HMAC_SHA256, 32), + (pbkdf2.PBKDF2_HMAC_SHA384, 48), + (pbkdf2.PBKDF2_HMAC_SHA512, 64), + (pbkdf2.legacy.PBKDF2_HMAC_SHA1, 20), + ): + secret = pbkdf2.Secret(algorithm, b"password") + with self.assertRaises(pyrind.WrongLength): + secret.derive(1, b"salt", (2**32 - 1) * hash_len + 1) def test_releases_the_gil(self): secret = pbkdf2.Secret(pbkdf2.PBKDF2_HMAC_SHA256, b"password") From 34d3e71b15991bcc5f6f013690bf89cb2db9b19a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 10:38:35 -0800 Subject: [PATCH 10/10] chore(python): wipe as rind does, plan the musl and armv7 wheel tests The binding's wipes are rind's own drops, so they follow rind's wipe rule; say so rather than claim an unconditional wipe. Plan the CI that loads the musl and armv7 wheels, and say why the PyPI publisher stays a maintainer step. Co-Authored-By: Claude Opus 5.5 --- quest/m2/adoption/README.md | 1 + quest/m2/adoption/pyrind-wheel-tests.md | 24 ++++++++++++++++++++++++ quest/m2/adoption/pyrind.md | 5 +++-- rind-python/README.md | 6 ++++-- rind-python/src/hmac.rs | 4 ++-- 5 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 quest/m2/adoption/pyrind-wheel-tests.md diff --git a/quest/m2/adoption/README.md b/quest/m2/adoption/README.md index 95da693fd..bd8a579eb 100644 --- a/quest/m2/adoption/README.md +++ b/quest/m2/adoption/README.md @@ -64,6 +64,7 @@ don't ask again (✅ is the chosen option): - [quinn's rustls support works with any provider](/quest/m2/adoption/quinn.md) - quinn-proto stops forcing ring or aws-lc-rs - [Bun's CryptoHasher takes SHA-3 from rind](/quest/m2/adoption/bun-sha3.md) - SHA-3 in Bun on rind, after the m1 Bun PR - [pyrind on PyPI](/quest/m2/adoption/pyrind.md) - rind for Python with its own API +- [pyrind's musl and armv7 wheels are tested](/quest/m2/adoption/pyrind-wheel-tests.md) - CI loads every wheel it publishes - [Lemmy's federation signs and verifies with rind](/quest/m2/adoption/lemmy.md) - activitypub-federation-rust's HTTP signatures off the `rsa` crate - [rind-snow](/quest/m2/adoption/rind-snow.md) - snow's `CryptoResolver` on rind, for rathole and other Noise users - [tailscale-rs's tunnel runs on rind](/quest/m2/adoption/tailscale-rs.md) - WireGuard's per-packet ChaCha20-Poly1305 on rind diff --git a/quest/m2/adoption/pyrind-wheel-tests.md b/quest/m2/adoption/pyrind-wheel-tests.md new file mode 100644 index 000000000..726658d9d --- /dev/null +++ b/quest/m2/adoption/pyrind-wheel-tests.md @@ -0,0 +1,24 @@ +# [S] pyrind's musl and armv7 wheels are tested + +## Goal + +`python.yml` loads and tests every wheel it publishes. Today the musl wheels +(x86_64 and aarch64) and the armv7 glibc wheel are built and uploaded but +never imported, so a wheel that fails to load or computes wrongly on those +targets reaches PyPI unnoticed. + +## Plan + +Planned on 2026-11-07, when the maintainer settled +[moq-dev/rind#459](https://github.com/moq-dev/rind/pull/459)'s decisions. + +- Run the wheel job's test step (the unit tests, Wycheproof, and mypy + stubtest) for these three targets, as it runs for the others. +- musl: a musl CPython 3.11 container on the runner that built the wheel, + which is already the wheel's architecture. +- armv7: an armhf CPython 3.11 container under qemu-user. Check that the run + lands on a CPU rind supports and say which of rind's armv7 paths it takes. +- Where pyca/cryptography ships no wheel for a target, skip the comparisons + against it, as the Windows on Arm entry does, rather than building pyca. +- The workflow runs nightly and from the release, never in the merge gate, + so the emulated job's time costs no PR. diff --git a/quest/m2/adoption/pyrind.md b/quest/m2/adoption/pyrind.md index 8e29983d5..86ccbf026 100644 --- a/quest/m2/adoption/pyrind.md +++ b/quest/m2/adoption/pyrind.md @@ -22,6 +22,7 @@ next push to `main` that runs the release job. - Maintainer: on PyPI, add a pending trusted publisher for the project `pyrind` (owner `moq-dev`, repository `rind`, workflow `release.yml`, environment `publish`) before merging the release PR that carries - rind-python 0.1.0, then merge it. An agent cannot create the PyPI account - or publisher. Check: `pip install pyrind==0.1.0` installs, and + rind-python 0.1.0, then merge it. An agent cannot: PyPI has no API for + adding a trusted publisher, only its web form behind the owner's login. + Check: `pip install pyrind==0.1.0` installs, and `python -c "import pyrind"` runs. diff --git a/rind-python/README.md b/rind-python/README.md index a0d1492b4..adb812fc1 100644 --- a/rind-python/README.md +++ b/rind-python/README.md @@ -108,8 +108,10 @@ anything but `bytes` is copied first, so no other thread can change it mid-call. RSA key generation, RSA signing and decryption, and PBKDF2 release the GIL while they run. -rind wipes keys and keyed state when Python frees them. It cannot wipe the -`bytes` a key was loaded from or exported to; Python owns those. +pyrind wipes keys and keyed state when Python frees them exactly when rind +does, through rind's own drops; the wheels build rind with its default +features. Nothing wipes the `bytes` a key was loaded from or exported to; +Python owns those. ## Platforms diff --git a/rind-python/src/hmac.rs b/rind-python/src/hmac.rs index 1d5d03184..328e90cec 100644 --- a/rind-python/src/hmac.rs +++ b/rind-python/src/hmac.rs @@ -67,8 +67,8 @@ impl Context { /// Returns the tag of the message; the context takes no more data. fn sign<'py>(&mut self, py: Python<'py>) -> PyResult> { - // Signs a stack copy: moving the context out of its box would free - // the heap copy unwiped, while dropping the box wipes it in place. + // Signs a stack copy and drops the box, so the heap copy gets rind's + // wipe on drop; moving the context out of its box would skip it. let context = self.0.take().ok_or_else(signed)?; Ok(bytes(py, (*context).clone().sign())) }