Skip to main content

CryptoVault-Python

A Python implementation of the cryptovault blob format, byte-compatible with the Rust crate. AES-256-GCM-SIV under an HKDF-SHA256 key, Argon2id key derivation and DEK/KEK key wrapping, followed by the crate's forward-error correction: Reed-Solomon, an interleaver and a Viterbi code, or no FEC at all.

Status: 0.2.0, alpha. Every FEC strategy of cryptovault 0.3.0 is implemented and checked in both directions against the crate.


What it does

Two services that share a 32-byte key can encrypt and authenticate messages for each other. A blob made on either side opens on the other, Rust → Python and Python → Rust. The package opens no connections and knows nothing about transports: it turns text or key material into a blob and back, and the caller moves the blob.

The Rust crate stacks two independent layers: an AEAD that provides all of the security, then an FEC that adds channel resilience and no security. Both are implemented here, with the same four FEC strategies as the crate.

Installation

pip install cryptovault-py

The import name is cryptovault. Requirements lists the supported Python versions and the cryptography and OpenSSL floor.

Quick start

>>> from cryptovault import CryptoVault, generate_dek, generate_salt
>>> vault = CryptoVault.default()             # Argon2id + AES-256-GCM-SIV + concatenated FEC

>>> key = generate_dek()                      # or vault.derive_key(password, salt)
>>> blob = vault.encrypt_with_key(key, "sk-secret")
>>> vault.decrypt_with_key(key, blob)
'sk-secret'

>>> salt = generate_salt()                    # one per context, kept by the caller
>>> kek = generate_dek()
>>> dek = generate_dek()
>>> wrapped = vault.wrap_key(kek, salt, dek)  # the salt is bound as AAD
>>> vault.unwrap_key(kek, salt, wrapped) == dek
True

CryptoVault.default() matches CryptoVault::default() in Rust. To pick another FEC strategy, compose the vault yourself; the constructor has no defaults:

>>> from cryptovault import Aes256GcmSivCipher, Argon2Kdf, NoFec
>>> aead_only = CryptoVault(Argon2Kdf(), Aes256GcmSivCipher(), NoFec())
>>> aead_only.decrypt_with_key(key, aead_only.encrypt_with_key(key, "hi"))
'hi'

derive_key(password, salt) runs Argon2id with the crate's OWASP-2025 profile (64 MiB, three passes, four lanes). Call it once per session and keep the key.

Blob layout

blob    = base64( fec.encode( header ‖ nonce(12 B) ‖ ciphertext ‖ tag(16 B) ) )   standard, padded
header  = version (0x01) ‖ plaintext_len (u32, little-endian)
key     = HKDF-SHA256( salt = none, ikm = 32-byte master key, info = "cryptovault:v1:aead", L = 32 )
AAD     = purpose ‖ header ‖ aad_extra
purpose = 0x01 data · 0x02 wrapped key
extra   = empty for data · the salt for a wrapped key (authenticated, never stored)
cipher  = AES-256-GCM-SIV, fresh random 12-byte nonce per message

The header is bound as associated data, so changing any bit of it fails authentication. The purpose byte keeps a data blob from opening as a wrapped key, and the other way round. As for the version byte, it leaves room for the format to evolve; today only 0x01 is accepted.

FEC strategies

The FEC layer sits after the AEAD and only helps a blob survive a noisy channel. Correction is all or nothing. Within capacity the blob comes back exact; past it, decryption fails with a typed error and never returns wrong plaintext, because the AEAD tag checks the result.

Strategy Pipeline Corrects Size of a 1 KiB message
ConcatenatedFec.default() RS(255, 223) → depth-5 block interleaver → Viterbi K=7 R=1/2 scattered bit errors and bursts 3404 B
ConcatenatedFec.with_csprng_from_master(master, depth) the same, plus a keyed shuffle per window as above, weaker burst spreading 3404 B
ViterbiOnlyFec() Viterbi K=7 R=1/2 scattered bit errors 2824 B
ReedSolomonCodec() RS(255, 223) up to 16 bad bytes per 255-byte codeword 1700 B
NoFec() none nothing; any change fails authentication 1412 B

