secure-keystore 3.0.0__tar.gz
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-3.0.0/.gitignore +88 -0
- secure_keystore-3.0.0/CHANGELOG.md +74 -0
- secure_keystore-3.0.0/LICENSE +21 -0
- secure_keystore-3.0.0/PKG-INFO +222 -0
- secure_keystore-3.0.0/README.md +192 -0
- secure_keystore-3.0.0/pyproject.toml +54 -0
- secure_keystore-3.0.0/src/secure_keystore/__init__.py +96 -0
- secure_keystore-3.0.0/src/secure_keystore/__main__.py +5 -0
- secure_keystore-3.0.0/src/secure_keystore/_crypto.py +181 -0
- secure_keystore-3.0.0/src/secure_keystore/_format.py +118 -0
- secure_keystore-3.0.0/src/secure_keystore/bundle.py +108 -0
- secure_keystore-3.0.0/src/secure_keystore/cli.py +353 -0
- secure_keystore-3.0.0/src/secure_keystore/errors.py +60 -0
- secure_keystore-3.0.0/src/secure_keystore/input_file.py +188 -0
- secure_keystore-3.0.0/src/secure_keystore/keystore.py +735 -0
- secure_keystore-3.0.0/src/secure_keystore/py.typed +0 -0
- secure_keystore-3.0.0/src/secure_keystore/token.py +134 -0
- secure_keystore-3.0.0/tests/test_conformance.py +190 -0
- secure_keystore-3.0.0/tests/test_keystore.py +317 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# ── Secrets and runtime data ─────────────────────────────────────────────────
|
|
2
|
+
#
|
|
3
|
+
# The one category in this repository that matters more than build output. A
|
|
4
|
+
# keystore directory holds encrypted values, but a plaintext keys file holds the
|
|
5
|
+
# real thing - and the whole point of the library is that neither ends up in a
|
|
6
|
+
# commit.
|
|
7
|
+
#
|
|
8
|
+
# Not anchored to the root: a keystore written while running an example under
|
|
9
|
+
# javascript/ or python/ must be ignored there too.
|
|
10
|
+
.keystore/
|
|
11
|
+
.keystore-*/
|
|
12
|
+
*.skb
|
|
13
|
+
*-backup-*.tar
|
|
14
|
+
backups/
|
|
15
|
+
|
|
16
|
+
# Plaintext input files, anchored to the root and to each implementation, so the
|
|
17
|
+
# deliberately committed samples under */examples/ stay tracked.
|
|
18
|
+
/keys.txt
|
|
19
|
+
/keys.env
|
|
20
|
+
/keys.json
|
|
21
|
+
/*/keys.txt
|
|
22
|
+
/*/keys.env
|
|
23
|
+
/*/keys.json
|
|
24
|
+
|
|
25
|
+
# ── JavaScript ───────────────────────────────────────────────────────────────
|
|
26
|
+
node_modules/
|
|
27
|
+
*/node_modules/
|
|
28
|
+
javascript/dist/
|
|
29
|
+
npm-debug.log*
|
|
30
|
+
# npm auth. The publish token is read from the NPM_TOKEN environment variable.
|
|
31
|
+
.npmrc
|
|
32
|
+
|
|
33
|
+
# ── .NET ─────────────────────────────────────────────────────────────────────
|
|
34
|
+
# Scoped to dotnet/, because javascript/bin holds the CLI entry point and is
|
|
35
|
+
# meant to be committed.
|
|
36
|
+
dotnet/**/bin/
|
|
37
|
+
dotnet/**/obj/
|
|
38
|
+
dotnet/**/TestResults/
|
|
39
|
+
*.user
|
|
40
|
+
|
|
41
|
+
# ── Python ───────────────────────────────────────────────────────────────────
|
|
42
|
+
__pycache__/
|
|
43
|
+
*.py[cod]
|
|
44
|
+
python/build/
|
|
45
|
+
python/dist/
|
|
46
|
+
python/.venv/
|
|
47
|
+
python/*.egg-info/
|
|
48
|
+
# An editable install writes its metadata beside the package.
|
|
49
|
+
python/src/*.egg-info/
|
|
50
|
+
*.egg-link
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
.ruff_cache/
|
|
53
|
+
|
|
54
|
+
# ── PHP ──────────────────────────────────────────────────────────────────────
|
|
55
|
+
# composer.lock is not committed: this is a library, and the lock file of a
|
|
56
|
+
# library binds nobody.
|
|
57
|
+
php/vendor/
|
|
58
|
+
php/composer.lock
|
|
59
|
+
php/.phpunit.cache/
|
|
60
|
+
.phpunit.result.cache
|
|
61
|
+
|
|
62
|
+
# ── Go ───────────────────────────────────────────────────────────────────────
|
|
63
|
+
go/secure-keystore
|
|
64
|
+
go/secure-keystore.exe
|
|
65
|
+
go/**/testdata/fuzz/
|
|
66
|
+
go/**/*.test
|
|
67
|
+
go/coverage.out
|
|
68
|
+
|
|
69
|
+
# ── Rust ─────────────────────────────────────────────────────────────────────
|
|
70
|
+
# Cargo.lock is not committed, for the same reason as composer.lock.
|
|
71
|
+
rust/target/
|
|
72
|
+
rust/Cargo.lock
|
|
73
|
+
rust/fuzz/target/
|
|
74
|
+
rust/fuzz/corpus/
|
|
75
|
+
rust/fuzz/artifacts/
|
|
76
|
+
|
|
77
|
+
# ── Test artifacts ───────────────────────────────────────────────────────────
|
|
78
|
+
# Tests write to the system temp directory, so this only catches the older
|
|
79
|
+
# suites and anything run by hand.
|
|
80
|
+
test-temp/
|
|
81
|
+
coverage/
|
|
82
|
+
|
|
83
|
+
# ── Editor and OS noise ──────────────────────────────────────────────────────
|
|
84
|
+
.DS_Store
|
|
85
|
+
Thumbs.db
|
|
86
|
+
.vscode/
|
|
87
|
+
.idea/
|
|
88
|
+
*.log
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the Python implementation are recorded here. The
|
|
4
|
+
format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the
|
|
5
|
+
project uses [semantic versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
The **package version** and the **store format version** are separate numbers and
|
|
8
|
+
move at different speeds — see [SPEC.md §11](../SPEC.md#11-versioning). This is
|
|
9
|
+
package 3.0.0, writing store format 3.0.
|
|
10
|
+
|
|
11
|
+
## [3.0.0] - 2026-10-13
|
|
12
|
+
|
|
13
|
+
First release of the Python implementation.
|
|
14
|
+
|
|
15
|
+
Written against [SPEC.md](../SPEC.md) rather than by reading another
|
|
16
|
+
implementation — which is the rule that let six of them agree — and tested against
|
|
17
|
+
the [shared conformance corpus](../conformance/), a set of fixtures the JavaScript
|
|
18
|
+
implementation produced.
|
|
19
|
+
|
|
20
|
+
**Not verified.** Written against the specification, but not yet run on a machine with a Python toolchain. See [ROADMAP.md §3](../ROADMAP.md#3-next--ci-across-all-six).
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- `Keystore`, the store: `secure`, `get`, `get_all`, `set`, `migrate`,
|
|
25
|
+
`delete_key` and `delete_store`, in this language's spelling. A user key
|
|
26
|
+
unlocks a per-store master key; the master key unlocks each value under its own
|
|
27
|
+
salt.
|
|
28
|
+
- **One password stretch per store open**, not one per value. The user key is
|
|
29
|
+
stretched with PBKDF2-HMAC-SHA256 at 600,000 rounds; value keys come from
|
|
30
|
+
HKDF-SHA256, because a master key is not a password and stretching it buys
|
|
31
|
+
nothing. See [SPEC.md §2.1](../SPEC.md#21-two-derivations-because-there-are-two-jobs).
|
|
32
|
+
- **Every value bound to its key name**, as GCM additional authenticated data and
|
|
33
|
+
as HKDF context, so a value file moved to another key's name fails to decrypt.
|
|
34
|
+
- Metadata without a user key: listing stores, listing keys, and store info. Key
|
|
35
|
+
names are not encrypted, which is what makes a store inspectable without a
|
|
36
|
+
password — and means a key name must not itself be a secret.
|
|
37
|
+
- Export and import as one portable `.skb` bundle, with every name in it reduced
|
|
38
|
+
to its base name before writing, because a bundle is an untrusted file that
|
|
39
|
+
arrived over a wire.
|
|
40
|
+
- Stateless value tokens: `encrypt_value` and `decrypt_value`, writing `skv2` and
|
|
41
|
+
reading both `skv2` and `skv1`. The iteration count is recorded in the token, so
|
|
42
|
+
a column encrypted cheaply and a single secret encrypted expensively both read
|
|
43
|
+
back.
|
|
44
|
+
- Reading format 2.0 stores, and `migrate` to carry one across to 3.0. A 2.0 store
|
|
45
|
+
keeps taking 2.0 writes until it is migrated, so a store is never half one
|
|
46
|
+
format and half the other.
|
|
47
|
+
- The three input formats — colon, dotenv and JSON — parseable from a string, with
|
|
48
|
+
no directory involved.
|
|
49
|
+
- Errors carrying the codes in [SPEC.md §10](../SPEC.md#10-errors), identical in
|
|
50
|
+
every implementation.
|
|
51
|
+
- A command line with the same verbs, flags and `--json` output as every other
|
|
52
|
+
implementation.
|
|
53
|
+
- **Silence by default.** The library prints nothing unless given a logger, which
|
|
54
|
+
is what lets it drop into an application that has opinions about its own
|
|
55
|
+
logging.
|
|
56
|
+
|
|
57
|
+
### Fixed before release
|
|
58
|
+
|
|
59
|
+
- **The command line raised `UnicodeEncodeError` on any non-ASCII value.**
|
|
60
|
+
Python defaults stdout to the ANSI code page on Windows - cp1252 - so printing
|
|
61
|
+
a secret containing an emoji aborted the command rather than printing
|
|
62
|
+
anything. The library was correct throughout; it could store a secret it could
|
|
63
|
+
not show. stdout and stderr are now reconfigured to UTF-8. Found by
|
|
64
|
+
[`cross-test.mjs`](../conformance/cross-test.mjs).
|
|
65
|
+
|
|
66
|
+
### Notes specific to Python
|
|
67
|
+
|
|
68
|
+
- `cryptography` for PBKDF2HMAC, HKDF and AESGCM — the one dependency.
|
|
69
|
+
- `AESGCM` accepts both the 12-byte nonce 3.0 uses and the 16-byte one 2.0 wrote, so reading a legacy store needs no special case.
|
|
70
|
+
- `AESGCM.encrypt` appends the tag to the ciphertext; the format stores them as separate hex fields, so the last 16 bytes are split off.
|
|
71
|
+
- Full type hints and a `py.typed` marker.
|
|
72
|
+
- The whole `master.json` document is kept, so unknown fields survive a rewrite.
|
|
73
|
+
|
|
74
|
+
[3.0.0]: https://github.com/eharain/secure-keystore/releases/tag/python%2Fv3.0.0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tech Style Ltd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: secure-keystore
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: Encrypted secrets at rest for Python, with no service to run. Two-layer AES-256-GCM over PBKDF2-SHA256 and HKDF-SHA256, with a documented cross-language format.
|
|
5
|
+
Project-URL: Homepage, https://tech-style.co/
|
|
6
|
+
Project-URL: Repository, https://github.com/eharain/secure-keystore
|
|
7
|
+
Project-URL: Issues, https://github.com/eharain/secure-keystore/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/eharain/secure-keystore/blob/main/python/CHANGELOG.md
|
|
9
|
+
Author-email: Ejaz Hussain Arain <hello@tech-style.co>
|
|
10
|
+
Maintainer-email: Tech Style Ltd <hello@tech-style.co>
|
|
11
|
+
License: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: aes-gcm,ci-cd,configuration,credentials,devops,dotenv,encryption,encryption-at-rest,hkdf,keystore,pbkdf2,secret-management,secrets,vault
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Security :: Cryptography
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: cryptography>=41.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# secure-keystore — Python
|
|
32
|
+
|
|
33
|
+
**Encrypted secrets at rest, with no service to run.** One dependency:
|
|
34
|
+
[`cryptography`](https://cryptography.io/).
|
|
35
|
+
|
|
36
|
+
[](https://pypi.org/project/secure-keystore/)
|
|
37
|
+
[](https://www.python.org/)
|
|
38
|
+
[](LICENSE)
|
|
39
|
+
[](../SPEC.md)
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install secure-keystore
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import os
|
|
47
|
+
from secure_keystore import Keystore
|
|
48
|
+
|
|
49
|
+
store = Keystore()
|
|
50
|
+
store.secure("./keys.env", os.environ["USER_KEY"], "production")
|
|
51
|
+
api_key = store.get("api_key", os.environ["USER_KEY"], "production")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
This is one of [six implementations](../README.md#one-format-six-languages) of the
|
|
55
|
+
[secure-keystore format](../SPEC.md). A store written here opens in the
|
|
56
|
+
JavaScript, Go, .NET, PHP and Rust ones, and the other way round — which is not a
|
|
57
|
+
claim but [a test](tests/test_conformance.py) against a corpus the JavaScript
|
|
58
|
+
implementation produced, and against
|
|
59
|
+
[`cross-test.mjs`](../conformance/cross-test.mjs), where all six write stores and
|
|
60
|
+
all six read every one of them.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Two ways in
|
|
65
|
+
|
|
66
|
+
**A store** — a directory of encrypted values that one user key opens.
|
|
67
|
+
|
|
68
|
+
**A token** — one string in, one string out, nothing on disk.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from secure_keystore import encrypt_value, decrypt_value
|
|
72
|
+
|
|
73
|
+
token = encrypt_value("sk_live_abc123", user_key)
|
|
74
|
+
back = decrypt_value(token, user_key)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## API
|
|
80
|
+
|
|
81
|
+
### `Keystore(keystore_dir=None, *, iterations=600_000, logger=None, create_dir=True)`
|
|
82
|
+
|
|
83
|
+
Binds one keystore directory and one set of options, validated once here rather
|
|
84
|
+
than on every call. The instance holds no secret: the user key is passed to each
|
|
85
|
+
call that needs one and is never retained.
|
|
86
|
+
|
|
87
|
+
| Argument | Default | |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| `keystore_dir` | `./.keystore` | Where stores live. |
|
|
90
|
+
| `iterations` | `600000` | PBKDF2 rounds for the *user key*, on new and migrated stores. Recorded in the store. Does not apply to value keys, which use HKDF. |
|
|
91
|
+
| `logger` | `None` | Called with one line of progress. **None means the package prints nothing** — the default, and what a server wants. |
|
|
92
|
+
| `create_dir` | `True` | Create the keystore directory if absent. |
|
|
93
|
+
|
|
94
|
+
### Store operations
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
store.secure(input_file, user_key, store_name=None) -> SecureResult
|
|
98
|
+
store.get(key, user_key, store_name=None) -> str
|
|
99
|
+
store.get_all(user_key, store_name) -> dict[str, str]
|
|
100
|
+
store.set(key, value, user_key, store_name) -> SetResult
|
|
101
|
+
store.migrate(store_name, user_key) -> MigrateResult
|
|
102
|
+
store.delete_key(key, store_name) -> None
|
|
103
|
+
store.delete_store(store_name) -> None
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`get_all` costs **one** PBKDF2 derivation however many keys the store holds — see
|
|
107
|
+
[SPEC.md §2.1](../SPEC.md#21-two-derivations-because-there-are-two-jobs).
|
|
108
|
+
|
|
109
|
+
### Metadata — no user key needed
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
store.list_stores() -> list[str]
|
|
113
|
+
store.list_keys(store_name) -> list[str]
|
|
114
|
+
store.get_store_info(store_name) -> StoreInfo
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Key names live in metadata in the clear, so **a key name must not itself be a
|
|
118
|
+
secret**.
|
|
119
|
+
|
|
120
|
+
### Bundles and tokens
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
store.export_store(store_name, backup_path=None) -> str
|
|
124
|
+
store.import_store(backup_path, store_name=None) -> str
|
|
125
|
+
|
|
126
|
+
encrypt_value(plaintext, user_key, *, iterations=None) -> str | None
|
|
127
|
+
decrypt_value(token, user_key) -> str
|
|
128
|
+
is_encrypted_value(value) -> bool
|
|
129
|
+
token_version(value) -> str | None
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`iterations` lowers the cost for bulk work — a token pays one stretch **per
|
|
133
|
+
value**, unlike a store. The count is recorded in the token, so decryption
|
|
134
|
+
follows whatever was chosen.
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Errors
|
|
139
|
+
|
|
140
|
+
Every failure is a `KeystoreError` with a stable `.code`:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from secure_keystore import ErrorCodes, KeystoreError
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
store.get("api_key", user_key, "production")
|
|
147
|
+
except KeystoreError as error:
|
|
148
|
+
if error.code == ErrorCodes.BAD_USER_KEY:
|
|
149
|
+
... # wrong password, tampered bytes, or a mismatched context
|
|
150
|
+
if error.code == ErrorCodes.STORE_NOT_FOUND:
|
|
151
|
+
... # not deployed yet
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The codes are identical in all six implementations —
|
|
155
|
+
[SPEC.md §10](../SPEC.md#10-errors). A wrong user key would otherwise surface as
|
|
156
|
+
`cryptography.exceptions.InvalidTag`, which names neither the store nor the
|
|
157
|
+
reason.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Notes specific to Python
|
|
162
|
+
|
|
163
|
+
**`AESGCM` handles both nonce lengths.** `cryptography` accepts a 16-byte nonce as
|
|
164
|
+
well as the 12-byte one format 3.0 uses, so reading a format 2.0 store needs no
|
|
165
|
+
special case here — unlike .NET, where it needs a whole GCM implementation. Do not
|
|
166
|
+
let that make the 16-byte IV look fine; see
|
|
167
|
+
[ROADMAP.md §1.1](../ROADMAP.md#11-a-12-byte-iv).
|
|
168
|
+
|
|
169
|
+
**`AESGCM.encrypt` appends the tag** to the ciphertext; the format stores them as
|
|
170
|
+
separate hex fields, so the last 16 bytes are split off.
|
|
171
|
+
|
|
172
|
+
**Type hints throughout**, and a `py.typed` marker, so `mypy` sees the API.
|
|
173
|
+
|
|
174
|
+
**Ordering.** `entries` is a list and stays one; `dict` preserves insertion order
|
|
175
|
+
in the versions this supports, so a parsed input file keeps the document's key
|
|
176
|
+
order.
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## The CLI
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
secure-keystore get api_key "$USER_KEY" production
|
|
184
|
+
python -m secure_keystore --help # the same thing, without the entry point
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Same verbs, flags, error codes and `--json` output as every other
|
|
188
|
+
implementation — see the [root README](../README.md#the-cli).
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## Development
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
pip install -e ".[dev]"
|
|
196
|
+
pytest # includes the shared corpus
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
python/
|
|
201
|
+
├── src/secure_keystore/
|
|
202
|
+
│ ├── __init__.py the public surface, and ALGORITHMS
|
|
203
|
+
│ ├── _format.py everything the format fixes
|
|
204
|
+
│ ├── errors.py KeystoreError and its codes
|
|
205
|
+
│ ├── _crypto.py PBKDF2, HKDF, AES-GCM — the only place a cipher is built
|
|
206
|
+
│ ├── token.py the stateless skv2 token
|
|
207
|
+
│ ├── input_file.py the three input formats, testable without a file
|
|
208
|
+
│ ├── bundle.py the .skb bundle
|
|
209
|
+
│ ├── keystore.py the store itself
|
|
210
|
+
│ └── cli.py
|
|
211
|
+
└── tests/
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Further reading
|
|
215
|
+
|
|
216
|
+
- **[../SPEC.md](../SPEC.md)** — the format, in enough detail to implement it
|
|
217
|
+
- **[../conformance/](../conformance/)** — how cross-language compatibility is tested
|
|
218
|
+
- **[CHANGELOG.md](CHANGELOG.md)**
|
|
219
|
+
|
|
220
|
+
## Licence
|
|
221
|
+
|
|
222
|
+
MIT © [Tech Style Ltd](https://tech-style.co/)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# secure-keystore — Python
|
|
2
|
+
|
|
3
|
+
**Encrypted secrets at rest, with no service to run.** One dependency:
|
|
4
|
+
[`cryptography`](https://cryptography.io/).
|
|
5
|
+
|
|
6
|
+
[](https://pypi.org/project/secure-keystore/)
|
|
7
|
+
[](https://www.python.org/)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
[](../SPEC.md)
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install secure-keystore
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import os
|
|
17
|
+
from secure_keystore import Keystore
|
|
18
|
+
|
|
19
|
+
store = Keystore()
|
|
20
|
+
store.secure("./keys.env", os.environ["USER_KEY"], "production")
|
|
21
|
+
api_key = store.get("api_key", os.environ["USER_KEY"], "production")
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
This is one of [six implementations](../README.md#one-format-six-languages) of the
|
|
25
|
+
[secure-keystore format](../SPEC.md). A store written here opens in the
|
|
26
|
+
JavaScript, Go, .NET, PHP and Rust ones, and the other way round — which is not a
|
|
27
|
+
claim but [a test](tests/test_conformance.py) against a corpus the JavaScript
|
|
28
|
+
implementation produced, and against
|
|
29
|
+
[`cross-test.mjs`](../conformance/cross-test.mjs), where all six write stores and
|
|
30
|
+
all six read every one of them.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Two ways in
|
|
35
|
+
|
|
36
|
+
**A store** — a directory of encrypted values that one user key opens.
|
|
37
|
+
|
|
38
|
+
**A token** — one string in, one string out, nothing on disk.
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from secure_keystore import encrypt_value, decrypt_value
|
|
42
|
+
|
|
43
|
+
token = encrypt_value("sk_live_abc123", user_key)
|
|
44
|
+
back = decrypt_value(token, user_key)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## API
|
|
50
|
+
|
|
51
|
+
### `Keystore(keystore_dir=None, *, iterations=600_000, logger=None, create_dir=True)`
|
|
52
|
+
|
|
53
|
+
Binds one keystore directory and one set of options, validated once here rather
|
|
54
|
+
than on every call. The instance holds no secret: the user key is passed to each
|
|
55
|
+
call that needs one and is never retained.
|
|
56
|
+
|
|
57
|
+
| Argument | Default | |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| `keystore_dir` | `./.keystore` | Where stores live. |
|
|
60
|
+
| `iterations` | `600000` | PBKDF2 rounds for the *user key*, on new and migrated stores. Recorded in the store. Does not apply to value keys, which use HKDF. |
|
|
61
|
+
| `logger` | `None` | Called with one line of progress. **None means the package prints nothing** — the default, and what a server wants. |
|
|
62
|
+
| `create_dir` | `True` | Create the keystore directory if absent. |
|
|
63
|
+
|
|
64
|
+
### Store operations
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
store.secure(input_file, user_key, store_name=None) -> SecureResult
|
|
68
|
+
store.get(key, user_key, store_name=None) -> str
|
|
69
|
+
store.get_all(user_key, store_name) -> dict[str, str]
|
|
70
|
+
store.set(key, value, user_key, store_name) -> SetResult
|
|
71
|
+
store.migrate(store_name, user_key) -> MigrateResult
|
|
72
|
+
store.delete_key(key, store_name) -> None
|
|
73
|
+
store.delete_store(store_name) -> None
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`get_all` costs **one** PBKDF2 derivation however many keys the store holds — see
|
|
77
|
+
[SPEC.md §2.1](../SPEC.md#21-two-derivations-because-there-are-two-jobs).
|
|
78
|
+
|
|
79
|
+
### Metadata — no user key needed
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
store.list_stores() -> list[str]
|
|
83
|
+
store.list_keys(store_name) -> list[str]
|
|
84
|
+
store.get_store_info(store_name) -> StoreInfo
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Key names live in metadata in the clear, so **a key name must not itself be a
|
|
88
|
+
secret**.
|
|
89
|
+
|
|
90
|
+
### Bundles and tokens
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
store.export_store(store_name, backup_path=None) -> str
|
|
94
|
+
store.import_store(backup_path, store_name=None) -> str
|
|
95
|
+
|
|
96
|
+
encrypt_value(plaintext, user_key, *, iterations=None) -> str | None
|
|
97
|
+
decrypt_value(token, user_key) -> str
|
|
98
|
+
is_encrypted_value(value) -> bool
|
|
99
|
+
token_version(value) -> str | None
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`iterations` lowers the cost for bulk work — a token pays one stretch **per
|
|
103
|
+
value**, unlike a store. The count is recorded in the token, so decryption
|
|
104
|
+
follows whatever was chosen.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Errors
|
|
109
|
+
|
|
110
|
+
Every failure is a `KeystoreError` with a stable `.code`:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from secure_keystore import ErrorCodes, KeystoreError
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
store.get("api_key", user_key, "production")
|
|
117
|
+
except KeystoreError as error:
|
|
118
|
+
if error.code == ErrorCodes.BAD_USER_KEY:
|
|
119
|
+
... # wrong password, tampered bytes, or a mismatched context
|
|
120
|
+
if error.code == ErrorCodes.STORE_NOT_FOUND:
|
|
121
|
+
... # not deployed yet
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The codes are identical in all six implementations —
|
|
125
|
+
[SPEC.md §10](../SPEC.md#10-errors). A wrong user key would otherwise surface as
|
|
126
|
+
`cryptography.exceptions.InvalidTag`, which names neither the store nor the
|
|
127
|
+
reason.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Notes specific to Python
|
|
132
|
+
|
|
133
|
+
**`AESGCM` handles both nonce lengths.** `cryptography` accepts a 16-byte nonce as
|
|
134
|
+
well as the 12-byte one format 3.0 uses, so reading a format 2.0 store needs no
|
|
135
|
+
special case here — unlike .NET, where it needs a whole GCM implementation. Do not
|
|
136
|
+
let that make the 16-byte IV look fine; see
|
|
137
|
+
[ROADMAP.md §1.1](../ROADMAP.md#11-a-12-byte-iv).
|
|
138
|
+
|
|
139
|
+
**`AESGCM.encrypt` appends the tag** to the ciphertext; the format stores them as
|
|
140
|
+
separate hex fields, so the last 16 bytes are split off.
|
|
141
|
+
|
|
142
|
+
**Type hints throughout**, and a `py.typed` marker, so `mypy` sees the API.
|
|
143
|
+
|
|
144
|
+
**Ordering.** `entries` is a list and stays one; `dict` preserves insertion order
|
|
145
|
+
in the versions this supports, so a parsed input file keeps the document's key
|
|
146
|
+
order.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## The CLI
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
secure-keystore get api_key "$USER_KEY" production
|
|
154
|
+
python -m secure_keystore --help # the same thing, without the entry point
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Same verbs, flags, error codes and `--json` output as every other
|
|
158
|
+
implementation — see the [root README](../README.md#the-cli).
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## Development
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
pip install -e ".[dev]"
|
|
166
|
+
pytest # includes the shared corpus
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
python/
|
|
171
|
+
├── src/secure_keystore/
|
|
172
|
+
│ ├── __init__.py the public surface, and ALGORITHMS
|
|
173
|
+
│ ├── _format.py everything the format fixes
|
|
174
|
+
│ ├── errors.py KeystoreError and its codes
|
|
175
|
+
│ ├── _crypto.py PBKDF2, HKDF, AES-GCM — the only place a cipher is built
|
|
176
|
+
│ ├── token.py the stateless skv2 token
|
|
177
|
+
│ ├── input_file.py the three input formats, testable without a file
|
|
178
|
+
│ ├── bundle.py the .skb bundle
|
|
179
|
+
│ ├── keystore.py the store itself
|
|
180
|
+
│ └── cli.py
|
|
181
|
+
└── tests/
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Further reading
|
|
185
|
+
|
|
186
|
+
- **[../SPEC.md](../SPEC.md)** — the format, in enough detail to implement it
|
|
187
|
+
- **[../conformance/](../conformance/)** — how cross-language compatibility is tested
|
|
188
|
+
- **[CHANGELOG.md](CHANGELOG.md)**
|
|
189
|
+
|
|
190
|
+
## Licence
|
|
191
|
+
|
|
192
|
+
MIT © [Tech Style Ltd](https://tech-style.co/)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "secure-keystore"
|
|
7
|
+
version = "3.0.0"
|
|
8
|
+
description = "Encrypted secrets at rest for Python, with no service to run. Two-layer AES-256-GCM over PBKDF2-SHA256 and HKDF-SHA256, with a documented cross-language format."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Ejaz Hussain Arain", email = "hello@tech-style.co" }]
|
|
13
|
+
maintainers = [{ name = "Tech Style Ltd", email = "hello@tech-style.co" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"encryption", "secrets", "secret-management", "keystore", "credentials",
|
|
16
|
+
"vault", "aes-gcm", "pbkdf2", "hkdf", "dotenv", "configuration",
|
|
17
|
+
"encryption-at-rest", "devops", "ci-cd",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Topic :: Security :: Cryptography",
|
|
29
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
30
|
+
"Typing :: Typed",
|
|
31
|
+
]
|
|
32
|
+
dependencies = ["cryptography>=41.0"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://tech-style.co/"
|
|
36
|
+
Repository = "https://github.com/eharain/secure-keystore"
|
|
37
|
+
Issues = "https://github.com/eharain/secure-keystore/issues"
|
|
38
|
+
Changelog = "https://github.com/eharain/secure-keystore/blob/main/python/CHANGELOG.md"
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
secure-keystore = "secure_keystore.cli:main"
|
|
42
|
+
|
|
43
|
+
[project.optional-dependencies]
|
|
44
|
+
dev = ["pytest>=7.0"]
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["src/secure_keystore"]
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
testpaths = ["tests"]
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
line-length = 100
|
|
54
|
+
target-version = "py310"
|