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.
- secure_keystore/__init__.py +96 -0
- secure_keystore/__main__.py +5 -0
- secure_keystore/_crypto.py +181 -0
- secure_keystore/_format.py +118 -0
- secure_keystore/bundle.py +108 -0
- secure_keystore/cli.py +353 -0
- secure_keystore/errors.py +60 -0
- secure_keystore/input_file.py +188 -0
- secure_keystore/keystore.py +735 -0
- secure_keystore/py.typed +0 -0
- secure_keystore/token.py +134 -0
- secure_keystore-3.0.0.dist-info/METADATA +222 -0
- secure_keystore-3.0.0.dist-info/RECORD +16 -0
- secure_keystore-3.0.0.dist-info/WHEEL +4 -0
- secure_keystore-3.0.0.dist-info/entry_points.txt +2 -0
- secure_keystore-3.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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
|