credenshare 0.1.2__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.
- credenshare/__init__.py +106 -0
- credenshare/client.py +581 -0
- credenshare/conformance/__init__.py +308 -0
- credenshare/conformance/__main__.py +50 -0
- credenshare/conformance/vectors.v1.json +131 -0
- credenshare/crypto.py +452 -0
- credenshare/errors.py +234 -0
- credenshare/webhooks.py +126 -0
- credenshare-0.1.2.dist-info/METADATA +256 -0
- credenshare-0.1.2.dist-info/RECORD +13 -0
- credenshare-0.1.2.dist-info/WHEEL +4 -0
- credenshare-0.1.2.dist-info/licenses/LICENSE +201 -0
- credenshare-0.1.2.dist-info/licenses/NOTICE +8 -0
credenshare/__init__.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""CredenShare SDK — end-to-end encrypted secret sharing.
|
|
2
|
+
|
|
3
|
+
Encryption happens here, on your machine. The content key never reaches CredenShare, which
|
|
4
|
+
is what makes "we cannot read your data" a property of the system rather than a promise.
|
|
5
|
+
|
|
6
|
+
from credenshare import CredenShare
|
|
7
|
+
|
|
8
|
+
crs = CredenShare(credential="crs_sk_live_...")
|
|
9
|
+
share = crs.shares.create(
|
|
10
|
+
title="Staging deploy credentials",
|
|
11
|
+
fields=[{"key": "Password", "value": "s3cr3t", "type": "password"}],
|
|
12
|
+
)
|
|
13
|
+
print(share.link) # contains the key in its fragment - treat it as the secret
|
|
14
|
+
|
|
15
|
+
The implementation follows the published wire specification, and its correctness is defined
|
|
16
|
+
by `conformance/vectors.v1.json` rather than by this code.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from . import webhooks
|
|
20
|
+
from .client import (
|
|
21
|
+
CredenShare,
|
|
22
|
+
Credential,
|
|
23
|
+
Share,
|
|
24
|
+
ShareList,
|
|
25
|
+
ShareSummary,
|
|
26
|
+
)
|
|
27
|
+
from .crypto import (
|
|
28
|
+
FIELD_TYPES,
|
|
29
|
+
SeedKeypair,
|
|
30
|
+
access_token,
|
|
31
|
+
custody_keypair,
|
|
32
|
+
decode_fragment,
|
|
33
|
+
decrypt_content,
|
|
34
|
+
encode_fragment,
|
|
35
|
+
encrypt_content,
|
|
36
|
+
keypair_from_seed,
|
|
37
|
+
new_content_key,
|
|
38
|
+
passcode_verifier,
|
|
39
|
+
unwrap_with_seed,
|
|
40
|
+
wrap_to_public_key,
|
|
41
|
+
)
|
|
42
|
+
from .errors import (
|
|
43
|
+
ApiError,
|
|
44
|
+
AuthenticationError,
|
|
45
|
+
CredenShareError,
|
|
46
|
+
CredentialFormatError,
|
|
47
|
+
CustodySecretMissingError,
|
|
48
|
+
CustodySecretTransmittedError,
|
|
49
|
+
DeliveryUnknownError,
|
|
50
|
+
IdempotencyConflictError,
|
|
51
|
+
InvalidArgumentError,
|
|
52
|
+
InvalidFieldError,
|
|
53
|
+
MalformedKeyError,
|
|
54
|
+
MissingKeyError,
|
|
55
|
+
NotFoundError,
|
|
56
|
+
PermissionError_,
|
|
57
|
+
QuotaExceededError,
|
|
58
|
+
RateLimitError,
|
|
59
|
+
ServiceUnavailableError,
|
|
60
|
+
WireFormatError,
|
|
61
|
+
)
|
|
62
|
+
from .webhooks import WebhookVerificationError
|
|
63
|
+
|
|
64
|
+
__version__ = "0.1.0"
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
"__version__",
|
|
68
|
+
"CredenShare",
|
|
69
|
+
"Credential",
|
|
70
|
+
"Share",
|
|
71
|
+
"ShareList",
|
|
72
|
+
"ShareSummary",
|
|
73
|
+
"webhooks",
|
|
74
|
+
"FIELD_TYPES",
|
|
75
|
+
"SeedKeypair",
|
|
76
|
+
"access_token",
|
|
77
|
+
"custody_keypair",
|
|
78
|
+
"decode_fragment",
|
|
79
|
+
"decrypt_content",
|
|
80
|
+
"encode_fragment",
|
|
81
|
+
"encrypt_content",
|
|
82
|
+
"keypair_from_seed",
|
|
83
|
+
"new_content_key",
|
|
84
|
+
"passcode_verifier",
|
|
85
|
+
"unwrap_with_seed",
|
|
86
|
+
"wrap_to_public_key",
|
|
87
|
+
"ApiError",
|
|
88
|
+
"AuthenticationError",
|
|
89
|
+
"CredenShareError",
|
|
90
|
+
"CredentialFormatError",
|
|
91
|
+
"CustodySecretMissingError",
|
|
92
|
+
"InvalidArgumentError",
|
|
93
|
+
"InvalidFieldError",
|
|
94
|
+
"WebhookVerificationError",
|
|
95
|
+
"CustodySecretTransmittedError",
|
|
96
|
+
"DeliveryUnknownError",
|
|
97
|
+
"IdempotencyConflictError",
|
|
98
|
+
"MalformedKeyError",
|
|
99
|
+
"MissingKeyError",
|
|
100
|
+
"NotFoundError",
|
|
101
|
+
"PermissionError_",
|
|
102
|
+
"QuotaExceededError",
|
|
103
|
+
"RateLimitError",
|
|
104
|
+
"ServiceUnavailableError",
|
|
105
|
+
"WireFormatError",
|
|
106
|
+
]
|
credenshare/client.py
ADDED
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
"""The CredenShare API client.
|
|
2
|
+
|
|
3
|
+
Everything sensitive happens before a request is built. By the time anything reaches the
|
|
4
|
+
network it is ciphertext plus metadata, and the content key exists only in the link this
|
|
5
|
+
client hands back to you.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, Dict, Iterator, List, Optional, Sequence
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from . import crypto
|
|
19
|
+
from .errors import (
|
|
20
|
+
ApiError,
|
|
21
|
+
AuthenticationError,
|
|
22
|
+
CredentialFormatError,
|
|
23
|
+
CustodySecretMissingError,
|
|
24
|
+
CustodySecretTransmittedError,
|
|
25
|
+
DeliveryUnknownError,
|
|
26
|
+
IdempotencyConflictError,
|
|
27
|
+
InvalidArgumentError,
|
|
28
|
+
NotFoundError,
|
|
29
|
+
PermissionError_,
|
|
30
|
+
QuotaExceededError,
|
|
31
|
+
RateLimitError,
|
|
32
|
+
ServiceUnavailableError,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = ["CredenShare", "Credential", "Share", "ShareList", "ShareSummary"]
|
|
36
|
+
|
|
37
|
+
DEFAULT_BASE_URL = "https://api.credenshare.io/v1"
|
|
38
|
+
DEFAULT_LINK_ORIGIN = "https://crs.sh"
|
|
39
|
+
|
|
40
|
+
#: The most pages :meth:`_Shares.iter_all` will walk before giving up.
|
|
41
|
+
#:
|
|
42
|
+
#: The end of a result set is not always knowable: a server that omits every paging figure and
|
|
43
|
+
#: returns full pages forever cannot be told from a very large account. An unbounded walk
|
|
44
|
+
#: against one never returns. At the default page size of 100 this is ten million shares.
|
|
45
|
+
MAX_PAGES = 100_000
|
|
46
|
+
|
|
47
|
+
#: The only accepted encryption type. Plaintext creates are refused by the server, and this
|
|
48
|
+
#: client has no way to express one.
|
|
49
|
+
ENCRYPTION_TYPE = "e2ee-aes256-gcm"
|
|
50
|
+
|
|
51
|
+
#: The API's numeric code for an exhausted plan allowance. Distinguished from other 403s
|
|
52
|
+
#: because waiting does not help and the remedy is a plan change, not a retry.
|
|
53
|
+
_QUOTA_EXCEEDED_CODE = 61
|
|
54
|
+
|
|
55
|
+
#: The API's numeric code for an Idempotency-Key replayed with a different body.
|
|
56
|
+
_IDEMPOTENCY_CONFLICT_CODE = 105
|
|
57
|
+
|
|
58
|
+
#: The identical request is still in flight. Wait briefly and repeat it unchanged - the
|
|
59
|
+
#: opposite remedy to 105, which means the body genuinely differed.
|
|
60
|
+
_IDEMPOTENCY_IN_FLIGHT_CODE = 106
|
|
61
|
+
|
|
62
|
+
#: Retries for network failures. Only connection and timeout errors are retried, never an
|
|
63
|
+
#: HTTP status: a 5xx may have committed, and this client cannot tell. A create is safe to
|
|
64
|
+
#: retry because the Idempotency-Key and the body are both identical on the second attempt —
|
|
65
|
+
#: which is the entire reason the header is mandatory.
|
|
66
|
+
DEFAULT_MAX_RETRIES = 2
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class Credential:
|
|
71
|
+
"""A parsed API credential: ``crs_sk_live_<keyId>.<authSecret>[.<custodySecret>]``.
|
|
72
|
+
|
|
73
|
+
The custody secret is held here but is NEVER placed in a request. It is a separate secret
|
|
74
|
+
precisely so the server cannot reconstruct the custody private key — deriving it from the
|
|
75
|
+
auth secret, which is transmitted on every call, would mean the server *could* decrypt.
|
|
76
|
+
Not that it would; that it could.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
key_id: str
|
|
80
|
+
auth_secret: str
|
|
81
|
+
custody_secret: Optional[str] = None
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def parse(cls, raw: str) -> "Credential":
|
|
85
|
+
text = (raw or "").strip()
|
|
86
|
+
if not text.startswith("crs_sk_live_"):
|
|
87
|
+
raise CredentialFormatError(
|
|
88
|
+
"a credential starts with 'crs_sk_live_'; this does not look like one"
|
|
89
|
+
)
|
|
90
|
+
parts = text.split(".")
|
|
91
|
+
if len(parts) not in (2, 3) or not all(parts):
|
|
92
|
+
raise CredentialFormatError(
|
|
93
|
+
"a credential is 'crs_sk_live_<keyId>.<authSecret>' with an optional "
|
|
94
|
+
f"'.<custodySecret>'; this has {len(parts)} part(s)"
|
|
95
|
+
)
|
|
96
|
+
return cls(
|
|
97
|
+
key_id=parts[0][len("crs_sk_live_") :],
|
|
98
|
+
auth_secret=parts[1],
|
|
99
|
+
custody_secret=parts[2] if len(parts) == 3 else None,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def bearer(self) -> str:
|
|
104
|
+
"""The two-part value sent in the Authorization header.
|
|
105
|
+
|
|
106
|
+
Assembled from the parts rather than by trimming the original string, so a third part
|
|
107
|
+
cannot survive a formatting mistake and reach the wire.
|
|
108
|
+
"""
|
|
109
|
+
return f"crs_sk_live_{self.key_id}.{self.auth_secret}"
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def has_custody(self) -> bool:
|
|
113
|
+
return self.custody_secret is not None
|
|
114
|
+
|
|
115
|
+
def custody_public_key(self) -> str:
|
|
116
|
+
"""The base64url custody public key to register for account custody.
|
|
117
|
+
|
|
118
|
+
Only the public half leaves this machine.
|
|
119
|
+
"""
|
|
120
|
+
if self.custody_secret is None:
|
|
121
|
+
raise CredentialFormatError(
|
|
122
|
+
"this credential has no custody secret, so no custody keypair exists"
|
|
123
|
+
)
|
|
124
|
+
return crypto.custody_keypair(self.custody_secret).public_key_b64url
|
|
125
|
+
|
|
126
|
+
def __repr__(self) -> str: # pragma: no cover - formatting only
|
|
127
|
+
# Never render the secrets. A credential in a traceback or a log line is a credential
|
|
128
|
+
# that has to be rotated.
|
|
129
|
+
custody = "with custody" if self.has_custody else "no custody"
|
|
130
|
+
return f"<Credential {self.key_id} ({custody})>"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class Share:
|
|
135
|
+
"""A created share, and the only place its link exists."""
|
|
136
|
+
|
|
137
|
+
short_code: str
|
|
138
|
+
#: The full recipient link, INCLUDING the key fragment. Treat this as the secret itself:
|
|
139
|
+
#: anyone holding it can read the content, and CredenShare cannot.
|
|
140
|
+
link: str
|
|
141
|
+
#: The content key, if you need to build your own link or decrypt later.
|
|
142
|
+
content_key: bytes
|
|
143
|
+
expired_at: Optional[str] = None
|
|
144
|
+
custody: Optional[str] = None
|
|
145
|
+
|
|
146
|
+
def __repr__(self) -> str: # pragma: no cover - formatting only
|
|
147
|
+
# The link carries the key, so it is not printed by default.
|
|
148
|
+
return f"<Share {self.short_code} (link withheld from repr)>"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True)
|
|
152
|
+
class ShareSummary:
|
|
153
|
+
"""Metadata for a share. Never content, and never a key.
|
|
154
|
+
|
|
155
|
+
Deliberately thin, because the API is: `/v1` returns the short code and the expiry and
|
|
156
|
+
nothing else. There is no `title` here even though you supply one on create — the server
|
|
157
|
+
does not return it, and an attribute that is always ``None`` reads as a broken field
|
|
158
|
+
rather than as an absent one.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
short_code: str
|
|
162
|
+
expired_at: Optional[str] = None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class ShareList(List[ShareSummary]):
|
|
166
|
+
"""A page of shares, with the paging figures attached.
|
|
167
|
+
|
|
168
|
+
A plain list would leave a caller guessing whether more exists, and a caller who has to
|
|
169
|
+
guess guesses wrong — usually by stopping at the first short page.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
def __init__(
|
|
173
|
+
self,
|
|
174
|
+
rows: Sequence[ShareSummary],
|
|
175
|
+
*,
|
|
176
|
+
page: int = 1,
|
|
177
|
+
limit: int = 25,
|
|
178
|
+
total: Optional[int] = None,
|
|
179
|
+
total_pages: Optional[int] = None,
|
|
180
|
+
) -> None:
|
|
181
|
+
super().__init__(rows)
|
|
182
|
+
self.page = page
|
|
183
|
+
self.limit = limit
|
|
184
|
+
self.total = total
|
|
185
|
+
self.total_pages = total_pages
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def has_more(self) -> bool:
|
|
189
|
+
"""Whether another page exists.
|
|
190
|
+
|
|
191
|
+
Falls back rather than answering ``False`` when the server sends no paging figures:
|
|
192
|
+
reporting "no more" on a full page is what makes :meth:`iter_all` stop after page one
|
|
193
|
+
and return a fraction of the account as though it were all of it. Node, Go and Rust
|
|
194
|
+
all fall back; Python was the only one that did not.
|
|
195
|
+
"""
|
|
196
|
+
if self.total_pages is not None:
|
|
197
|
+
return self.page < self.total_pages
|
|
198
|
+
if self.total is not None:
|
|
199
|
+
return self.page * self.limit < self.total
|
|
200
|
+
# Nothing to go on but the page itself: a full page probably has a successor.
|
|
201
|
+
return len(self) >= self.limit > 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class _Shares:
|
|
205
|
+
"""The ``/v1/shares`` surface."""
|
|
206
|
+
|
|
207
|
+
def __init__(self, client: "CredenShare") -> None:
|
|
208
|
+
self._client = client
|
|
209
|
+
|
|
210
|
+
def create(
|
|
211
|
+
self,
|
|
212
|
+
*,
|
|
213
|
+
title: str,
|
|
214
|
+
fields: Sequence[Dict[str, Any]],
|
|
215
|
+
description: Optional[str] = None,
|
|
216
|
+
passcode: Optional[str] = None,
|
|
217
|
+
expired_at: Optional[str] = None,
|
|
218
|
+
access_counts_left: Optional[int] = None,
|
|
219
|
+
timed_view: Optional[int] = None,
|
|
220
|
+
idempotency_key: Optional[str] = None,
|
|
221
|
+
content_key: Optional[bytes] = None,
|
|
222
|
+
custody: bool = False,
|
|
223
|
+
item_key_wrap: Optional[str] = None,
|
|
224
|
+
organization_id: Optional[str] = None,
|
|
225
|
+
) -> Share:
|
|
226
|
+
"""Encrypt ``fields`` locally and create a share.
|
|
227
|
+
|
|
228
|
+
Each field is ``{"key": <label>, "value": <content>, "type": <one of FIELD_TYPES>}``.
|
|
229
|
+
``key`` is the visible label — not ``label``, ``name`` or ``title``, which are
|
|
230
|
+
silently ignored and would render every field blank. This client refuses those
|
|
231
|
+
rather than letting the mistake through.
|
|
232
|
+
|
|
233
|
+
Returns a :class:`Share` whose ``link`` contains the content key in its fragment.
|
|
234
|
+
That link is the secret; CredenShare never sees the key and cannot recover it.
|
|
235
|
+
|
|
236
|
+
``idempotency_key`` is generated per call unless you pass one. Passing your own does
|
|
237
|
+
NOT make a second call a no-op: encryption is randomised per call — a fresh salt and
|
|
238
|
+
IV every time — so the body differs and the API refuses with
|
|
239
|
+
:class:`IdempotencyConflictError`. That is the header working, not failing. What it
|
|
240
|
+
protects is a network retry, where the same already-encrypted request is sent again;
|
|
241
|
+
this client performs those itself. Pass a key to control the value for your own
|
|
242
|
+
tracing, not to deduplicate calls.
|
|
243
|
+
|
|
244
|
+
``custody=True`` additionally wraps the content key to the custody public key derived
|
|
245
|
+
from your credential's third part, so the share is readable from the dashboard later.
|
|
246
|
+
Without it — the default — an API-created share is custody ``"none"``: the link is the
|
|
247
|
+
only way back to the content, and losing it loses the secret. The custody secret is
|
|
248
|
+
used locally to derive a public key and is never transmitted. Pass ``item_key_wrap``
|
|
249
|
+
instead to supply a wrap you computed yourself.
|
|
250
|
+
|
|
251
|
+
``content_key`` lets you create a share under a key you already hold — a link you
|
|
252
|
+
handed out before the create, or a fixed key in a test. It does not make the request
|
|
253
|
+
body reproducible. Never reuse a key across shares whose content differs: this client
|
|
254
|
+
draws a fresh IV each time, which is what keeps that safe, but nothing here can stop
|
|
255
|
+
you from carrying a key somewhere that does not.
|
|
256
|
+
"""
|
|
257
|
+
content_key = content_key if content_key is not None else crypto.new_content_key()
|
|
258
|
+
|
|
259
|
+
if custody and item_key_wrap is not None:
|
|
260
|
+
raise InvalidArgumentError("pass either custody=True or item_key_wrap, not both")
|
|
261
|
+
if custody:
|
|
262
|
+
secret = self._client.credential.custody_secret
|
|
263
|
+
if not secret:
|
|
264
|
+
raise CustodySecretMissingError(
|
|
265
|
+
"custody=True needs a three-part credential "
|
|
266
|
+
"'crs_sk_live_<keyId>.<authSecret>.<custodySecret>'; this one has two "
|
|
267
|
+
"parts, so there is no custody key to wrap to"
|
|
268
|
+
)
|
|
269
|
+
item_key_wrap = crypto.wrap_to_public_key(
|
|
270
|
+
content_key, crypto.custody_keypair(secret).public_key_raw
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
blob = crypto.encrypt_content(content_key, fields, passcode)
|
|
274
|
+
|
|
275
|
+
body: Dict[str, Any] = {
|
|
276
|
+
"title": title,
|
|
277
|
+
"encryption_type": ENCRYPTION_TYPE,
|
|
278
|
+
"data": blob,
|
|
279
|
+
"access_token": crypto.access_token(content_key),
|
|
280
|
+
}
|
|
281
|
+
if description is not None:
|
|
282
|
+
body["description"] = description
|
|
283
|
+
if passcode is not None:
|
|
284
|
+
body["passcode_verifier"] = crypto.passcode_verifier(passcode)
|
|
285
|
+
if expired_at is not None:
|
|
286
|
+
body["expired_at"] = expired_at
|
|
287
|
+
if access_counts_left is not None:
|
|
288
|
+
body["access_counts_left"] = access_counts_left
|
|
289
|
+
if timed_view is not None:
|
|
290
|
+
body["timed_view"] = timed_view
|
|
291
|
+
if item_key_wrap is not None:
|
|
292
|
+
body["item_key_wrap"] = item_key_wrap
|
|
293
|
+
if organization_id is not None:
|
|
294
|
+
body["organization_id"] = organization_id
|
|
295
|
+
|
|
296
|
+
# Required by the API, not optional. A retried automation must not create a second
|
|
297
|
+
# copy of a credential in the world, with its own link and audit trail, that the
|
|
298
|
+
# caller does not know exists.
|
|
299
|
+
headers = {"Idempotency-Key": idempotency_key or str(uuid.uuid4())}
|
|
300
|
+
|
|
301
|
+
data = self._client._request("POST", "/shares", json=body, headers=headers)
|
|
302
|
+
short_code = data["short_code"]
|
|
303
|
+
return Share(
|
|
304
|
+
short_code=short_code,
|
|
305
|
+
link=self._client.link_for(short_code, content_key),
|
|
306
|
+
content_key=content_key,
|
|
307
|
+
expired_at=data.get("expired_at"),
|
|
308
|
+
custody=data.get("custody"),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
def list(self, *, limit: int = 25, page: int = 1) -> ShareList:
|
|
312
|
+
"""One page of the account's shares, newest first. Metadata only.
|
|
313
|
+
|
|
314
|
+
The returned list carries ``page``, ``limit``, ``total``, ``total_pages`` and
|
|
315
|
+
``has_more``. Use :meth:`iter_all` to walk every page.
|
|
316
|
+
"""
|
|
317
|
+
data = self._client._request("GET", "/shares", params={"limit": limit, "page": page})
|
|
318
|
+
rows = data.get("shares") or data.get("data") or []
|
|
319
|
+
pagination = data.get("pagination") or {}
|
|
320
|
+
return ShareList(
|
|
321
|
+
[
|
|
322
|
+
ShareSummary(short_code=r["short_code"], expired_at=r.get("expired_at"))
|
|
323
|
+
for r in rows
|
|
324
|
+
],
|
|
325
|
+
page=pagination.get("page", page),
|
|
326
|
+
limit=pagination.get("limit", limit),
|
|
327
|
+
total=pagination.get("total"),
|
|
328
|
+
total_pages=pagination.get("total_pages"),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
def iter_all(self, *, limit: int = 100) -> Iterator[ShareSummary]:
|
|
332
|
+
"""Every share, page by page.
|
|
333
|
+
|
|
334
|
+
Written here because the hand-rolled version is usually wrong in the same way: it
|
|
335
|
+
stops on the first page shorter than ``limit``, which is a page the server is
|
|
336
|
+
entitled to return in the middle of a result set.
|
|
337
|
+
"""
|
|
338
|
+
page = 1
|
|
339
|
+
while True:
|
|
340
|
+
batch = self.list(limit=limit, page=page)
|
|
341
|
+
yield from batch
|
|
342
|
+
if not batch.has_more:
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
# The API echoing a page number other than the one asked for makes progress
|
|
346
|
+
# unobservable, so no termination condition can be trusted. Refuse rather than
|
|
347
|
+
# loop - Go and Rust do the same.
|
|
348
|
+
if batch.page != page:
|
|
349
|
+
raise ApiError(
|
|
350
|
+
f"asked for page {page} and the API answered with page {batch.page}, so "
|
|
351
|
+
f"paging cannot be trusted to terminate"
|
|
352
|
+
)
|
|
353
|
+
if page >= MAX_PAGES:
|
|
354
|
+
raise ApiError(
|
|
355
|
+
f"stopped after {MAX_PAGES} pages without the API signalling the end of "
|
|
356
|
+
f"the result set. A walk that cannot terminate is worse than one that "
|
|
357
|
+
f"stops and says so."
|
|
358
|
+
)
|
|
359
|
+
page += 1
|
|
360
|
+
|
|
361
|
+
def get(self, short_code: str) -> ShareSummary:
|
|
362
|
+
"""One share's metadata.
|
|
363
|
+
|
|
364
|
+
Does not consume a view, evaluate a passcode, or return content. A share belonging to
|
|
365
|
+
another account reports exactly as one that does not exist.
|
|
366
|
+
"""
|
|
367
|
+
data = self._client._request("GET", f"/shares/{quote(short_code, safe='')}")
|
|
368
|
+
return ShareSummary(
|
|
369
|
+
short_code=data.get("short_code", short_code),
|
|
370
|
+
expired_at=data.get("expired_at"),
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
def expire(self, short_code: str) -> None:
|
|
374
|
+
"""Expire a share immediately.
|
|
375
|
+
|
|
376
|
+
Irreversible: afterwards the content is unrecoverable by anyone, including
|
|
377
|
+
CredenShare — the key was never ours, and now the ciphertext is gone too.
|
|
378
|
+
|
|
379
|
+
The share is REMOVED, not flagged. A later :meth:`get` raises
|
|
380
|
+
:class:`NotFoundError` rather than returning a row with an expiry set, and it drops
|
|
381
|
+
out of :meth:`list`. Worth knowing if you reconcile against your own records: a
|
|
382
|
+
share you expired and one that never existed look identical afterwards.
|
|
383
|
+
|
|
384
|
+
A key can only expire shares its own account created. A short code belonging to
|
|
385
|
+
somebody else reports as not-found, so this cannot be used to probe for shares
|
|
386
|
+
elsewhere — and an organization-scoped key cannot expire a colleague's share even
|
|
387
|
+
though :meth:`list` shows it.
|
|
388
|
+
"""
|
|
389
|
+
self._client._request("DELETE", f"/shares/{quote(short_code, safe='')}")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class CredenShare:
|
|
393
|
+
"""The API client.
|
|
394
|
+
|
|
395
|
+
``credential`` accepts the two- or three-part form. If you pass the three-part one, the
|
|
396
|
+
custody secret stays on this machine — it is used to derive a keypair locally and is
|
|
397
|
+
never transmitted.
|
|
398
|
+
"""
|
|
399
|
+
|
|
400
|
+
def __init__(
|
|
401
|
+
self,
|
|
402
|
+
credential: str,
|
|
403
|
+
*,
|
|
404
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
405
|
+
link_origin: str = DEFAULT_LINK_ORIGIN,
|
|
406
|
+
timeout: float = 30.0,
|
|
407
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
408
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
409
|
+
) -> None:
|
|
410
|
+
self.credential = Credential.parse(credential)
|
|
411
|
+
self._max_retries = max(0, max_retries)
|
|
412
|
+
self._base_url = base_url.rstrip("/")
|
|
413
|
+
self._link_origin = link_origin.rstrip("/")
|
|
414
|
+
self._client = httpx.Client(
|
|
415
|
+
base_url=self._base_url,
|
|
416
|
+
timeout=timeout,
|
|
417
|
+
transport=transport,
|
|
418
|
+
headers={
|
|
419
|
+
"Authorization": f"Bearer {self.credential.bearer}",
|
|
420
|
+
"Content-Type": "application/json",
|
|
421
|
+
"User-Agent": _user_agent(),
|
|
422
|
+
},
|
|
423
|
+
)
|
|
424
|
+
self.shares = _Shares(self)
|
|
425
|
+
|
|
426
|
+
# ── context manager ──────────────────────────────────────────────────────────────
|
|
427
|
+
|
|
428
|
+
def __enter__(self) -> "CredenShare":
|
|
429
|
+
return self
|
|
430
|
+
|
|
431
|
+
def __exit__(self, *exc: Any) -> None:
|
|
432
|
+
self.close()
|
|
433
|
+
|
|
434
|
+
def close(self) -> None:
|
|
435
|
+
self._client.close()
|
|
436
|
+
|
|
437
|
+
# ── links ────────────────────────────────────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
def link_for(self, short_code: str, content_key: bytes) -> str:
|
|
440
|
+
"""Assemble a recipient link.
|
|
441
|
+
|
|
442
|
+
The key lives in the fragment, which browsers never send to a server. That is what
|
|
443
|
+
makes the link readable by its holder and opaque to us.
|
|
444
|
+
"""
|
|
445
|
+
return f"{self._link_origin}/{short_code}#{crypto.encode_fragment(content_key)}"
|
|
446
|
+
|
|
447
|
+
def read_link(self, link: str, passcode: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
448
|
+
"""Fetch and decrypt a share from a full link.
|
|
449
|
+
|
|
450
|
+
Not implemented against ``/v1``: the recipient path is deliberately absent from the
|
|
451
|
+
API, because bearer auth skips the proof-of-work and captcha gates that protect it,
|
|
452
|
+
and exposing it to a credential would be an enumeration bypass. Open the link in a
|
|
453
|
+
browser, or decrypt a blob you already hold with
|
|
454
|
+
:func:`credenshare.decrypt_content`.
|
|
455
|
+
"""
|
|
456
|
+
raise NotImplementedError(
|
|
457
|
+
"the recipient read path is not exposed over the API by design; open the link in "
|
|
458
|
+
"a browser, or use decrypt_content() on a blob you already have"
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
# ── transport ────────────────────────────────────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
def _request(
|
|
464
|
+
self,
|
|
465
|
+
method: str,
|
|
466
|
+
path: str,
|
|
467
|
+
*,
|
|
468
|
+
json: Optional[Dict[str, Any]] = None,
|
|
469
|
+
params: Optional[Dict[str, Any]] = None,
|
|
470
|
+
headers: Optional[Dict[str, str]] = None,
|
|
471
|
+
) -> Dict[str, Any]:
|
|
472
|
+
# Belt and braces. `bearer` is assembled from parts so a custody secret cannot reach
|
|
473
|
+
# the header, but this asserts the property at the boundary rather than trusting a
|
|
474
|
+
# constructor three files away.
|
|
475
|
+
if self.credential.custody_secret and self.credential.custody_secret in (
|
|
476
|
+
self._client.headers.get("Authorization") or ""
|
|
477
|
+
):
|
|
478
|
+
raise CustodySecretTransmittedError(
|
|
479
|
+
"the custody secret was about to be transmitted; rotate this credential"
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
# Retry only the failures that prove nothing was received. A 5xx might have
|
|
483
|
+
# committed and this client cannot tell, so it is surfaced rather than repeated. A
|
|
484
|
+
# POST is safe to repeat here because the body and the Idempotency-Key are both
|
|
485
|
+
# byte-identical on the second attempt — which is what the header is for.
|
|
486
|
+
attempt = 0
|
|
487
|
+
delivered = False
|
|
488
|
+
while True:
|
|
489
|
+
try:
|
|
490
|
+
response = self._client.request(
|
|
491
|
+
method, path, json=json, params=params, headers=headers
|
|
492
|
+
)
|
|
493
|
+
break
|
|
494
|
+
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as exc:
|
|
495
|
+
# A read timeout is not a connect failure. The request was written to an
|
|
496
|
+
# established connection, so the server may have committed it; saying
|
|
497
|
+
# "nothing was created" here is what makes a caller retry with a new key
|
|
498
|
+
# and end up with two copies of one secret.
|
|
499
|
+
delivered = delivered or isinstance(exc, httpx.ReadTimeout)
|
|
500
|
+
if attempt >= self._max_retries:
|
|
501
|
+
if delivered:
|
|
502
|
+
raise DeliveryUnknownError(
|
|
503
|
+
f"the request was delivered but no response was read after "
|
|
504
|
+
f"{attempt + 1} attempt(s): {exc}",
|
|
505
|
+
attempts=attempt + 1,
|
|
506
|
+
) from exc
|
|
507
|
+
raise ServiceUnavailableError(
|
|
508
|
+
f"could not reach the API after {attempt + 1} attempt(s): {exc}"
|
|
509
|
+
) from exc
|
|
510
|
+
# Plain exponential backoff, no jitter: the retry count is 2 by default, so
|
|
511
|
+
# a thundering herd is not the failure mode worth complicating this for.
|
|
512
|
+
time.sleep(0.5 * (2**attempt))
|
|
513
|
+
attempt += 1
|
|
514
|
+
|
|
515
|
+
if response.is_success:
|
|
516
|
+
if not response.content:
|
|
517
|
+
return {}
|
|
518
|
+
try:
|
|
519
|
+
parsed: Dict[str, Any] = response.json()
|
|
520
|
+
except ValueError:
|
|
521
|
+
return {}
|
|
522
|
+
return parsed
|
|
523
|
+
raise _error_for(response)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _error_for(response: httpx.Response) -> ApiError:
|
|
527
|
+
"""Map a failed response onto an error whose type implies the remedy."""
|
|
528
|
+
message = f"HTTP {response.status_code}"
|
|
529
|
+
code: Optional[int] = None
|
|
530
|
+
try:
|
|
531
|
+
parsed = response.json()
|
|
532
|
+
# A JSON body that is not an object (a bare string, a list) is still a failed
|
|
533
|
+
# response. Reading .get off it would raise AttributeError from inside the error
|
|
534
|
+
# path, replacing the API's error with a bug in this client.
|
|
535
|
+
payload = parsed if isinstance(parsed, dict) else {}
|
|
536
|
+
message = payload.get("message") or (
|
|
537
|
+
parsed[:200] if isinstance(parsed, str) and parsed else message
|
|
538
|
+
)
|
|
539
|
+
code = payload.get("error_code")
|
|
540
|
+
except ValueError:
|
|
541
|
+
if response.text:
|
|
542
|
+
message = response.text[:200]
|
|
543
|
+
|
|
544
|
+
request_id = response.headers.get("x-request-id") or response.headers.get("x-amzn-requestid")
|
|
545
|
+
kwargs = {"status": response.status_code, "code": code, "request_id": request_id}
|
|
546
|
+
|
|
547
|
+
if response.status_code == 401:
|
|
548
|
+
return AuthenticationError(message, **kwargs)
|
|
549
|
+
if response.status_code == 403:
|
|
550
|
+
# A spent allowance is a 403 like a missing scope, but the remedies are opposite:
|
|
551
|
+
# one needs a plan change, the other a different key. The numeric code is what
|
|
552
|
+
# separates them.
|
|
553
|
+
if code == _QUOTA_EXCEEDED_CODE:
|
|
554
|
+
return QuotaExceededError(message, **kwargs)
|
|
555
|
+
return PermissionError_(message, **kwargs)
|
|
556
|
+
if response.status_code == 404:
|
|
557
|
+
return NotFoundError(message, **kwargs)
|
|
558
|
+
if response.status_code == 409 and code in (
|
|
559
|
+
_IDEMPOTENCY_CONFLICT_CODE,
|
|
560
|
+
_IDEMPOTENCY_IN_FLIGHT_CODE,
|
|
561
|
+
):
|
|
562
|
+
# Both arrive as IdempotencyConflictError so `except` keeps working; err.code
|
|
563
|
+
# separates them, and the docstring says which is which. 106 previously fell through
|
|
564
|
+
# to a bare ApiError, so the documented discrimination could not be performed at all.
|
|
565
|
+
return IdempotencyConflictError(message, **kwargs)
|
|
566
|
+
if response.status_code == 429:
|
|
567
|
+
retry_after = response.headers.get("retry-after")
|
|
568
|
+
return RateLimitError(
|
|
569
|
+
message,
|
|
570
|
+
retry_after=int(retry_after) if retry_after and retry_after.isdigit() else None,
|
|
571
|
+
**kwargs,
|
|
572
|
+
)
|
|
573
|
+
if response.status_code == 503:
|
|
574
|
+
return ServiceUnavailableError(message, **kwargs)
|
|
575
|
+
return ApiError(message, **kwargs)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _user_agent() -> str:
|
|
579
|
+
from . import __version__
|
|
580
|
+
|
|
581
|
+
return f"credenshare-python/{__version__}"
|