psst-secret-cli 1.0.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.
- psst_secret_cli-1.0.0/PKG-INFO +62 -0
- psst_secret_cli-1.0.0/README.md +51 -0
- psst_secret_cli-1.0.0/pyproject.toml +32 -0
- psst_secret_cli-1.0.0/pyproject.toml.orig +32 -0
- psst_secret_cli-1.0.0/src/psst_cli/__init__.py +19 -0
- psst_secret_cli-1.0.0/src/psst_cli/api.py +285 -0
- psst_secret_cli-1.0.0/src/psst_cli/auth.py +148 -0
- psst_secret_cli-1.0.0/src/psst_cli/cli.py +723 -0
- psst_secret_cli-1.0.0/src/psst_cli/config.py +58 -0
- psst_secret_cli-1.0.0/src/psst_cli/crypto.py +97 -0
- psst_secret_cli-1.0.0/src/psst_cli/envelope.py +98 -0
- psst_secret_cli-1.0.0/src/psst_cli/links.py +100 -0
- psst_secret_cli-1.0.0/src/psst_cli/operations.py +325 -0
- psst_secret_cli-1.0.0/src/psst_cli/saved.py +74 -0
- psst_secret_cli-1.0.0/src/psst_cli/xwing.py +68 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: psst-secret-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Command-line client for psst-secret
|
|
5
|
+
Requires-Dist: cryptography>=50.0.2
|
|
6
|
+
Requires-Dist: pydantic>=2.14.3
|
|
7
|
+
Requires-Dist: requests>=2.34.2
|
|
8
|
+
Requires-Dist: typer>=0.28.0
|
|
9
|
+
Requires-Python: >=3.15
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# psst-secret-cli
|
|
13
|
+
|
|
14
|
+
Command-line client for [psst-secret](https://github.com/ch0wm3in/psst-secret). Encryption happens locally; the link fragment, keys, and passwords are never sent to the server.
|
|
15
|
+
|
|
16
|
+
Implements format version 1 as specified in psst-secret `v1.5.2` (`docs/format.md`).
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
psst config set-server https://psst.example.com # or --server / PSST_SERVER
|
|
22
|
+
psst info # compatibility check
|
|
23
|
+
|
|
24
|
+
# Send mode: secret text is read from stdin or a hidden prompt, never from arguments
|
|
25
|
+
psst send < secret.txt
|
|
26
|
+
psst send --file id_ed25519 --password --max-views 1 --expiry 1h
|
|
27
|
+
|
|
28
|
+
# Reveal: the link is read from stdin or a hidden prompt
|
|
29
|
+
psst reveal -o ./downloads
|
|
30
|
+
|
|
31
|
+
# Receive mode
|
|
32
|
+
psst request --password # prints a submit link and a view link
|
|
33
|
+
psst request --notify-email me@example.com --reader alice@example.com
|
|
34
|
+
psst status # has the request been submitted? (view link from stdin/prompt)
|
|
35
|
+
psst submit '<submit link>' --file creds.env
|
|
36
|
+
|
|
37
|
+
# Saved receive requests (opt-in, kept in a private file on this machine)
|
|
38
|
+
psst config save-requests on # or `psst request --save` for a single request
|
|
39
|
+
psst request --label "deploy key"
|
|
40
|
+
psst requests list # pending / submitted / gone, without using a view
|
|
41
|
+
psst requests reveal "deploy key"
|
|
42
|
+
psst requests prune # forget expired or revealed requests
|
|
43
|
+
psst config save-requests off # offers to delete everything saved
|
|
44
|
+
|
|
45
|
+
# Sign in (servers that require it): browser sign-in with PKCE, token saved per server
|
|
46
|
+
psst login # --port 53682 --no-browser for SSH forwarding, --manual if enabled
|
|
47
|
+
psst logout # revokes the token
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`PSST_TOKEN` overrides the saved token, e.g. in automation.
|
|
51
|
+
|
|
52
|
+
## Development
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
uv sync
|
|
56
|
+
uv run pytest # unit tests and spec vectors
|
|
57
|
+
|
|
58
|
+
docker compose -f tests/integration/docker-compose.yml up -d
|
|
59
|
+
PSST_TEST_SERVER=http://127.0.0.1:18000 uv run pytest -m integration
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The test vectors in `tests/vectors/v1/` are copied from `spec/vectors/v1/` at psst-secret `v1.5.2`.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# psst-secret-cli
|
|
2
|
+
|
|
3
|
+
Command-line client for [psst-secret](https://github.com/ch0wm3in/psst-secret). Encryption happens locally; the link fragment, keys, and passwords are never sent to the server.
|
|
4
|
+
|
|
5
|
+
Implements format version 1 as specified in psst-secret `v1.5.2` (`docs/format.md`).
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
psst config set-server https://psst.example.com # or --server / PSST_SERVER
|
|
11
|
+
psst info # compatibility check
|
|
12
|
+
|
|
13
|
+
# Send mode: secret text is read from stdin or a hidden prompt, never from arguments
|
|
14
|
+
psst send < secret.txt
|
|
15
|
+
psst send --file id_ed25519 --password --max-views 1 --expiry 1h
|
|
16
|
+
|
|
17
|
+
# Reveal: the link is read from stdin or a hidden prompt
|
|
18
|
+
psst reveal -o ./downloads
|
|
19
|
+
|
|
20
|
+
# Receive mode
|
|
21
|
+
psst request --password # prints a submit link and a view link
|
|
22
|
+
psst request --notify-email me@example.com --reader alice@example.com
|
|
23
|
+
psst status # has the request been submitted? (view link from stdin/prompt)
|
|
24
|
+
psst submit '<submit link>' --file creds.env
|
|
25
|
+
|
|
26
|
+
# Saved receive requests (opt-in, kept in a private file on this machine)
|
|
27
|
+
psst config save-requests on # or `psst request --save` for a single request
|
|
28
|
+
psst request --label "deploy key"
|
|
29
|
+
psst requests list # pending / submitted / gone, without using a view
|
|
30
|
+
psst requests reveal "deploy key"
|
|
31
|
+
psst requests prune # forget expired or revealed requests
|
|
32
|
+
psst config save-requests off # offers to delete everything saved
|
|
33
|
+
|
|
34
|
+
# Sign in (servers that require it): browser sign-in with PKCE, token saved per server
|
|
35
|
+
psst login # --port 53682 --no-browser for SSH forwarding, --manual if enabled
|
|
36
|
+
psst logout # revokes the token
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`PSST_TOKEN` overrides the saved token, e.g. in automation.
|
|
40
|
+
|
|
41
|
+
## Development
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
uv sync
|
|
45
|
+
uv run pytest # unit tests and spec vectors
|
|
46
|
+
|
|
47
|
+
docker compose -f tests/integration/docker-compose.yml up -d
|
|
48
|
+
PSST_TEST_SERVER=http://127.0.0.1:18000 uv run pytest -m integration
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The test vectors in `tests/vectors/v1/` are copied from `spec/vectors/v1/` at psst-secret `v1.5.2`.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "psst-secret-cli"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Command-line client for psst-secret"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.15"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"cryptography>=50.0.2",
|
|
9
|
+
"pydantic>=2.14.3",
|
|
10
|
+
"requests>=2.34.2",
|
|
11
|
+
"typer>=0.28.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
psst = "psst_cli.cli:main"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["uv_build>=0.11.17,<0.12"]
|
|
19
|
+
build-backend = "uv_build"
|
|
20
|
+
|
|
21
|
+
[tool.uv.build-backend]
|
|
22
|
+
module-name = "psst_cli"
|
|
23
|
+
|
|
24
|
+
[tool.pytest.ini_options]
|
|
25
|
+
testpaths = ["tests"]
|
|
26
|
+
markers = ["integration: needs a running psst-secret server (set PSST_TEST_SERVER)"]
|
|
27
|
+
|
|
28
|
+
[dependency-groups]
|
|
29
|
+
dev = [
|
|
30
|
+
"pre-commit>=4.7.0",
|
|
31
|
+
"pytest>=9.2.0",
|
|
32
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "psst-secret-cli"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Command-line client for psst-secret"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.15"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"cryptography>=50.0.2",
|
|
9
|
+
"pydantic>=2.14.3",
|
|
10
|
+
"requests>=2.34.2",
|
|
11
|
+
"typer>=0.28.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
psst = "psst_cli.cli:main"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["uv_build>=0.11.17,<0.12"]
|
|
19
|
+
build-backend = "uv_build"
|
|
20
|
+
|
|
21
|
+
[tool.uv.build-backend]
|
|
22
|
+
module-name = "psst_cli"
|
|
23
|
+
|
|
24
|
+
[tool.pytest.ini_options]
|
|
25
|
+
testpaths = ["tests"]
|
|
26
|
+
markers = ["integration: needs a running psst-secret server (set PSST_TEST_SERVER)"]
|
|
27
|
+
|
|
28
|
+
[dependency-groups]
|
|
29
|
+
dev = [
|
|
30
|
+
"pre-commit>=4.7.0",
|
|
31
|
+
"pytest>=9.2.0",
|
|
32
|
+
]
|
|
@@ -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."""
|
|
@@ -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")
|
|
@@ -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
|
+
)
|