burnedsecret-cli 1.0.0__py3-none-any.whl

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,3 @@
1
+ """burnedsecret-cli — command-line interface for the burnedsecret.com API."""
2
+
3
+ __version__ = "1.0.0" # semver (D-04): CLI major pinned to SDK major
@@ -0,0 +1,369 @@
1
+ """The reusable D-10 PKCE + loopback browser-login recipe (client half).
2
+
3
+ This module owns the terminal side of ``bs auth login``: it performs an
4
+ RFC 7636 PKCE S256 + RFC 8252 loopback-redirect flow against the web
5
+ ``/cli-auth`` consent page (plan 11-02) and the ``POST /v1/cli-auth/exchange``
6
+ endpoint (plan 11-01), and returns a freshly minted long-lived API key.
7
+
8
+ Design constraints (security-critical):
9
+ * stdlib ONLY — no HTTP client dependency. The two ``/v1/cli-auth/*`` calls
10
+ this module makes use ``urllib.request``; the SDK owns its own transport
11
+ for the business endpoints and is intentionally not reused here.
12
+ * The API key is returned ONLY in the exchange RESPONSE BODY. It is never
13
+ printed and never placed in any URL. The browser callback carries only
14
+ ``code`` + ``state`` (and, on failure, ``error``).
15
+ * The single-use ``code`` (60s, PKCE-bound) and the long-lived ``api_key``
16
+ it is exchanged for are kept terminologically and lifecycle-wise distinct.
17
+ * The returned ``state`` is compared against the sent CSRF ``state``; a
18
+ mismatch aborts login and stores nothing (the caller never sees a key).
19
+
20
+ Flow (``run_login``):
21
+ 1. Generate a CSRF ``state``, a PKCE ``(verifier, challenge)`` pair, and pick
22
+ a free loopback port.
23
+ 2. Start a one-shot ``127.0.0.1:<port>`` HTTP server (in a thread, with a
24
+ timeout) that captures ``?code=&state=`` / ``?error=``, validates the
25
+ returned ``state``, serves a terminal-themed "you can close this tab"
26
+ page, and shuts down after one request.
27
+ 3. Open the browser to the web ``/cli-auth`` authorize URL (carrying the
28
+ loopback callback, the CSRF state, and the S256 challenge). The URL is
29
+ also echoed to STDERR for the headless / can't-open-browser fallback.
30
+ 4. Exchange the captured ``code`` + the PKCE ``verifier`` at
31
+ ``/v1/cli-auth/exchange`` and return ``{api_key, key_prefix, name,
32
+ hostname}``.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import base64
37
+ import hashlib
38
+ import http.server
39
+ import json
40
+ import secrets
41
+ import socket
42
+ import sys
43
+ import threading
44
+ import urllib.error
45
+ import urllib.parse
46
+ import urllib.request
47
+ from dataclasses import dataclass, field
48
+ from typing import Optional
49
+
50
+ # How long we wait for the browser round-trip before giving up. The server's
51
+ # single-use code TTL is 60s; we allow a little longer for the human to land on
52
+ # the consent page and click Authorize.
53
+ _LOGIN_TIMEOUT_SECONDS = 120
54
+
55
+
56
+ class AuthFlowError(Exception):
57
+ """A recoverable login-flow failure (CSRF mismatch, timeout, exchange error).
58
+
59
+ Carries a human-facing message only; never embeds the verifier, the code,
60
+ or the minted key.
61
+ """
62
+
63
+
64
+ def pkce_pair() -> tuple[str, str]:
65
+ """Return an RFC 7636 ``(code_verifier, code_challenge)`` S256 pair.
66
+
67
+ ``verifier`` = base64url(32 random bytes) with padding stripped.
68
+ ``challenge`` = base64url(sha256(verifier)) with padding stripped — exactly
69
+ the transform the server's ``_compute_pkce_challenge`` recomputes and
70
+ constant-time compares against the stored challenge.
71
+ """
72
+ verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode("ascii")
73
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
74
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
75
+ return verifier, challenge
76
+
77
+
78
+ def free_port() -> int:
79
+ """Bind ``127.0.0.1:0`` to grab an OS-assigned free port, then release it.
80
+
81
+ There is an inherent TOCTOU window between releasing the port here and the
82
+ one-shot server re-binding it; in practice this is fine for a same-machine,
83
+ same-process loopback login and matches the RFC 8252 native-app pattern.
84
+ """
85
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
86
+ sock.bind(("127.0.0.1", 0))
87
+ return sock.getsockname()[1]
88
+
89
+
90
+ def web_origin_from_base_url(base_url: Optional[str]) -> str:
91
+ """Derive the web origin (scheme://host[:port]) hosting ``/cli-auth``.
92
+
93
+ ``base_url`` is the configured API base (e.g.
94
+ ``https://burnedsecret.com/api/v1``). The web consent page lives at the same
95
+ origin's ``/cli-auth`` path, so we strip the API path entirely and keep only
96
+ the origin. ``None`` (no override) maps to the production origin.
97
+ """
98
+ if not base_url:
99
+ return "https://burnedsecret.com"
100
+ parsed = urllib.parse.urlsplit(base_url)
101
+ if not parsed.scheme or not parsed.netloc:
102
+ # Not a parseable URL — fall back to production rather than build a
103
+ # malformed authorize URL.
104
+ return "https://burnedsecret.com"
105
+ return f"{parsed.scheme}://{parsed.netloc}"
106
+
107
+
108
+ def api_base_from_base_url(base_url: Optional[str]) -> str:
109
+ """Derive the ``/api/v1`` base used for the exchange POST.
110
+
111
+ Honors an explicit configured override; otherwise returns the production
112
+ ``https://burnedsecret.com/api/v1`` (matching the SDK's own default). The
113
+ returned value has no trailing slash.
114
+ """
115
+ if base_url:
116
+ return base_url.rstrip("/")
117
+ return "https://burnedsecret.com/api/v1"
118
+
119
+
120
+ # Terminal-themed confirmation page served to the browser after a successful
121
+ # (or failed) callback. Self-contained, no external assets.
122
+ _SUCCESS_HTML = """<!doctype html>
123
+ <html lang="en"><head><meta charset="utf-8">
124
+ <title>BurnedSecret CLI</title>
125
+ <style>
126
+ html,body{height:100%;margin:0}
127
+ body{display:flex;align-items:center;justify-content:center;
128
+ background:#0b0f10;color:#d6f5d6;
129
+ font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
130
+ .card{text-align:center;padding:2rem 2.5rem;border:1px solid #1f2d1f;
131
+ border-radius:10px;background:#0e1412}
132
+ .ok{font-size:1.4rem;color:#7CFC7C}
133
+ .sub{margin-top:.5rem;color:#8aa88a;font-size:.95rem}
134
+ </style></head>
135
+ <body><div class="card">
136
+ <div class="ok">&#10003; Authorized</div>
137
+ <div class="sub">Return to your terminal &mdash; you can close this tab.</div>
138
+ </div></body></html>"""
139
+
140
+ _ERROR_HTML = """<!doctype html>
141
+ <html lang="en"><head><meta charset="utf-8">
142
+ <title>BurnedSecret CLI</title>
143
+ <style>
144
+ html,body{height:100%;margin:0}
145
+ body{display:flex;align-items:center;justify-content:center;
146
+ background:#0b0f10;color:#f5d6d6;
147
+ font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
148
+ .card{text-align:center;padding:2rem 2.5rem;border:1px solid #2d1f1f;
149
+ border-radius:10px;background:#140e0e}
150
+ .err{font-size:1.4rem;color:#FC7C7C}
151
+ .sub{margin-top:.5rem;color:#a88a8a;font-size:.95rem}
152
+ </style></head>
153
+ <body><div class="card">
154
+ <div class="err">&#10007; Login failed</div>
155
+ <div class="sub">Return to your terminal for details &mdash; you can close this tab.</div>
156
+ </div></body></html>"""
157
+
158
+
159
+ @dataclass
160
+ class _CallbackResult:
161
+ """Mutable holder the one-shot handler writes the captured params into."""
162
+
163
+ code: Optional[str] = None
164
+ error: Optional[str] = None
165
+ state_ok: bool = False
166
+ received: threading.Event = field(default_factory=threading.Event)
167
+
168
+
169
+ def _make_handler(expected_state: str, result: _CallbackResult):
170
+ """Build a one-shot ``BaseHTTPRequestHandler`` bound to this login attempt.
171
+
172
+ Captures ``code``/``state``/``error`` from the first GET, validates the CSRF
173
+ ``state`` against ``expected_state``, serves the confirmation page, and signals
174
+ ``result.received``. Subsequent requests (favicon, etc.) get a bare 204.
175
+ """
176
+
177
+ class _Handler(http.server.BaseHTTPRequestHandler):
178
+ # Silence the default stderr request logging (it would pollute the
179
+ # CLI's stderr discipline with raw HTTP log lines).
180
+ def log_message(self, *args, **kwargs): # noqa: D401, ANN001
181
+ return
182
+
183
+ def _respond(self, status: int, html: str) -> None:
184
+ body = html.encode("utf-8")
185
+ self.send_response(status)
186
+ self.send_header("Content-Type", "text/html; charset=utf-8")
187
+ self.send_header("Content-Length", str(len(body)))
188
+ self.end_headers()
189
+ self.wfile.write(body)
190
+
191
+ def do_GET(self) -> None: # noqa: N802 (http.server API)
192
+ parsed = urllib.parse.urlsplit(self.path)
193
+ # Ignore everything but the callback root (e.g. /favicon.ico).
194
+ if result.received.is_set():
195
+ self._respond(204, "")
196
+ return
197
+ params = urllib.parse.parse_qs(parsed.query)
198
+ error = params.get("error", [None])[0]
199
+ code = params.get("code", [None])[0]
200
+ returned_state = params.get("state", [None])[0]
201
+
202
+ if error:
203
+ result.error = error
204
+ self._respond(400, _ERROR_HTML)
205
+ result.received.set()
206
+ return
207
+
208
+ # CSRF: the returned state MUST match the one we sent. A mismatch is
209
+ # a hard abort — we capture NO code, so no exchange happens.
210
+ if returned_state != expected_state:
211
+ result.state_ok = False
212
+ result.error = "state_mismatch"
213
+ self._respond(400, _ERROR_HTML)
214
+ result.received.set()
215
+ return
216
+
217
+ if not code:
218
+ result.error = "missing_code"
219
+ self._respond(400, _ERROR_HTML)
220
+ result.received.set()
221
+ return
222
+
223
+ result.state_ok = True
224
+ result.code = code
225
+ self._respond(200, _SUCCESS_HTML)
226
+ result.received.set()
227
+
228
+ return _Handler
229
+
230
+
231
+ def _exchange_code(api_base: str, code: str, code_verifier: str) -> dict:
232
+ """POST ``{code, code_verifier}`` to ``/cli-auth/exchange``; return its body.
233
+
234
+ Returns the parsed JSON ``{api_key, key_prefix, name}``. Raises
235
+ :class:`AuthFlowError` on any HTTP/transport/shape failure — never leaking
236
+ the verifier or code into the message.
237
+ """
238
+ url = f"{api_base}/cli-auth/exchange"
239
+ payload = json.dumps({"code": code, "code_verifier": code_verifier}).encode("utf-8")
240
+ req = urllib.request.Request(
241
+ url,
242
+ data=payload,
243
+ headers={"Content-Type": "application/json", "Accept": "application/json"},
244
+ method="POST",
245
+ )
246
+ try:
247
+ with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 (https URL)
248
+ raw = resp.read()
249
+ except urllib.error.HTTPError as exc: # server said no (400 INVALID_CODE, etc.)
250
+ raise AuthFlowError(
251
+ "Code exchange was rejected by the server "
252
+ f"(HTTP {exc.code}). The login code may have expired — retry `bs auth login`."
253
+ ) from None
254
+ except urllib.error.URLError as exc:
255
+ raise AuthFlowError(f"Could not reach the exchange endpoint: {exc.reason}") from None
256
+
257
+ try:
258
+ body = json.loads(raw.decode("utf-8"))
259
+ except (ValueError, UnicodeDecodeError):
260
+ raise AuthFlowError("Exchange response was not valid JSON.") from None
261
+
262
+ api_key = body.get("api_key")
263
+ if not isinstance(api_key, str) or not api_key:
264
+ raise AuthFlowError("Exchange response did not contain an api_key.")
265
+ return body
266
+
267
+
268
+ def build_authorize_url(
269
+ web_origin: str,
270
+ callback: str,
271
+ state: str,
272
+ challenge: str,
273
+ hostname: str,
274
+ ) -> str:
275
+ """Construct the web ``/cli-auth`` authorize URL the browser is sent to.
276
+
277
+ Carries the loopback ``callback``, the CSRF ``state``, the PKCE S256
278
+ ``code_challenge`` (+ method), and the ``hostname`` to label the key. It
279
+ deliberately carries NO secret — only the public challenge and state.
280
+ """
281
+ query = urllib.parse.urlencode(
282
+ {
283
+ "callback": callback,
284
+ "state": state,
285
+ "code_challenge": challenge,
286
+ "code_challenge_method": "S256",
287
+ "hostname": hostname,
288
+ }
289
+ )
290
+ return f"{web_origin}/cli-auth?{query}"
291
+
292
+
293
+ def run_login(
294
+ base_url: Optional[str],
295
+ hostname: str,
296
+ *,
297
+ open_browser: bool = True,
298
+ timeout: int = _LOGIN_TIMEOUT_SECONDS,
299
+ ) -> dict:
300
+ """Run the full PKCE + loopback + exchange login and return the minted key.
301
+
302
+ ``base_url`` is the configured API base (``None`` → production). ``hostname``
303
+ labels the minted key ("CLI - <hostname>"). Returns
304
+ ``{api_key, key_prefix, name, hostname}`` on success.
305
+
306
+ Raises :class:`AuthFlowError` on CSRF/state mismatch, browser ``error=``
307
+ callback, timeout, or an exchange failure — in every failure path NO key is
308
+ returned, so the caller stores nothing.
309
+ """
310
+ verifier, challenge = pkce_pair()
311
+ state = secrets.token_urlsafe(24)
312
+ port = free_port()
313
+ callback = f"http://127.0.0.1:{port}"
314
+
315
+ web_origin = web_origin_from_base_url(base_url)
316
+ api_base = api_base_from_base_url(base_url)
317
+ authorize_url = build_authorize_url(web_origin, callback, state, challenge, hostname)
318
+
319
+ result = _CallbackResult()
320
+ handler = _make_handler(state, result)
321
+ httpd = http.server.HTTPServer(("127.0.0.1", port), handler)
322
+ httpd.timeout = 1 # poll granularity for the serve loop
323
+
324
+ def _serve() -> None:
325
+ # Serve requests until the callback is received (or the thread is asked
326
+ # to stop via the daemon flag at interpreter exit).
327
+ while not result.received.is_set():
328
+ httpd.handle_request()
329
+
330
+ server_thread = threading.Thread(target=_serve, daemon=True)
331
+ server_thread.start()
332
+
333
+ # Always surface the URL on STDERR — both as the headless fallback and so a
334
+ # user whose browser didn't open can paste it manually.
335
+ print(f"Opening your browser to authorize this CLI:\n {authorize_url}", file=sys.stderr)
336
+ if open_browser:
337
+ import webbrowser
338
+
339
+ try:
340
+ webbrowser.open(authorize_url)
341
+ except Exception: # noqa: BLE001 — headless: the URL is already on stderr
342
+ pass
343
+
344
+ got_it = result.received.wait(timeout=timeout)
345
+ # Stop the server loop regardless of outcome.
346
+ httpd.server_close()
347
+
348
+ if not got_it:
349
+ raise AuthFlowError(
350
+ f"Timed out after {timeout}s waiting for browser authorization. "
351
+ "Retry `bs auth login`."
352
+ )
353
+ if result.error == "state_mismatch":
354
+ raise AuthFlowError(
355
+ "CSRF state mismatch — the browser callback did not match this login "
356
+ "request. Aborted; nothing was stored."
357
+ )
358
+ if result.error:
359
+ raise AuthFlowError(f"Authorization failed: {result.error}.")
360
+ if not result.code:
361
+ raise AuthFlowError("No authorization code was returned.")
362
+
363
+ body = _exchange_code(api_base, result.code, verifier)
364
+ return {
365
+ "api_key": body["api_key"],
366
+ "key_prefix": body.get("key_prefix"),
367
+ "name": body.get("name"),
368
+ "hostname": hostname,
369
+ }
@@ -0,0 +1,41 @@
1
+ """Build a shipped-SDK ``BurnedSecret`` client from resolved config.
2
+
3
+ The CRITICAL invariant: when the user has NOT configured a ``base_url``,
4
+ construct the client WITHOUT a ``base_url`` kwarg so the SDK applies its own
5
+ ``_DEFAULT_BASE`` (``https://burnedsecret.com/api/v1`` — with ``/v1``). We never
6
+ pass a hardcoded ``.../api`` (no ``/v1``) string; that would 404 against the
7
+ live API. When the user DID configure an explicit ``base_url``, we honor it.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Optional
12
+
13
+ from burnedsecret import BurnedSecret
14
+
15
+ from .config import resolve_api_key, resolve_base_url
16
+ from .output import ExitCode, exit_with
17
+
18
+
19
+ def build_client(cfg: dict, profile: str) -> BurnedSecret:
20
+ """Construct a :class:`BurnedSecret` for ``profile`` using ``cfg``.
21
+
22
+ Resolves the API key (env > config) and base URL (profile > global > None).
23
+ Passes ``base_url`` to the constructor ONLY when an explicit override is
24
+ configured, so the SDK's correct ``/v1`` default applies otherwise.
25
+
26
+ Exits with :attr:`ExitCode.AUTH` and a friendly message if no API key is
27
+ resolved.
28
+ """
29
+ api_key: Optional[str] = resolve_api_key(cfg, profile)
30
+ if not api_key:
31
+ exit_with(
32
+ "Not authenticated — run `bs auth login` or set "
33
+ "BURNEDSECRET_API_KEY.",
34
+ ExitCode.AUTH,
35
+ )
36
+
37
+ base_url = resolve_base_url(cfg, profile)
38
+ if base_url:
39
+ return BurnedSecret(api_key=api_key, base_url=base_url)
40
+ # No override → let the SDK apply its own _DEFAULT_BASE (.../api/v1).
41
+ return BurnedSecret(api_key=api_key)
@@ -0,0 +1 @@
1
+ """Concrete CLI command groups (secret/request in plan 04; key/auth in plan 05)."""
@@ -0,0 +1,77 @@
1
+ """Shared TTL handling for ``bs secret`` and ``bs request`` (Conflict 3 / Pitfall 5).
2
+
3
+ The SDK's ``ttl=`` argument accepts ONLY the exact seconds in
4
+ ``burnedsecret.client._VALID_TTLS == {300, 3600, 86400, 604800}``. Anything else
5
+ raises ``ValueError`` inside the SDK. The CLI exposes those four values as a
6
+ friendly 4-member Typer ``Enum`` (``5m``/``1h``/``1d``/``7d``), and translates
7
+ the human-facing config knob ``[global].default_ttl_days`` (in DAYS) into one of
8
+ those exact seconds — refusing day values that have no member rather than
9
+ silently computing ``days * 86400`` (which would hand the SDK an invalid TTL).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import enum
14
+
15
+ from ..output import ExitCode, exit_with
16
+
17
+ # Default when neither --ttl nor config default_ttl_days is set: 1 day.
18
+ DEFAULT_TTL_SECONDS = 86400
19
+
20
+
21
+ class Ttl(str, enum.Enum):
22
+ """The four TTL choices the SDK accepts, as human-friendly labels.
23
+
24
+ ``.value`` is the label (what Typer parses / shows); ``.seconds`` is the
25
+ exact SDK seconds. A bad CLI value fails at parse time (exit 2) because
26
+ Typer validates against the enum members.
27
+ """
28
+
29
+ FIVE_MIN = "5m"
30
+ ONE_HOUR = "1h"
31
+ ONE_DAY = "1d"
32
+ SEVEN_DAYS = "7d"
33
+
34
+ @property
35
+ def seconds(self) -> int:
36
+ return _TTL_TO_SECONDS[self]
37
+
38
+
39
+ _TTL_TO_SECONDS: dict[Ttl, int] = {
40
+ Ttl.FIVE_MIN: 300,
41
+ Ttl.ONE_HOUR: 3600,
42
+ Ttl.ONE_DAY: 86400,
43
+ Ttl.SEVEN_DAYS: 604800,
44
+ }
45
+
46
+ # The ONLY whole-day-expressible TTL members. 5m/1h are sub-day, so a
47
+ # ``default_ttl_days`` of anything other than 1 or 7 has no valid mapping.
48
+ _DAY_DEFAULT_MAP: dict[int, int] = {1: 86400, 7: 604800}
49
+
50
+
51
+ def resolve_ttl_seconds(ttl: Ttl | None, cfg: dict) -> int:
52
+ """Resolve the effective TTL in SDK seconds.
53
+
54
+ Priority: an explicit ``--ttl`` enum (``ttl``) wins; otherwise the config's
55
+ ``[global].default_ttl_days`` is mapped through :data:`_DAY_DEFAULT_MAP`;
56
+ otherwise :data:`DEFAULT_TTL_SECONDS` (1 day).
57
+
58
+ A ``default_ttl_days`` present but NOT in ``{1, 7}`` is a configuration
59
+ error: exit ``INVALID_ARGS`` WITHOUT contacting the SDK (never silently
60
+ forward ``days * 86400``).
61
+ """
62
+ if ttl is not None:
63
+ return ttl.seconds
64
+
65
+ global_table = cfg.get("global")
66
+ if isinstance(global_table, dict) and "default_ttl_days" in global_table:
67
+ days = global_table.get("default_ttl_days")
68
+ if days in _DAY_DEFAULT_MAP:
69
+ return _DAY_DEFAULT_MAP[days]
70
+ exit_with(
71
+ f"Invalid [global].default_ttl_days = {days!r}: only "
72
+ f"{sorted(_DAY_DEFAULT_MAP)} (whole days) are supported. "
73
+ "Use --ttl for 5m/1h.",
74
+ ExitCode.INVALID_ARGS,
75
+ )
76
+
77
+ return DEFAULT_TTL_SECONDS