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
cryptovault0.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
strandbytescannot 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_keyfor 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 aNoFecvault needs aNoFecvault 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.
cryptography45.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
NoFecblob format, Argon2id and key wrapping. Implemented and cross-checked against the crate in both directions.- The FEC strategies. Reed-Solomon, the interleaver and Viterbi, so any
cryptovaultblob can be read from Python. Implemented and cross-checked in both directions. - 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
reedsolomonandviterbicrates 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
- MIT license (LICENSE-MIT), or
- Apache License, Version 2.0 (LICENSE-APACHE)
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)
| File | Size | Uploaded | |
|---|---|---|---|
| cryptovault_py-0.2.0.tar.gz | 37.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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