secure-keystore 3.0.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,96 @@
1
+ """secure-keystore: encrypted secrets at rest, with no service to run.
2
+
3
+ Two ways in, for different things:
4
+
5
+ 1. **A store.** A directory of encrypted values that one user key opens. The
6
+ store owns a master key, one salt per value, and a catalogue - so an
7
+ application deploys a directory and reads secrets out of it at run time. That
8
+ is :class:`Keystore`.
9
+ 2. **A token.** One string in, one string out, nothing on disk. The application
10
+ already has somewhere to put it - a database column, a config row - and only
11
+ wants the value unreadable while it sits there. That is :func:`encrypt_value`
12
+ and :func:`decrypt_value`.
13
+
14
+ Both use the same primitives: AES-256-GCM to encrypt, PBKDF2-HMAC-SHA256 to
15
+ stretch the user key, HKDF-SHA256 to derive a value key from the master key. A
16
+ fresh random salt and IV every time.
17
+
18
+ .. code-block:: python
19
+
20
+ from secure_keystore import Keystore
21
+
22
+ store = Keystore()
23
+ store.secure("./keys.env", os.environ["USER_KEY"], "production")
24
+ api_key = store.get("api_key", os.environ["USER_KEY"], "production")
25
+
26
+ **Which KDF, and why two.** A user key is a password, so it is stretched:
27
+ deliberately slow, 600,000 rounds by default. A master key is not a password - it
28
+ is already a strong secret - so value keys come from HKDF, which is fast.
29
+ Stretching the master key once per value, which format 2.0 did, costs seconds on
30
+ a store of any size and buys nothing.
31
+
32
+ The on-disk format is specified in SPEC.md at the root of the repository, and
33
+ this implementation reads and writes the same bytes as the JavaScript, Go, .NET,
34
+ PHP and Rust ones - which is not a claim but a test, in ``tests/test_conformance.py``.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from ._format import (
40
+ BACKUP_VERSION,
41
+ DEFAULT_ITERATIONS,
42
+ STORE_FORMAT_LEGACY,
43
+ STORE_FORMAT_VERSION,
44
+ VALUE_TOKEN_PREFIX,
45
+ )
46
+ from .errors import ErrorCodes, KeystoreError
47
+ from .input_file import Entry, InputFormat, ParsedInput, format_for, parse_input, parse_text
48
+ from .keystore import Keystore, MigrateResult, SecureResult, SetResult, StoreInfo
49
+ from .token import decrypt_value, encrypt_value, is_encrypted_value, token_version
50
+
51
+ #: The version of this implementation, which is not the same thing as the
52
+ #: version of the store format (:data:`STORE_FORMAT_VERSION`).
53
+ __version__ = "3.0.0"
54
+
55
+ #: What this build actually does, in one mapping, so that a caller who has to
56
+ #: write it down in a compliance document does not have to read the source.
57
+ ALGORITHMS = {
58
+ "cipher": "aes-256-gcm",
59
+ "ivBytes": 12,
60
+ # How the user key - a password - is stretched. Deliberately slow.
61
+ "passwordKdf": "pbkdf2-hmac-sha256",
62
+ "defaultIterations": DEFAULT_ITERATIONS,
63
+ # How a value key comes from the master key. Fast, and correctly so.
64
+ "keyKdf": "hkdf-sha256",
65
+ "digest": "sha256",
66
+ "keyBits": 256,
67
+ # Whether a value is cryptographically bound to its key name.
68
+ "bindsKeyName": True,
69
+ }
70
+
71
+ __all__ = [
72
+ "Keystore",
73
+ "KeystoreError",
74
+ "ErrorCodes",
75
+ "StoreInfo",
76
+ "SecureResult",
77
+ "SetResult",
78
+ "MigrateResult",
79
+ "encrypt_value",
80
+ "decrypt_value",
81
+ "is_encrypted_value",
82
+ "token_version",
83
+ "InputFormat",
84
+ "Entry",
85
+ "ParsedInput",
86
+ "format_for",
87
+ "parse_input",
88
+ "parse_text",
89
+ "ALGORITHMS",
90
+ "STORE_FORMAT_VERSION",
91
+ "STORE_FORMAT_LEGACY",
92
+ "BACKUP_VERSION",
93
+ "VALUE_TOKEN_PREFIX",
94
+ "DEFAULT_ITERATIONS",
95
+ "__version__",
96
+ ]
@@ -0,0 +1,5 @@
1
+ """``python -m secure_keystore``."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,181 @@
1
+ """The cryptographic primitives, and nothing else.
2
+
3
+ Every AES call in this package goes through this module, so there is exactly one
4
+ place where an algorithm, an IV length or an encoding is chosen.
5
+
6
+ Two derivations, for two different jobs, which format 2.0 conflated:
7
+
8
+ ``stretch_password``
9
+ A *password* into a key. Deliberately slow. Used once per store, on the user
10
+ key, and on every value token.
11
+
12
+ ``derive_from_key``
13
+ A key into another key. Fast, because the input is already high-entropy key
14
+ material and stretching it buys nothing.
15
+
16
+ Running 600,000 PBKDF2 rounds on the master key once per value - which 2.0 did -
17
+ costs seconds on a store of any size and adds no security the master key did not
18
+ already have. That is the misuse HKDF exists to correct.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import os
25
+ from dataclasses import dataclass
26
+ from typing import Any
27
+
28
+ from cryptography.exceptions import InvalidTag
29
+ from cryptography.hazmat.primitives import hashes
30
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
31
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
32
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
33
+
34
+ from . import _format as fmt
35
+ from .errors import ErrorCodes, KeystoreError
36
+
37
+
38
+ @dataclass(slots=True)
39
+ class Encrypted:
40
+ """One AES-GCM result, as it appears in a store file. Every field is hex."""
41
+
42
+ iv: str
43
+ auth_tag: str
44
+ data: str
45
+
46
+ def to_json(self) -> dict[str, str]:
47
+ return {"iv": self.iv, "authTag": self.auth_tag, "data": self.data}
48
+
49
+ @staticmethod
50
+ def from_json(raw: Any, what: str = "value") -> "Encrypted":
51
+ if not isinstance(raw, dict):
52
+ raise KeystoreError(
53
+ ErrorCodes.STORE_CORRUPT, f"the encrypted {what} is not an object"
54
+ )
55
+
56
+ iv, auth_tag, data = raw.get("iv"), raw.get("authTag"), raw.get("data")
57
+ if not isinstance(iv, str) or not isinstance(auth_tag, str) or not isinstance(data, str):
58
+ raise KeystoreError(
59
+ ErrorCodes.STORE_CORRUPT,
60
+ f"the encrypted {what} is missing its iv, authTag or data",
61
+ )
62
+
63
+ return Encrypted(iv=iv, auth_tag=auth_tag, data=data)
64
+
65
+
66
+ def random_bytes(length: int) -> bytes:
67
+ """Random bytes from the system source."""
68
+ return os.urandom(length)
69
+
70
+
71
+ def stretch_password(password: str, salt: bytes, iterations: int) -> bytes:
72
+ """PBKDF2-HMAC-SHA256: a password, stretched.
73
+
74
+ The count is a parameter rather than a constant because it is recorded
75
+ alongside whatever it derives. Deriving with a count that does not match the
76
+ one a record was written with produces a different key, and the GCM tag then
77
+ rejects it - which is the failure you want rather than the one where it
78
+ nearly works.
79
+ """
80
+ kdf = PBKDF2HMAC(
81
+ algorithm=hashes.SHA256(),
82
+ length=fmt.KEY_LENGTH,
83
+ salt=salt,
84
+ iterations=iterations,
85
+ )
86
+ return kdf.derive(password.encode("utf-8"))
87
+
88
+
89
+ def derive_from_key(key: str, salt: bytes, info: str) -> bytes:
90
+ """HKDF-SHA256: one key into another, bound to a context.
91
+
92
+ The input is the master key, which is already a strong secret, so there is
93
+ nothing to stretch - the job is separation, not hardening. ``info`` carries
94
+ the context that separates them, and for a value that context contains the
95
+ key name, which is half of what stops two value files being swapped. The
96
+ other half is the same string passed as GCM additional data.
97
+ """
98
+ kdf = HKDF(
99
+ algorithm=hashes.SHA256(),
100
+ length=fmt.KEY_LENGTH,
101
+ salt=salt,
102
+ info=info.encode("utf-8"),
103
+ )
104
+ return kdf.derive(key.encode("utf-8"))
105
+
106
+
107
+ def encrypt(plaintext: str, key: bytes, iv_length: int = fmt.IV_LENGTH, aad: str = "") -> Encrypted:
108
+ """Seal a plaintext under a fresh random IV.
109
+
110
+ The same plaintext under the same key never produces the same bytes twice.
111
+ ``aad`` is empty for a format 2.0 record, which bound nothing.
112
+ """
113
+ iv = random_bytes(iv_length)
114
+ associated = aad.encode("utf-8") if aad else None
115
+
116
+ # AESGCM.encrypt returns ciphertext with the tag appended; the format stores
117
+ # them as separate hex fields.
118
+ sealed = AESGCM(key).encrypt(iv, plaintext.encode("utf-8"), associated)
119
+ split = len(sealed) - fmt.AUTH_TAG_LENGTH
120
+
121
+ return Encrypted(
122
+ iv=iv.hex(),
123
+ auth_tag=sealed[split:].hex(),
124
+ data=sealed[:split].hex(),
125
+ )
126
+
127
+
128
+ def decrypt(record: Encrypted, key: bytes, aad: str = "", what: str = "value") -> str:
129
+ """Reverse :func:`encrypt`, and translate the failure.
130
+
131
+ GCM cannot tell a wrong key from tampered bytes from mismatched additional
132
+ data - all three are a tag that does not verify - and ``cryptography``
133
+ reports any of them as :class:`InvalidTag`, which tells a caller nothing.
134
+ All three become ``BAD_USER_KEY`` here.
135
+ """
136
+ try:
137
+ iv = bytes.fromhex(record.iv)
138
+ tag = bytes.fromhex(record.auth_tag)
139
+ data = bytes.fromhex(record.data)
140
+ except ValueError as cause:
141
+ raise KeystoreError(
142
+ ErrorCodes.STORE_CORRUPT, f"the encrypted {what} has a malformed hex field"
143
+ ) from cause
144
+
145
+ if not iv:
146
+ raise KeystoreError(ErrorCodes.STORE_CORRUPT, f"the encrypted {what} has an empty iv")
147
+
148
+ associated = aad.encode("utf-8") if aad else None
149
+
150
+ try:
151
+ plaintext = AESGCM(key).decrypt(iv, data + tag, associated)
152
+ except (InvalidTag, ValueError) as cause:
153
+ raise KeystoreError(
154
+ ErrorCodes.BAD_USER_KEY,
155
+ f"could not decrypt the {what}: the key is wrong, "
156
+ "or the stored bytes have been altered",
157
+ ) from cause
158
+
159
+ return plaintext.decode("utf-8")
160
+
161
+
162
+ def safe_file_name(key: str) -> str:
163
+ """A filesystem-safe, collision-resistant filename for a key.
164
+
165
+ The stem is for humans, so it is the key with anything unsafe replaced -
166
+ which neutralises ``../``, drive letters and the characters Windows refuses.
167
+ Sanitising alone would map ``a/b`` and ``a_b`` onto one file, so the real
168
+ identity is the hash suffix; the stem is only a label.
169
+
170
+ The stem is built from UTF-8 *bytes* and truncated by bytes, matching every
171
+ other implementation. A multi-byte character straddling the limit is cut, but
172
+ the name is a label and the hash is the identity, so a mangled label costs
173
+ nothing.
174
+ """
175
+ raw = key.encode("utf-8")
176
+ allowed = set(b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-")
177
+
178
+ stem = bytes(byte if byte in allowed else ord("_") for byte in raw[: fmt.FILENAME_STEM_LIMIT])
179
+ digest = hashlib.sha256(raw).hexdigest()[: fmt.FILENAME_HASH_LENGTH]
180
+
181
+ return f"{stem.decode('ascii')}-{digest}{fmt.VALUE_EXTENSION}"
@@ -0,0 +1,118 @@
1
+ """Wire-format constants for the secure-keystore format.
2
+
3
+ Nothing here is a tuning knob. These are the values that define what a store
4
+ written by this package looks like, and changing one changes what the other five
5
+ implementations can read - which is why they live in one module rather than
6
+ scattered through the code that happens to use them.
7
+
8
+ Two format versions appear throughout. 3.0 is what this build writes; 2.0 is
9
+ what earlier releases wrote and what this build still reads. Everywhere the two
10
+ differ, the legacy value is named ``*_LEGACY`` and the difference is a parameter
11
+ rather than a separate code path.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ #: The store format stamped into every master.json this build writes.
17
+ STORE_FORMAT_VERSION = "3.0"
18
+
19
+ #: The older format this build still reads and, on a store already written that
20
+ #: way, still writes.
21
+ STORE_FORMAT_LEGACY = "2.0"
22
+
23
+ #: Identifies an export bundle, checked before anything is written to disk.
24
+ BACKUP_FORMAT = "secure-keystore-backup"
25
+
26
+ #: The bundle format this build writes and the highest it reads.
27
+ BACKUP_VERSION = 1
28
+
29
+ # Value token layouts. skv2 records its own iteration count, which is the one
30
+ # thing skv1 got wrong: a token that cannot say how it was derived is a token
31
+ # whose parameters can never change.
32
+ #
33
+ # skv1:<salt>:<iv>:<authTag>:<ciphertext>
34
+ # skv2:<iterations>:<salt>:<iv>:<authTag>:<ciphertext>
35
+ VALUE_TOKEN_PREFIX = "skv2"
36
+ VALUE_TOKEN_PARTS = 6
37
+ VALUE_TOKEN_PREFIX_LEGACY = "skv1"
38
+ VALUE_TOKEN_PARTS_LEGACY = 5
39
+
40
+ #: AES-256: a 32-byte key.
41
+ KEY_LENGTH = 32
42
+
43
+ #: 12 bytes, which is what NIST SP 800-38D recommends for GCM and the only
44
+ #: length several platforms accept at all - .NET's AesGcm refuses anything else.
45
+ IV_LENGTH = 12
46
+
47
+ #: The 16 bytes format 2.0 wrote.
48
+ IV_LENGTH_LEGACY = 16
49
+
50
+ #: The GCM tag length, and the only one this format uses.
51
+ AUTH_TAG_LENGTH = 16
52
+
53
+ #: What a new store stretches the user key with, following OWASP's 2023 guidance
54
+ #: for PBKDF2-HMAC-SHA256.
55
+ DEFAULT_ITERATIONS = 600_000
56
+
57
+ #: The floor below which the option is refused. Test suites run at this.
58
+ MIN_ITERATIONS = 10_000
59
+
60
+ #: Assumed for any 2.0 record written before the count was recorded.
61
+ LEGACY_ITERATIONS = 100_000
62
+
63
+ #: Fixed by the skv1 layout, which has no field to record a count in.
64
+ TOKEN_ITERATIONS_LEGACY = 100_000
65
+
66
+ STORE_SALT_LENGTH = 32
67
+ VALUE_SALT_LENGTH = 32
68
+
69
+ #: 16, because the token carries its salt inline, in hex, in every stored value.
70
+ TOKEN_SALT_LENGTH = 16
71
+
72
+ # Domain-separation contexts, used as GCM additional authenticated data and, for
73
+ # a value, as the HKDF info as well.
74
+ #
75
+ # This is what closes the gap 2.0 had: there, the tag covered the ciphertext and
76
+ # nothing else, so anyone who could write to a store could swap two value files
77
+ # and both would decrypt - under each other's names.
78
+ CONTEXT_MASTER = "secure-keystore:v3:master"
79
+ CONTEXT_VALUE_PREFIX = "secure-keystore:v3:value:"
80
+ CONTEXT_TOKEN = "secure-keystore:v3:token"
81
+
82
+
83
+ def value_context(key_name: str) -> str:
84
+ """The full context string binding a value to its key name."""
85
+ return CONTEXT_VALUE_PREFIX + key_name
86
+
87
+
88
+ #: Catalogue of stores: names and key names, no secrets.
89
+ INDEX_FILE = "index.json"
90
+
91
+ #: Per-store metadata and the encrypted master key.
92
+ MASTER_FILE = "master.json"
93
+
94
+ #: One authenticated-encrypted file per value.
95
+ VALUE_EXTENSION = ".enc"
96
+
97
+ #: Created under the working directory when no keystore directory is given.
98
+ DEFAULT_KEYSTORE_DIRNAME = ".keystore"
99
+
100
+ #: What ``export_store`` writes when given no path.
101
+ DEFAULT_BACKUP_EXTENSION = ".skb"
102
+
103
+ #: The name an input file uses for the master key, in every input format.
104
+ MASTER_ENTRY_KEY = "master"
105
+
106
+ # Value filenames are a readable stem plus a hash of the key. The stem may
107
+ # collide between two different keys; the hash is what keeps their files apart.
108
+ FILENAME_STEM_LIMIT = 64
109
+ FILENAME_HASH_LENGTH = 8
110
+
111
+
112
+ def major_of(version: str) -> int:
113
+ """The major part of a version string, or 0 if it is not a number."""
114
+ head = version.split(".", 1)[0]
115
+ try:
116
+ return int(head)
117
+ except ValueError:
118
+ return 0
@@ -0,0 +1,108 @@
1
+ """Export and import, as one JSON bundle.
2
+
3
+ The contents are already encrypted, so a bundle is safe to put through a CI
4
+ artifact store or an ``scp``, and useless to anyone who intercepts it without the
5
+ user key. It is pure JSON - no tar, no shell - so it behaves the same on every
6
+ platform.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from . import _format as fmt
16
+ from .errors import ErrorCodes, KeystoreError
17
+
18
+ __all__ = ["build_bundle", "read_bundle", "write_bundle"]
19
+
20
+
21
+ def build_bundle(store_dir: Path, store_name: str) -> dict[str, Any]:
22
+ """Read every file in a store directory into a bundle."""
23
+ return {
24
+ "format": fmt.BACKUP_FORMAT,
25
+ "version": fmt.BACKUP_VERSION,
26
+ "storeName": store_name,
27
+ "files": {
28
+ path.name: path.read_text(encoding="utf-8")
29
+ for path in sorted(store_dir.iterdir())
30
+ if path.is_file()
31
+ },
32
+ }
33
+
34
+
35
+ def read_bundle(backup_path: str | Path) -> dict[str, Any]:
36
+ """Read a bundle off disk and check it is one of ours before any of it is believed.
37
+
38
+ The checks are in the order that matters: a file that is not JSON, a JSON
39
+ document that is not a bundle, a bundle from a future version, and a bundle
40
+ with no master.json are four different mistakes and each says so.
41
+ """
42
+ path = Path(backup_path)
43
+ if not path.is_file():
44
+ raise KeystoreError(ErrorCodes.BAD_BACKUP, f"backup file not found: {backup_path}")
45
+
46
+ try:
47
+ bundle = json.loads(path.read_text(encoding="utf-8"))
48
+ except (ValueError, OSError) as cause:
49
+ raise KeystoreError(
50
+ ErrorCodes.BAD_BACKUP,
51
+ f"invalid backup file: {backup_path} is not readable as JSON",
52
+ ) from cause
53
+
54
+ if (
55
+ not isinstance(bundle, dict)
56
+ or bundle.get("format") != fmt.BACKUP_FORMAT
57
+ or not isinstance(bundle.get("files"), dict)
58
+ ):
59
+ raise KeystoreError(
60
+ ErrorCodes.BAD_BACKUP, f"invalid backup file: {backup_path} is not a keystore bundle"
61
+ )
62
+
63
+ version = bundle.get("version", 0)
64
+ if isinstance(version, int) and version > fmt.BACKUP_VERSION:
65
+ raise KeystoreError(
66
+ ErrorCodes.UNSUPPORTED_FORMAT,
67
+ f"backup was written in bundle format {version}, "
68
+ f"and this build reads up to {fmt.BACKUP_VERSION}",
69
+ )
70
+
71
+ if fmt.MASTER_FILE not in bundle["files"]:
72
+ raise KeystoreError(
73
+ ErrorCodes.BAD_BACKUP, f"invalid backup: {fmt.MASTER_FILE} not found in bundle"
74
+ )
75
+
76
+ return bundle
77
+
78
+
79
+ def write_bundle(bundle: dict[str, Any], target_dir: Path) -> None:
80
+ """Write a bundle's files into a target directory.
81
+
82
+ Every name is reduced to its base name first. A bundle is an untrusted file
83
+ that arrived over a wire, and ``{"files": {"../../.ssh/authorized_keys": ...}}``
84
+ is a bundle someone could write by hand.
85
+
86
+ ``PurePath.name`` alone is not enough for a Windows path arriving on Unix or
87
+ the reverse, so both separators are handled explicitly.
88
+ """
89
+ target_dir.mkdir(parents=True, exist_ok=True)
90
+
91
+ for name, content in bundle["files"].items():
92
+ safe = _base_name(str(name))
93
+ if safe in ("", ".", ".."):
94
+ continue
95
+ (target_dir / safe).write_text(content, encoding="utf-8")
96
+
97
+
98
+ def _base_name(name: str) -> str:
99
+ """Strip every directory component, whichever separator was used."""
100
+ for index in range(len(name) - 1, -1, -1):
101
+ if name[index] in ("/", "\\"):
102
+ return name[index + 1 :]
103
+
104
+ # A drive-relative name like "C:evil.txt" has no separator at all.
105
+ if len(name) > 1 and name[1] == ":":
106
+ return name[2:]
107
+
108
+ return name