psst-secret-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.
psst_cli/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """Command-line client for psst-secret."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("psst-secret-cli")
7
+ except PackageNotFoundError:
8
+ __version__ = "unknown"
9
+
10
+ # psst-secret release whose docs/format.md and spec/vectors/ this client implements.
11
+ SPEC_VERSION = "v1.5.2"
12
+ API_VERSION = 1
13
+ FORMAT_VERSION = 1
14
+ # Versions this client can read; it only writes FORMAT_VERSION.
15
+ READABLE_FORMAT_VERSIONS = frozenset({0, 1})
16
+
17
+
18
+ class PsstError(Exception):
19
+ """Base class for errors shown to the user."""
psst_cli/api.py ADDED
@@ -0,0 +1,285 @@
1
+ """HTTP client for the psst-secret /api/v1/ endpoints."""
2
+
3
+ from datetime import datetime
4
+ from enum import StrEnum
5
+ from typing import Literal
6
+
7
+ import requests
8
+ from pydantic import BaseModel, Field, ValidationError
9
+
10
+ from . import API_VERSION, FORMAT_VERSION, PsstError
11
+
12
+ DEFAULT_TIMEOUT = 30
13
+
14
+
15
+ class Expiry(StrEnum):
16
+ FIVE_MINUTES = "5m"
17
+ ONE_HOUR = "1h"
18
+ ONE_DAY = "1d"
19
+ ONE_WEEK = "1w"
20
+ ONE_MONTH = "1M"
21
+
22
+
23
+ class ApiError(PsstError):
24
+ def __init__(self, status: int, message: str):
25
+ super().__init__(f"Server returned {status}: {message}")
26
+ self.status = status
27
+
28
+
29
+ class IncompatibleServerError(PsstError):
30
+ pass
31
+
32
+
33
+ class ApiInfoAuth(BaseModel):
34
+ login_required: bool
35
+ loopback_pkce: bool
36
+ manual_code: bool
37
+ scopes: list[str]
38
+
39
+
40
+ class ApiInfoReceiveAccess(BaseModel):
41
+ enabled: bool
42
+ default: bool
43
+ required: bool
44
+
45
+
46
+ class ApiInfoFeatures(BaseModel):
47
+ email_notifications: bool
48
+ receive_access: ApiInfoReceiveAccess
49
+ receive_history: bool
50
+ rich_text: bool
51
+
52
+
53
+ class ApiInfoPolicies(BaseModel):
54
+ view_requires_auth: bool
55
+ submit_requires_auth: bool
56
+
57
+
58
+ class ApiInfoLimits(BaseModel):
59
+ max_upload_bytes: int
60
+ max_views: int
61
+ expiry_options: list[str]
62
+
63
+
64
+ class ApiInfo(BaseModel):
65
+ server_version: str
66
+ api_version: int
67
+ format_versions: list[int]
68
+ auth: ApiInfoAuth
69
+ # Added in psst-secret v1.5.2; None on older servers.
70
+ features: ApiInfoFeatures | None = None
71
+ policies: ApiInfoPolicies | None = None
72
+ limits: ApiInfoLimits | None = None
73
+
74
+
75
+ class CreateWhisper(BaseModel):
76
+ v: Literal[1] = FORMAT_VERSION
77
+ reveal_token_hash: str
78
+ ciphertext: str
79
+ iv: str
80
+ salt: str = ""
81
+ max_views: int = Field(default=1, ge=0, le=100)
82
+ expiry: Expiry = Expiry.ONE_DAY
83
+ require_auth_view: bool = False
84
+ notify_email: str = ""
85
+
86
+
87
+ class CreateWhisperResponse(BaseModel):
88
+ id: str
89
+ url: str
90
+
91
+
92
+ class CreateRequest(BaseModel):
93
+ v: Literal[1] = FORMAT_VERSION
94
+ reveal_token_hash: str
95
+ public_key: str
96
+ salt: str = ""
97
+ wrapped_key: str = ""
98
+ wrapped_key_iv: str = ""
99
+ max_views: int = Field(default=1, ge=0, le=100)
100
+ expiry: Expiry = Expiry.ONE_DAY
101
+ require_auth_view: bool = False
102
+ require_auth_submit: bool = False
103
+ notify_email: str = ""
104
+ # None lets the server apply its default.
105
+ creator_only: bool | None = None
106
+ allowed_reader_emails: list[str] | None = Field(default=None, max_length=20)
107
+
108
+
109
+ class CreateRequestResponse(BaseModel):
110
+ id: str
111
+ submit_url: str
112
+ view_url: str
113
+ expires_at: datetime
114
+
115
+
116
+ class SubmitRequestKey(BaseModel):
117
+ public_key: str
118
+ v: int = 0
119
+
120
+
121
+ class SubmitWhisper(BaseModel):
122
+ v: Literal[1] = FORMAT_VERSION
123
+ ciphertext: str
124
+ iv: str
125
+ encapsulated_key: str
126
+
127
+
128
+ class SubmitWhisperResponse(BaseModel):
129
+ success: bool
130
+
131
+
132
+ class _RevealBody(BaseModel):
133
+ reveal_token: str
134
+
135
+
136
+ class RevealWhisperResponse(BaseModel):
137
+ v: int = 0
138
+ ciphertext: str
139
+ iv: str
140
+ salt: str = ""
141
+ encapsulated_key: str = ""
142
+ wrapped_key: str = ""
143
+ wrapped_key_iv: str = ""
144
+ view_count: int
145
+ max_views: int
146
+ remaining_views: int
147
+
148
+
149
+ class WhisperStatus(BaseModel):
150
+ status: Literal["pending", "submitted", "gone"]
151
+ expires_at: datetime | None = None
152
+ view_count: int | None = None
153
+ max_views: int | None = None
154
+ remaining_views: int | None = None
155
+
156
+
157
+ class TokenExchange(BaseModel):
158
+ code: str
159
+ code_verifier: str
160
+ redirect_uri: str
161
+
162
+
163
+ class TokenResponse(BaseModel):
164
+ access_token: str
165
+ token_type: str
166
+ expires_in: int
167
+ expires_at: datetime
168
+ scope: str
169
+
170
+
171
+ def normalize_server_url(url: str) -> str:
172
+ url = url.strip().rstrip("/")
173
+ if not url.startswith(("http://", "https://")):
174
+ raise PsstError("Server URL must start with http:// or https://")
175
+ if "#" in url or "?" in url:
176
+ raise PsstError("Server URL must not contain a query or fragment")
177
+ return url
178
+
179
+
180
+ class Client:
181
+ def __init__(
182
+ self, server: str, token: str | None = None, timeout: float = DEFAULT_TIMEOUT
183
+ ):
184
+ self.server = normalize_server_url(server)
185
+ self.timeout = timeout
186
+ self._info: ApiInfo | None = None
187
+ self.session = requests.Session()
188
+ self.session.headers["Accept"] = "application/json"
189
+ if token:
190
+ self.session.headers["Authorization"] = f"Bearer {token}"
191
+
192
+ @property
193
+ def has_token(self) -> bool:
194
+ return "Authorization" in self.session.headers
195
+
196
+ def _send(
197
+ self, method: str, path: str, body: BaseModel | None = None
198
+ ) -> requests.Response:
199
+ url = f"{self.server}/api/v1/{path}"
200
+ try:
201
+ resp = self.session.request(
202
+ method,
203
+ url,
204
+ json=(
205
+ body.model_dump(mode="json", exclude_none=True)
206
+ if body is not None
207
+ else None
208
+ ),
209
+ timeout=self.timeout,
210
+ allow_redirects=False,
211
+ )
212
+ except requests.RequestException as exc:
213
+ raise PsstError(
214
+ f"Could not reach {self.server}: {exc.__class__.__name__}"
215
+ ) from exc
216
+ if not resp.ok:
217
+ try:
218
+ message = resp.json().get("error") or resp.reason
219
+ except ValueError, AttributeError:
220
+ message = resp.reason
221
+ raise ApiError(resp.status_code, str(message))
222
+ return resp
223
+
224
+ def _request[T: BaseModel](
225
+ self,
226
+ method: str,
227
+ path: str,
228
+ response_model: type[T],
229
+ body: BaseModel | None = None,
230
+ ) -> T:
231
+ resp = self._send(method, path, body)
232
+ try:
233
+ return response_model.model_validate_json(resp.content)
234
+ except ValidationError as exc:
235
+ raise PsstError(
236
+ f"Unexpected response from {self.server}/api/v1/{path}"
237
+ ) from exc
238
+
239
+ def info(self) -> ApiInfo:
240
+ if self._info is None:
241
+ self._info = self._request("GET", "info", ApiInfo)
242
+ return self._info
243
+
244
+ def check_compatible(self, *, creating: bool) -> ApiInfo:
245
+ """Verify the server speaks API v1 and, when creating, accepts our format version."""
246
+ info = self.info()
247
+ if info.api_version != API_VERSION:
248
+ raise IncompatibleServerError(
249
+ f"Server API version {info.api_version} is not supported (expected {API_VERSION})"
250
+ )
251
+ if creating and FORMAT_VERSION not in info.format_versions:
252
+ raise IncompatibleServerError(
253
+ f"Server does not accept format version {FORMAT_VERSION} "
254
+ f"(accepts {info.format_versions})"
255
+ )
256
+ return info
257
+
258
+ def create_whisper(self, body: CreateWhisper) -> CreateWhisperResponse:
259
+ return self._request("POST", "whisper", CreateWhisperResponse, body)
260
+
261
+ def create_request(self, body: CreateRequest) -> CreateRequestResponse:
262
+ return self._request("POST", "whisper/request", CreateRequestResponse, body)
263
+
264
+ def get_submit_key(self, request_id: str) -> SubmitRequestKey:
265
+ return self._request("GET", f"whisper/submit/{request_id}", SubmitRequestKey)
266
+
267
+ def submit(self, request_id: str, body: SubmitWhisper) -> SubmitWhisperResponse:
268
+ return self._request(
269
+ "POST", f"whisper/submit/{request_id}", SubmitWhisperResponse, body
270
+ )
271
+
272
+ def reveal(self, view_id: str, reveal_token: str | None) -> RevealWhisperResponse:
273
+ body = _RevealBody(reveal_token=reveal_token or "")
274
+ return self._request(
275
+ "POST", f"whisper/{view_id}/reveal", RevealWhisperResponse, body
276
+ )
277
+
278
+ def status(self, view_id: str) -> WhisperStatus:
279
+ return self._request("GET", f"whisper/{view_id}/status", WhisperStatus)
280
+
281
+ def exchange_token(self, body: TokenExchange) -> TokenResponse:
282
+ return self._request("POST", "cli/token", TokenResponse, body)
283
+
284
+ def revoke_token(self) -> None:
285
+ self._send("POST", "cli/revoke")
psst_cli/auth.py ADDED
@@ -0,0 +1,148 @@
1
+ """API token sign-in: loopback redirect with PKCE (RFC 8252, RFC 7636)."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ import secrets
7
+ import time
8
+ import webbrowser
9
+ from collections.abc import Callable
10
+ from http.server import BaseHTTPRequestHandler, HTTPServer
11
+ from urllib.parse import parse_qs, urlencode, urlsplit
12
+
13
+ from . import PsstError
14
+ from .api import Client, TokenExchange, TokenResponse
15
+
16
+ CLIENT_NAME = "psst CLI"
17
+ CALLBACK_PATH = "/callback"
18
+ MANUAL_REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob"
19
+ LOGIN_TIMEOUT = 300
20
+
21
+ _CALLBACK_PAGE = (
22
+ "<!doctype html><meta charset=utf-8><title>psst CLI</title>"
23
+ "<p>{message} You can close this window and return to the terminal.</p>"
24
+ )
25
+
26
+
27
+ class LoginError(PsstError):
28
+ pass
29
+
30
+
31
+ def new_pkce_pair() -> tuple[str, str]:
32
+ verifier = secrets.token_urlsafe(64)
33
+ challenge = base64.urlsafe_b64encode(
34
+ hashlib.sha256(verifier.encode("ascii")).digest()
35
+ )
36
+ return verifier, challenge.rstrip(b"=").decode("ascii")
37
+
38
+
39
+ def authorize_url(
40
+ server: str, redirect_uri: str, challenge: str, state: str, scope: str | None
41
+ ) -> str:
42
+ params = {
43
+ "redirect_uri": redirect_uri,
44
+ "code_challenge": challenge,
45
+ "code_challenge_method": "S256",
46
+ "state": state,
47
+ "client_name": CLIENT_NAME,
48
+ }
49
+ if scope:
50
+ params["scope"] = scope
51
+ return f"{server}/cli/authorize?{urlencode(params)}"
52
+
53
+
54
+ class _CallbackServer(HTTPServer):
55
+ result: dict[str, str] | None = None
56
+
57
+
58
+ class _CallbackHandler(BaseHTTPRequestHandler):
59
+ server: _CallbackServer
60
+
61
+ def do_GET(self) -> None:
62
+ parts = urlsplit(self.path)
63
+ if parts.path != CALLBACK_PATH:
64
+ self.send_error(404)
65
+ return
66
+ self.server.result = {k: v[0] for k, v in parse_qs(parts.query).items()}
67
+ ok = "code" in self.server.result
68
+ body = _CALLBACK_PAGE.format(
69
+ message="Signed in." if ok else "Sign-in was not completed."
70
+ )
71
+ self.send_response(200)
72
+ self.send_header("Content-Type", "text/html; charset=utf-8")
73
+ self.send_header("Cache-Control", "no-store")
74
+ self.end_headers()
75
+ self.wfile.write(body.encode("utf-8"))
76
+
77
+ def log_message(self, format: str, *args: object) -> None:
78
+ # The default logger would print the authorization code to stderr.
79
+ pass
80
+
81
+
82
+ def _wait_for_callback(httpd: _CallbackServer, timeout: float) -> dict[str, str]:
83
+ deadline = time.monotonic() + timeout
84
+ while httpd.result is None:
85
+ remaining = deadline - time.monotonic()
86
+ if remaining <= 0:
87
+ raise LoginError("Timed out waiting for the browser sign-in")
88
+ httpd.timeout = remaining
89
+ httpd.handle_request()
90
+ return httpd.result
91
+
92
+
93
+ def login_loopback(
94
+ client: Client,
95
+ *,
96
+ port: int = 0,
97
+ scope: str | None = None,
98
+ open_browser: bool = True,
99
+ show_url: Callable[[str], None] = print,
100
+ timeout: float = LOGIN_TIMEOUT,
101
+ ) -> TokenResponse:
102
+ verifier, challenge = new_pkce_pair()
103
+ state = secrets.token_urlsafe(32)
104
+ try:
105
+ httpd = _CallbackServer(("127.0.0.1", port), _CallbackHandler)
106
+ except OSError as exc:
107
+ raise LoginError(
108
+ f"Could not listen on 127.0.0.1:{port}: {exc.strerror}"
109
+ ) from exc
110
+ with httpd:
111
+ redirect_uri = f"http://127.0.0.1:{httpd.server_address[1]}{CALLBACK_PATH}"
112
+ url = authorize_url(client.server, redirect_uri, challenge, state, scope)
113
+ show_url(url)
114
+ if open_browser:
115
+ webbrowser.open(url)
116
+ result = _wait_for_callback(httpd, timeout)
117
+
118
+ if not hmac.compare_digest(result.get("state", ""), state):
119
+ raise LoginError("Sign-in response had an invalid state; aborting")
120
+ if "error" in result:
121
+ raise LoginError(f"Sign-in failed: {result['error']}")
122
+ if "code" not in result:
123
+ raise LoginError("Sign-in response did not contain a code")
124
+ return client.exchange_token(
125
+ TokenExchange(
126
+ code=result["code"], code_verifier=verifier, redirect_uri=redirect_uri
127
+ )
128
+ )
129
+
130
+
131
+ def login_manual(
132
+ client: Client,
133
+ *,
134
+ read_code: Callable[[], str],
135
+ scope: str | None = None,
136
+ show_url: Callable[[str], None] = print,
137
+ ) -> TokenResponse:
138
+ verifier, challenge = new_pkce_pair()
139
+ state = secrets.token_urlsafe(32)
140
+ show_url(authorize_url(client.server, MANUAL_REDIRECT_URI, challenge, state, scope))
141
+ code = read_code().strip()
142
+ if not code:
143
+ raise LoginError("No code entered")
144
+ return client.exchange_token(
145
+ TokenExchange(
146
+ code=code, code_verifier=verifier, redirect_uri=MANUAL_REDIRECT_URI
147
+ )
148
+ )