A modern C++26 cryptography library with four interchangeable backends: PSA/MbedTLS 4.1, ARM intrinsics (ARMv8.2-A+crypto+sha3), OpenSSL 3.x, and Intel x86-64 intrinsics (SHA-NI, AES-NI, PCLMULQDQ). All operations return std::expected — no exceptions, no output parameters. Secrets are held in SecureBuffer / FixedSecureBuffer types that scrub memory on destruction.
Warning
This is an experimental research project and is not suitable for production use. The API, implementation, and security properties have not been formally audited or reviewed for production deployment. Use at your own risk.
A long-standing problem in the applied cryptography arena is that most cryptographic libraries assume the developer using them has sufficient applied crypto knowledge to use the libraries correctly. This is an unreasonable assumption. Most developers do not have applied crypto expertise, nor should they be expected to. The problem is exacerbated by the fact that the design goals of most cryptographic libraries are maximal functionality and flexibility — not ensuring best practices, NIST/FIPS compliance, or enterprise policy enforcement. This library is an attempt to provide a very thin API layer on top of existing crypto implementations (SW and HW) that is easy to use correctly, and difficult to use incorrectly.
Every line of code, test, and CI configuration in this repository was written by AI coding agents — primarily Claude (Anthropic) and Codex (OpenAI). No human has written any of the source code, test cases, CMake build system, or GitHub Actions workflows. Human involvement has been limited to directing the agents and reviewing their output. Most pull requests have been merged by the agents themselves.
This project serves as an ongoing experiment in AI-driven software development: exploring how far autonomous coding agents can carry a non-trivial systems project — applied cryptography in modern C++ — without human authorship of the implementation itself.
| Area | API |
|---|---|
| Digests | SHA-256/384/512, SHA3-256/384/512 |
| MAC | HMAC (any SHA variant) |
| AEAD | AES-256-GCM, ChaCha20-Poly1305 |
| Asymmetric encryption | RSA-OAEP (3072, 4096-bit) |
| Signatures | ECDSA P-256/384/521, RSA-PSS (3072, 4096-bit), SLH-DSA SHA2-128s/128f/192s/192f/256s/256f (FIPS 205), ML-DSA-44/65/87 (FIPS 204) |
| Key encapsulation | ML-KEM-512/768/1024 (FIPS 203) |
| Key agreement | ECDH P-256/384/521 |
| Key derivation | HKDF (hkdf_derive), HKDF-Expand (hkdf_expand) (SHA-384) |
| Key exchange protocols | SIGMA, SIGMA-I (identity-hiding) |
| Random | Cryptographically secure random bytes |
Every function that can fail returns std::expected<T, CryptoError> — no exceptions, no output parameters, no boolean return codes. The error type carries a typed CryptoErrorCode and a human-readable message string.
Callers use the monadic interface (value_or, and_then, transform, or_else) or simple has_value() / .error() checks. There is nothing to catch and no way to silently discard a failure: the [[nodiscard]] attribute is applied to every returning function, so ignoring a result is a compile-time warning.
The distinction between the two failure channels is deliberate:
std::unexpected(CryptoError(...))— a recoverable runtime condition: a PSA operation failed, a signature didn't verify, wire data was malformed. The caller is expected to handle or propagate these.SAFE_CRYPTO_PRE(cond)contract — a programming error: passing zero asoutput_length, indexing past the end of a buffer, callingget()on a moved-from key handle. These are bugs in the caller, not conditions to recover from.
contracts.hpp defines a SAFE_CRYPTO_PRE(cond) macro that expands to the C++26 [[pre: cond]] attribute on GCC 15+ (compiled with -fcontracts) and to a no-op on other compilers. The macro is placed between the cv/ref qualifiers and the trailing return type:
auto operator[](std::size_t i) SAFE_CRYPTO_PRE(i < data_.size()) -> CryptoByte&;
auto get() const noexcept SAFE_CRYPTO_PRE(valid_) -> KeyId;Contracts are used at all points where a violated precondition indicates a bug rather than a runtime condition:
| Location | Precondition |
|---|---|
SecureBuffer::operator[] |
i < data_.size() |
FixedSecureBuffer::operator[] |
i < N |
SecureBuffer::resize |
new_size <= data_.size() (shrink-only) |
PsaKeyHandle::get() |
handle is valid (not moved-from) |
sigma_i_serialize_bundle |
both length fields fit in uint16_t |
derive_key_impl, expand_key_impl |
output_length > 0 (hkdf_derive / hkdf_expand in the public API) |
random_bytes_impl |
length > 0 |
The companion macro SAFE_CRYPTO_CONTRACTS_ENFORCED is defined when contracts produce runtime checks. Test death-assertions are gated on this macro so they only run on compilers that actually enforce contracts.
All crypto operations are templated on a CryptoProvider concept defined in crypto_provider.hpp. The concept requires associated types (Status, KeyId, Algorithm, KeyAttributes, KdfOperation, KdfStep), status sentinels, object factories, algorithm constants, and all low-level crypto primitives.
The default provider (RealPsaBackend) forwards to PSA/MbedTLS. A second provider (ArmAsmBackend) implements all operations directly via ARM Crypto Extension intrinsics — see ARM ASM provider below. A third provider (OpenSslBackend) implements the full API using the OpenSSL 3.x EVP high-level API — see OpenSSL provider below. A fourth provider (IaAsmBackend) implements hash/HMAC/HKDF/AEAD via Intel SHA-NI/AES-NI/PCLMULQDQ intrinsics — see IA ASM provider below. Tests use MockPsaBackend (GMock) to exercise every error path without inducing real PSA failures.
The safe-crypto-lib INTERFACE target has zero dependency on MbedTLS headers. PSA-specific code lives entirely in providers/psa_mbedtls/. Swapping or adding a provider requires no changes to the library headers or any _impl function body.
SecureBuffer (heap) and FixedSecureBuffer<N> (stack) zeroize their contents on destruction using a volatile byte loop that the compiler cannot optimize away. All key material, derived secrets, and intermediate buffers flow through these types — there is no std::vector<uint8_t> holding sensitive data anywhere in the library.
PsaKeyHandle<Provider> is an RAII wrapper around a PSA key ID. It calls destroy_key in its destructor and on every move-assignment path, ensuring that imported and generated keys are destroyed even when an error is returned mid-function. get() carries a precondition that the handle is valid, making use-after-move a detectable bug rather than silent UB.
providers/arm_asm/ targets ARMv8.2-A+crypto+sha3 (Apple Silicon M1 and later; Linux ARM64 on Graviton 2/3/4, Neoverse N1/N2, Raspberry Pi 5). Most operations are header-only intrinsics — no MbedTLS dependency. When the LIBOQS supplement is enabled, dilithium_ntt_neon.cpp is added as an OBJECT library that overrides liboqs's scalar Dilithium NTT via link-order interposition. Compiled with -march=armv8.2-a+crypto+sha3. Requires the crypto and sha3 extensions; ARMv8.0-A hardware (Graviton 1, Raspberry Pi 3/4) is not supported.
Implemented operations:
| Operation | Notes |
|---|---|
| SHA-256 | vsha256h_u32 / vsha256h2_u32 compression intrinsics; full padding |
| SHA-384 | vsha512hq_u64 / vsha512h2q_u64 with SHA-384 initial state; first 48 bytes of output |
| SHA-512 | Same compression function, SHA-512 initial state; 64-byte output |
| HMAC-SHA-256 | Incremental Sha256Ctx; key hashing when key > 64 bytes |
| HMAC-SHA-384 | Incremental Sha512Ctx initialised with SHA-384 H₀; key hashing uses SHA-384 |
| HMAC-SHA-512 | Incremental Sha512Ctx initialised with SHA-512 H₀ |
| SHA3-256 | Keccak-f[1600] — 25 named uint64_t scalar registers throughout, no NEON lane extractions; rate=136B |
| SHA3-384 | Same scalar Keccak permutation, rate=104B, output=48B |
| SHA3-512 | Same scalar Keccak permutation, rate=72B, output=64B |
| HMAC-SHA3-256 | FIPS 198-1 HMAC with SHA3 block size (136B) as key pad width |
| HMAC-SHA3-384 | Block size 104B; key hashing uses SHA3-384 |
| HMAC-SHA3-512 | Block size 72B; key hashing uses SHA3-512 |
| AES-256-GCM encrypt | AES-256 key expansion + CTR via vaeseq_u8/vaesmcq_u8; GHASH via vmull_p64 PMULL; NIST SP 800-38D compliant |
| AES-256-GCM decrypt | Tag verification (constant-time compare) before decryption; output zeroized on auth failure |
| Random bytes | arc4random_buf — OS CSPRNG, never blocks |
generate_key |
Generates a random symmetric key of the size specified in KeyAttributes |
import_key / export_key / destroy_key |
Full implementation backed by the key store; keys zeroized on destroy |
mac_compute / mac_verify |
HMAC dispatch; mac_verify uses a constant-time compare |
| HKDF (SHA-384) | RFC 5869 Extract+Expand; full KDF state machine (setup/input_key/input_bytes/output_bytes/abort) |
| HKDF-Expand (SHA-384) | Expand-only variant; PRK supplied directly via input_key |
| Key store | 16-slot static store (up to 512 bytes/key) |
| ChaCha20-Poly1305 encrypt | NEON uint32x4_t quarter-round; Poly1305 over AAD‖CT‖lengths; RFC 8439 compliant |
| ChaCha20-Poly1305 decrypt | Tag verification (constant-time compare) before decryption; output zeroized on auth failure |
| ECDSA P-256/384/521 sign | RFC 6979 deterministic k (HMAC-SHA-256/384/512); 4-bit fixed-base window on G; raw r‖s big-endian output |
| ECDSA P-256/384/521 verify | 4-bit fixed-base window for u1·G; variable-base for u2·Q; constant-time scalar multiplication |
| ECDH P-256/384/521 | x-coordinate shared secret; 32/48/66-byte output |
| EC key generation | Random private scalar; public key computed as k·G (Jacobian → affine) |
| EC key import/export | 16-slot EC key store separate from symmetric key store; P-521 public key 133 bytes |
| RSA-OAEP-3072/4096 encrypt/decrypt | Pure C++ Montgomery multiplication + CRT; SHA-384 MGF1; separate 8-slot RSA key store (key ID base 0xC000) |
| RSA-PSS-3072/4096 sign/verify | Pure C++ Montgomery multiplication + CRT; SHA-384 MGF1; constant-time OAEP/PSS padding |
| RSA key generation | Miller-Rabin primality (40 rounds) + CRT parameters; PKCS#1 DER output, SubjectPublicKeyInfo DER public key; no PSA/MbedTLS dependency |
| ML-DSA-44/65/87 keygen/sign/verify | Via liboqs supplement (SAFE_CRYPTO_PQC=LIBOQS); forward/inverse NTT replaced by dilithium_ntt_neon.cpp NEON implementation |
| ML-KEM-512/768/1024 keygen/encap/decap | Via liboqs supplement; uses mlkem-native's hand-written AArch64 assembly NTT (already optimal) |
SHA-512 compression loop detail. The two-round step pattern cycles through four roles (ab/cd/ef/gh) every eight rounds. Each step requires cross-pair word interleaving that cannot be expressed as a simple state rotation:
// Rounds 0,1 — targets gh, updates cd
initial_sum = vaddq_u64(s0, vld1q_u64(sha512_k));
sum = vaddq_u64(vextq_u64(initial_sum, initial_sum, 1), gh);
intermed = vsha512hq_u64(sum, vextq_u64(ef, gh, 1), vextq_u64(cd, ef, 1));
gh = vsha512h2q_u64(intermed, cd, ab);
cd = vaddq_u64(cd, intermed);Rounds 16–79 interleave message schedule (vsha512su0q_u64 / vsha512su1q_u64) with compression, processing eight word pairs per loop iteration.
safe-crypto-lib/ # INTERFACE library — headers only, no PSA dependency
providers/
psa_mbedtls/ # INTERFACE library — RealPsaBackend, links MbedTLS
arm_asm/ # INTERFACE + OBJECT library — ArmAsmBackend, ARM intrinsics
openssl/ # INTERFACE library — OpenSslBackend, OpenSSL 3.x EVP API
liboqs/ # INTERFACE library — PQC supplement (ML-DSA, ML-KEM via liboqs); OQS_KEM/OQS_SIG descriptors cached per variant (thread-safe local statics, never freed)
ia_asm/ # INTERFACE library — IaAsmBackend, x86-64 SHA-NI/AES-NI/PCLMULQDQ
safe-crypto-cli/ # scli executable — aead, digest, ecdh, ecdsa, kdf, mac, ml-dsa, ml-kem, random, rsa, slh-dsa subcommands; CLI11 v2.6.2; --log-level / --log-config logging
safe-crypto-cli-test/ # GoogleTest suite for scli — 111 subprocess-based tests; validates stdout, stderr, and exit codes
safe-crypto-lib-test/ # GoogleTest suite + MockPsaBackend (243 tests in PSA_MBEDTLS; 243 in IA_ASM; 467 in ARM_ASM; 492 in ARM_ASM+LIBOQS; 272 in OPENSSL+LIBOQS; 256 in PSA_MBEDTLS+LIBOQS)
safe-crypto-lib-bench/ # Google Benchmark harness — PSA, ARM ASM, and OpenSSL (PQC) compared side-by-side
cmake/ # FetchContent modules for MbedTLS, GoogleTest, Google Benchmark, CLI11, spdlog, nlohmann/json; PermBuildOptions (warnings, optimisation, hardening, Sanitize build type)
# Configure (PSA/MbedTLS provider, Debug build — the default)
cmake -G Ninja -B cmake-build-debug -S .
# Build
cmake --build cmake-build-debug
# Test
./cmake-build-debug/safe-crypto-lib-test/safe_crypto_lib_testAll build types are defined in cmake/PermBuildOptions.cmake. Debug is the default when no -DCMAKE_BUILD_TYPE is specified.
| Build type | Optimisation | Hardening | Use for |
|---|---|---|---|
Debug |
-O0 -g |
— | Development |
Release |
-O3 -mtune=native -flto=thin |
-fstack-protector-strong -mbranch-protection=standard -D_FORTIFY_SOURCE=3 dead-strip |
Production / benchmarking |
MinSizeRel |
-Os -flto=thin |
Same as Release | Size-constrained deployments |
RelWithDebInfo |
-O2 -g |
— | Profiling / coverage |
Sanitize |
-O1 -g |
ASan + UBSan (-fsanitize=address,undefined) |
Defect detection |
Hardening notes:
-fstack-protector-strong— stack canaries on any function with a buffer, array, or address-taken local-mbranch-protection=standard— ARM PAC (pointer authentication for return addresses) + BTI (branch target identification); enforced in hardware on Apple Silicon (ARMv8.5-a)-D_FORTIFY_SOURCE=3— compile-time and runtime bounds checks on libc memory/string functions
# Speed-optimised release build
cmake -G Ninja -B cmake-build-release -S . -DCMAKE_BUILD_TYPE=Release
cmake --build cmake-build-release
# Size-optimised build
cmake -G Ninja -B cmake-build-minsizerel -S . -DCMAKE_BUILD_TYPE=MinSizeRel
cmake --build cmake-build-minsizerel
# Sanitizer build (ASan + UBSan)
cmake -G Ninja -B cmake-build-sanitize -S . -DCMAKE_BUILD_TYPE=Sanitize
cmake --build cmake-build-sanitize
./cmake-build-sanitize/safe-crypto-lib-test/safe_crypto_lib_testThe test suite (safe-crypto-lib-test/) uses GoogleTest + GMock and is organised into five distinct testing strategies plus PQC-specific tests.
MockPsaBackend is a GMock implementation of the CryptoProvider concept that intercepts every PSA call. Tests configure expectations with EXPECT_CALL to return specific psa_status_t error codes, then call the high-level _impl functions and assert the correct CryptoError variant is returned.
This covers every error branch in the library without needing to induce real PSA failures: key import failure, hash failure, MAC failure, KDF step failures, AEAD tag-check failure, ECDH failure, RSA failure, and so on. Every std::unexpected path in the library has at least one test here.
2. Known-answer-vector tests (digests_tests.hpp, mac_tests.hpp, aead_tests.hpp, chacha20_tests.hpp, kdf_tests.hpp, ecc_tests.hpp, ecdh_tests.hpp, asymmetric_tests.hpp, sigma_tests.hpp, sigma_i_tests.hpp, random_tests.hpp)
These run against the active provider (default: RealPsaBackend) and verify correct output for the full public API:
- Digests — SHA-256/384/512, SHA3-256/384/512: output length checks and known round-trip sanity.
- MAC — HMAC with all SHA variants: NIST HMAC test vectors; verify/reject paths; minimum key size enforcement (key ≥ hash output length).
- AEAD — AES-256-GCM: NIST SP 800-38D test vectors (with and without AAD); tag-tamper rejection;
symmetric_encrypt/symmetric_decryptzero-choice wrappers (AES-256-GCM). - ChaCha20-Poly1305 — RFC 8439 test vectors; tag-tamper rejection; cross-decrypt (encrypt with one provider, decrypt with the other).
- KDF — HKDF and HKDF-Expand (SHA-384): RFC 5869 test vectors; state-machine error paths.
- ECC / ECDH — P-256/384/521 key generation, ECDSA sign/verify, ECDH shared-secret agreement.
- Asymmetric — RSA-OAEP 3072/4096 encrypt/decrypt round-trips.
- SIGMA / SIGMA-I — Full two-party handshake; identity hiding; tamper detection on MAC, signature, ephemeral key, and encrypted bundle IV fields; session key encrypt/decrypt round-trip; replay attack rejection; fresh handshakes produce distinct session keys.
- Random — Output length, non-zero probability, successive calls differ.
Guarded by SAFE_CRYPTO_PROVIDER_ARM_ASM, these test the ARM intrinsic implementations directly against published test vectors and boundary conditions:
- AES-256-GCM — NIST CAVP vectors (no-AAD and with-AAD cases); empty-plaintext tag-only case; decrypt tag-tamper rejection.
- SHA3-256/384/512 — NIST test vectors for all three output sizes.
- ChaCha20-Poly1305 — RFC 8439 test vectors; tag-tamper rejection.
- Poly1305 — RFC 8439 MAC vectors.
- HKDF-SHA-384 — RFC 5869 test vectors exercising the full Extract+Expand path and the Expand-only variant.
- EC key store — boundary and error-path tests for the 16-slot EC key store.
- Symmetric key store — boundary and error-path tests for the 16-slot symmetric key store.
- RSA key store — boundary and error-path tests for the 8-slot RSA key store.
- Key management —
generate_key,import_key,export_key,destroy_keyround-trips and error paths. - ARM ASM backend errors — every
std::unexpectedpath exercised via direct backend calls. - Point utilities — P-256/384/521 scalar multiplication edge cases.
- ECDH peer validation — invalid-curve attack prevention: wrong prefix, identity point, coordinate ≥ p, off-curve y, P-521 non-canonical high bits.
- ECDSA signature decode — strict r/s validation: r=0, s=0, r=n, s=n, r=n+1, all-ones, P-521 high-bit-set encodings rejected across all three curves.
These verify the ARM ASM provider's correctness independently of the PSA layer.
Both ArmAsmBackend and RealPsaBackend are instantiated in the same binary regardless of the active provider. For each operation, the same input is fed to both backends and the outputs are compared byte-for-byte.
Operations covered: SHA-256/384/512, SHA3-256/384/512, HMAC-SHA-256/384/512, HMAC-SHA3-256/384/512, AES-256-GCM encrypt/decrypt/cross-decrypt (both directions), ChaCha20-Poly1305 encrypt/decrypt/cross-decrypt (both directions), HKDF-SHA-384 extract+expand and HKDF-Expand-only parity, ECDH shared-secret parity (P-256/384/521), ECDSA cross-verify (P-384), RSA-OAEP cross-decrypt (3072/4096-bit), and RSA-PSS cross-verify (3072/4096-bit).
The cross-decrypt and cross-verify tests are particularly valuable: they encrypt or sign with one backend and decrypt or verify with the other, confirming wire-format compatibility rather than just output equality.
This strategy catches implementation drift that KAT tests cannot find — a wrong implementation can still pass a KAT if the reference vector was derived from the same wrong code.
Verify SecureBuffer and FixedSecureBuffer<N> behaviour: index operator reads and writes (mutable and const), iterator traversal (all four begin()/end() overloads), move semantics, resize (shrink-only), and — where C++26 contracts are enforced — death assertions for out-of-bounds access and resize-beyond-current-size.
LLVM source-based coverage measured against the safe-crypto-lib/ headers for each provider build (Debug + -fprofile-instr-generate -fcoverage-mapping, run against the full test suite). Branch coverage is per-file; the TOTAL row includes all instrumented code in the binary (provider implementation headers, test utilities, etc.) which lowers the overall branch %.
| File | Lines | Line % | Functions | Fn % | Branches | Branch % |
|---|---|---|---|---|---|---|
aead.hpp |
156 | 91.0% | 8 | 100% | 44 | 75.0% |
asymmetric.hpp |
153 | 94.1% | 8 | 100% | 36 | 75.0% |
crypto_error.hpp |
7 | 100% | 3 | 100% | 0 | — |
crypto_provider.hpp |
7 | 100% | 3 | 100% | 2 | 50.0% |
digests.hpp |
23 | 95.7% | 2 | 100% | 4 | 75.0% |
ecc.hpp |
125 | 93.6% | 7 | 100% | 32 | 84.4% |
ecdh.hpp |
83 | 100% | 4 | 100% | 14 | 100% |
kdf.hpp |
176 | 87.5% | 6 | 100% | 54 | 77.8% |
mac.hpp |
62 | 95.2% | 4 | 100% | 14 | 85.7% |
ml_dsa_variant.hpp |
21 | 0% | 3 | 0% | 0 | — |
ml_kem_variant.hpp |
24 | 0% | 4 | 0% | 0 | — |
random.hpp |
31 | 93.6% | 3 | 100% | 8 | 75.0% |
secure_buffer.hpp |
83 | 91.6% | 29 | 89.7% | 4 | 100% |
sigma.hpp |
226 | 87.6% | 11 | 90.9% | 56 | 78.6% |
sigma_i.hpp |
387 | 81.7% | 15 | 100% | 84 | 69.1% |
slh_dsa_variant.hpp |
30 | 0% | 3 | 0% | 0 | — |
ml_dsa_variant.hpp, ml_kem_variant.hpp, and slh_dsa_variant.hpp show 0% because PSA/MbedTLS has no PQC implementation — those headers are only instantiated when SAFE_CRYPTO_PQC=LIBOQS or via the OpenSSL provider.
| File | Lines | Line % | Functions | Fn % | Branches | Branch % |
|---|---|---|---|---|---|---|
aead.hpp |
156 | 91.0% | 8 | 100% | 44 | 75.0% |
asymmetric.hpp |
153 | 94.1% | 8 | 100% | 36 | 75.0% |
crypto_error.hpp |
7 | 100% | 3 | 100% | 0 | — |
crypto_provider.hpp |
7 | 100% | 3 | 100% | 2 | 50.0% |
digests.hpp |
23 | 95.7% | 2 | 100% | 4 | 75.0% |
ecc.hpp |
125 | 93.6% | 7 | 100% | 32 | 84.4% |
ecdh.hpp |
83 | 100% | 4 | 100% | 14 | 100% |
kdf.hpp |
176 | 87.5% | 6 | 100% | 54 | 77.8% |
mac.hpp |
62 | 95.2% | 4 | 100% | 14 | 85.7% |
ml_dsa_variant.hpp |
21 | 0% | 3 | 0% | 0 | — |
ml_kem_variant.hpp |
24 | 0% | 4 | 0% | 0 | — |
random.hpp |
31 | 93.6% | 3 | 100% | 8 | 75.0% |
secure_buffer.hpp |
83 | 91.6% | 29 | 89.7% | 4 | 100% |
sigma.hpp |
226 | 87.6% | 11 | 90.9% | 56 | 78.6% |
sigma_i.hpp |
387 | 81.7% | 15 | 100% | 84 | 69.1% |
slh_dsa_variant.hpp |
30 | 0% | 3 | 0% | 0 | — |
| File | Lines | Line % | Functions | Fn % | Branches | Branch % |
|---|---|---|---|---|---|---|
aead.hpp |
156 | 91.0% | 8 | 100% | 44 | 75.0% |
asymmetric.hpp |
153 | 94.1% | 8 | 100% | 36 | 75.0% |
crypto_error.hpp |
7 | 100% | 3 | 100% | 0 | — |
crypto_provider.hpp |
7 | 100% | 3 | 100% | 2 | 50.0% |
digests.hpp |
23 | 95.7% | 2 | 100% | 4 | 75.0% |
ecc.hpp |
125 | 93.6% | 7 | 100% | 32 | 84.4% |
ecdh.hpp |
83 | 100% | 4 | 100% | 14 | 100% |
kdf.hpp |
176 | 87.5% | 6 | 100% | 54 | 77.8% |
mac.hpp |
62 | 95.2% | 4 | 100% | 14 | 85.7% |
ml_dsa_variant.hpp |
21 | 100% | 3 | 100% | 24 | 87.5% |
ml_kem_variant.hpp |
24 | 100% | 4 | 100% | 24 | 87.5% |
pqc_dsa.hpp |
214 | 62.6% | 6 | 100% | 44 | 54.6% |
pqc_kem.hpp |
109 | 63.3% | 3 | 100% | 20 | 50.0% |
random.hpp |
31 | 93.6% | 3 | 100% | 8 | 75.0% |
secure_buffer.hpp |
83 | 91.6% | 29 | 89.7% | 4 | 100% |
sigma.hpp |
226 | 87.6% | 11 | 90.9% | 56 | 78.6% |
sigma_i.hpp |
387 | 81.7% | 15 | 100% | 84 | 69.1% |
slh_dsa_variant.hpp |
30 | 100% | 3 | 100% | 42 | 92.9% |
What the uncovered lines are, across all providers:
Every file with less than 100% line coverage has the same category of gap: error-path std::unexpected returns. No happy-path code is uncovered. Specifically:
aead.hpp,asymmetric.hpp,random.hpp—crypto_init()failure return, key-import failure return, encrypt/decrypt failure return. These branches require inducing low-level PSA/OpenSSL failures; they are covered in thePsaErrorTestsmock suite but that suite runs againstMockPsaBackend, not the real backends, so real-provider builds don't count them.kdf.hpp— HKDF-info input failure (one specific KDF step error path) and a fewexpand_key_implerror returns not covered byPsaErrorTests.sigma.hpp/sigma_i.hpp— KDF setup/input failure returns insidederive_keysandrespondpaths. The happy-path handshake is fully covered; the threesigma_i_deserialize_bundleparse-error paths are directly tested; only injected-KDF/AES-failure branches remain uncovered in real-provider builds.pqc_dsa.hpp(OpenSSL — 62.6% lines) /pqc_kem.hpp(OpenSSL — 63.3% lines) — Same pattern: all error-path returns for keygen failure, key-export failure, sign failure, encap/decap failure. The PQC tests cover the happy path and tamper-detection path but do not inject low-level OpenSSL failures intoOQS_SIG_keypair/OQS_KEM_encapsetc.
Files at or above 80% in all providers: aead.hpp, asymmetric.hpp, crypto_error.hpp, crypto_provider.hpp, digests.hpp, ecc.hpp, ecdh.hpp, kdf.hpp, mac.hpp, random.hpp, secure_buffer.hpp, sigma.hpp.
Files below 80% in at least one provider:
sigma_i.hpp— 81.7% line / 69.1% branch (all providers). Remaining gap: injected-failure branches inderive_keys_implandsigma_i_aes_gcm_{encrypt,decrypt}_impl— require fault injection into PSA/AES primitives.pqc_dsa.hpp— 62.6% lines (OpenSSL only). Missing: error-path returns for keygen/export/sign/verify failures — no mock infrastructure forOQS_SIG_*failures exists.pqc_kem.hpp— 63.3% lines (OpenSSL only). Same: error-path returns for keygen/export/encap/decap failures.
safe-crypto-lib-bench uses Google Benchmark (via FetchContent) to measure throughput across all operations. RealPsaBackend, ArmAsmBackend, and OpenSslBackend are all instantiated in the same binary so results are directly comparable. Symmetric payloads are swept across 64 B / 1 KiB / 16 KiB / 256 KiB; throughput is reported in GB/s or MB/s via SetBytesProcessed. EC, RSA, and PQC operations report ops/s. PQC benchmarks (ML-DSA, ML-KEM) across all three providers require -DSAFE_CRYPTO_PQC=LIBOQS at configure time.
# Build and run (Release mandatory for meaningful numbers)
cmake -G Ninja -B cmake-build-release -S . -DCMAKE_BUILD_TYPE=Release
cmake --build cmake-build-release --target safe_crypto_lib_bench
./cmake-build-release/safe-crypto-lib-bench/safe_crypto_lib_bench
# With PQC benchmarks (ML-DSA and ML-KEM across PSA, ARM, OpenSSL)
cmake -G Ninja -B cmake-build-pqc-bench -S . \
-DCMAKE_BUILD_TYPE=Release \
-DSAFE_CRYPTO_PQC=LIBOQS \
-DCMAKE_PREFIX_PATH=/opt/homebrew/opt/openssl@3
cmake --build cmake-build-pqc-bench --target safe_crypto_lib_bench
./cmake-build-pqc-bench/safe-crypto-lib-bench/safe_crypto_lib_bench --benchmark_filter="MLDSA|MLKEM"
# Filter to one family
./cmake-build-release/safe-crypto-lib-bench/safe_crypto_lib_bench --benchmark_filter=SHA256
# Machine-readable output
./cmake-build-release/safe-crypto-lib-bench/safe_crypto_lib_bench --benchmark_format=json > results.jsonRepresentative results — Apple M3 Pro, Release build (256 KiB payload for symmetric; ops/s for EC and RSA):
| Operation | PSA/MbedTLS | ARM ASM | Speedup |
|---|---|---|---|
| SHA-256 | 366 MB/s | 2,275 MB/s | 6.2× |
| SHA-384 | 509 MB/s | 1,585 MB/s | 3.1× |
| SHA-512 | 515 MB/s | 1,563 MB/s | 3.0× |
| SHA3-256 | 374 MB/s | 770 MB/s | 2.1× |
| SHA3-384 | 300 MB/s | 616 MB/s | 2.1× |
| SHA3-512 | 207 MB/s | 424 MB/s | 2.0× |
| HMAC-SHA-256 | 356 MB/s | 2,282 MB/s | 6.4× |
| HMAC-SHA-384 | 512 MB/s | 1,557 MB/s | 3.0× |
| HMAC-SHA-512 | 469 MB/s | 1,566 MB/s | 3.3× |
| HMAC-SHA3-256 | 367 MB/s | 774 MB/s | 2.1× |
| HMAC-SHA3-384 | 293 MB/s | 618 MB/s | 2.1× |
| HMAC-SHA3-512 | 207 MB/s | 425 MB/s | 2.1× |
| AES-256-GCM encrypt | 1,060 MB/s | 1,249 MB/s | 1.18× |
| AES-256-GCM decrypt | 1,071 MB/s | 1,216 MB/s | 1.14× |
| ChaCha20-Poly1305 encrypt | 564 MB/s | 753 MB/s | 1.33× |
| ChaCha20-Poly1305 decrypt | 562 MB/s | 769 MB/s | 1.37× |
| HKDF-SHA-384 (48 B output) | 346 K ops/s | 619 K ops/s | 1.8× |
| ECDSA sign P-256 | 5,087 ops/s | 5,728 ops/s | 1.13× |
| ECDSA verify P-256 | 1,471 ops/s | 2,173 ops/s | 1.48× |
| ECDSA sign P-384 | 3,057 ops/s | 2,801 ops/s | 0.92× |
| ECDSA verify P-384 | 829 ops/s | 995 ops/s | 1.20× |
| ECDSA sign P-521 | 1,949 ops/s | 1,306 ops/s | 0.67× |
| ECDSA verify P-521 | 551 ops/s | 651 ops/s | 1.18× |
| ECDH P-256 | 2,177 ops/s | 3,218 ops/s | 1.48× |
| ECDH P-384 | 1,216 ops/s | 1,473 ops/s | 1.21× |
| ECDH P-521 | 850 ops/s | 1,073 ops/s | 1.26× |
| RSA-3072 OAEP encrypt | 6,413 ops/s | 6,303 ops/s | 0.98× |
| RSA-3072 OAEP decrypt | 157 ops/s | 157 ops/s | 1.00× |
| RSA-3072 PSS sign | 157 ops/s | 157 ops/s | 1.00× |
| RSA-3072 PSS verify | 6,418 ops/s | 6,830 ops/s | 1.06× |
| RSA-4096 OAEP encrypt | 3,888 ops/s | 3,671 ops/s | 0.94× |
| RSA-4096 OAEP decrypt | 79 ops/s | 77 ops/s | 0.98× |
| RSA-4096 PSS sign | 77 ops/s | 78 ops/s | 1.01× |
| RSA-4096 PSS verify | 4,121 ops/s | 3,968 ops/s | 0.96× |
PQC results — ARM ASM+LIBOQS vs PSA/MbedTLS+LIBOQS (-DSAFE_CRYPTO_PQC=LIBOQS):
| Operation | PSA/liboqs-ref | ARM/NEON-NTT | Speedup |
|---|---|---|---|
| ML-DSA-44 Keygen | 43.0 µs | 38.3 µs | 1.12× |
| ML-DSA-44 Sign | 167 µs | 123 µs | 1.36× |
| ML-DSA-44 Verify | 40.7 µs | 33.7 µs | 1.21× |
| ML-DSA-65 Keygen | 81.5 µs | 74.9 µs | 1.09× |
| ML-DSA-65 Sign | 279 µs | 203 µs | 1.37× |
| ML-DSA-65 Verify | 66.1 µs | 56.3 µs | 1.17× |
| ML-DSA-87 Keygen | 115 µs | 106 µs | 1.08× |
| ML-DSA-87 Sign | 343 µs | 256 µs | 1.34× |
| ML-DSA-87 Verify | 110 µs | 95.7 µs | 1.15× |
| ML-KEM-512 Keygen | 9.29 µs | 9.31 µs | 1.00× |
| ML-KEM-512 Encap | 7.00 µs | 7.03 µs | 1.00× |
| ML-KEM-512 Decap | 8.11 µs | 8.14 µs | 1.00× |
| ML-KEM-768 Keygen | 14.7 µs | 14.8 µs | 1.00× |
| ML-KEM-768 Encap | 11.2 µs | 11.2 µs | 1.00× |
| ML-KEM-768 Decap | 12.5 µs | 12.8 µs | 1.00× |
| ML-KEM-1024 Keygen | 20.8 µs | 20.8 µs | 1.00× |
| ML-KEM-1024 Encap | 16.4 µs | 16.1 µs | 1.00× |
| ML-KEM-1024 Decap | 18.9 µs | 19.0 µs | 1.00× |
ML-KEM is unaffected because the ARM backend uses mlkem-native's hand-written AArch64 assembly for its NTT — already optimal. ML-DSA uses the NEON NTT implemented in providers/arm_asm/dilithium_ntt_neon.cpp; it overrides the liboqs scalar C reference via link-order interposition (object files are resolved before archive members).
Notable findings:
- SHA-256 sees the largest gain —
vsha256h/vsha256h2intrinsics compress two rounds per cycle vs MbedTLS's scalar loop. - AES-256-GCM is near-parity because MbedTLS already uses
vaeseq_u8/vmull_p64hardware acceleration on this platform. - SHA3 / HMAC-SHA3 beats PSA at 2.1× after two optimization passes. (1) The first pass fully unrolled the ρ+π step, naming each of the 25 intermediate values explicitly so every rotation became a single
ROR Xd, Xn, #N. (2) The second pass replaced the NEONuint64x2_t-pair implementation with a pure scalar one: all 25 Keccak state lanes remain in nameduint64_tlocal variables throughout the round, eliminating the 50vgetq_lane_u64vector-to-scalar lane extractions that dominated the NEON implementation's latency (each extraction costs ~3 cycles at 50×24 rounds = 3,600 scalar-pipeline stalls per permutation call). - ChaCha20-Poly1305 leads MbedTLS at 1.33–1.37× after adding a 4-block NEON parallel ChaCha20 path. The keystream generator uses a word-major state layout where each of the 16
uint32x4_tregisters holds one ChaCha20 state word across all four blocks. Both column and diagonal quarter-rounds are plainchacha20_qrcalls (novextqneeded), and all four QRs within each round type operate on disjoint registers so the out-of-order pipeline issues them simultaneously. After 10 double-rounds the state is transposed back to block-major order withvzipq_u32/vzip1q_u64/vzip2q_u64and XOR'd directly with the input. Poly1305 already used 4-block parallelism and 3-limb 44-bit integer arithmetic (9 MUL+UMULH pairs per block) from the prior optimization pass. - ECDSA / ECDH beats PSA across all three curves for verify and key agreement, and for sign on P-256. Four optimization passes compound here. First, a 4-bit fixed-base window over a precomputed [1..15]·G affine table replaces the variable-base double-and-add for k·G (signing) and u1·G (verification). Each nibble costs 4 doublings + 1 mixed Jacobian–affine add (Z₂=1, saving ~4 field multiplications per step), reducing the per-sign iteration count from 256→64 (P-256), 384→96 (P-384), 521→131 (P-521). Second, P-384
fe384_mulwas rewritten as a 6×6 u64 schoolbook multiply (36 MUL-ACC) before Solinas reduction, replacing the old 12×12 u32 schoolbook (144 MUL-ACC). Third, P-384 field inversion and scalar inversion were replaced with addition chains:fe384_invert(used in affine conversion after every scalar multiplication) drops from ~702 field ops to ~490;p384_scalar_invertnow stays in Montgomery domain throughout its addition chain, reducing cost from ~1344 to ~490 Montgomery multiplications. Fourth, a dormant bug in the point-doubling formula (present across all three curves) was fixed — the8γ²term was squaring γ twice rather than once. P-384 sign still trails PSA (0.92×) and P-521 sign at 0.67×; both have no hardware modular reduction and the variable-basep*_scalar_mul(Q, ...)for ECDH and u2·Q in verification does not yet use a precomputed window. - RSA (3072 and 4096-bit) is implemented entirely in pure C++ without any PSA/MbedTLS dependency. Key generation uses Miller-Rabin primality testing (40 rounds) followed by CRT parameter computation. Public and private operations use CIOS Montgomery multiplication with a constant-time final reduction. OAEP and PSS padding use SHA-384 MGF1. ARM and PSA show near-parity because both use the same pure C++ implementation (PSA delegates to the same code path rather than MbedTLS's hardware-accelerated RSA).
The active backend is controlled by the SAFE_CRYPTO_ACTIVE_PROVIDER CMake cache variable. Pass it at configure time — CMake propagates the correct provider library and compile definitions to every target that links safe_crypto_lib, so no manual link step or compile flag is needed.
| Value | Backend | Status |
|---|---|---|
PSA_MBEDTLS (default) |
MbedTLS 4.1 PSA Crypto API | Production |
ARM_ASM |
ARMv8.2-A+crypto intrinsics (Apple Silicon; Linux ARM64 Graviton 2+, Neoverse N1/N2, Raspberry Pi 5) | Full — hashing, HMAC, AES-256-GCM, ChaCha20-Poly1305, HKDF, ECDSA/ECDH P-256/384/521, RSA-OAEP/PSS 3072/4096 (pure C++, no MbedTLS), key management |
OPENSSL |
OpenSSL 3.x EVP API | Full + SLH-DSA (FIPS 205, all 6 SHA2 variants) + ML-DSA (FIPS 204, parameter sets 44/65/87) + ML-KEM (FIPS 203, parameter sets 512/768/1024) — 249 tests; requires OpenSSL 3.0+ (find_package(OpenSSL 3.0 REQUIRED)) |
IA_ASM |
x86-64 SHA-NI + AES-NI + PCLMULQDQ + SSE2/SSSE3 | Full — hash/HMAC/HKDF/AEAD via x86 intrinsics; EC/RSA/PQC/random reuse arm_asm pure-C++ bignum (x86-portable); cross-compiled on Apple Silicon via -DCMAKE_OSX_ARCHITECTURES=x86_64, runs under Rosetta 2 (SHA-NI not emulated) |
A second CMake variable, SAFE_CRYPTO_PQC, controls an optional PQC supplement fetched via FetchContent:
SAFE_CRYPTO_PQC |
Effect |
|---|---|
NONE (default) |
No PQC supplement; ARM_ASM and PSA_MBEDTLS providers return err_invalid_arg for ML-DSA and ML-KEM |
LIBOQS |
Fetches liboqs 0.13.0 and wires ML-DSA 44/65/87 and ML-KEM 512/768/1024 into the ARM_ASM and PSA_MBEDTLS backends via providers/liboqs/liboqs_pqc.hpp; defines SAFE_CRYPTO_PQC_LIBOQS; adds 13 PQC tests (447 total for ARM_ASM+LIBOQS; 239 total for PSA_MBEDTLS+LIBOQS); when combined with OPENSSL, also activates 6 cross-provider parity tests (255 total for OPENSSL+LIBOQS); OQS_KEM and OQS_SIG descriptors are allocated once per variant (C++11 thread-safe local statics) and reused across all operations — OQS_KEM_new()/OQS_SIG_new() are no longer called per-operation |
SLH-DSA is not yet available via liboqs (liboqs 0.13.0 uses SPHINCS+ naming internally and has no slh_dsa aliases). It remains OpenSSL-only.
# Use the ARM ASM provider
cmake -G Ninja -B cmake-build-arm-asm -S . -DSAFE_CRYPTO_ACTIVE_PROVIDER=ARM_ASM
# Use the IA ASM provider (cross-compile for x86_64 on Apple Silicon)
cmake -G Ninja -B cmake-build-ia-asm -S . \
-DCMAKE_OSX_ARCHITECTURES=x86_64 \
-DSAFE_CRYPTO_ACTIVE_PROVIDER=IA_ASM
# Use the ARM ASM provider with liboqs PQC supplement
cmake -G Ninja -B cmake-build-arm-asm-pqc -S . \
-DSAFE_CRYPTO_ACTIVE_PROVIDER=ARM_ASM \
-DSAFE_CRYPTO_PQC=LIBOQS
# Use the OpenSSL provider (macOS with Homebrew)
cmake -G Ninja -B cmake-build-openssl -S . \
-DSAFE_CRYPTO_ACTIVE_PROVIDER=OPENSSL \
-DCMAKE_PREFIX_PATH=/opt/homebrew/opt/openssl@3
# Use the OpenSSL provider with liboqs PQC supplement (enables cross-provider parity tests)
cmake -G Ninja -B cmake-build-openssl-pqc -S . \
-DSAFE_CRYPTO_ACTIVE_PROVIDER=OPENSSL \
-DSAFE_CRYPTO_PQC=LIBOQS \
-DCMAKE_PREFIX_PATH=/opt/homebrew/opt/openssl@3
# Use the PSA/MbedTLS provider with liboqs PQC supplement
cmake -G Ninja -B cmake-build-psa-pqc -S . \
-DSAFE_CRYPTO_PQC=LIBOQSSpecifying an unrecognised value is a configure-time fatal error that lists the valid choices. Adding a new provider means creating a providers/<name>/ subdirectory with a backend struct and CMakeLists, then registering it in the top-level SAFE_CRYPTO_ACTIVE_PROVIDER string list.
providers/ia_asm/ targets x86-64 with SHA-NI, AES-NI, PCLMULQDQ, and SSE2/SSSE3. Hash/HMAC/HKDF/AEAD operations use x86 intrinsics; EC scalar math, RSA bignum, and PQC operations reuse the arm_asm::detail pure-C++ implementation (which has no NEON dependency). Compiled with -march=x86-64-v2 -maes -msha -mpclmul -mssse3.
On macOS with Apple Silicon, cross-compile with -DCMAKE_OSX_ARCHITECTURES=x86_64. The resulting binary runs under Rosetta 2, which emulates AES-NI and most SSE extensions but does not emulate SHA-NI (sha256rnds2, sha256msg1, sha256msg2). Tests that require SHA-NI (all digest and HMAC tests) will SIGILL under Rosetta; RSA, AEAD (ChaCha20-Poly1305), and most buffer/error-path tests pass.
Implemented operations:
| Operation | Notes |
|---|---|
| SHA-256 | _mm_sha256rnds2_epu32 SHA-NI rounds; full padding; requires native x86 |
| SHA-384 / SHA-512 | SSE2 scalar compression; no special extensions needed |
| SHA3-256/384/512 | Same scalar Keccak as ARM ASM, x86-portable |
| HMAC-SHA-256/384/512 | SHA-NI / SSE2 |
| HMAC-SHA3-256/384/512 | Scalar Keccak |
| AES-256-GCM encrypt/decrypt | AES-NI key expansion + CTR; PCLMULQDQ GHASH |
| ChaCha20-Poly1305 encrypt/decrypt | SSE2 quarter-round; Poly1305 with SIMD |
| HKDF / HKDF-Expand (SHA-384) | RFC 5869 state machine |
| ECDSA P-256/384/521 sign/verify | RFC 6979 deterministic k via ia_asm HMAC; EC math reuses arm_asm::detail |
| ECDH P-256/384/521 | Pure C++ scalar multiplication |
| RSA-OAEP-3072/4096 encrypt/decrypt | SHA-384 MGF1 using ia_asm SHA-384; same pure C++ bignum |
| RSA-PSS-3072/4096 sign/verify | SHA-384 MGF1 using ia_asm SHA-384 |
| RSA key generation | Same Miller-Rabin + CRT as ARM ASM |
| EC/RSA key store | Shared arm_asm::detail key stores (same 16-slot EC, 8-slot RSA) |
| Random bytes | arc4random_buf |
| ML-DSA / ML-KEM | Via liboqs supplement (SAFE_CRYPTO_PQC=LIBOQS) |
providers/openssl/ implements the full CryptoProvider API via the OpenSSL 3.x EVP high-level interface. It uses find_package(OpenSSL 3.0 REQUIRED) — no FetchContent. On macOS with Homebrew, pass -DCMAKE_PREFIX_PATH=/opt/homebrew/opt/openssl@3 at configure time.
Key design choices:
- EC private keys are stored and exported as native-endian BIGNUM scalars (via
BN_bn2nativepad) so they round-trip throughOSSL_PARAM_construct_BN/EVP_PKEY_fromdatacorrectly on both ARM (little-endian) and x86 (big-endian). - EC key pairs are imported using
EVP_PKEY_fromdatawith only the private scalar — OpenSSL computes the public key automatically. The resulting key pair passes sign/verify and ECDH agreement. - EC public keys are imported via
EVP_PKEY_fromdatawith the uncompressed point (04‖x‖y); exported viaOSSL_PKEY_PARAM_PUB_KEY. - RSA private keys are PKCS#1 DER (
i2d_PrivateKey/d2i_PrivateKey). RSA public keys are SubjectPublicKeyInfo DER (i2d_PublicKey/d2i_PublicKey). This matches what PSA exports and enables cross-provider key compatibility. - RSA-OAEP uses SHA-384 for both the hash and MGF1 parameters. The optional label is heap-allocated via
OPENSSL_mallocand ownership is transferred to OpenSSL viaEVP_PKEY_CTX_set0_rsa_oaep_label. - RSA-PSS uses
RSA_PSS_SALTLEN_DIGEST(salt length = hash length = 48 bytes for SHA-384). - Asymmetric keys are stored in a 64-slot
EVP_PKEY*store (IDs 1–64); symmetric and derive keys are stored as raw bytes in a 64-slotFixedSecureBuffer<256>store (IDs 65–128).
safe-crypto-cli/scli is a command-line tool that exposes the library's cryptographic operations directly from the shell. It is built alongside the library and tests; no separate install step is needed.
cmake -G Ninja -B build -S .
cmake --build build --target scli
./build/safe-crypto-cli/scli --helpAll subcommands share the same input/output model:
| Spec | Meaning |
|---|---|
base64:<data> |
Literal base64-encoded bytes on the command line |
- |
Read from stdin (input) / write raw binary to stdout (output) |
<path> |
Read from / write to a file (raw binary) |
(omit --output) |
Base64-encode and print to stdout |
Secret vs. public file outputs. Options that write secret material (private keys, shared secrets, generated IKM, and derived keys) use a hardened writer: the output file is created with mode 0600 (owner read/write only), will not follow symlinks, and will fail if the path already exists. Options that write public material (public keys, ciphertexts, signatures, digests) use the standard writer, which respects the process umask and will overwrite existing files.
Bounded input. All input paths (file, stdin, base64:) are read into memory with operation-specific size caps to prevent accidental or adversarial exhaustion of process memory:
| Input role | Maximum size |
|---|---|
| Key / PRK / IKM / salt / info | 64 KiB |
| Signature / MAC | 64 KiB |
| Message / plaintext / ciphertext / AAD | 64 MiB |
Inputs that exceed the cap are rejected immediately with a non-zero exit code before any cryptographic work is performed.
scli digest --algo <alg> --input <spec> [--output <spec>]
--algo: sha256 sha384 sha512 sha3-256 sha3-384 sha3-512
# SHA-256 of "hello world" from stdin
echo -n "hello world" | scli digest --algo sha256 --input -
# SHA-384 of a file, save raw binary to another file
scli digest --algo sha384 --input message.bin --output hash.bin
# Inline base64 input
scli digest --algo sha3-256 --input base64:aGVsbG8=scli mac --algo <alg> --key <spec> --input <spec> [--output <spec>] [--verify <spec>]
--algo: sha256 sha384 sha512
Without --verify, generates and outputs the MAC. With --verify, compares the computed MAC to the one supplied — exits 0 on match, 1 on mismatch (no output either way).
# Generate HMAC-SHA-256
scli mac --algo sha256 --key base64:<key-b64> --input message.bin
# Verify (exits 0 if correct, 1 if not)
scli mac --algo sha256 --key base64:<key-b64> --input message.bin --verify base64:<mac-b64>scli aead --algo <alg> --op encrypt|decrypt --key <spec> --input <spec>
[--output <spec>] [--aad <spec>]
--algo: aes256-gcm chacha20-poly1305
Key must be exactly 32 bytes. Wire format for both algorithms: IV (12 bytes) ‖ ciphertext+tag.
# Encrypt
scli aead --algo aes256-gcm --op encrypt \
--key base64:<32-byte-key-b64> \
--input plaintext.bin --output ciphertext.bin
# Decrypt
scli aead --algo aes256-gcm --op decrypt \
--key base64:<32-byte-key-b64> \
--input ciphertext.bin --output plaintext.bin
# With additional authenticated data
scli aead --algo chacha20-poly1305 --op encrypt \
--key base64:<key-b64> --input message.bin --aad base64:<aad-b64>scli ecdsa keygen --curve p256|p384|p521
[--out-private <spec>] # default: base64 to stdout
[--out-public <spec>] # default: base64 to stdout
scli ecdsa sign --curve p256|p384|p521
--key <spec> # private key (raw DER bytes)
--input <spec> # message to sign
[--output <spec>] # signature (default: base64 to stdout)
scli ecdsa verify --curve p256|p384|p521
--key <spec> # public key (raw DER bytes)
--input <spec> # message
--signature <spec> # signature
# exits 0 = valid, 1 = invalid
Keys are raw DER-encoded bytes (private key: raw scalar; public key: uncompressed point).
# Generate a P-256 key pair
scli ecdsa keygen --curve p256 \
--out-private priv.der --out-public pub.der
# Sign a message
scli ecdsa sign --curve p256 \
--key priv.der --input base64:aGVsbG8gd29ybGQ=
# Verify a signature (exits 0 = valid)
scli ecdsa verify --curve p256 \
--key pub.der --input base64:aGVsbG8gd29ybGQ= \
--signature base64:<sig-b64>scli ecdh keygen --curve p256|p384|p521
[--out-private <spec>] # default: base64 to stdout
[--out-public <spec>] # default: base64 to stdout
scli ecdh compute --curve p256|p384|p521
--key <spec> # our private key (raw DER bytes)
--peer-public <spec> # peer's public key (raw DER bytes)
[--output <spec>] # shared secret (default: base64 to stdout)
Typical use: both parties run keygen, exchange public keys out-of-band, then each runs compute with their own private key and the peer's public key — both sides obtain the same shared secret. Output sizes: P-256 → 32 bytes, P-384 → 48 bytes, P-521 → 66 bytes.
# Party A generates a key pair
scli ecdh keygen --curve p256 \
--out-private a_priv.der --out-public a_pub.der
# Party B generates a key pair
scli ecdh keygen --curve p256 \
--out-private b_priv.der --out-public b_pub.der
# A computes shared secret using B's public key
scli ecdh compute --curve p256 \
--key a_priv.der --peer-public b_pub.der
# B computes shared secret using A's public key (same result)
scli ecdh compute --curve p256 \
--key b_priv.der --peer-public a_pub.derscli rsa keygen --bits 3072|4096
[--out-private <spec>] # PKCS#1 DER, default: base64 to stdout
[--out-public <spec>] # SPKI DER, default: base64 to stdout
scli rsa oaep-encrypt --bits 3072|4096
--key <spec> # public key DER
--input <spec> # plaintext
[--output <spec>] # ciphertext (default: base64 to stdout)
[--label <spec>] # optional OAEP label
scli rsa oaep-decrypt --bits 3072|4096
--key <spec> # private key DER
--input <spec> # ciphertext
[--output <spec>]
[--label <spec>]
scli rsa pss-sign --bits 3072|4096
--key <spec> # private key DER
--input <spec> # message
[--output <spec>] # signature (default: base64 to stdout)
scli rsa pss-verify --bits 3072|4096
--key <spec> # public key DER
--input <spec> # message
--signature <spec>
# exits 0 = valid, 1 = invalid
# Generate a 3072-bit key pair
scli rsa keygen --bits 3072 \
--out-private priv.der --out-public pub.der
# Encrypt / decrypt
scli rsa oaep-encrypt --bits 3072 --key pub.der \
--input base64:aGVsbG8gd29ybGQ= --output ct.bin
scli rsa oaep-decrypt --bits 3072 --key priv.der \
--input ct.bin
# Sign / verify
scli rsa pss-sign --bits 3072 --key priv.der --input message.bin
scli rsa pss-verify --bits 3072 --key pub.der --input message.bin \
--signature base64:<sig-b64>scli kdf derive --length <N> [--ikm <spec>] [--salt <spec>] [--info <spec>]
[--output <spec>] [--out-ikm <file>]
scli kdf expand --length <N> --prk <spec> [--info <spec>] [--output <spec>]
deriveruns HKDF Extract+Expand.--ikmmust be at least2 * lengthbytes. If omitted, random IKM is generated and written to--out-ikmfor reproducibility.expandruns HKDF-Expand only;--prkis the SHA-384-length pseudorandom key (48 bytes).
# Derive a 32-byte key from random IKM, saving the IKM for later reproduction
scli kdf derive --length 32 --out-ikm ikm.bin
# Reproduce the same key from the saved IKM
scli kdf derive --length 32 --ikm ikm.bin
# Derive with salt and context info
scli kdf derive --length 32 --ikm base64:<ikm-b64> \
--salt base64:<salt-b64> --info base64:<info-b64>
# Expand from a PRK, output raw binary
scli kdf expand --length 32 --prk prk.bin --output derived.binscli ml-kem keygen --variant 512|768|1024
--out-private <spec> # private (decapsulation) key
--out-public <spec> # public (encapsulation) key
scli ml-kem encapsulate --variant 512|768|1024
--key <spec> # recipient public key
--out-ciphertext <spec> # KEM ciphertext
--out-secret <spec> # shared secret (32 bytes)
scli ml-kem decapsulate --variant 512|768|1024
--key <spec> # private key
--ciphertext <spec>
--output <spec> # shared secret (32 bytes)
scli ml-kem keygen --variant 512 --out-private priv.bin --out-public pub.bin
scli ml-kem encapsulate --variant 512 --key pub.bin \
--out-ciphertext ct.bin --out-secret ss_sender.bin
scli ml-kem decapsulate --variant 512 --key priv.bin \
--ciphertext ct.bin --output ss_recipient.bin
# ss_sender.bin and ss_recipient.bin are identicalscli ml-dsa keygen --variant 44|65|87
--out-private <spec>
--out-public <spec>
scli ml-dsa sign --variant 44|65|87
--key <spec> # private key
--input <spec> # message
--output <spec> # signature
scli ml-dsa verify --variant 44|65|87
--key <spec> # public key
--input <spec> # message
--signature <spec>
# exits 0 = valid, 1 = invalid
scli ml-dsa keygen --variant 44 --out-private priv.bin --out-public pub.bin
scli ml-dsa sign --variant 44 --key priv.bin --input message.bin --output sig.bin
scli ml-dsa verify --variant 44 --key pub.bin --input message.bin --signature sig.binscli slh-dsa keygen --variant sha2-128s|sha2-128f|sha2-192s|sha2-192f|sha2-256s|sha2-256f
--out-private <spec>
--out-public <spec>
scli slh-dsa sign --variant <variant>
--key <spec> # private key
--input <spec> # message
--output <spec> # signature
scli slh-dsa verify --variant <variant>
--key <spec> # public key
--input <spec> # message
--signature <spec>
# exits 0 = valid, 1 = invalid
scli slh-dsa keygen --variant sha2-128f --out-private priv.bin --out-public pub.bin
scli slh-dsa sign --variant sha2-128f --key priv.bin --input message.bin --output sig.bin
scli slh-dsa verify --variant sha2-128f --key pub.bin --input message.bin --signature sig.binscli emits structured diagnostic output via spdlog. Two top-level options control it — they must appear before the subcommand name:
scli [--log-level <level>] [--log-config <path>] <subcommand> ...
--log-level (env: SCLI_LOG_LEVEL, default: warn)
| Level | Output |
|---|---|
trace / debug |
Operation entry and success traces: sha: input=5 bytes, sha: digest=32 bytes |
info |
Informational messages |
warn |
Warnings only (default — silent on success) |
error / critical |
Errors only |
off |
Suppress all operational logging; fatal CLI errors are still written to stderr |
# Debug trace to stderr
scli --log-level debug digest --algo sha256 --input message.bin
# Via env var
SCLI_LOG_LEVEL=debug scli digest --algo sha256 --input message.bin--log-config (overrides --log-level)
Reads a JSON file to configure sinks and level. Supported sink types: stderr (default), stdout, file (rotating). If the file is missing, malformed, or contains an unknown sink type, scli prints an error and falls back to default warn-level logging.
{
"level": "debug",
"sinks": [
{ "type": "stderr" },
{ "type": "file", "path": "/var/log/scli.log", "max_size_mb": 10, "max_files": 3 }
]
}scli --log-config /etc/scli/logging.json digest --algo sha256 --input message.binLog messages never include key material, IVs, plaintexts, ciphertexts, or digests — only operation names and byte-length metadata.
scli random --length <N> [--output <spec>]
# 32 random bytes to stdout (base64)
scli random --length 32
# 64 random bytes to a file (raw binary)
scli random --length 64 --output keyfile.binA separate test suite (safe-crypto-cli-test/) drives scli as a subprocess and validates stdout, stderr, and exit codes using GoogleTest. 111 tests on the default build, plus 17 ML-KEM/ML-DSA tests (LIBOQS build) and 8 SLH-DSA tests (OpenSSL build):
cmake --build build --target safe_crypto_cli_test
cd build && ctest -R "AeadTests|DigestTests|EcdhTests|EcdsaTests|IoTests|KdfTests|LogConfigTests|LoggingTests|MacTests|MlDsaTests|MlKemTests|RandomTests|RsaTests|SlhDsaTests"Note: RSA tests are slow in debug builds (~4 min) due to Miller-Rabin primality testing. Use a release build for speed:
cmake -DCMAKE_BUILD_TYPE=Release.
- Language: C++26
- Build system: CMake 3.31 + Ninja
- Crypto backend: MbedTLS 4.1.0 (PSA Crypto API, via FetchContent)
- Test framework: GoogleTest + GMock (via FetchContent)
- Benchmark framework: Google Benchmark 1.9.1 (via FetchContent)
- CLI argument parsing: CLI11 2.6.2 (via FetchContent)
- CLI logging: spdlog 1.15.3 (via FetchContent)
- CLI log config: nlohmann/json 3.11.3 (via FetchContent)