The CSPRNG variant derives its seed from the master key with HKDF (info = "cryptovault:v1:interleaver"), and both ends must build it from the same master and depth. It is obfuscation, not security.

The codecs are pure Python, so they are much slower than the Rust crate. Measured with CPython 3.11 on Windows:

Strategy Encrypt 128 KiB Decrypt 128 KiB Decrypt 128 KiB, one flipped bit Decrypt 1 KiB, one flipped bit
ConcatenatedFec 1.4 s 1.7 s 15.6 s 0.16 s
ViterbiOnlyFec 0.25 s 0.66 s 14.3 s 0.12 s
ReedSolomonCodec 1.1 s 1.0 s 0.82 s 0.01 s
NoFec < 0.01 s < 0.01 s fails, < 0.01 s fails, < 0.01 s

A clean Viterbi sub-block decodes directly. One with channel errors runs the full trellis, which in Python costs about 12 µs per coded bit pair, or about 12 s for a full 124 950-byte sub-block. Noisy channels carrying large messages pay that price.

Security model

Property Mechanism Level
Confidentiality AES-256-GCM-SIV (RFC 8452) 256-bit key
Integrity / authenticity 128-bit GCM-SIV tag, header bound as AAD forgery ≤ 2⁻¹²⁸ per message
Nonce-misuse resistance SIV construction a nonce collision reveals only plaintext equality
Key separation HKDF-SHA256 with a fixed info label the AEAD key is never the raw master key
Password hardening Argon2id v1.3, m = 64 MiB, t = 3, p = 4 OWASP 2025 profile
Context binding salt and purpose bound as AAD on wrapped keys wrong salt or purpose fails authentication
Typed failures one exception class per failing stage, fixed messages no input echoed in an error
Secret hygiene no key, secret, blob or plaintext in errors, logs or repr —

All cryptographic primitives come from the cryptography package. Nothing here is home-rolled crypto.

Out of scope (what it does NOT defend against)

  • Weak passwords. Argon2id slows guessing down; it does not make a weak password strong.
  • Replay. A valid blob stays valid. Detecting repeats is the caller's protocol concern.
  • Traffic analysis. The header carries the plaintext length in the clear, and the blob size follows from it.
  • Channel errors past the FEC capacity. The blob fails closed and never decrypts to something wrong. With NoFec, a single flipped bit is enough.
  • Memory disclosure. The crate wipes secrets from memory; Python's immutable str and bytes cannot promise that, and this package does not try.
  • Host compromise. A key in process memory is as safe as the process.

Operational contracts (the caller must honor)

  • Use exactly 32 bytes of key. A shorter or longer key is an error, not something the package pads or truncates.
  • Use a fresh salt per context, from generate_salt(). The same password and salt give the same key.
  • Rate-limit derive_key for untrusted callers. Each call costs 64 MiB and noticeable CPU.
  • Rekey before ~2⁴⁸ messages per key. That is the birthday bound for random 96-bit nonces.
  • Decode with the same FEC strategy you encoded with. A blob from a default Rust vault opens with CryptoVault.default(); one from a NoFec vault needs a NoFec vault here.
  • Bound decrypt input from untrusted sources. with_max_blob_len(cap) returns a vault that rejects larger blobs before any decryption work. With FEC this also bounds the decode time, which grows with the number of corrupted Viterbi sub-blocks.
  • One blob, one message. Framing and delivery belong to the caller.

Errors

Every failure is a CryptoVaultError. The subclass names the stage that failed, and str() matches the crate's Display for the same input, word for word:

Class Raised for
InvalidInputError wrong key, salt or seed length, empty password, interleave depth out of range, oversized input, malformed blob, unknown version
EncodingError non-canonical base64, text that is not valid UTF-8
CipherError a tag that does not verify (wrong key, salt or purpose, any tampering)
KeyDerivationError Argon2id failure, or no OS entropy for a salt or DEK
ErrorCorrectionError corruption beyond the Reed-Solomon capacity, or no ChaCha20 in the backend for the CSPRNG layer

Compatibility testing

