seekrit 0.1.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.
- seekrit-0.1.0/.gitignore +7 -0
- seekrit-0.1.0/LICENSE +21 -0
- seekrit-0.1.0/PKG-INFO +96 -0
- seekrit-0.1.0/README.md +77 -0
- seekrit-0.1.0/pyproject.toml +31 -0
- seekrit-0.1.0/src/seekrit/__init__.py +29 -0
- seekrit-0.1.0/src/seekrit/_client.py +108 -0
- seekrit-0.1.0/src/seekrit/_crypto.py +127 -0
- seekrit-0.1.0/src/seekrit/errors.py +31 -0
- seekrit-0.1.0/testdata/vectors.json +65 -0
- seekrit-0.1.0/tests/test_vectors.py +60 -0
seekrit-0.1.0/.gitignore
ADDED
seekrit-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 seekrit
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
seekrit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: seekrit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read-path SDK for seekrit — resolve and decrypt secrets client-side with a service token.
|
|
5
|
+
Project-URL: Homepage, https://seekrit.dev
|
|
6
|
+
Project-URL: Documentation, https://seekrit.dev/docs
|
|
7
|
+
Project-URL: Source, https://github.com/seekritdev/python-sdk
|
|
8
|
+
Author: seekrit
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: encryption,secrets,secrets-manager,seekrit,zero-knowledge
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Security :: Cryptography
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Requires-Dist: cryptography>=41
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# seekrit — Python SDK
|
|
21
|
+
|
|
22
|
+
Read-path SDK for [seekrit](https://seekrit.dev). Authenticate with a service
|
|
23
|
+
token, resolve your environment, and get **decrypted** secrets — the API only
|
|
24
|
+
ever returns ciphertext; decryption happens in your process.
|
|
25
|
+
|
|
26
|
+
> This repo is a **read-only mirror** published from seekrit's monorepo so the
|
|
27
|
+
> code that holds your token and decrypts plaintext is auditable. Don't commit
|
|
28
|
+
> here — it's overwritten on each sync. Issues and PRs welcome.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
pip install seekrit
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Requires Python 3.9+. The only dependency is [`cryptography`](https://cryptography.io).
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import seekrit
|
|
42
|
+
|
|
43
|
+
client = seekrit.Client() # token from $SEEKRIT_TOKEN
|
|
44
|
+
secrets = client.resolve() # {"DATABASE_URL": "postgres://…", …}
|
|
45
|
+
|
|
46
|
+
db_url = client.get("DATABASE_URL")
|
|
47
|
+
api_key = client.get("API_KEY", default="")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Load everything into the process environment:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import os, seekrit
|
|
54
|
+
seekrit.Client().into_env() # existing os.environ vars win by default
|
|
55
|
+
print(os.environ["DATABASE_URL"])
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Configuration
|
|
59
|
+
|
|
60
|
+
| Argument | Env var | Default |
|
|
61
|
+
| --- | --- | --- |
|
|
62
|
+
| `token` | `SEEKRIT_TOKEN` | — (required) |
|
|
63
|
+
| `api_url` | `SEEKRIT_API_URL` | `https://api.seekrit.dev` |
|
|
64
|
+
| `overrides` | — | `{}` |
|
|
65
|
+
| `timeout` | — | `30.0` (seconds) |
|
|
66
|
+
|
|
67
|
+
A service token binds to a single app environment (plus its composed group
|
|
68
|
+
slices). To pull a different environment slice of a composed group, pass
|
|
69
|
+
`overrides` (the `?with=` override):
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
seekrit.Client(overrides={"shared": "dev"}).resolve()
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Errors
|
|
76
|
+
|
|
77
|
+
- `SeekritApiError` — non-2xx from the API; has `.status` and `.code`
|
|
78
|
+
(`"unauthorized"`, `"forbidden"`, `"not_found"`, …).
|
|
79
|
+
- `SeekritCryptoError` — a token or ciphertext could not be parsed/decrypted.
|
|
80
|
+
- `SeekritError` — base class (also covers network failures).
|
|
81
|
+
|
|
82
|
+
The client is **fail-closed**: any resolve or decrypt failure raises rather than
|
|
83
|
+
returning partial results.
|
|
84
|
+
|
|
85
|
+
## Zero-knowledge
|
|
86
|
+
|
|
87
|
+
`GET /v1/resolve` returns ciphertext plus a data-encryption key wrapped to your
|
|
88
|
+
token's public key. This SDK recovers the token's private key, unwraps the DEK
|
|
89
|
+
(ECDH P-256 → HKDF-SHA256 → AES-256-GCM), and decrypts each secret
|
|
90
|
+
(AES-256-GCM, AAD-bound to `environmentId/NAME`) — the exact scheme used by the
|
|
91
|
+
CLI, `seekrit run`, and every other seekrit client. See
|
|
92
|
+
[seekrit.dev/docs](https://seekrit.dev/docs/concepts/encryption).
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT
|
seekrit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# seekrit — Python SDK
|
|
2
|
+
|
|
3
|
+
Read-path SDK for [seekrit](https://seekrit.dev). Authenticate with a service
|
|
4
|
+
token, resolve your environment, and get **decrypted** secrets — the API only
|
|
5
|
+
ever returns ciphertext; decryption happens in your process.
|
|
6
|
+
|
|
7
|
+
> This repo is a **read-only mirror** published from seekrit's monorepo so the
|
|
8
|
+
> code that holds your token and decrypts plaintext is auditable. Don't commit
|
|
9
|
+
> here — it's overwritten on each sync. Issues and PRs welcome.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pip install seekrit
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires Python 3.9+. The only dependency is [`cryptography`](https://cryptography.io).
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import seekrit
|
|
23
|
+
|
|
24
|
+
client = seekrit.Client() # token from $SEEKRIT_TOKEN
|
|
25
|
+
secrets = client.resolve() # {"DATABASE_URL": "postgres://…", …}
|
|
26
|
+
|
|
27
|
+
db_url = client.get("DATABASE_URL")
|
|
28
|
+
api_key = client.get("API_KEY", default="")
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Load everything into the process environment:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import os, seekrit
|
|
35
|
+
seekrit.Client().into_env() # existing os.environ vars win by default
|
|
36
|
+
print(os.environ["DATABASE_URL"])
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Configuration
|
|
40
|
+
|
|
41
|
+
| Argument | Env var | Default |
|
|
42
|
+
| --- | --- | --- |
|
|
43
|
+
| `token` | `SEEKRIT_TOKEN` | — (required) |
|
|
44
|
+
| `api_url` | `SEEKRIT_API_URL` | `https://api.seekrit.dev` |
|
|
45
|
+
| `overrides` | — | `{}` |
|
|
46
|
+
| `timeout` | — | `30.0` (seconds) |
|
|
47
|
+
|
|
48
|
+
A service token binds to a single app environment (plus its composed group
|
|
49
|
+
slices). To pull a different environment slice of a composed group, pass
|
|
50
|
+
`overrides` (the `?with=` override):
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
seekrit.Client(overrides={"shared": "dev"}).resolve()
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Errors
|
|
57
|
+
|
|
58
|
+
- `SeekritApiError` — non-2xx from the API; has `.status` and `.code`
|
|
59
|
+
(`"unauthorized"`, `"forbidden"`, `"not_found"`, …).
|
|
60
|
+
- `SeekritCryptoError` — a token or ciphertext could not be parsed/decrypted.
|
|
61
|
+
- `SeekritError` — base class (also covers network failures).
|
|
62
|
+
|
|
63
|
+
The client is **fail-closed**: any resolve or decrypt failure raises rather than
|
|
64
|
+
returning partial results.
|
|
65
|
+
|
|
66
|
+
## Zero-knowledge
|
|
67
|
+
|
|
68
|
+
`GET /v1/resolve` returns ciphertext plus a data-encryption key wrapped to your
|
|
69
|
+
token's public key. This SDK recovers the token's private key, unwraps the DEK
|
|
70
|
+
(ECDH P-256 → HKDF-SHA256 → AES-256-GCM), and decrypts each secret
|
|
71
|
+
(AES-256-GCM, AAD-bound to `environmentId/NAME`) — the exact scheme used by the
|
|
72
|
+
CLI, `seekrit run`, and every other seekrit client. See
|
|
73
|
+
[seekrit.dev/docs](https://seekrit.dev/docs/concepts/encryption).
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "seekrit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Read-path SDK for seekrit — resolve and decrypt secrets client-side with a service token."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "seekrit" }]
|
|
13
|
+
keywords = ["seekrit", "secrets", "secrets-manager", "encryption", "zero-knowledge"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Security :: Cryptography",
|
|
19
|
+
]
|
|
20
|
+
dependencies = ["cryptography>=41"]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://seekrit.dev"
|
|
24
|
+
Documentation = "https://seekrit.dev/docs"
|
|
25
|
+
Source = "https://github.com/seekritdev/python-sdk"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/seekrit"]
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.sdist]
|
|
31
|
+
include = ["src/seekrit", "tests", "testdata", "README.md"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""seekrit — read-path SDK for the zero-knowledge secrets manager.
|
|
2
|
+
|
|
3
|
+
import seekrit
|
|
4
|
+
|
|
5
|
+
client = seekrit.Client() # reads $SEEKRIT_TOKEN
|
|
6
|
+
secrets = client.resolve() # {"DATABASE_URL": "...", ...}
|
|
7
|
+
db = client.get("DATABASE_URL")
|
|
8
|
+
|
|
9
|
+
Secrets are decrypted in-process; the API only ever sees ciphertext.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from ._client import Client, DEFAULT_API_URL
|
|
13
|
+
from ._crypto import TokenKey, decrypt_secret, materialize, secret_aad
|
|
14
|
+
from .errors import SeekritApiError, SeekritCryptoError, SeekritError
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0" # x-release-please-version
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Client",
|
|
20
|
+
"DEFAULT_API_URL",
|
|
21
|
+
"TokenKey",
|
|
22
|
+
"decrypt_secret",
|
|
23
|
+
"materialize",
|
|
24
|
+
"secret_aad",
|
|
25
|
+
"SeekritError",
|
|
26
|
+
"SeekritApiError",
|
|
27
|
+
"SeekritCryptoError",
|
|
28
|
+
"__version__",
|
|
29
|
+
]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""The resolve client: fetch ``GET /v1/resolve`` and decrypt it locally."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from typing import Dict, Mapping, MutableMapping, Optional
|
|
10
|
+
|
|
11
|
+
from ._crypto import TokenKey, materialize
|
|
12
|
+
from .errors import SeekritApiError, SeekritCryptoError, SeekritError
|
|
13
|
+
|
|
14
|
+
DEFAULT_API_URL = "https://api.seekrit.dev"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Client:
|
|
18
|
+
"""A read-only seekrit client bound to one service token.
|
|
19
|
+
|
|
20
|
+
A service token selects exactly one app environment (plus its composed
|
|
21
|
+
group slices); resolving returns the merged, decrypted secrets for it.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
token: ``skt_...`` service token. Defaults to ``$SEEKRIT_TOKEN``.
|
|
25
|
+
api_url: API base URL. Defaults to ``$SEEKRIT_API_URL`` or
|
|
26
|
+
``https://api.seekrit.dev``.
|
|
27
|
+
overrides: optional ``{group_slug: env_slug}`` map to pull a different
|
|
28
|
+
environment slice of a composed group (the ``?with=`` override).
|
|
29
|
+
timeout: per-request timeout in seconds.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
token: Optional[str] = None,
|
|
35
|
+
*,
|
|
36
|
+
api_url: Optional[str] = None,
|
|
37
|
+
overrides: Optional[Mapping[str, str]] = None,
|
|
38
|
+
timeout: float = 30.0,
|
|
39
|
+
) -> None:
|
|
40
|
+
token = token or os.environ.get("SEEKRIT_TOKEN")
|
|
41
|
+
if not token:
|
|
42
|
+
raise SeekritError("no service token: pass token= or set SEEKRIT_TOKEN")
|
|
43
|
+
self._token = token
|
|
44
|
+
self._key = TokenKey.parse(token) # fail fast on a bad token
|
|
45
|
+
self._api_url = (api_url or os.environ.get("SEEKRIT_API_URL") or DEFAULT_API_URL).rstrip("/")
|
|
46
|
+
self._overrides = dict(overrides or {})
|
|
47
|
+
self._timeout = timeout
|
|
48
|
+
|
|
49
|
+
def resolve(self) -> Dict[str, str]:
|
|
50
|
+
"""Fetch, decrypt, and merge; return ``{NAME: value}``."""
|
|
51
|
+
return materialize(self._fetch(), self._key)
|
|
52
|
+
|
|
53
|
+
def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
|
|
54
|
+
"""Return a single secret's value, or ``default`` if it is not present."""
|
|
55
|
+
return self.resolve().get(name, default)
|
|
56
|
+
|
|
57
|
+
def into_env(
|
|
58
|
+
self,
|
|
59
|
+
env: Optional[MutableMapping[str, str]] = None,
|
|
60
|
+
*,
|
|
61
|
+
override: bool = False,
|
|
62
|
+
) -> Dict[str, str]:
|
|
63
|
+
"""Load resolved secrets into ``env`` (default ``os.environ``).
|
|
64
|
+
|
|
65
|
+
By default an existing variable is left untouched (process env wins);
|
|
66
|
+
pass ``override=True`` to let resolved secrets take precedence.
|
|
67
|
+
Returns the merged secrets that were resolved.
|
|
68
|
+
"""
|
|
69
|
+
target = os.environ if env is None else env
|
|
70
|
+
merged = self.resolve()
|
|
71
|
+
for name, value in merged.items():
|
|
72
|
+
if override or name not in target:
|
|
73
|
+
target[name] = value
|
|
74
|
+
return merged
|
|
75
|
+
|
|
76
|
+
# -- internal ---------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def _fetch(self) -> dict:
|
|
79
|
+
url = self._api_url + "/v1/resolve"
|
|
80
|
+
query = "&".join(f"with={g}:{e}" for g, e in sorted(self._overrides.items()))
|
|
81
|
+
if query:
|
|
82
|
+
url += "?" + query
|
|
83
|
+
request = urllib.request.Request(
|
|
84
|
+
url,
|
|
85
|
+
method="GET",
|
|
86
|
+
headers={"authorization": f"Bearer {self._token}", "accept": "application/json"},
|
|
87
|
+
)
|
|
88
|
+
try:
|
|
89
|
+
with urllib.request.urlopen(request, timeout=self._timeout) as response:
|
|
90
|
+
return json.loads(response.read())
|
|
91
|
+
except urllib.error.HTTPError as exc:
|
|
92
|
+
raise self._api_error(exc.code, exc.read()) from exc
|
|
93
|
+
except urllib.error.URLError as exc:
|
|
94
|
+
raise SeekritError(f"resolve request failed: {exc.reason}") from exc
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _api_error(status: int, body: bytes) -> SeekritApiError:
|
|
98
|
+
code, message = "internal", f"HTTP {status}"
|
|
99
|
+
try:
|
|
100
|
+
error = json.loads(body).get("error", {})
|
|
101
|
+
code = error.get("code", code)
|
|
102
|
+
message = error.get("message", message)
|
|
103
|
+
except (ValueError, AttributeError):
|
|
104
|
+
pass
|
|
105
|
+
return SeekritApiError(status, code, message)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
__all__ = ["Client", "DEFAULT_API_URL", "SeekritError", "SeekritApiError", "SeekritCryptoError"]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Zero-knowledge read path, byte-compatible with ``@seekrit/crypto``.
|
|
2
|
+
|
|
3
|
+
Three steps, matching ``crates/seekrit-core`` and ``packages/crypto``:
|
|
4
|
+
|
|
5
|
+
1. Recover the service token's P-256 private key (:class:`TokenKey`).
|
|
6
|
+
2. Unwrap each environment DEK (:meth:`TokenKey.unwrap_dek`).
|
|
7
|
+
3. Decrypt each secret (:func:`decrypt_secret`).
|
|
8
|
+
|
|
9
|
+
Blob formats (all segments base64url, no padding):
|
|
10
|
+
|
|
11
|
+
* token ``skt_<id>_<pkcs8 private key>``
|
|
12
|
+
* wrapped DEK ``wd1.<ephemeral pub (raw SEC1)>.<hkdf salt>.<iv>.<ciphertext||tag>``
|
|
13
|
+
* secret ``sc1.<iv>.<ciphertext||tag>``
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import re
|
|
20
|
+
from typing import Dict, List
|
|
21
|
+
|
|
22
|
+
from cryptography.exceptions import InvalidTag
|
|
23
|
+
from cryptography.hazmat.primitives import hashes
|
|
24
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
25
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
26
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
27
|
+
from cryptography.hazmat.primitives.serialization import load_der_private_key
|
|
28
|
+
|
|
29
|
+
from .errors import SeekritCryptoError
|
|
30
|
+
|
|
31
|
+
_HKDF_INFO = b"seekrit/wrap-dek/v1"
|
|
32
|
+
# Only the first two underscores are separators; the key segment may contain "_".
|
|
33
|
+
_TOKEN_RE = re.compile(r"^(skt_[0-9A-Za-z]+)_([A-Za-z0-9_-]+)$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _b64url_decode(text: str) -> bytes:
|
|
37
|
+
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _split_blob(blob: str, prefix: str, segments: int) -> List[str]:
|
|
41
|
+
parts = blob.split(".")
|
|
42
|
+
if not parts or parts[0] != prefix:
|
|
43
|
+
raise SeekritCryptoError(f'expected a "{prefix}" blob, got "{parts[0] if parts else ""}"')
|
|
44
|
+
if len(parts) != segments + 1 or any(p == "" for p in parts):
|
|
45
|
+
raise SeekritCryptoError(f'malformed "{prefix}" blob')
|
|
46
|
+
return parts[1:]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def secret_aad(environment_id: str, name: str) -> bytes:
|
|
50
|
+
"""AAD binding a secret ciphertext to its environment and name."""
|
|
51
|
+
return f"{environment_id}/{name}".encode("utf-8")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TokenKey:
|
|
55
|
+
"""The P-256 private key recovered from a ``skt_...`` service token."""
|
|
56
|
+
|
|
57
|
+
__slots__ = ("token_id", "_private_key")
|
|
58
|
+
|
|
59
|
+
def __init__(self, token_id: str, private_key: ec.EllipticCurvePrivateKey) -> None:
|
|
60
|
+
self.token_id = token_id
|
|
61
|
+
self._private_key = private_key
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def parse(cls, token: str) -> "TokenKey":
|
|
65
|
+
match = _TOKEN_RE.match(token)
|
|
66
|
+
if not match:
|
|
67
|
+
raise SeekritCryptoError("not a valid seekrit service token")
|
|
68
|
+
token_id, key_b64 = match.group(1), match.group(2)
|
|
69
|
+
try:
|
|
70
|
+
key = load_der_private_key(_b64url_decode(key_b64), password=None)
|
|
71
|
+
except Exception as exc: # noqa: BLE001 - opaque on purpose
|
|
72
|
+
raise SeekritCryptoError("service token private key is corrupted") from exc
|
|
73
|
+
if not isinstance(key, ec.EllipticCurvePrivateKey):
|
|
74
|
+
raise SeekritCryptoError("service token key is not an EC private key")
|
|
75
|
+
return cls(token_id, key)
|
|
76
|
+
|
|
77
|
+
def unwrap_dek(self, wrapped: str) -> bytes:
|
|
78
|
+
"""Recover a 32-byte environment DEK from a ``wd1.`` blob."""
|
|
79
|
+
eph_b64, salt_b64, iv_b64, ct_b64 = _split_blob(wrapped, "wd1", 4)
|
|
80
|
+
try:
|
|
81
|
+
peer = ec.EllipticCurvePublicKey.from_encoded_point(
|
|
82
|
+
ec.SECP256R1(), _b64url_decode(eph_b64)
|
|
83
|
+
)
|
|
84
|
+
shared = self._private_key.exchange(ec.ECDH(), peer)
|
|
85
|
+
wrapping_key = HKDF(
|
|
86
|
+
algorithm=hashes.SHA256(),
|
|
87
|
+
length=32,
|
|
88
|
+
salt=_b64url_decode(salt_b64),
|
|
89
|
+
info=_HKDF_INFO,
|
|
90
|
+
).derive(shared)
|
|
91
|
+
dek = AESGCM(wrapping_key).decrypt(_b64url_decode(iv_b64), _b64url_decode(ct_b64), None)
|
|
92
|
+
except (InvalidTag, ValueError) as exc:
|
|
93
|
+
raise SeekritCryptoError(
|
|
94
|
+
"DEK unwrap failed: wrong private key or tampered grant"
|
|
95
|
+
) from exc
|
|
96
|
+
if len(dek) != 32:
|
|
97
|
+
raise SeekritCryptoError("unwrapped DEK has wrong length")
|
|
98
|
+
return dek
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def decrypt_secret(dek: bytes, blob: str, aad: bytes) -> str:
|
|
102
|
+
"""Decrypt an ``sc1.`` secret ciphertext to its UTF-8 plaintext."""
|
|
103
|
+
iv_b64, ct_b64 = _split_blob(blob, "sc1", 2)
|
|
104
|
+
try:
|
|
105
|
+
plaintext = AESGCM(dek).decrypt(_b64url_decode(iv_b64), _b64url_decode(ct_b64), aad)
|
|
106
|
+
except (InvalidTag, ValueError) as exc:
|
|
107
|
+
raise SeekritCryptoError(
|
|
108
|
+
"secret decryption failed: wrong key, tampered data, or mismatched context"
|
|
109
|
+
) from exc
|
|
110
|
+
return plaintext.decode("utf-8")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def materialize(resolve_response: dict, key: TokenKey) -> Dict[str, str]:
|
|
114
|
+
"""Decrypt every layer and merge by precedence.
|
|
115
|
+
|
|
116
|
+
``layers`` arrive lowest precedence first (composed groups, then the app
|
|
117
|
+
environment); later layers overwrite earlier ones on a name collision.
|
|
118
|
+
"""
|
|
119
|
+
merged: Dict[str, str] = {}
|
|
120
|
+
for layer in resolve_response["layers"]:
|
|
121
|
+
dek = key.unwrap_dek(layer["wrappedDek"])
|
|
122
|
+
env_id = layer["environmentId"]
|
|
123
|
+
for secret in layer["secrets"]:
|
|
124
|
+
merged[secret["name"]] = decrypt_secret(
|
|
125
|
+
dek, secret["ciphertext"], secret_aad(env_id, secret["name"])
|
|
126
|
+
)
|
|
127
|
+
return merged
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Exception hierarchy for the seekrit SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SeekritError(Exception):
|
|
7
|
+
"""Base class for every error raised by this SDK."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SeekritCryptoError(SeekritError):
|
|
11
|
+
"""A token, wrapped DEK, or secret ciphertext could not be parsed or decrypted.
|
|
12
|
+
|
|
13
|
+
Deliberately unspecific: a decrypt failure does not distinguish "wrong key"
|
|
14
|
+
from "tampered data" from "mismatched context", to avoid acting as an oracle.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SeekritApiError(SeekritError):
|
|
19
|
+
"""The resolve API returned a non-2xx response.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
status: HTTP status code.
|
|
23
|
+
code: the ``error.code`` string from the API (e.g. ``"unauthorized"``,
|
|
24
|
+
``"forbidden"``, ``"not_found"``), or ``"internal"`` if the body did
|
|
25
|
+
not parse.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, status: int, code: str, message: str) -> None:
|
|
29
|
+
super().__init__(f"{status} {code}: {message}")
|
|
30
|
+
self.status = status
|
|
31
|
+
self.code = code
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"token": "skt_r36AUXMxoX0xkQoH8vimHN_MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg1bTaK96XZQzEUpfGe5dlc-sp3n7mF8WpZNVJweEJZ06hRANCAATgUoWLRDgyG0qsUzdQXzr1VSL-p9kSp_zhAULqF2qHUAWDPcuoX-f4JTUX6lyzPEs_0chQ9nKvgmEJUpXx9_q5",
|
|
3
|
+
"tokenId": "skt_r36AUXMxoX0xkQoH8vimHN",
|
|
4
|
+
"publicKeyJwk": "{\"key_ops\":[],\"ext\":true,\"kty\":\"EC\",\"x\":\"4FKFi0Q4MhtKrFM3UF869VUi_qfZEqf84QFC6hdqh1A\",\"y\":\"BYM9y6hf5_glNRfqXLM8Sz_RyFD2cq-CYQlSlfH3-rk\",\"crv\":\"P-256\"}",
|
|
5
|
+
"resolve": {
|
|
6
|
+
"scope": {
|
|
7
|
+
"orgId": "org_test",
|
|
8
|
+
"orgSlug": "acme",
|
|
9
|
+
"appId": "app_test",
|
|
10
|
+
"appSlug": "web",
|
|
11
|
+
"envId": "env_apptest0000000000001",
|
|
12
|
+
"envSlug": "production"
|
|
13
|
+
},
|
|
14
|
+
"layers": [
|
|
15
|
+
{
|
|
16
|
+
"source": "group",
|
|
17
|
+
"environmentId": "env_grouptest0000000001",
|
|
18
|
+
"slug": "production",
|
|
19
|
+
"groupSlug": "shared",
|
|
20
|
+
"wrappedDek": "wd1.BEoGSn3SoICO6tA13EjveZHvxTTRVijE0OUpBkzVkFU28q_jfgY4P9IkyrpjMVJK27zogh61rwC_NUBjoe2TKhY.5EI1On9xCtVuntRHMA6xFA.JNqiqNRxDpNN3hkP.8RxEujnGaEIUIVq9VVcOZlpC07-gxgyz25VCtffNZWSRh8J8iFfMA6p-_ubXSfv5",
|
|
21
|
+
"secrets": [
|
|
22
|
+
{
|
|
23
|
+
"name": "SHARED",
|
|
24
|
+
"ciphertext": "sc1.4XNyaS_ilRPDTlIg.LhAGzqP_cm4fx_dKeST8_3NITle_77x0Qjo"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "DATABASE_URL",
|
|
28
|
+
"ciphertext": "sc1.-THIwVQA7yVq6SWt.1RaVPddl8QDuhc2wValcmtArCxafHBCAk24Fv00gB_FNc6k"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "UNICODE",
|
|
32
|
+
"ciphertext": "sc1.7qB3RU8qhHKpqwbB.BsKeZYWDv4Qp9cWIB0I684pLazslk2nFtIq-OvgDjIaTaw3Pbw"
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"source": "app",
|
|
38
|
+
"environmentId": "env_apptest0000000000001",
|
|
39
|
+
"slug": "production",
|
|
40
|
+
"wrappedDek": "wd1.BMywXqAVq3Ee3XX7mGBt2KLcRRwPLQn9FjlXc19Z8HNdDPsg-KyKLwg_uz4tgQ8MdVevMgtjfBkOiqmAi7ENI2Y.FLBNZ3z8-8CnYoBN-Azr9g.2x8wkp6BSw9QjnG2.3b32qenHIHC92eCehRW6QGWNFTlIe3nTKPje0WsRFWSHkjV6PYIxCXmlQaAJJkp3",
|
|
41
|
+
"secrets": [
|
|
42
|
+
{
|
|
43
|
+
"name": "SHARED",
|
|
44
|
+
"ciphertext": "sc1.QF697pJ61KM9Xqj2.HH9PNlW7aIIWZvpLm6yc8ZCRRemlxoOS"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"name": "API_KEY",
|
|
48
|
+
"ciphertext": "sc1.x4G_KqJP7ags8SBf.obw89lhKqcMAy1vzjdTjch88R7Trx9JtFWS2Rw"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name": "EMPTY",
|
|
52
|
+
"ciphertext": "sc1.qdub6o6QejpKH97e.KFe1fiDvtHF3agstKhL5ng"
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
"expectedManagedValues": {
|
|
59
|
+
"SHARED": "from-app",
|
|
60
|
+
"DATABASE_URL": "postgres://group/db",
|
|
61
|
+
"UNICODE": "héllo-🌍-\n-tab\tend",
|
|
62
|
+
"API_KEY": "sk-app-12345",
|
|
63
|
+
"EMPTY": ""
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Cross-implementation parity: decrypt the shared golden vectors and assert we
|
|
2
|
+
recover exactly what the canonical @seekrit/crypto (WebCrypto) produced.
|
|
3
|
+
|
|
4
|
+
Runnable with either ``pytest`` or ``python -m unittest``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import json
|
|
9
|
+
import unittest
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
13
|
+
from cryptography.hazmat.primitives.serialization import (
|
|
14
|
+
Encoding,
|
|
15
|
+
NoEncryption,
|
|
16
|
+
PrivateFormat,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
import seekrit
|
|
20
|
+
from seekrit import SeekritCryptoError
|
|
21
|
+
|
|
22
|
+
VECTORS = json.loads((Path(__file__).parent.parent / "testdata" / "vectors.json").read_text())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _make_valid_but_different_token() -> str:
|
|
26
|
+
"""A well-formed token carrying a freshly generated (wrong) P-256 key."""
|
|
27
|
+
pkcs8 = ec.generate_private_key(ec.SECP256R1()).private_bytes(
|
|
28
|
+
Encoding.DER, PrivateFormat.PKCS8, NoEncryption()
|
|
29
|
+
)
|
|
30
|
+
key_b64 = base64.urlsafe_b64encode(pkcs8).rstrip(b"=").decode("ascii")
|
|
31
|
+
return "skt_AAAAAAAAAAAAAAAAAAAAAA_" + key_b64
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class VectorTest(unittest.TestCase):
|
|
35
|
+
def test_materialize_matches_expected(self):
|
|
36
|
+
key = seekrit.TokenKey.parse(VECTORS["token"])
|
|
37
|
+
merged = seekrit.materialize(VECTORS["resolve"], key)
|
|
38
|
+
self.assertEqual(merged, VECTORS["expectedManagedValues"])
|
|
39
|
+
|
|
40
|
+
def test_app_layer_wins_over_group(self):
|
|
41
|
+
key = seekrit.TokenKey.parse(VECTORS["token"])
|
|
42
|
+
merged = seekrit.materialize(VECTORS["resolve"], key)
|
|
43
|
+
self.assertEqual(merged["SHARED"], "from-app")
|
|
44
|
+
|
|
45
|
+
def test_unicode_and_empty_round_trip(self):
|
|
46
|
+
key = seekrit.TokenKey.parse(VECTORS["token"])
|
|
47
|
+
merged = seekrit.materialize(VECTORS["resolve"], key)
|
|
48
|
+
self.assertEqual(merged["UNICODE"], "héllo-🌍-\n-tab\tend")
|
|
49
|
+
self.assertEqual(merged["EMPTY"], "")
|
|
50
|
+
|
|
51
|
+
def test_wrong_token_cannot_unwrap(self):
|
|
52
|
+
# A valid, well-formed token holding the wrong key must not silently
|
|
53
|
+
# succeed — the DEK grant was wrapped to a different public key.
|
|
54
|
+
key = seekrit.TokenKey.parse(_make_valid_but_different_token())
|
|
55
|
+
with self.assertRaises(SeekritCryptoError):
|
|
56
|
+
seekrit.materialize(VECTORS["resolve"], key)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
unittest.main()
|