Skip to content

Commit d1d1bf3

Browse files
author
seekrit-bot
committed
sync: mileszim/seekrit@94a840312a2d8804a5deb775c84e32ff65be7e54
1 parent b454853 commit d1d1bf3

13 files changed

Lines changed: 639 additions & 2 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# CI for this mirror: run the cross-implementation vector test on every
2+
# supported Python. Nothing here publishes — releases are cut from seekrit's
3+
# monorepo. This file is overwritten on each sync.
4+
name: CI
5+
6+
on:
7+
push:
8+
branches: [main]
9+
pull_request:
10+
11+
jobs:
12+
test:
13+
runs-on: ubuntu-latest
14+
strategy:
15+
fail-fast: false
16+
matrix:
17+
python: ["3.9", "3.10", "3.11", "3.12", "3.13"]
18+
steps:
19+
- uses: actions/checkout@v6
20+
- uses: actions/setup-python@v5
21+
with:
22+
python-version: ${{ matrix.python }}
23+
- name: Install
24+
run: pip install .
25+
- name: Vector test
26+
run: python -m unittest discover -s tests -v

‎.github/workflows/publish.yml‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Publishes to PyPI via OIDC trusted publishing (no long-lived token). Runs in
2+
# THIS public repo — trusted publishing authenticates the workflow itself, so it
3+
# can't live in the private monorepo. This file is overwritten on each sync.
4+
#
5+
# Triggered by a version tag (`vX.Y.Z`) pushed to this repo, or manually.
6+
#
7+
# One-time setup on PyPI: add a Trusted Publisher for project `seekrit` →
8+
# Owner: seekritdev Repo: python-sdk Workflow: publish.yml
9+
# (leave the environment field blank to match this workflow).
10+
name: Publish to PyPI
11+
12+
on:
13+
push:
14+
tags: ["v*.*.*"]
15+
workflow_dispatch:
16+
17+
permissions:
18+
contents: read
19+
20+
jobs:
21+
publish:
22+
runs-on: ubuntu-latest
23+
permissions:
24+
id-token: write # mint the OIDC token for trusted publishing
25+
steps:
26+
- uses: actions/checkout@v6
27+
- uses: actions/setup-python@v5
28+
with:
29+
python-version: "3.12"
30+
- name: Verify before publishing
31+
run: |
32+
pip install .
33+
python -m unittest discover -s tests
34+
- name: Build sdist + wheel
35+
run: |
36+
pip install build
37+
python -m build
38+
- name: Publish
39+
uses: pypa/gh-action-pypi-publish@release/v1

‎.gitignore‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
__pycache__/
2+
*.py[cod]
3+
/dist
4+
/build
5+
*.egg-info/
6+
.venv/
7+
.pytest_cache/

‎LICENSE‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.

‎README.md‎

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,77 @@
1-
# seekrit python SDK
1+
# seekrit — Python SDK
22

3-
Read-only mirror, published from seekrit's monorepo. Populated by CI on the next sync.
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

‎examples/basic.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Resolve and print secret names (not values) for the token in $SEEKRIT_TOKEN.
2+
3+
export SEEKRIT_TOKEN=skt_...
4+
python examples/basic.py
5+
"""
6+
7+
import seekrit
8+
9+
10+
def main() -> None:
11+
client = seekrit.Client()
12+
secrets = client.resolve()
13+
print(f"resolved {len(secrets)} secret(s):")
14+
for name in sorted(secrets):
15+
print(f" - {name}")
16+
17+
18+
if __name__ == "__main__":
19+
main()

‎pyproject.toml‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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"]

‎src/seekrit/__init__.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
]

‎src/seekrit/_client.py‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 commit comments

Comments
 (0)