The test suite replays reference data that the crate itself produced. For NoFec: its constants, data blobs up to 128 KiB, wrapped keys, Argon2id outputs, and the exact error for 358 adversarial inputs (every single-bit flip of a blob, truncations, bad versions and lengths, non-canonical base64, wrong keys and salts, invalid UTF-8). For the FEC layer: the Reed-Solomon codewords and the outcome of 146 corrupted decodes, the interleaver permutations (the CSPRNG layer included), Viterbi encodings and 26 corrupted decodes down to the wrong bytes a heavily damaged stream decodes to, and 335 adversarial blobs under three strategies (sampled bit flips, bursts, truncation, rewritten headers, wrong keys and salts). In the other direction, a second test hands the crate blobs made in Python under every strategy and checks that it opens each one. The generator lives in interop/rust:

uv run pytest                     # full suite, Rust fixtures included
uv run pytest -m interop          # Python → Rust; needs the Rust toolchain
cargo run --release --manifest-path interop/rust/Cargo.toml -- fixtures \
    > tests/fixtures/rust_nofec.json   # regenerate the NoFec fixtures
cargo run --release --manifest-path interop/rust/Cargo.toml -- fec-fixtures \
    > tests/fixtures/rust_fec.json     # regenerate the FEC fixtures

Requirements

  • Python 3.11 or newer. Tested on Linux and Windows with 3.11 and 3.14.
  • cryptography 45.0.1 or newer, linked against OpenSSL 3.2 or newer (AES-GCM-SIV and Argon2id need it; the CSPRNG layer also uses its ChaCha20). Releases before 45.0.1 reject an empty plaintext, which the crate allows. The official wheels qualify.

Development

uv sync --locked
uv run ruff check . && uv run ruff format --check . && uv run mypy . && uv run pytest
uv run pip-audit
uv build

Roadmap

  1. NoFec blob format, Argon2id and key wrapping. Implemented and cross-checked against the crate in both directions.
  2. The FEC strategies. Reed-Solomon, the interleaver and Viterbi, so any cryptovault blob can be read from Python. Implemented and cross-checked in both directions.
  3. A compiled FEC accelerator. The codecs are pure Python, and a damaged sub-block runs the full Viterbi trellis at about 12 µs per stage: a 128 KiB blob with one flipped bit takes about 15 s to decrypt. The plan is an optional Rust extension (PyO3) that wraps the same reedsolomon and viterbi crates the reference crate uses, with the pure-Python codecs kept as the fallback where no wheel exists. The crate fixtures stay as its parity check.

License

Licensed under either of

at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this package by you, as defined in the Apache-2.0 license, shall be dual-licensed as above, without any additional terms or conditions.

Copyright © 2026 Julian Bolivar / BolivarTech.

Metadata

Release files for cryptovault-py 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for cryptovault-py 0.2.0
File Size Uploaded
cryptovault_py-0.2.0.tar.gz 37.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for cryptovault-py 0.2.0
File Interpreter ABI Platform
cryptovault_py-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 76.8 kB

Release files / cryptovault_py-0.2.0.tar.gz

Download URL cryptovault_py-0.2.0.tar.gz
Size 37.1 kB
Tags Source
SHA-256 checksum
How to use checksums
a0b620cb6d6a91574eaae56845d67c7f2e3b9f2d0e294e215b6f96c8a1835290
BLAKE2b-256 checksum
How to use checksums
cdff70a11d685c4da4f8d7faa3a98ea36b3fe907442327ae6c70d3ad50b60ec8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.

Transparency log

Release files / cryptovault_py-0.2.0-py3-none-any.whl

Download URL cryptovault_py-0.2.0-py3-none-any.whl
Size 39.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
3a4ace64b38b0ecb5630e22a986b6c1215a48a2ca4d9109d03227825a54eda91
BLAKE2b-256 checksum
How to use checksums
d9b3e447f0c6fd9abc06f89b0ff8097dff2e3b1f6585ab1b2c95163385a126fe
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.

Transparency log

Release history Release notifications | RSS feed

0.3.0

8 release files

This release

0.2.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page