aps-vault 0.41.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.
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.4
2
+ Name: aps-vault
3
+ Version: 0.41.0
4
+ Summary: Client for APS Vault machine API (service tokens), standard library only
5
+ Author: Konstantin Zhebenev
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/kzhebenev/aps-vault
8
+ Project-URL: Documentation, https://github.com/kzhebenev/aps-vault/tree/main/docs
9
+ Project-URL: Source, https://github.com/kzhebenev/aps-vault/tree/main/clients/python
10
+ Project-URL: Changelog, https://github.com/kzhebenev/aps-vault/blob/main/CHANGELOG.md
11
+ Project-URL: Issues, https://github.com/kzhebenev/aps-vault/issues
12
+ Keywords: secrets,vault,secret-manager,service-token,sealed-delivery,gost,ml-kem,post-quantum
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Security
17
+ Classifier: Topic :: Security :: Cryptography
18
+ Classifier: Intended Audience :: Developers
19
+ Classifier: Intended Audience :: System Administrators
20
+ Classifier: Operating System :: OS Independent
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ Provides-Extra: sealed
24
+ Requires-Dist: cryptography>=42; extra == "sealed"
25
+ Provides-Extra: gost
26
+ Requires-Dist: cryptography>=42; extra == "gost"
27
+ Requires-Dist: gostcrypto>=1.2; extra == "gost"
28
+ Provides-Extra: pkcs11
29
+ Requires-Dist: cryptography>=42; extra == "pkcs11"
30
+ Requires-Dist: python-pkcs11>=0.10; extra == "pkcs11"
31
+ Provides-Extra: pqc
32
+ Requires-Dist: cryptography>=42; extra == "pqc"
33
+ Requires-Dist: kyber-py>=1.2; extra == "pqc"
34
+
35
+ # aps-vault (Python)
36
+
37
+ Standard-library client for the APS Vault machine API. Python 3.9+, no dependencies.
38
+
39
+ ```bash
40
+ pip install ./clients/python # or: pip install aps-vault (once published)
41
+ ```
42
+
43
+ ```python
44
+ import os
45
+ from aps_vault import Vault, VaultError
46
+
47
+ v = Vault("https://vault.example.com", os.environ["VAULT_TOKEN"]) # or Vault.from_env()
48
+ try:
49
+ dsn_password = v.get("db-password")
50
+ smtp = v.get_full("smtp") # {"name","value","login","updated_at",...}
51
+ except VaultError as e:
52
+ if e.status == 404: ...
53
+ ```
54
+
55
+ - `get(name)` / `get_full(name)` — cached for `cache_ttl` seconds (default 300). While the
56
+ vault is unreachable a stale cached value is returned (`fail_open_cache=True`), so a vault
57
+ restart never takes your service down; the first fetch still fails loudly.
58
+ - `put(name, value, login=..., tags=..., url=...)` — token needs `can_write`.
59
+ - `totp(name)` — current 6-digit code, never cached; token needs `can_read_totp`.
60
+ - `list()`, `health()`.
61
+ - Retries 429/5xx/network with 1 s, 2 s, 4 s back-off.
62
+
63
+ Tokens: pass via environment (`VAULT_TOKEN`) or a `0600` file (`VAULT_TOKEN_FILE`). Never
64
+ commit one, never log one. `Vault` refuses anything that does not look like a service token.
65
+
66
+ ## Sealed delivery (0.17)
67
+
68
+ If the token is bound to this application's X25519 public key, values arrive encrypted and the
69
+ client decrypts them in-process (needs the optional extra: `pip install 'aps-vault[sealed]'`):
70
+
71
+ ```bash
72
+ python -m aps_vault keygen # prints VAULT_CLIENT_KEY (private, keep with the token) and client_public_key (for the token)
73
+ python -m aps_vault keygen --gost # GOST R 34.10-2012 pair → the vault seals with VKO + Kuznyechik-MGM; needs pip install 'aps-vault[gost]'
74
+ ```
75
+ ```python
76
+ v = Vault(url, token, client_private_key=os.environ["VAULT_CLIENT_KEY"]) # or just set VAULT_CLIENT_KEY
77
+ v.get("db-password")
78
+ ```
79
+
80
+ Without the key the client raises `VaultError("… sealed values …")` rather than returning the
81
+ envelope; with the wrong key it says so. See `docs/SEALED.md`.
@@ -0,0 +1,47 @@
1
+ # aps-vault (Python)
2
+
3
+ Standard-library client for the APS Vault machine API. Python 3.9+, no dependencies.
4
+
5
+ ```bash
6
+ pip install ./clients/python # or: pip install aps-vault (once published)
7
+ ```
8
+
9
+ ```python
10
+ import os
11
+ from aps_vault import Vault, VaultError
12
+
13
+ v = Vault("https://vault.example.com", os.environ["VAULT_TOKEN"]) # or Vault.from_env()
14
+ try:
15
+ dsn_password = v.get("db-password")
16
+ smtp = v.get_full("smtp") # {"name","value","login","updated_at",...}
17
+ except VaultError as e:
18
+ if e.status == 404: ...
19
+ ```
20
+
21
+ - `get(name)` / `get_full(name)` — cached for `cache_ttl` seconds (default 300). While the
22
+ vault is unreachable a stale cached value is returned (`fail_open_cache=True`), so a vault
23
+ restart never takes your service down; the first fetch still fails loudly.
24
+ - `put(name, value, login=..., tags=..., url=...)` — token needs `can_write`.
25
+ - `totp(name)` — current 6-digit code, never cached; token needs `can_read_totp`.
26
+ - `list()`, `health()`.
27
+ - Retries 429/5xx/network with 1 s, 2 s, 4 s back-off.
28
+
29
+ Tokens: pass via environment (`VAULT_TOKEN`) or a `0600` file (`VAULT_TOKEN_FILE`). Never
30
+ commit one, never log one. `Vault` refuses anything that does not look like a service token.
31
+
32
+ ## Sealed delivery (0.17)
33
+
34
+ If the token is bound to this application's X25519 public key, values arrive encrypted and the
35
+ client decrypts them in-process (needs the optional extra: `pip install 'aps-vault[sealed]'`):
36
+
37
+ ```bash
38
+ python -m aps_vault keygen # prints VAULT_CLIENT_KEY (private, keep with the token) and client_public_key (for the token)
39
+ python -m aps_vault keygen --gost # GOST R 34.10-2012 pair → the vault seals with VKO + Kuznyechik-MGM; needs pip install 'aps-vault[gost]'
40
+ ```
41
+ ```python
42
+ v = Vault(url, token, client_private_key=os.environ["VAULT_CLIENT_KEY"]) # or just set VAULT_CLIENT_KEY
43
+ v.get("db-password")
44
+ ```
45
+
46
+ Without the key the client raises `VaultError("… sealed values …")` rather than returning the
47
+ envelope; with the wrong key it says so. See `docs/SEALED.md`.
@@ -0,0 +1,504 @@
1
+ """APS Vault client for Python 3.9+ — standard library only.
2
+
3
+ from aps_vault import Vault
4
+ v = Vault("https://vault.example.com", os.environ["VAULT_TOKEN"])
5
+ db_password = v.get("db-password") # str
6
+ full = v.get_full("db-password") # {"name","value","login"?,"notes"?,"totp"?,"updated_at"}
7
+ v.put("db-password", "new-value", login="app") # token needs can_write
8
+
9
+ Sealed delivery (0.17): a token bound to this application's X25519 public key gets values
10
+ encrypted to that key — plaintext never crosses the wire or the proxy chain. Generate a key
11
+ pair once (``python -m aps_vault keygen``), hand the public half to the vault administrator
12
+ for the token, keep the private half next to the token:
13
+
14
+ v = Vault(url, token, client_private_key=os.environ["VAULT_CLIENT_KEY"]) # base64 raw 32 bytes
15
+ v.get("db-password") # decrypted in this process; needs `cryptography`
16
+
17
+ Behaviour:
18
+ * token must be a service token (``vlt_…``) — the master password never belongs in code;
19
+ * in-memory cache (``cache_ttl``, default 300 s; 0 disables) so a restart storm does not
20
+ hammer the vault, and a vault restart does not take the application down;
21
+ * retries with exponential back-off on 429/5xx/network errors (``max_retries``);
22
+ * ``fail_open_cache=True`` returns the last cached value when the vault is unreachable
23
+ and the entry is stale — the right trade-off for an encryption key at startup;
24
+ * nothing is logged; exceptions carry the HTTP status and the server's ``detail``.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ import time
31
+ import urllib.error
32
+ import urllib.parse
33
+ import urllib.request
34
+ from dataclasses import dataclass
35
+ from typing import Any, Optional
36
+
37
+ __version__ = "0.41.0"
38
+
39
+
40
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
41
+ """0.37: never follow a redirect — urllib would carry the Authorization header to whatever host the answer names."""
42
+
43
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
44
+ raise urllib.error.HTTPError(req.full_url, code, f"vault: redirect to {newurl[:100]} refused", headers, fp)
45
+
46
+
47
+ _OPENER = urllib.request.build_opener(_NoRedirect())
48
+
49
+
50
+ class VaultError(Exception):
51
+ """HTTP-level error from the vault (status + server detail)."""
52
+
53
+ def __init__(self, status: int, message: str, body: Any = None) -> None:
54
+ super().__init__(message)
55
+ self.status = status
56
+ self.body = body
57
+
58
+
59
+ @dataclass
60
+ class _Entry:
61
+ value: dict
62
+ expires_at: float
63
+
64
+
65
+ SEALED_ALG = "X25519-HKDF-SHA256-AES256GCM"
66
+ _SEALED_INFO = b"aps-vault/sealed/v1"
67
+ SEALED_ALG_GOST = "VKO-GOSTR3410-2012-256-KDFTREE-KUZNYECHIK-MGM"
68
+ _SEALED_LABEL_GOST = b"aps-vault/sealed-gost/v1"
69
+ SEALED_ALG_P256 = "P256-HKDF-SHA256-AES256GCM"
70
+ SEALED_ALG_PQC = "X25519MLKEM768-HKDF-SHA256-AES256GCM" # 0.27: X25519 + ML-KEM-768 hybrid
71
+ SEALED_ALG_GOST_PQC = "VKO-GOSTR3410-2012-256-MLKEM768-KDFTREE-KUZNYECHIK-MGM" # 0.32: GOST + ML-KEM-768 hybrid
72
+ _SEALED_LABEL_GOST_PQC = b"aps-vault/sealed-gost-pqc/v1"
73
+ _SEALED_INFO_PQC = b"aps-vault/sealed-pqc/v1"
74
+ _SEALED_INFO_P256 = b"aps-vault/sealed-p256/v1"
75
+
76
+
77
+ class Pkcs11Key:
78
+ """A P-256 private key that lives in a PKCS#11 token — a TPM 2.0 through tpm2-pkcs11, an HSM,
79
+ a smart card, or SoftHSM2 for tests (0.22). The key never leaves the token: the client only
80
+ asks it for one ECDH derivation per envelope. Needs `pip install 'aps-vault[pkcs11]'`.
81
+
82
+ key = Pkcs11Key("/usr/lib/softhsm/libsofthsm2.so", token_label="node", pin="1234", key_label="vault-node")
83
+ Pkcs11Key.generate(...) # make the pair inside the token (once, at install)
84
+ Vault(url, token, client_private_key=key)
85
+ """
86
+
87
+ kind = "p256"
88
+
89
+ def __init__(self, module: str, token_label: str, pin: str, key_label: str = "aps-vault-node") -> None:
90
+ self.module, self.token_label, self.pin, self.key_label = module, token_label, pin, key_label
91
+
92
+ def _lib(self):
93
+ try:
94
+ import pkcs11 # noqa: F401
95
+ except ImportError: # pragma: no cover
96
+ raise RuntimeError("vault: a PKCS#11 key needs the `python-pkcs11` package (pip install 'aps-vault[pkcs11]')") from None
97
+ import pkcs11
98
+ return pkcs11, pkcs11.lib(self.module).get_token(token_label=self.token_label)
99
+
100
+ @classmethod
101
+ def generate(cls, module: str, token_label: str, pin: str, key_label: str = "aps-vault-node") -> "Pkcs11Key":
102
+ """Create a non-extractable P-256 key pair in the token (fails if the label exists)."""
103
+ self = cls(module, token_label, pin, key_label)
104
+ pkcs11, tok = self._lib()
105
+ from pkcs11 import Attribute, KeyType
106
+ from pkcs11.util.ec import encode_named_curve_parameters
107
+ with tok.open(user_pin=pin, rw=True) as s:
108
+ try:
109
+ s.get_key(label=key_label, object_class=pkcs11.ObjectClass.PRIVATE_KEY)
110
+ raise RuntimeError(f"vault: a key labelled {key_label!r} already exists in the token")
111
+ except pkcs11.exceptions.NoSuchKey:
112
+ pass
113
+ s.generate_keypair(KeyType.EC, 256, public_template={Attribute.EC_PARAMS: encode_named_curve_parameters("secp256r1")},
114
+ private_template={Attribute.EXTRACTABLE: False, Attribute.SENSITIVE: True, Attribute.DERIVE: True}, store=True, label=key_label)
115
+ return self
116
+
117
+ def public_bytes(self) -> bytes:
118
+ """Uncompressed point 0x04 ‖ X ‖ Y (65 bytes) — what goes into the token's `client_public_key`."""
119
+ pkcs11, tok = self._lib()
120
+ from pkcs11.util.ec import encode_ec_public_key
121
+ from cryptography.hazmat.primitives import serialization
122
+ with tok.open(user_pin=self.pin) as s:
123
+ pub = s.get_key(label=self.key_label, object_class=pkcs11.ObjectClass.PUBLIC_KEY)
124
+ spki = encode_ec_public_key(pub)
125
+ return serialization.load_der_public_key(spki).public_bytes(serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint)
126
+
127
+ def public_b64(self) -> str:
128
+ import base64
129
+ return base64.b64encode(self.public_bytes()).decode()
130
+
131
+ def ecdh(self, peer_point: bytes) -> bytes:
132
+ """Shared secret with the vault's ephemeral point, computed inside the token (CKM_ECDH1_DERIVE)."""
133
+ pkcs11, tok = self._lib()
134
+ from pkcs11 import Attribute, KeyType
135
+ with tok.open(user_pin=self.pin) as s:
136
+ priv = s.get_key(label=self.key_label, object_class=pkcs11.ObjectClass.PRIVATE_KEY)
137
+ shared = priv.derive_key(KeyType.GENERIC_SECRET, 256, mechanism_param=(pkcs11.KDF.NULL, None, peer_point),
138
+ template={Attribute.EXTRACTABLE: True, Attribute.SENSITIVE: False})
139
+ return bytes(shared[Attribute.VALUE])
140
+
141
+
142
+ def _p256_unseal(envelope: dict, key, name: str) -> dict:
143
+ """P-256 envelope: ECDH with the ephemeral point (in software from the scalar, or inside a PKCS#11
144
+ token), HKDF-SHA256(info = label ‖ epk ‖ our point), AES-256-GCM with the name as AAD."""
145
+ import base64
146
+ hashes, _, AESGCM, HKDF, ser = _crypto()
147
+ from cryptography.hazmat.primitives.asymmetric import ec
148
+ epk = base64.b64decode(envelope["epk"])
149
+ try:
150
+ if isinstance(key, Pkcs11Key):
151
+ our = key.public_bytes()
152
+ shared = key.ecdh(epk)
153
+ else:
154
+ sk = ec.derive_private_key(int.from_bytes(base64.b64decode(key), "big"), ec.SECP256R1())
155
+ our = sk.public_key().public_bytes(ser.Encoding.X962, ser.PublicFormat.UncompressedPoint)
156
+ shared = sk.exchange(ec.ECDH(), ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), epk))
157
+ k = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=_SEALED_INFO_P256 + epk + our).derive(shared)
158
+ pt = AESGCM(k).decrypt(base64.b64decode(envelope["nonce"]), base64.b64decode(envelope["ct"]), name.encode("utf-8"))
159
+ except Exception:
160
+ raise VaultError(0, "vault: sealed value does not open with this private key (wrong key, or the token is bound to another key)") from None
161
+ return json.loads(pt.decode("utf-8"))
162
+
163
+
164
+ def _gost():
165
+ try:
166
+ import gostcrypto # noqa: F401
167
+ except ImportError: # pragma: no cover
168
+ raise RuntimeError("vault: the GOST envelope needs the `gostcrypto` package (pip install 'aps-vault[gost]')") from None
169
+ from . import gost
170
+ return gost
171
+
172
+
173
+ def _mlkem():
174
+ try:
175
+ from kyber_py.ml_kem import ML_KEM_768
176
+ except ImportError: # pragma: no cover
177
+ raise RuntimeError("vault: the post-quantum envelope needs the `kyber-py` package (pip install 'aps-vault[pqc]')") from None
178
+ return ML_KEM_768
179
+
180
+
181
+ def _pqc_unseal(envelope: dict, private_key_b64: str, name: str) -> dict:
182
+ """Hybrid envelope (0.27): X25519 with the ephemeral key + ML-KEM-768 decapsulation with the key derived
183
+ from our 64-byte seed; HKDF-SHA256 over both shared secrets (info = label ‖ epk ‖ kem_ct); AES-256-GCM
184
+ with the secret name as AAD. Private key = X25519 sk (32) ‖ seed (64) = 96 bytes."""
185
+ import base64
186
+ hashes, x25519, AESGCM, HKDF, ser = _crypto()
187
+ kem = _mlkem()
188
+ raw = base64.b64decode(private_key_b64)
189
+ if len(raw) != 96:
190
+ raise VaultError(0, "vault: hybrid private key must be 96 bytes (X25519 sk ‖ ML-KEM-768 seed) — use generate_keypair('pqc')")
191
+ try:
192
+ _, dk = kem.key_derive(raw[32:])
193
+ epk, kem_ct = base64.b64decode(envelope["epk"]), base64.b64decode(envelope["kem"])
194
+ ss_x = x25519.X25519PrivateKey.from_private_bytes(raw[:32]).exchange(x25519.X25519PublicKey.from_public_bytes(epk))
195
+ ss_kem = kem.decaps(dk, kem_ct)
196
+ key = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=_SEALED_INFO_PQC + epk + kem_ct).derive(ss_x + ss_kem)
197
+ pt = AESGCM(key).decrypt(base64.b64decode(envelope["nonce"]), base64.b64decode(envelope["ct"]), name.encode("utf-8"))
198
+ except Exception:
199
+ raise VaultError(0, "vault: sealed value does not open with this private key (wrong key, or the token is bound to another key)") from None
200
+ return json.loads(pt.decode("utf-8"))
201
+
202
+
203
+ def _gost_pqc_unseal(envelope: dict, private_key_b64: str, name: str) -> dict:
204
+ """GOST hybrid envelope (0.32): VKO GOST R 34.10-2012 with the ephemeral key (UKM from the envelope) + ML-KEM-768
205
+ decapsulation with the key derived from our 64-byte seed; KDF_TREE_256(KEK ‖ ss_kem, label, epk ‖ kem) →
206
+ Kuznyechik-MGM with the secret name as AAD. Private key = GOST scalar (32) ‖ seed (64) = 96 bytes."""
207
+ import base64
208
+ g = _gost()
209
+ kem = _mlkem()
210
+ raw = base64.b64decode(private_key_b64)
211
+ if len(raw) != 96:
212
+ raise VaultError(0, "vault: GOST hybrid private key must be 96 bytes (GOST scalar ‖ ML-KEM-768 seed) — use generate_keypair('gost-pqc')")
213
+ try:
214
+ d = int.from_bytes(raw[:32], "big")
215
+ _, dk = kem.key_derive(raw[32:])
216
+ epk, kem_ct = base64.b64decode(envelope["epk"]), base64.b64decode(envelope["kem"])
217
+ kek = g.vko(d, g.decode_point(epk), base64.b64decode(envelope["ukm"]))
218
+ ss_kem = kem.decaps(dk, kem_ct)
219
+ key = g.kdf_tree_256(kek + ss_kem, _SEALED_LABEL_GOST_PQC, epk + kem_ct, 1)
220
+ pt = g.MGM(g.Kuznyechik(key)).open(base64.b64decode(envelope["nonce"]), base64.b64decode(envelope["ct"]), name.encode("utf-8"))
221
+ except Exception:
222
+ raise VaultError(0, "vault: sealed value does not open with this private key (wrong key, or the token is bound to another key)") from None
223
+ return json.loads(pt.decode("utf-8"))
224
+
225
+
226
+ def gost_pqc_public_from_private(private_key_b64: str) -> str:
227
+ """The base64 1248-byte public key of a 96-byte GOST hybrid private key."""
228
+ import base64
229
+ g = _gost()
230
+ raw = base64.b64decode(private_key_b64)
231
+ if len(raw) != 96:
232
+ raise VaultError(0, "vault: GOST hybrid private key must be 96 bytes (GOST scalar ‖ ML-KEM-768 seed)")
233
+ gost_pk = g.encode_point(g.public_from_private(int.from_bytes(raw[:32], "big")))
234
+ ek, _ = _mlkem().key_derive(raw[32:])
235
+ return base64.b64encode(gost_pk + ek).decode()
236
+
237
+
238
+ def pqc_public_from_private(private_key_b64: str) -> str:
239
+ """The base64 1216-byte public key of a 96-byte hybrid private key (for checking what the vault was given)."""
240
+ import base64
241
+ _, x25519, _, _, ser = _crypto()
242
+ raw = base64.b64decode(private_key_b64)
243
+ x_pk = x25519.X25519PrivateKey.from_private_bytes(raw[:32]).public_key().public_bytes(ser.Encoding.Raw, ser.PublicFormat.Raw)
244
+ ek, _ = _mlkem().key_derive(raw[32:])
245
+ return base64.b64encode(x_pk + ek).decode()
246
+
247
+
248
+ def _crypto():
249
+ try:
250
+ from cryptography.hazmat.primitives import hashes
251
+ from cryptography.hazmat.primitives.asymmetric import x25519
252
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
253
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
254
+ from cryptography.hazmat.primitives import serialization
255
+ except ImportError: # pragma: no cover
256
+ raise RuntimeError("vault: sealed delivery needs the `cryptography` package (pip install 'aps-vault[sealed]')") from None
257
+ return hashes, x25519, AESGCM, HKDF, serialization
258
+
259
+
260
+ def generate_keypair(kind: str = "x25519") -> "tuple[str, str]":
261
+ """(private_b64, public_b64) in standard base64. `x25519` (default): raw 32-byte keys.
262
+ `gost`: GOST R 34.10-2012 key pair on id-tc26-gost-3410-2012-256-paramSetB — 32-byte big-endian
263
+ scalar and a 64-byte X‖Y little-endian point; the vault then seals with VKO + Kuznyechik-MGM.
264
+ `p256`: NIST P-256 — 32-byte scalar and a 65-byte uncompressed point; the curve a TPM or any PKCS#11
265
+ token can hold (see Pkcs11Key for the hardware version).
266
+ `pqc` (0.27): post-quantum hybrid X25519 + ML-KEM-768 — private = X25519 sk (32) ‖ ML-KEM seed (64),
267
+ public = X25519 pk (32) ‖ ML-KEM encapsulation key (1184); needs `pip install 'aps-vault[pqc]'`.
268
+ `gost-pqc` (0.32): GOST hybrid — private = GOST scalar (32) ‖ ML-KEM seed (64), public = GOST point (64) ‖
269
+ ML-KEM ek (1184) = 1248 bytes; the vault seals with VKO + ML-KEM → KDF_TREE → Kuznyechik-MGM (gost + pqc extras).
270
+ Give the public half to the vault administrator (token field `client_public_key`); keep the
271
+ private half with the token (environment / secret store), never in the repository."""
272
+ import base64
273
+ if kind == "gost":
274
+ g = _gost()
275
+ sk_raw, pk_raw = g.generate_keypair()
276
+ return base64.b64encode(sk_raw).decode(), base64.b64encode(pk_raw).decode()
277
+ if kind == "gost-pqc":
278
+ import os as _os
279
+ g = _gost()
280
+ sk_raw, pk_raw = g.generate_keypair()
281
+ seed = _os.urandom(64)
282
+ ek, _ = _mlkem().key_derive(seed)
283
+ return base64.b64encode(sk_raw + seed).decode(), base64.b64encode(pk_raw + ek).decode()
284
+ if kind == "pqc":
285
+ _, x25519, _, _, ser = _crypto()
286
+ import os as _os
287
+ x = x25519.X25519PrivateKey.generate()
288
+ seed = _os.urandom(64)
289
+ ek, _ = _mlkem().key_derive(seed)
290
+ return (base64.b64encode(x.private_bytes(ser.Encoding.Raw, ser.PrivateFormat.Raw, ser.NoEncryption()) + seed).decode(),
291
+ base64.b64encode(x.public_key().public_bytes(ser.Encoding.Raw, ser.PublicFormat.Raw) + ek).decode())
292
+ if kind == "p256":
293
+ _, _, _, _, ser = _crypto()
294
+ from cryptography.hazmat.primitives.asymmetric import ec
295
+ sk = ec.generate_private_key(ec.SECP256R1())
296
+ return (base64.b64encode(sk.private_numbers().private_value.to_bytes(32, "big")).decode(),
297
+ base64.b64encode(sk.public_key().public_bytes(ser.Encoding.X962, ser.PublicFormat.UncompressedPoint)).decode())
298
+ _, x25519, _, _, ser = _crypto()
299
+ sk = x25519.X25519PrivateKey.generate()
300
+ return (base64.b64encode(sk.private_bytes(ser.Encoding.Raw, ser.PrivateFormat.Raw, ser.NoEncryption())).decode(),
301
+ base64.b64encode(sk.public_key().public_bytes(ser.Encoding.Raw, ser.PublicFormat.Raw)).decode())
302
+
303
+
304
+ def unseal(envelope: dict, private_key_b64: str, name: str) -> dict:
305
+ """Open a sealed envelope from GET /api/v1/m/secret/{name}: X25519 with the ephemeral key,
306
+ HKDF-SHA256 over the shared secret (info = label || epk || our pk), AES-256-GCM with the
307
+ secret name as AAD. Returns the payload dict ({value, login?, notes?, totp?})."""
308
+ import base64
309
+ if envelope.get("alg") == SEALED_ALG_PQC and envelope.get("v") == 1:
310
+ if isinstance(private_key_b64, Pkcs11Key):
311
+ raise VaultError(0, "vault: a PKCS#11 key opens only the P-256 envelope, the token sent the hybrid one")
312
+ return _pqc_unseal(envelope, private_key_b64, name)
313
+ if envelope.get("alg") == SEALED_ALG_GOST_PQC and envelope.get("v") == 1:
314
+ if isinstance(private_key_b64, Pkcs11Key):
315
+ raise VaultError(0, "vault: a PKCS#11 key opens only the P-256 envelope, the token sent the GOST hybrid one")
316
+ return _gost_pqc_unseal(envelope, private_key_b64, name)
317
+ if envelope.get("alg") == SEALED_ALG_GOST and envelope.get("v") == 1:
318
+ return _unseal_gost(envelope, private_key_b64, name)
319
+ if envelope.get("alg") == SEALED_ALG_P256 and envelope.get("v") == 1:
320
+ return _p256_unseal(envelope, private_key_b64, name)
321
+ if isinstance(private_key_b64, Pkcs11Key):
322
+ raise VaultError(0, f"vault: a PKCS#11 key opens only the P-256 envelope, the token sent {envelope.get('alg')!r}")
323
+ hashes, x25519, AESGCM, HKDF, ser = _crypto()
324
+ if envelope.get("alg") != SEALED_ALG or envelope.get("v") != 1:
325
+ raise VaultError(0, f"vault: unsupported sealed envelope {envelope.get('alg')!r} v{envelope.get('v')!r}")
326
+ sk = x25519.X25519PrivateKey.from_private_bytes(base64.b64decode(private_key_b64))
327
+ pk = sk.public_key().public_bytes(ser.Encoding.Raw, ser.PublicFormat.Raw)
328
+ epk = base64.b64decode(envelope["epk"])
329
+ shared = sk.exchange(x25519.X25519PublicKey.from_public_bytes(epk))
330
+ key = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=_SEALED_INFO + epk + pk).derive(shared)
331
+ try:
332
+ pt = AESGCM(key).decrypt(base64.b64decode(envelope["nonce"]), base64.b64decode(envelope["ct"]), name.encode("utf-8"))
333
+ except Exception:
334
+ raise VaultError(0, "vault: sealed value does not open with this private key (wrong key, or the token is bound to another key)") from None
335
+ return json.loads(pt.decode("utf-8"))
336
+
337
+
338
+ def _unseal_gost(envelope: dict, private_key_b64: str, name: str) -> dict:
339
+ """GOST envelope: VKO GOST R 34.10-2012 (UKM from the envelope) → KEK; KDF_TREE(KEK, label, epk‖our pk)
340
+ → key; Kuznyechik-MGM with the secret name as AAD."""
341
+ import base64
342
+ g = _gost()
343
+ try:
344
+ d = int.from_bytes(base64.b64decode(private_key_b64), "big")
345
+ our_pk = g.encode_point(g.public_from_private(d))
346
+ epk = base64.b64decode(envelope["epk"])
347
+ kek = g.vko(d, g.decode_point(epk), base64.b64decode(envelope["ukm"]))
348
+ key = g.kdf_tree_256(kek, _SEALED_LABEL_GOST, epk + our_pk, 1)
349
+ pt = g.MGM(g.Kuznyechik(key)).open(base64.b64decode(envelope["nonce"]), base64.b64decode(envelope["ct"]), name.encode("utf-8"))
350
+ except Exception:
351
+ raise VaultError(0, "vault: sealed value does not open with this private key (wrong key, or the token is bound to another key)") from None
352
+ return json.loads(pt.decode("utf-8"))
353
+
354
+
355
+ def enroll(base_url: str, code: str, *, name: str = "", gost: bool = False, kind: str | None = None,
356
+ hardware_key: "Pkcs11Key | None" = None, timeout: float = 10.0) -> dict:
357
+ """Node enrolment (0.21): generate this machine's key pair, present the one-time code, receive a
358
+ token sealed to the new key. Returns {token, private_key, public_key, token_name, folder_name,
359
+ vault_url}. Keep `token` and `private_key` with mode 0600 (VAULT_TOKEN / VAULT_CLIENT_KEY);
360
+ the token alone opens nothing."""
361
+ import socket
362
+ if hardware_key is not None:
363
+ private, public = hardware_key, hardware_key.public_b64() # the private key stays in the token
364
+ else:
365
+ private, public = generate_keypair(kind or ("gost" if gost else "x25519"))
366
+ body = json.dumps({"code": code, "public_key": public, "name": name or socket.gethostname()[:64]}).encode("utf-8")
367
+ req = urllib.request.Request(base_url.rstrip("/") + "/api/enroll", data=body, method="POST",
368
+ headers={"Content-Type": "application/json", "Accept": "application/json", "User-Agent": f"aps-vault-python/{__version__}"})
369
+ try:
370
+ with _OPENER.open(req, timeout=timeout) as r: # nosec — URL is the configured vault
371
+ data = json.loads(r.read())
372
+ except urllib.error.HTTPError as e:
373
+ raw = e.read()
374
+ try:
375
+ detail = json.loads(raw).get("detail")
376
+ except Exception:
377
+ detail = raw.decode("utf-8", "replace")
378
+ raise VaultError(e.code, f"vault enrol: HTTP {e.code} {detail}") from None
379
+ return {"token": data["raw_token"], "private_key": private, "public_key": public, "token_name": data.get("token_name"),
380
+ "folder_name": data.get("folder_name"), "vault_url": data.get("vault_url") or base_url}
381
+
382
+
383
+ class Vault:
384
+ def __init__(self, base_url: str, token: str, *, cache_ttl: float = 300.0,
385
+ timeout: float = 5.0, max_retries: int = 3, fail_open_cache: bool = True,
386
+ user_agent: str = f"aps-vault-python/{__version__}", client_private_key: Optional[str] = None) -> None:
387
+ if not base_url:
388
+ raise ValueError("vault: base_url required")
389
+ if not token or not token.startswith("vlt_"):
390
+ raise ValueError("vault: a service token (vlt_…) is required, not a master password")
391
+ self._base = base_url.rstrip("/")
392
+ self._token = token
393
+ self._ttl = cache_ttl
394
+ self._timeout = timeout
395
+ self._retries = max_retries
396
+ self._fail_open = fail_open_cache
397
+ self._client_key = client_private_key or os.environ.get("VAULT_CLIENT_KEY") or None
398
+ self._ua = user_agent
399
+ self._cache: dict[str, _Entry] = {}
400
+
401
+ @classmethod
402
+ def from_env(cls, **kw: Any) -> "Vault":
403
+ """VAULT_URL + VAULT_TOKEN, or VAULT_TOKEN_FILE pointing at a 0600 file."""
404
+ url = os.environ.get("VAULT_URL", "")
405
+ token = os.environ.get("VAULT_TOKEN", "")
406
+ if not token and os.environ.get("VAULT_TOKEN_FILE"):
407
+ with open(os.environ["VAULT_TOKEN_FILE"], encoding="utf-8") as f:
408
+ token = f.read().strip()
409
+ return cls(url, token, **kw)
410
+
411
+ # ── public API ────────────────────────────────────────────────────────────
412
+ def health(self) -> dict:
413
+ return self._req("GET", "/api/v1/m/health")
414
+
415
+ def list(self) -> list[dict]:
416
+ return self._req("GET", "/api/v1/m/secrets")
417
+
418
+ def get(self, name: str, version: int | None = None) -> str:
419
+ """Current value, or an older one by number (`version`) — e.g. the previous encryption
420
+ key while files encrypted with it are still being re-wrapped."""
421
+ return self.get_full(name, version)["value"]
422
+
423
+ def versions(self, name: str) -> dict:
424
+ """{"current_version": N, "versions": [{"version", "current", "changed_at", ...}]} — no decrypt."""
425
+ return self._req("GET", "/api/v1/m/secret/" + urllib.parse.quote(name, safe="") + "/versions")
426
+
427
+ def get_full(self, name: str, version: int | None = None) -> dict:
428
+ if not name:
429
+ raise ValueError("vault: name required")
430
+ now = time.time()
431
+ key = f"{name}@{version}" if version else name
432
+ hit = self._cache.get(key)
433
+ if hit and hit.expires_at > now:
434
+ return hit.value
435
+ try:
436
+ data = self._req("GET", "/api/v1/m/secret/" + urllib.parse.quote(name, safe="") + (f"?version={int(version)}" if version else ""))
437
+ except (VaultError, OSError) as e:
438
+ if hit and self._fail_open and (not isinstance(e, VaultError) or e.status >= 500 or e.status == 429):
439
+ return hit.value # stale but known-good beats an outage
440
+ raise
441
+ if isinstance(data, dict) and "sealed" in data:
442
+ if not self._client_key:
443
+ raise VaultError(0, "vault: this token delivers sealed values — pass client_private_key (or VAULT_CLIENT_KEY)")
444
+ # 0.37: the AAD is the name WE asked for — the response's own "name" could come from a swapped envelope
445
+ payload = unseal(data.pop("sealed"), self._client_key, name)
446
+ data.update(payload)
447
+ elif self._client_key and isinstance(data, dict):
448
+ # 0.37: with a key configured a plaintext answer is not accepted — a tampering proxy could drop the envelope
449
+ raise VaultError(0, "vault: a client key is configured but the response is not sealed — refusing (a proxy may have replaced it)")
450
+ if self._ttl > 0:
451
+ self._cache[key] = _Entry(data, now + self._ttl)
452
+ return data
453
+
454
+ def put(self, name: str, value: str, *, login: str = "", tags: str = "", url: str = "") -> dict:
455
+ """Create or update a secret in the token's folder (token must have can_write)."""
456
+ body = {"value": value, "login": login, "tags": tags, "url": url}
457
+ data = self._req("POST", "/api/v1/m/secret/" + urllib.parse.quote(name, safe=""), body)
458
+ self._cache.pop(name, None)
459
+ return data
460
+
461
+ def totp(self, name: str) -> Optional[str]:
462
+ """Current TOTP code (token must have can_read_totp); None if the secret has no seed."""
463
+ self._cache.pop(name, None) # codes change every 30 s — never serve from cache
464
+ ttl, self._ttl = self._ttl, 0
465
+ try:
466
+ return self.get_full(name).get("totp")
467
+ finally:
468
+ self._ttl = ttl
469
+
470
+ def clear_cache(self) -> None:
471
+ self._cache.clear()
472
+
473
+ # ── transport ─────────────────────────────────────────────────────────────
474
+ def _req(self, method: str, path: str, body: Optional[dict] = None) -> Any:
475
+ payload = json.dumps(body).encode("utf-8") if body is not None else None
476
+ last: Optional[Exception] = None
477
+ for attempt in range(self._retries + 1):
478
+ req = urllib.request.Request(self._base + path, data=payload, method=method, headers={
479
+ "Authorization": f"Bearer {self._token}", "Accept": "application/json",
480
+ "Content-Type": "application/json", "User-Agent": self._ua,
481
+ })
482
+ try:
483
+ with _OPENER.open(req, timeout=self._timeout) as r: # nosec — URL is the configured vault
484
+ return json.loads(r.read() or b"null")
485
+ except urllib.error.HTTPError as e:
486
+ raw = e.read()
487
+ try:
488
+ parsed: Any = json.loads(raw)
489
+ detail = parsed.get("detail") if isinstance(parsed, dict) else None
490
+ except Exception:
491
+ parsed, detail = raw.decode("utf-8", "replace"), None
492
+ if (e.code == 429 or e.code >= 500) and attempt < self._retries:
493
+ time.sleep(2 ** attempt)
494
+ continue
495
+ raise VaultError(e.code, f"vault {method} {path}: HTTP {e.code} {detail or e.reason}", parsed) from None
496
+ except (urllib.error.URLError, TimeoutError, OSError) as e:
497
+ last = e
498
+ if attempt < self._retries:
499
+ time.sleep(2 ** attempt)
500
+ continue
501
+ raise OSError(f"vault: request failed after {self._retries + 1} attempts: {last}")
502
+
503
+
504
+ __all__ = ["Vault", "VaultError", "Pkcs11Key", "generate_keypair", "unseal", "enroll", "__version__"]
@@ -0,0 +1,72 @@
1
+ """python -m aps_vault keygen [--gost|--p256|--pqc|--gost-pqc] — a key pair for sealed delivery (--pqc: X25519 + ML-KEM-768; --gost-pqc: GOST + ML-KEM-768)
2
+ python -m aps_vault enroll <vault-url> <code> [--gost|--p256|--pqc|--gost-pqc] [--pkcs11 module:token:pin[:label]] [--name <host>] [--out <dir>]
3
+ — with --pkcs11 the key pair is made INSIDE the token (TPM via
4
+ tpm2-pkcs11, HSM, smart card) and never leaves it
5
+ — node enrolment (0.21): make a key pair, redeem the
6
+ one-time code, store token + key (0600) in <dir>
7
+ (default: print them)"""
8
+ import os
9
+ import sys
10
+
11
+ from . import Pkcs11Key, enroll, generate_keypair
12
+
13
+
14
+ def _usage(code=2):
15
+ print(__doc__, file=sys.stderr)
16
+ sys.exit(code)
17
+
18
+
19
+ args = sys.argv[1:]
20
+ if not args:
21
+ _usage()
22
+ if args[0] == "keygen":
23
+ if any(a not in ("--gost", "--p256", "--pqc", "--gost-pqc") for a in args[1:]):
24
+ _usage()
25
+ kind = "gost-pqc" if "--gost-pqc" in args else "gost" if "--gost" in args else "p256" if "--p256" in args else "pqc" if "--pqc" in args else "x25519"
26
+ private, public = generate_keypair(kind)
27
+ print(f"VAULT_CLIENT_KEY={private} # keep with the token, never in git ({kind})")
28
+ print(f"client_public_key={public} # paste into the token in the vault UI")
29
+ elif args[0] == "enroll":
30
+ if len(args) < 3:
31
+ _usage()
32
+ url, code, rest = args[1], args[2], args[3:]
33
+ kind, name, out, hw = "x25519", "", "", None
34
+ i = 0
35
+ while i < len(rest):
36
+ if rest[i] == "--name" and i + 1 < len(rest):
37
+ name = rest[i + 1]; i += 2
38
+ elif rest[i] == "--out" and i + 1 < len(rest):
39
+ out = rest[i + 1]; i += 2
40
+ elif rest[i] == "--pkcs11" and i + 1 < len(rest):
41
+ parts = rest[i + 1].split(":")
42
+ if len(parts) < 3:
43
+ _usage()
44
+ hw = Pkcs11Key.generate(parts[0], parts[1], parts[2], parts[3] if len(parts) > 3 else "aps-vault-node"); i += 2
45
+ elif rest[i] in ("--gost", "--p256", "--pqc", "--gost-pqc"):
46
+ kind = rest[i][2:]; i += 1
47
+ else:
48
+ _usage()
49
+ try:
50
+ r = enroll(url, code, name=name, kind=kind, hardware_key=hw)
51
+ except Exception as e:
52
+ print(f"enrol failed: {e}", file=sys.stderr)
53
+ sys.exit(1)
54
+ if out:
55
+ os.makedirs(out, mode=0o700, exist_ok=True)
56
+ for fn, val in ((("vault.token", r["token"]),) + ((("vault.key", r["private_key"]),) if hw is None else ())):
57
+ p = os.path.join(out, fn)
58
+ with open(os.open(p, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w") as f:
59
+ f.write(val + "\n")
60
+ if hw is None:
61
+ print(f"enrolled as {r['token_name']} (folder {r['folder_name']}); VAULT_TOKEN_FILE={out}/vault.token VAULT_CLIENT_KEY=$(cat {out}/vault.key) VAULT_URL={r['vault_url']}")
62
+ else:
63
+ print(f"enrolled as {r['token_name']} (folder {r['folder_name']}); VAULT_TOKEN_FILE={out}/vault.token; the private key lives in the PKCS#11 token {hw.token_label!r} as {hw.key_label!r} VAULT_URL={r['vault_url']}")
64
+ else:
65
+ print(f"VAULT_URL={r['vault_url']}")
66
+ print(f"VAULT_TOKEN={r['token']} # {r['token_name']} → {r['folder_name']}")
67
+ if hw is None:
68
+ print(f"VAULT_CLIENT_KEY={r['private_key']} # the private key: 0600, never in git")
69
+ else:
70
+ print(f"# private key inside the PKCS#11 token {hw.token_label!r}, label {hw.key_label!r} — use Pkcs11Key(...) in code")
71
+ else:
72
+ _usage()
@@ -0,0 +1,447 @@
1
+ """GOST primitives for the Python client's sealed-delivery envelope (0.19) — a verbatim copy of
2
+ backend/gost.py + backend/gostec.py (test_python_client.py checks they stay identical).
3
+ Needs the MIT `gostcrypto` package for Streebog: pip install 'aps-vault[gost]'."""
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import struct
8
+
9
+ # ── Kuznyechik (GOST R 34.12-2015) ───────────────────────────────────────────
10
+ _PI = bytes([
11
+ 252, 238, 221, 17, 207, 110, 49, 22, 251, 196, 250, 218, 35, 197, 4, 77, 233, 119, 240, 219, 147, 46, 153, 186, 23, 54, 241, 187, 20, 205, 95, 193, 249, 24, 101, 90, 226, 92, 239, 33, 129, 28, 60, 66, 139, 1, 142, 79, 5, 132, 2, 174, 227, 106, 143, 160, 6, 11, 237, 152, 127, 212, 211, 31, 235, 52, 44, 81, 234, 200, 72, 171, 242, 42, 104, 162, 253, 58, 206, 204, 181, 112, 14, 86, 8, 12, 118, 18, 191, 114, 19, 71, 156, 183, 93, 135, 21, 161, 150, 41, 16, 123, 154, 199, 243, 145, 120, 111, 157, 158, 178, 177, 50, 117, 25, 61, 255, 53, 138, 126, 109, 84, 198, 128, 195, 189, 13, 87, 223, 245, 36, 169, 62, 168, 67, 201, 215, 121, 214, 246, 124, 34, 185, 3, 224, 15, 236, 222, 122, 148, 176, 188, 220, 232, 40, 80, 78, 51, 10, 74, 167, 151, 96, 115, 30, 0, 98, 68, 26, 184, 56, 130, 100, 159, 38, 65, 173, 69, 70, 146, 39, 94, 85, 47, 140, 163, 165, 125, 105, 213, 149, 59, 7, 88, 179, 64, 134, 172, 29, 247, 48, 55, 107, 228, 136, 217, 231, 137, 225, 27, 131, 73, 76, 63, 248, 254, 141, 83, 170, 144, 202, 216, 133, 97, 32, 113, 103, 164, 45, 43, 9, 91, 203, 155, 37, 208, 190, 229, 108, 82, 89, 166, 116, 210, 230, 244, 180, 192, 209, 102, 175, 194, 57, 75, 99, 182,
12
+ ])
13
+ _PI_INV = bytes(256)
14
+ _PI_INV = bytearray(256)
15
+ for _i, _v in enumerate(_PI):
16
+ _PI_INV[_v] = _i
17
+ _PI_INV = bytes(_PI_INV)
18
+ _LVEC = (148, 32, 133, 16, 194, 192, 1, 251, 1, 192, 194, 16, 133, 32, 148, 1)
19
+
20
+
21
+ def _gf_mul(a: int, b: int) -> int:
22
+ """Multiplication in GF(2^8) with the polynomial x^8 + x^7 + x^6 + x + 1 (0x1C3)."""
23
+ p = 0
24
+ while b:
25
+ if b & 1:
26
+ p ^= a
27
+ a <<= 1
28
+ if a & 0x100:
29
+ a ^= 0x1C3
30
+ b >>= 1
31
+ return p
32
+
33
+
34
+ _MUL = [[_gf_mul(a, b) for b in range(256)] for a in range(256)]
35
+
36
+
37
+ def _l_step(state: list[int]) -> list[int]:
38
+ """One R step of the linear transform: shift right, new byte 0 = l(state)."""
39
+ acc = 0
40
+ for i in range(16):
41
+ acc ^= _MUL[state[i]][_LVEC[i]]
42
+ return [acc] + state[:15]
43
+
44
+
45
+ def _l(state: list[int]) -> list[int]:
46
+ for _ in range(16):
47
+ state = _l_step(state)
48
+ return state
49
+
50
+
51
+ def _l_inv_step(state: list[int]) -> list[int]:
52
+ a = state[0]
53
+ state = state[1:] + [0]
54
+ acc = 0
55
+ for i in range(16):
56
+ acc ^= _MUL[state[i]][_LVEC[i]]
57
+ state[15] = a ^ acc
58
+ return state
59
+
60
+
61
+ def _l_inv(state: list[int]) -> list[int]:
62
+ for _ in range(16):
63
+ state = _l_inv_step(state)
64
+ return state
65
+
66
+
67
+ def _vec(b: bytes) -> list[int]:
68
+ return list(b)
69
+
70
+
71
+ def _to_int(v: list[int]) -> int:
72
+ return int.from_bytes(bytes(v), "big")
73
+
74
+
75
+ # LS tables: LS[i][x] = L(e_i * pi(x)) as a 128-bit int — the linear map is linear, so
76
+ # L(S(block)) = XOR_i LS[i][block[i]]. Inverse tables for decryption: LS_INV[i][x] = L^-1(e_i * x).
77
+ _LS = []
78
+ _LSI = []
79
+ for _pos in range(16):
80
+ _t = []
81
+ _ti = []
82
+ for _x in range(256):
83
+ _blk = [0] * 16
84
+ _blk[_pos] = _PI[_x]
85
+ _t.append(_to_int(_l(_blk)))
86
+ _blk2 = [0] * 16
87
+ _blk2[_pos] = _x
88
+ _ti.append(_to_int(_l_inv(_blk2)))
89
+ _LS.append(_t)
90
+ _LSI.append(_ti)
91
+
92
+ _MASK128 = (1 << 128) - 1
93
+ _SHIFTS = [8 * (15 - i) for i in range(16)] # byte i of a big-endian 128-bit int
94
+
95
+
96
+ def _ls_int(x: int) -> int:
97
+ acc = 0
98
+ for i in range(16):
99
+ acc ^= _LS[i][(x >> _SHIFTS[i]) & 0xFF]
100
+ return acc
101
+
102
+
103
+ def _l_inv_int(x: int) -> int:
104
+ acc = 0
105
+ for i in range(16):
106
+ acc ^= _LSI[i][(x >> _SHIFTS[i]) & 0xFF]
107
+ return acc
108
+
109
+
110
+ def _s_inv_int(x: int) -> int:
111
+ out = 0
112
+ for i in range(16):
113
+ out |= _PI_INV[(x >> _SHIFTS[i]) & 0xFF] << _SHIFTS[i]
114
+ return out
115
+
116
+
117
+ # round constants C_i = L(Vec(i)), i = 1..32
118
+ _C = []
119
+ for _i in range(1, 33):
120
+ _blk = [0] * 15 + [_i]
121
+ _C.append(_to_int(_l(_blk)))
122
+
123
+
124
+ class Kuznyechik:
125
+ """GOST R 34.12-2015 block cipher; `key` is 32 bytes."""
126
+
127
+ def __init__(self, key: bytes) -> None:
128
+ if len(key) != 32:
129
+ raise ValueError("Kuznyechik key must be 32 bytes")
130
+ k1 = int.from_bytes(key[:16], "big")
131
+ k2 = int.from_bytes(key[16:], "big")
132
+ keys = [k1, k2]
133
+ for i in range(4):
134
+ for j in range(8):
135
+ c = _C[8 * i + j]
136
+ k1, k2 = _ls_int(k1 ^ c) ^ k2, k1
137
+ keys += [k1, k2]
138
+ self._k = keys # 10 round keys
139
+ self._kd = keys[::-1]
140
+
141
+ def encrypt_block(self, block: bytes) -> bytes:
142
+ x = int.from_bytes(block, "big")
143
+ k = self._k
144
+ for i in range(9):
145
+ x = _ls_int(x ^ k[i])
146
+ return (x ^ k[9]).to_bytes(16, "big")
147
+
148
+ def decrypt_block(self, block: bytes) -> bytes:
149
+ x = int.from_bytes(block, "big")
150
+ k = self._kd
151
+ x ^= k[0]
152
+ for i in range(1, 10):
153
+ x = _s_inv_int(_l_inv_int(x)) ^ k[i]
154
+ return x.to_bytes(16, "big")
155
+
156
+
157
+ # ── MGM (RFC 9058 / R 1323565.1.026-2019) ─────────────────────────────────────
158
+ _R = 0x87 # reduction for the GF(2^128) polynomial w^128 + w^7 + w^2 + w + 1
159
+
160
+
161
+ def _gf128_mul(a: int, b: int) -> int:
162
+ """Multiplication in GF(2^128) with f(w) = w^128 + w^7 + w^2 + w + 1 (RFC 9058 §4.1), MSB-first."""
163
+ p = 0
164
+ for _ in range(128):
165
+ if b & 1:
166
+ p ^= a
167
+ carry = a >> 127
168
+ a = (a << 1) & _MASK128
169
+ if carry:
170
+ a ^= _R
171
+ b >>= 1
172
+ return p
173
+
174
+
175
+ def _incr_r(x: int) -> int:
176
+ hi, lo = x >> 64, x & ((1 << 64) - 1)
177
+ return (hi << 64) | ((lo + 1) & ((1 << 64) - 1))
178
+
179
+
180
+ def _incr_l(x: int) -> int:
181
+ hi, lo = x >> 64, x & ((1 << 64) - 1)
182
+ return (((hi + 1) & ((1 << 64) - 1)) << 64) | lo
183
+
184
+
185
+ def _blocks(data: bytes):
186
+ for i in range(0, len(data), 16):
187
+ yield data[i:i + 16]
188
+
189
+
190
+ class MGM:
191
+ """AEAD over a 128-bit block cipher: nonce 16 bytes (top bit must be 0), tag 16 bytes."""
192
+
193
+ TAG = 16
194
+ NONCE = 16
195
+
196
+ def __init__(self, cipher: Kuznyechik) -> None:
197
+ self._c = cipher
198
+
199
+ def _check_nonce(self, nonce: bytes) -> int:
200
+ if len(nonce) != 16 or nonce[0] & 0x80:
201
+ raise ValueError("MGM nonce must be 16 bytes with the top bit clear")
202
+ return int.from_bytes(nonce, "big")
203
+
204
+ def _tag(self, nonce_int: int, aad: bytes, ct: bytes) -> bytes:
205
+ enc = self._c.encrypt_block
206
+ z = int.from_bytes(enc(((1 << 127) | nonce_int).to_bytes(16, "big")), "big")
207
+ acc = 0
208
+ for part in (aad, ct):
209
+ for blk in _blocks(part):
210
+ if len(blk) < 16:
211
+ blk = blk + b"\x00" * (16 - len(blk))
212
+ h = int.from_bytes(enc(z.to_bytes(16, "big")), "big")
213
+ acc ^= _gf128_mul(h, int.from_bytes(blk, "big"))
214
+ z = _incr_l(z)
215
+ h = int.from_bytes(enc(z.to_bytes(16, "big")), "big")
216
+ length_block = struct.pack(">QQ", len(aad) * 8, len(ct) * 8)
217
+ acc ^= _gf128_mul(h, int.from_bytes(length_block, "big"))
218
+ return enc(acc.to_bytes(16, "big"))[: self.TAG]
219
+
220
+ def _keystream_xor(self, nonce_int: int, data: bytes) -> bytes:
221
+ enc = self._c.encrypt_block
222
+ y = int.from_bytes(enc(nonce_int.to_bytes(16, "big")), "big") # top bit of the nonce is 0
223
+ out = bytearray()
224
+ for blk in _blocks(data):
225
+ ks = enc(y.to_bytes(16, "big"))
226
+ out += bytes(a ^ b for a, b in zip(blk, ks))
227
+ y = _incr_r(y)
228
+ return bytes(out)
229
+
230
+ def seal(self, nonce: bytes, plaintext: bytes, aad: bytes = b"") -> bytes:
231
+ n = self._check_nonce(nonce)
232
+ ct = self._keystream_xor(n, plaintext)
233
+ return ct + self._tag(n, aad, ct)
234
+
235
+ def open(self, nonce: bytes, ciphertext: bytes, aad: bytes = b"") -> bytes:
236
+ n = self._check_nonce(nonce)
237
+ if len(ciphertext) < self.TAG:
238
+ raise ValueError("ciphertext too short")
239
+ ct, tag = ciphertext[: -self.TAG], ciphertext[-self.TAG:]
240
+ expected = self._tag(n, aad, ct)
241
+ # constant-time compare
242
+ diff = 0
243
+ for a, b in zip(expected, tag):
244
+ diff |= a ^ b
245
+ if diff:
246
+ raise ValueError("MGM tag mismatch")
247
+ return self._keystream_xor(n, ct)
248
+
249
+
250
+ def mgm_nonce() -> bytes:
251
+ n = bytearray(os.urandom(16))
252
+ n[0] &= 0x7F
253
+ return bytes(n)
254
+
255
+
256
+ # ── Streebog family via gostcrypto (MIT) ─────────────────────────────────────
257
+ def streebog256(data: bytes) -> bytes:
258
+ from gostcrypto import gosthash
259
+ return gosthash.new("streebog256", data=data).digest()
260
+
261
+
262
+ def streebog512(data: bytes) -> bytes:
263
+ from gostcrypto import gosthash
264
+ return gosthash.new("streebog512", data=data).digest()
265
+
266
+
267
+ _HMAC_BLOCK = 64 # Streebog block size (R 50.1.113-2016 §4.1: B = 64 bytes for both lengths)
268
+
269
+
270
+ def _hmac(hash_fn, key: bytes, data: bytes) -> bytes:
271
+ """RFC 2104 HMAC over Streebog. gostcrypto's HMAC refuses keys longer than 64 bytes, whereas
272
+ the standard hashes them first — and our tokens (vlt_…, 83 characters) are longer."""
273
+ if len(key) > _HMAC_BLOCK:
274
+ key = hash_fn(key)
275
+ key = key + b"\x00" * (_HMAC_BLOCK - len(key))
276
+ ipad = bytes(b ^ 0x36 for b in key)
277
+ opad = bytes(b ^ 0x5C for b in key)
278
+ return hash_fn(opad + hash_fn(ipad + data))
279
+
280
+
281
+ def hmac_streebog256(key: bytes, data: bytes) -> bytes:
282
+ return _hmac(streebog256, key, data)
283
+
284
+
285
+ def hmac_streebog512(key: bytes, data: bytes) -> bytes:
286
+ return _hmac(streebog512, key, data)
287
+
288
+
289
+ def kdf_tree_256(key: bytes, label: bytes, seed: bytes, keys: int = 1) -> bytes:
290
+ """KDF_TREE_GOSTR3411_2012_256 (R 50.1.113-2016 §4.5): K(i) = HMAC256(key, i‖label‖0x00‖seed‖L),
291
+ i one byte (R = 1), L = 256·keys bits as two bytes big-endian. Returns `keys` × 32 bytes."""
292
+ if not 1 <= keys <= 255:
293
+ raise ValueError("keys out of range")
294
+ L = (256 * keys).to_bytes(2, "big")
295
+ out = b""
296
+ for i in range(1, keys + 1):
297
+ out += hmac_streebog256(key, bytes([i]) + label + b"\x00" + seed + L)
298
+ return out
299
+
300
+
301
+ def pbkdf2_streebog512(password: bytes, salt: bytes, iterations: int, length: int = 32) -> bytes:
302
+ """PBKDF2 with HMAC_GOSTR3411_2012_512 (R 50.1.111-2016)."""
303
+ from gostcrypto import gostpbkdf
304
+ return gostpbkdf.new(password, salt=salt, counter=iterations).derive(length)
305
+
306
+
307
+ # ── GOST R 34.10-2012 curve and VKO (backend/gostec.py) ──
308
+
309
+ # id-tc26-gost-3410-2012-256-paramSetB
310
+ P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFD97
311
+ A = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFD94
312
+ B = 0xA6
313
+ Q = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF6C611070995AD10045841B09B761B893
314
+ GX = 0x1
315
+ GY = 0x8D91E471E0989CDA27DF505A453F2B7635294F2DDF23E3B122ACC99C9E9F1E14
316
+ CURVE_NAME = "id-tc26-gost-3410-2012-256-paramSetB"
317
+
318
+ INF = None
319
+
320
+
321
+ def _inv(x: int) -> int:
322
+ return pow(x % P, P - 2, P)
323
+
324
+
325
+ # Jacobian coordinates (X, Y, Z) with x = X/Z², y = Y/Z²: one inversion per scalar multiplication
326
+ # instead of one per step — ~10 ms for a 256-bit scalar in CPython, the affine version takes ~120.
327
+ def _jdouble(pt):
328
+ X1, Y1, Z1 = pt
329
+ if Y1 == 0:
330
+ return (0, 1, 0)
331
+ S = 4 * X1 * Y1 * Y1 % P
332
+ Z1sq = Z1 * Z1 % P
333
+ M = (3 * X1 * X1 + A * Z1sq * Z1sq) % P
334
+ X3 = (M * M - 2 * S) % P
335
+ Y3 = (M * (S - X3) - 8 * pow(Y1, 4, P)) % P
336
+ Z3 = 2 * Y1 * Z1 % P
337
+ return (X3, Y3, Z3)
338
+
339
+
340
+ def _jadd(p1, p2):
341
+ if p1[2] == 0:
342
+ return p2
343
+ if p2[2] == 0:
344
+ return p1
345
+ X1, Y1, Z1 = p1
346
+ X2, Y2, Z2 = p2
347
+ Z1sq, Z2sq = Z1 * Z1 % P, Z2 * Z2 % P
348
+ U1, U2 = X1 * Z2sq % P, X2 * Z1sq % P
349
+ S1, S2 = Y1 * Z2sq * Z2 % P, Y2 * Z1sq * Z1 % P
350
+ if U1 == U2:
351
+ if S1 != S2:
352
+ return (0, 1, 0)
353
+ return _jdouble(p1)
354
+ H = (U2 - U1) % P
355
+ R = (S2 - S1) % P
356
+ H2 = H * H % P
357
+ H3 = H2 * H % P
358
+ X3 = (R * R - H3 - 2 * U1 * H2) % P
359
+ Y3 = (R * (U1 * H2 - X3) - S1 * H3) % P
360
+ Z3 = H * Z1 * Z2 % P
361
+ return (X3, Y3, Z3)
362
+
363
+
364
+ def _to_affine(pt):
365
+ X, Y, Z = pt
366
+ if Z == 0:
367
+ return INF
368
+ zi = _inv(Z)
369
+ zi2 = zi * zi % P
370
+ return (X * zi2 % P, Y * zi2 * zi % P)
371
+
372
+
373
+ def add(p1, p2):
374
+ """Affine point addition (tests / small cases)."""
375
+ if p1 is INF:
376
+ return p2
377
+ if p2 is INF:
378
+ return p1
379
+ return _to_affine(_jadd((p1[0], p1[1], 1), (p2[0], p2[1], 1)))
380
+
381
+
382
+ def mul(k: int, pt):
383
+ """k·pt by double-and-add in Jacobian coordinates. Not constant-time: the vault's ephemeral
384
+ scalars are single-use and the client's private key runs on the client's own machine — the same
385
+ model as the X25519 envelope's libraries."""
386
+ k %= Q
387
+ if k == 0 or pt is INF:
388
+ return INF
389
+ result = (0, 1, 0)
390
+ addend = (pt[0], pt[1], 1)
391
+ while k:
392
+ if k & 1:
393
+ result = _jadd(result, addend)
394
+ addend = _jdouble(addend)
395
+ k >>= 1
396
+ return _to_affine(result)
397
+
398
+
399
+ def on_curve(pt) -> bool:
400
+ if pt is INF:
401
+ return False
402
+ x, y = pt
403
+ return 0 <= x < P and 0 <= y < P and (y * y - (x * x * x + A * x + B)) % P == 0
404
+
405
+
406
+ def encode_point(pt) -> bytes:
407
+ x, y = pt
408
+ return x.to_bytes(32, "little") + y.to_bytes(32, "little")
409
+
410
+
411
+ def decode_point(raw: bytes):
412
+ if len(raw) != 64:
413
+ raise ValueError("GOST public key must be 64 bytes (X‖Y little-endian)")
414
+ pt = (int.from_bytes(raw[:32], "little"), int.from_bytes(raw[32:], "little"))
415
+ if not on_curve(pt):
416
+ raise ValueError("point is not on the GOST curve") # prime order (cofactor 1): on the curve ⇒ in the group
417
+ return pt
418
+
419
+
420
+ def generate_private() -> int:
421
+ while True:
422
+ d = int.from_bytes(os.urandom(32), "big")
423
+ if 1 <= d < Q:
424
+ return d
425
+
426
+
427
+ def public_from_private(d: int):
428
+ return mul(d, (GX, GY))
429
+
430
+
431
+ def generate_keypair() -> tuple[bytes, bytes]:
432
+ """(private 32 bytes big-endian, public 64 bytes X‖Y little-endian)."""
433
+ d = generate_private()
434
+ return d.to_bytes(32, "big"), encode_point(public_from_private(d))
435
+
436
+
437
+ def vko(private: int, peer, ukm: bytes) -> bytes:
438
+ """VKO_GOSTR3410_2012_256: Streebog-256 of the shared point UKM·d·Q (little-endian X‖Y)."""
439
+ if len(ukm) != 8:
440
+ raise ValueError("UKM must be 8 bytes")
441
+ u = int.from_bytes(ukm, "little")
442
+ if u == 0:
443
+ raise ValueError("UKM must be non-zero")
444
+ shared = mul((u * private) % Q, peer)
445
+ if shared is INF:
446
+ raise ValueError("degenerate shared point")
447
+ return streebog256(encode_point(shared))
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.4
2
+ Name: aps-vault
3
+ Version: 0.41.0
4
+ Summary: Client for APS Vault machine API (service tokens), standard library only
5
+ Author: Konstantin Zhebenev
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/kzhebenev/aps-vault
8
+ Project-URL: Documentation, https://github.com/kzhebenev/aps-vault/tree/main/docs
9
+ Project-URL: Source, https://github.com/kzhebenev/aps-vault/tree/main/clients/python
10
+ Project-URL: Changelog, https://github.com/kzhebenev/aps-vault/blob/main/CHANGELOG.md
11
+ Project-URL: Issues, https://github.com/kzhebenev/aps-vault/issues
12
+ Keywords: secrets,vault,secret-manager,service-token,sealed-delivery,gost,ml-kem,post-quantum
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Security
17
+ Classifier: Topic :: Security :: Cryptography
18
+ Classifier: Intended Audience :: Developers
19
+ Classifier: Intended Audience :: System Administrators
20
+ Classifier: Operating System :: OS Independent
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ Provides-Extra: sealed
24
+ Requires-Dist: cryptography>=42; extra == "sealed"
25
+ Provides-Extra: gost
26
+ Requires-Dist: cryptography>=42; extra == "gost"
27
+ Requires-Dist: gostcrypto>=1.2; extra == "gost"
28
+ Provides-Extra: pkcs11
29
+ Requires-Dist: cryptography>=42; extra == "pkcs11"
30
+ Requires-Dist: python-pkcs11>=0.10; extra == "pkcs11"
31
+ Provides-Extra: pqc
32
+ Requires-Dist: cryptography>=42; extra == "pqc"
33
+ Requires-Dist: kyber-py>=1.2; extra == "pqc"
34
+
35
+ # aps-vault (Python)
36
+
37
+ Standard-library client for the APS Vault machine API. Python 3.9+, no dependencies.
38
+
39
+ ```bash
40
+ pip install ./clients/python # or: pip install aps-vault (once published)
41
+ ```
42
+
43
+ ```python
44
+ import os
45
+ from aps_vault import Vault, VaultError
46
+
47
+ v = Vault("https://vault.example.com", os.environ["VAULT_TOKEN"]) # or Vault.from_env()
48
+ try:
49
+ dsn_password = v.get("db-password")
50
+ smtp = v.get_full("smtp") # {"name","value","login","updated_at",...}
51
+ except VaultError as e:
52
+ if e.status == 404: ...
53
+ ```
54
+
55
+ - `get(name)` / `get_full(name)` — cached for `cache_ttl` seconds (default 300). While the
56
+ vault is unreachable a stale cached value is returned (`fail_open_cache=True`), so a vault
57
+ restart never takes your service down; the first fetch still fails loudly.
58
+ - `put(name, value, login=..., tags=..., url=...)` — token needs `can_write`.
59
+ - `totp(name)` — current 6-digit code, never cached; token needs `can_read_totp`.
60
+ - `list()`, `health()`.
61
+ - Retries 429/5xx/network with 1 s, 2 s, 4 s back-off.
62
+
63
+ Tokens: pass via environment (`VAULT_TOKEN`) or a `0600` file (`VAULT_TOKEN_FILE`). Never
64
+ commit one, never log one. `Vault` refuses anything that does not look like a service token.
65
+
66
+ ## Sealed delivery (0.17)
67
+
68
+ If the token is bound to this application's X25519 public key, values arrive encrypted and the
69
+ client decrypts them in-process (needs the optional extra: `pip install 'aps-vault[sealed]'`):
70
+
71
+ ```bash
72
+ python -m aps_vault keygen # prints VAULT_CLIENT_KEY (private, keep with the token) and client_public_key (for the token)
73
+ python -m aps_vault keygen --gost # GOST R 34.10-2012 pair → the vault seals with VKO + Kuznyechik-MGM; needs pip install 'aps-vault[gost]'
74
+ ```
75
+ ```python
76
+ v = Vault(url, token, client_private_key=os.environ["VAULT_CLIENT_KEY"]) # or just set VAULT_CLIENT_KEY
77
+ v.get("db-password")
78
+ ```
79
+
80
+ Without the key the client raises `VaultError("… sealed values …")` rather than returning the
81
+ envelope; with the wrong key it says so. See `docs/SEALED.md`.
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ aps_vault/__init__.py
4
+ aps_vault/__main__.py
5
+ aps_vault/gost.py
6
+ aps_vault.egg-info/PKG-INFO
7
+ aps_vault.egg-info/SOURCES.txt
8
+ aps_vault.egg-info/dependency_links.txt
9
+ aps_vault.egg-info/requires.txt
10
+ aps_vault.egg-info/top_level.txt
@@ -0,0 +1,15 @@
1
+
2
+ [gost]
3
+ cryptography>=42
4
+ gostcrypto>=1.2
5
+
6
+ [pkcs11]
7
+ cryptography>=42
8
+ python-pkcs11>=0.10
9
+
10
+ [pqc]
11
+ cryptography>=42
12
+ kyber-py>=1.2
13
+
14
+ [sealed]
15
+ cryptography>=42
@@ -0,0 +1 @@
1
+ aps_vault
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "aps-vault"
3
+ version = "0.41.0"
4
+ description = "Client for APS Vault machine API (service tokens), standard library only"
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ requires-python = ">=3.9"
8
+ dependencies = []
9
+ authors = [{ name = "Konstantin Zhebenev" }]
10
+ keywords = ["secrets", "vault", "secret-manager", "service-token", "sealed-delivery", "gost", "ml-kem", "post-quantum"]
11
+ classifiers = [
12
+ "License :: OSI Approved :: MIT License",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3 :: Only",
15
+ "Topic :: Security",
16
+ "Topic :: Security :: Cryptography",
17
+ "Intended Audience :: Developers",
18
+ "Intended Audience :: System Administrators",
19
+ "Operating System :: OS Independent",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/kzhebenev/aps-vault"
24
+ Documentation = "https://github.com/kzhebenev/aps-vault/tree/main/docs"
25
+ Source = "https://github.com/kzhebenev/aps-vault/tree/main/clients/python"
26
+ Changelog = "https://github.com/kzhebenev/aps-vault/blob/main/CHANGELOG.md"
27
+ Issues = "https://github.com/kzhebenev/aps-vault/issues"
28
+
29
+ [project.optional-dependencies]
30
+ sealed = ["cryptography>=42"]
31
+ gost = ["cryptography>=42", "gostcrypto>=1.2"]
32
+ pkcs11 = ["cryptography>=42", "python-pkcs11>=0.10"]
33
+ pqc = ["cryptography>=42", "kyber-py>=1.2"]
34
+
35
+ [build-system]
36
+ requires = ["setuptools>=68"]
37
+ build-backend = "setuptools.build_meta"
38
+
39
+ [tool.setuptools.packages.find]
40
+ include = ["aps_vault*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+