cryptovault-py 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,97 @@
1
+ # Author: Julian Bolivar
2
+ # Version: 0.2.0
3
+ # Date: 2026-10-16
4
+ """Authenticated encryption byte-compatible with the ``cryptovault`` Rust crate.
5
+
6
+ A blob produced here opens with the crate and the other way round, for data
7
+ and for wrapped keys, as long as both sides use the same FEC strategy. The
8
+ payload is sealed with AES-256-GCM-SIV under an HKDF-SHA256 sub-key, with the
9
+ header bound as associated data, and then protected for the channel by one of
10
+ the crate's FEC strategies (see :mod:`cryptovault.fec`).
11
+
12
+ Example:
13
+ >>> from cryptovault import CryptoVault, generate_dek
14
+ >>> vault = CryptoVault.default()
15
+ >>> key = generate_dek()
16
+ >>> blob = vault.encrypt_with_key(key, "sk-secret")
17
+ >>> vault.decrypt_with_key(key, blob)
18
+ 'sk-secret'
19
+ """
20
+
21
+ from cryptovault.cipher import Aes256GcmSivCipher, AuthenticatedCipher
22
+ from cryptovault.constants import (
23
+ ARGON2_M_KIB,
24
+ ARGON2_P,
25
+ ARGON2_T,
26
+ BLOB_VERSION,
27
+ HEADER_LEN,
28
+ KEY_LEN,
29
+ MAX_B64_LEN,
30
+ MAX_BLOB_LEN,
31
+ MAX_PLAINTEXT_LEN,
32
+ NONCE_LEN,
33
+ RECOMMENDED_MAX_PAYLOAD,
34
+ RS_BLOCK,
35
+ RS_DATA,
36
+ RS_INTERLEAVE_MAX,
37
+ RS_PARITY,
38
+ SALT_LEN,
39
+ TAG_LEN,
40
+ TERMINATION_OVERHEAD,
41
+ VITERBI_CHUNK,
42
+ )
43
+ from cryptovault.error import (
44
+ CipherError,
45
+ CryptoVaultError,
46
+ EncodingError,
47
+ ErrorCorrectionError,
48
+ InvalidInputError,
49
+ KeyDerivationError,
50
+ )
51
+ from cryptovault.fec import ConcatenatedFec, ErrorCorrection, NoFec
52
+ from cryptovault.kdf import Argon2Kdf, KeyDerivation
53
+ from cryptovault.vault import (
54
+ CryptoVault,
55
+ constant_time_eq,
56
+ generate_dek,
57
+ generate_salt,
58
+ )
59
+
60
+ __all__ = [
61
+ "ARGON2_M_KIB",
62
+ "ARGON2_P",
63
+ "ARGON2_T",
64
+ "BLOB_VERSION",
65
+ "HEADER_LEN",
66
+ "KEY_LEN",
67
+ "MAX_B64_LEN",
68
+ "MAX_BLOB_LEN",
69
+ "MAX_PLAINTEXT_LEN",
70
+ "NONCE_LEN",
71
+ "RECOMMENDED_MAX_PAYLOAD",
72
+ "RS_BLOCK",
73
+ "RS_DATA",
74
+ "RS_INTERLEAVE_MAX",
75
+ "RS_PARITY",
76
+ "SALT_LEN",
77
+ "TAG_LEN",
78
+ "TERMINATION_OVERHEAD",
79
+ "VITERBI_CHUNK",
80
+ "Aes256GcmSivCipher",
81
+ "Argon2Kdf",
82
+ "AuthenticatedCipher",
83
+ "CipherError",
84
+ "ConcatenatedFec",
85
+ "CryptoVault",
86
+ "CryptoVaultError",
87
+ "EncodingError",
88
+ "ErrorCorrection",
89
+ "ErrorCorrectionError",
90
+ "InvalidInputError",
91
+ "KeyDerivation",
92
+ "KeyDerivationError",
93
+ "NoFec",
94
+ "constant_time_eq",
95
+ "generate_dek",
96
+ "generate_salt",
97
+ ]
cryptovault/_blob.py ADDED
@@ -0,0 +1,63 @@
1
+ # Author: Julian Bolivar
2
+ # Version: 0.1.0
3
+ # Date: 2026-10-16
4
+ """Blob wire layout and the structural checks made before decryption.
5
+
6
+ Private, as in the crate: the public doors are the vault's methods. Layout::
7
+
8
+ protected = version (1) ‖ plaintext_len (u32 LE, 4) ‖ nonce ‖ ciphertext ‖ tag
9
+ blob = fec.encode(protected)
10
+
11
+ Every rejection uses a fixed message that repeats nothing of the input.
12
+ """
13
+
14
+ import struct
15
+ from typing import Final
16
+
17
+ from cryptovault.constants import (
18
+ BLOB_VERSION,
19
+ HEADER_LEN,
20
+ MAX_PLAINTEXT_LEN,
21
+ NONCE_LEN,
22
+ TAG_LEN,
23
+ )
24
+ from cryptovault.error import InvalidInputError
25
+ from cryptovault.fec import ErrorCorrection
26
+
27
+ _MALFORMED: Final = "malformed blob"
28
+ _LEN_FIELD: Final = struct.Struct("<I")
29
+
30
+
31
+ def decode_blob(fec: ErrorCorrection, received: bytes) -> tuple[int, int, bytes]:
32
+ """Undo the FEC layer and split the protected payload, checking structure.
33
+
34
+ Checks run in the crate's order: the FEC length gate, then the header
35
+ length, the version, the declared plaintext length against
36
+ ``MAX_PLAINTEXT_LEN``, and that length against the bytes received. Bytes
37
+ after the tag are ignored, as in the crate.
38
+
39
+ Args:
40
+ fec: The strategy that encoded the blob.
41
+ received: The base64-decoded blob.
42
+
43
+ Returns:
44
+ ``(version, plaintext_len, body)`` with ``body = nonce ‖ ciphertext ‖ tag``.
45
+
46
+ Raises:
47
+ InvalidInputError: ``"malformed blob"`` for a short or inconsistent blob,
48
+ ``"unsupported blob version"`` for a version other than 1, or the
49
+ FEC strategy's own rejection.
50
+ """
51
+ protected = fec.decode(received, fec.validate_pre_fec(received))
52
+ if len(protected) < HEADER_LEN:
53
+ raise InvalidInputError(_MALFORMED)
54
+ version = protected[0]
55
+ if version != BLOB_VERSION:
56
+ raise InvalidInputError("unsupported blob version")
57
+ (plaintext_len,) = _LEN_FIELD.unpack_from(protected, 1)
58
+ if plaintext_len > MAX_PLAINTEXT_LEN:
59
+ raise InvalidInputError(_MALFORMED)
60
+ protected_len = HEADER_LEN + NONCE_LEN + plaintext_len + TAG_LEN
61
+ if protected_len > len(protected):
62
+ raise InvalidInputError(_MALFORMED)
63
+ return version, plaintext_len, protected[HEADER_LEN:protected_len]
cryptovault/cipher.py ADDED
@@ -0,0 +1,138 @@
1
+ # Author: Julian Bolivar
2
+ # Version: 0.1.0
3
+ # Date: 2026-10-16
4
+ """AEAD strategy: AES-256-GCM-SIV (RFC 8452) from ``cryptography``."""
5
+
6
+ import os
7
+ from typing import Final, Protocol
8
+
9
+ from cryptography.exceptions import InvalidTag, UnsupportedAlgorithm
10
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCMSIV
11
+
12
+ from cryptovault.constants import KEY_LEN, NONCE_LEN
13
+ from cryptovault.error import CipherError
14
+
15
+ _AUTH_FAILED: Final = "AES-256-GCM-SIV authentication failed"
16
+
17
+
18
+ def sample_nonce() -> bytes:
19
+ """Draw a fresh random nonce from the operating-system CSPRNG.
20
+
21
+ Every message gets its own nonce; a counter or a fixed nonce is never used.
22
+
23
+ Returns:
24
+ ``NONCE_LEN`` (12) random bytes.
25
+
26
+ Raises:
27
+ CipherError: If the operating system cannot supply entropy. Weak bytes
28
+ are never returned instead.
29
+ """
30
+ try:
31
+ return os.urandom(NONCE_LEN)
32
+ except OSError as exc:
33
+ raise CipherError(f"OsRng nonce sampling failed: {exc}") from exc
34
+
35
+
36
+ class AuthenticatedCipher(Protocol):
37
+ """AEAD strategy, the crate's ``AuthenticatedCipher`` trait."""
38
+
39
+ def encrypt(self, key: bytes, nonce: bytes, aad: bytes, plaintext: bytes) -> bytes:
40
+ """Encrypt and authenticate ``plaintext`` under ``aad``.
41
+
42
+ Args:
43
+ key: The AEAD key.
44
+ nonce: The per-message nonce.
45
+ aad: Associated data, authenticated but not encrypted.
46
+ plaintext: The data to encrypt.
47
+
48
+ Returns:
49
+ ``ciphertext ‖ tag``.
50
+
51
+ Raises:
52
+ CipherError: If the key or nonce is invalid.
53
+ """
54
+ ...
55
+
56
+ def decrypt(self, key: bytes, nonce: bytes, aad: bytes, sealed: bytes) -> bytes:
57
+ """Verify and decrypt ``ciphertext ‖ tag``.
58
+
59
+ Args:
60
+ key: The AEAD key.
61
+ nonce: The per-message nonce.
62
+ aad: The associated data the blob was sealed with.
63
+ sealed: ``ciphertext ‖ tag``.
64
+
65
+ Returns:
66
+ The plaintext.
67
+
68
+ Raises:
69
+ CipherError: If the tag does not verify or the inputs are invalid.
70
+ """
71
+ ...
72
+
73
+
74
+ class Aes256GcmSivCipher:
75
+ """AES-256-GCM-SIV, the crate's ``Aes256GcmSivCipher``.
76
+
77
+ Stateless: an instance holds no key and may be shared between threads.
78
+ """
79
+
80
+ @staticmethod
81
+ def _aead_for(key: bytes, nonce: bytes) -> AESGCMSIV:
82
+ """Check the key and nonce lengths, then build the primitive.
83
+
84
+ The key is checked first, as in the crate. ``cryptography`` would accept
85
+ a 16- or 24-byte key and silently run AES-128 or AES-192.
86
+
87
+ Raises:
88
+ CipherError: If the key is not 32 bytes, the nonce not 12, or the
89
+ OpenSSL backend has no AES-GCM-SIV (it needs OpenSSL 3.2+).
90
+ """
91
+ if len(key) != KEY_LEN:
92
+ raise CipherError(f"AES-256 key must be {KEY_LEN} bytes, got {len(key)}")
93
+ if len(nonce) != NONCE_LEN:
94
+ raise CipherError(f"nonce must be {NONCE_LEN} bytes, got {len(nonce)}")
95
+ try:
96
+ return AESGCMSIV(key)
97
+ except UnsupportedAlgorithm as exc:
98
+ raise CipherError(f"AES-256-GCM-SIV is unavailable: {exc}") from exc
99
+
100
+ def encrypt(self, key: bytes, nonce: bytes, aad: bytes, plaintext: bytes) -> bytes:
101
+ """Encrypt and authenticate ``plaintext`` under ``aad``.
102
+
103
+ Args:
104
+ key: The 32-byte AES-256 key.
105
+ nonce: The 12-byte nonce.
106
+ aad: Associated data, authenticated but not encrypted.
107
+ plaintext: The data to encrypt.
108
+
109
+ Returns:
110
+ ``ciphertext ‖ tag``, 16 bytes longer than ``plaintext``.
111
+
112
+ Raises:
113
+ CipherError: If the key is not 32 bytes or the nonce not 12.
114
+ """
115
+ return self._aead_for(key, nonce).encrypt(nonce, plaintext, aad)
116
+
117
+ def decrypt(self, key: bytes, nonce: bytes, aad: bytes, sealed: bytes) -> bytes:
118
+ """Verify and decrypt ``sealed`` (``ciphertext ‖ tag``).
119
+
120
+ Args:
121
+ key: The 32-byte AES-256 key.
122
+ nonce: The 12-byte nonce.
123
+ aad: The associated data the blob was sealed with.
124
+ sealed: ``ciphertext ‖ tag``.
125
+
126
+ Returns:
127
+ The plaintext.
128
+
129
+ Raises:
130
+ CipherError: If the key is not 32 bytes, the nonce not 12, or the
131
+ tag does not verify (wrong key, wrong AAD or tampered data; the
132
+ message does not say which).
133
+ """
134
+ aead = self._aead_for(key, nonce)
135
+ try:
136
+ return aead.decrypt(nonce, sealed, aad)
137
+ except InvalidTag as exc:
138
+ raise CipherError(_AUTH_FAILED) from exc
@@ -0,0 +1,85 @@
1
+ # Author: Julian Bolivar
2
+ # Version: 0.2.0
3
+ # Date: 2026-10-16
4
+ """Wire-format constants, with the values of the ``cryptovault`` crate 0.3.0.
5
+
6
+ The blob size limits are derived exactly as the crate derives them. The crate
7
+ sizes them for its concatenated FEC stack, and they apply unchanged to
8
+ ``NoFec`` blobs, so a blob is rejected on the same lengths in both languages.
9
+ """
10
+
11
+ from typing import Final
12
+
13
+ KEY_LEN: Final = 32
14
+ """AES-256 key length and master-key length, in bytes."""
15
+
16
+ SALT_LEN: Final = 16
17
+ """Argon2id salt length, in bytes."""
18
+
19
+ NONCE_LEN: Final = 12
20
+ """AES-256-GCM-SIV nonce length, in bytes."""
21
+
22
+ TAG_LEN: Final = 16
23
+ """AEAD authentication tag length, in bytes."""
24
+
25
+ HEADER_LEN: Final = 5
26
+ """Blob header length: ``version (1)`` + ``plaintext_len (u32 LE, 4)``."""
27
+
28
+ BLOB_VERSION: Final = 1
29
+ """The only blob format version accepted."""
30
+
31
+ MAX_PLAINTEXT_LEN: Final = 10 * 1024 * 1024
32
+ """Largest plaintext accepted in one blob (10 MiB)."""
33
+
34
+ RECOMMENDED_MAX_PAYLOAD: Final = 128 * 1024
35
+ """Recommended largest plaintext per blob (128 KiB); frame larger data."""
36
+
37
+ ARGON2_M_KIB: Final = 65536
38
+ """Argon2id memory cost, KiB (OWASP 2025: 64 MiB)."""
39
+
40
+ ARGON2_T: Final = 3
41
+ """Argon2id time cost (iterations)."""
42
+
43
+ ARGON2_P: Final = 4
44
+ """Argon2id parallelism (lanes)."""
45
+
46
+ RS_DATA: Final = 223
47
+ """Reed-Solomon data bytes per codeword."""
48
+
49
+ RS_PARITY: Final = 32
50
+ """Reed-Solomon parity bytes per codeword."""
51
+
52
+ RS_BLOCK: Final = RS_DATA + RS_PARITY
53
+ """Reed-Solomon codeword length, in bytes (255)."""
54
+
55
+ RS_INTERLEAVE_MAX: Final = 16
56
+ """Largest interleave depth: codewords per interleaver window."""
57
+
58
+ VITERBI_CHUNK: Final = 490 * RS_BLOCK
59
+ """Reed-Solomon stream bytes per Viterbi sub-block (124 950)."""
60
+
61
+ TERMINATION_OVERHEAD: Final = 2
62
+ """Bytes the Viterbi zero tail adds to each sub-block (``2L + 2``)."""
63
+
64
+
65
+ def _max_blob_len() -> int:
66
+ """Derive the crate's ``MAX_BLOB_LEN``.
67
+
68
+ The largest protected payload is expanded by Reed-Solomon (``255/223``,
69
+ whole codewords), doubled by the Viterbi inner code, and given a two-byte
70
+ zero tail per Viterbi sub-block.
71
+
72
+ Returns:
73
+ The largest decoded blob length accepted, in bytes.
74
+ """
75
+ protected_max = MAX_PLAINTEXT_LEN + HEADER_LEN + NONCE_LEN + TAG_LEN
76
+ rs_max = -(-protected_max // RS_DATA) * RS_BLOCK
77
+ viterbi_chunks = -(-rs_max // VITERBI_CHUNK)
78
+ return rs_max * 2 + TERMINATION_OVERHEAD * viterbi_chunks
79
+
80
+
81
+ MAX_BLOB_LEN: Final = _max_blob_len()
82
+ """Largest decoded blob accepted, in bytes (23 981 412)."""
83
+
84
+ MAX_B64_LEN: Final = MAX_BLOB_LEN * 4 // 3 + 4
85
+ """Largest base64 input accepted, checked before decoding (31 975 220)."""
cryptovault/error.py ADDED
@@ -0,0 +1,76 @@
1
+ # Author: Julian Bolivar
2
+ # Version: 0.1.0
3
+ # Date: 2026-10-16
4
+ """Typed error domain: one exception class per ``CryptoError`` variant.
5
+
6
+ Every failure the package reports is a :class:`CryptoVaultError`. The subclass
7
+ names the stage that failed, so a caller can tell a structural rejection from a
8
+ failed authentication tag; the message stays generic and never repeats the
9
+ caller's input. ``str(error)`` matches the ``Display`` output of the Rust crate
10
+ for the same failure, prefix included.
11
+
12
+ Example:
13
+ >>> from cryptovault.error import CipherError, CryptoVaultError
14
+ >>> error = CipherError("AES-256-GCM-SIV authentication failed")
15
+ >>> str(error)
16
+ 'Cipher error: AES-256-GCM-SIV authentication failed'
17
+ >>> isinstance(error, CryptoVaultError)
18
+ True
19
+ """
20
+
21
+ from typing import ClassVar
22
+
23
+
24
+ class CryptoVaultError(Exception):
25
+ """Base of every error the package raises.
26
+
27
+ Subclasses set ``PREFIX``, the stage label the crate prints before the
28
+ message. Catch this class to handle every failure of the package at once.
29
+
30
+ Args:
31
+ message: The fixed, oracle-free description of the failure.
32
+
33
+ Attributes:
34
+ message: The description without the stage prefix.
35
+ """
36
+
37
+ PREFIX: ClassVar[str] = "CryptoVault error"
38
+
39
+ def __init__(self, message: str) -> None:
40
+ """Store the message and pass it to :class:`Exception`."""
41
+ super().__init__(message)
42
+ self.message = message
43
+
44
+ def __str__(self) -> str:
45
+ """Return ``"<PREFIX>: <message>"``, the crate's ``Display`` form."""
46
+ return f"{self.PREFIX}: {self.message}"
47
+
48
+
49
+ class KeyDerivationError(CryptoVaultError):
50
+ """Argon2id or HKDF failure, or no entropy for a salt or DEK."""
51
+
52
+ PREFIX = "Key derivation error"
53
+
54
+
55
+ class CipherError(CryptoVaultError):
56
+ """AEAD failure: a tag that does not verify, or no entropy for a nonce."""
57
+
58
+ PREFIX = "Cipher error"
59
+
60
+
61
+ class ErrorCorrectionError(CryptoVaultError):
62
+ """Corruption beyond the capacity of a forward-error-correction codec."""
63
+
64
+ PREFIX = "Error correction error"
65
+
66
+
67
+ class EncodingError(CryptoVaultError):
68
+ """Non-canonical base64, or text that is not valid UTF-8."""
69
+
70
+ PREFIX = "Encoding error"
71
+
72
+
73
+ class InvalidInputError(CryptoVaultError):
74
+ """Structural or input-validation failure, raised before any decryption."""
75
+
76
+ PREFIX = "Invalid input"