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.
- aps_vault-0.41.0/PKG-INFO +81 -0
- aps_vault-0.41.0/README.md +47 -0
- aps_vault-0.41.0/aps_vault/__init__.py +504 -0
- aps_vault-0.41.0/aps_vault/__main__.py +72 -0
- aps_vault-0.41.0/aps_vault/gost.py +447 -0
- aps_vault-0.41.0/aps_vault.egg-info/PKG-INFO +81 -0
- aps_vault-0.41.0/aps_vault.egg-info/SOURCES.txt +10 -0
- aps_vault-0.41.0/aps_vault.egg-info/dependency_links.txt +1 -0
- aps_vault-0.41.0/aps_vault.egg-info/requires.txt +15 -0
- aps_vault-0.41.0/aps_vault.egg-info/top_level.txt +1 -0
- aps_vault-0.41.0/pyproject.toml +40 -0
- aps_vault-0.41.0/setup.cfg +4 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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*"]
|