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.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ /dist
4
+ /build
5
+ *.egg-info/
6
+ .venv/
7
+ .pytest_cache/
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
@@ -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()