pow-core 0.2.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.
pow_core/__init__.py ADDED
@@ -0,0 +1,184 @@
1
+ """pow_core: the Proof of Work protocol core for Python agents (SPEC v1.5).
2
+
3
+ A byte-compatible port of ``@pow/core``'s protocol layer: canonical JSON and
4
+ the wire rule, base64url, Ed25519 device keys, ``pw1:`` accounts, event ids
5
+ and signatures, per-kind body validation for all 30 kinds, root-signed
6
+ delegations and revocations, wallet and Offline ID links (EIP-191 recovery
7
+ included), sponsorships, validity classification, the one-active-per-day
8
+ rule, sync buckets, the pw/1 gossip codec (including transport binding) and a
9
+ SQLite EventStore. ``packages/core/vectors/*.json`` are the shared test
10
+ vectors both ports reproduce byte for byte.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from . import testing
16
+ from .active import ActiveBeacons, ActiveOutcome, ActiveStats, beats
17
+ from .base64url import base64url_decode, base64url_encode, is_base64url
18
+ from .bodies import KINDS, LINK_TYPES, OPERATOR_LABELS, REACTIONS, RECOMMENDED_LABELS, ROOT_SIGNED_KINDS, SCOPES, SUBSCRIBE_TYPES, required_scope, validate_body
19
+ from .budget import VerificationBudget
20
+ from .buckets import (
21
+ ACTIVE_PREFIX,
22
+ CHILD_KINDS,
23
+ IDENTITY_KINDS,
24
+ BucketName,
25
+ BucketPlacement,
26
+ Digest,
27
+ bucket_of,
28
+ digest_of,
29
+ parent_of,
30
+ parse_bucket,
31
+ )
32
+ from .canonical import canonical_bytes, canonicalize, has_lone_surrogate, parse_strict_json
33
+ from .copresence import copresence_message, new_copresence_nonce, sign_copresence, verify_copresence
34
+ from .crypto import (
35
+ DeviceKeyPair,
36
+ device_key_from_secret,
37
+ generate_device_key,
38
+ is_strong_public_key,
39
+ sha256,
40
+ sign_bytes,
41
+ verify_bytes,
42
+ )
43
+ from .day import day_of, day_start
44
+ from .delegation import AuthStatus, DelegationIndex, DelegationRecord, IndexAddResult, TransportBinding
45
+ from .eip191 import (
46
+ OfflineEoaVerifier,
47
+ address_from_private_key,
48
+ address_from_public_key,
49
+ create_offline_eoa_verifier,
50
+ keccak256,
51
+ personal_message_hash,
52
+ recover_personal_message_signer,
53
+ sign_personal_message,
54
+ )
55
+ from .errors import ErrorCode, Issue, ProtocolError, issue
56
+ from .event import (
57
+ Envelope,
58
+ VerifyResult,
59
+ build_event,
60
+ create_event,
61
+ event_id,
62
+ expected_refs,
63
+ sign_event,
64
+ unsigned_bytes,
65
+ verify_event,
66
+ verify_event_cached,
67
+ )
68
+ from .formats import (
69
+ ACCOUNT_PREFIX,
70
+ EXTERNAL_LINK_TYPES,
71
+ RESERVED_SLUGS,
72
+ account_id_of,
73
+ account_root,
74
+ code_point_length,
75
+ format_caip10_account,
76
+ is_account,
77
+ is_board_slug,
78
+ is_caip10_account,
79
+ is_ens_like_name,
80
+ is_event_id,
81
+ is_evm_address,
82
+ is_external_link_type,
83
+ is_external_proof,
84
+ is_external_ref,
85
+ is_gist_id,
86
+ is_hash32,
87
+ is_http_url,
88
+ is_iso_day,
89
+ is_label_slug,
90
+ is_offline_id_subject,
91
+ is_op_address,
92
+ is_proof_token,
93
+ is_public_key_text,
94
+ is_signature_text,
95
+ is_tag,
96
+ is_tx_ref,
97
+ is_uint256_decimal,
98
+ is_wallet_sig_hex,
99
+ parse_caip10_account,
100
+ )
101
+ from .limits import LIMITS, MAX_SAFE_INTEGER, WALLET_LINK_CHAIN_IDS, is_wallet_link_chain
102
+ from .links import LinkAddResult, LinkIndex, LinkRecord
103
+ from .messages import MAX_WIRE_HOPS, PROTOCOL_VERSION, batch_events, chunk_ids, decode_message, encode_message, encoded_size
104
+ from .recovery import (
105
+ RECOVERY_APPROVAL_TAG,
106
+ RECOVERY_CANCEL_TAG,
107
+ RECOVERY_PLAN_TAG,
108
+ REROOT_TAG,
109
+ guardian_key,
110
+ recovery_approval_message,
111
+ recovery_cancel_message,
112
+ recovery_plan_id,
113
+ recovery_plan_message,
114
+ recovery_root_sig_problem,
115
+ reroot_id,
116
+ reroot_message,
117
+ sign_recovery_cancel,
118
+ sign_recovery_plan,
119
+ sign_reroot,
120
+ )
121
+ from .signing import (
122
+ DELEGATION_SIGNING_TAG,
123
+ LINK_SIGNING_TAG,
124
+ LINK_SIGNING_TAG_V2,
125
+ REVOCATION_SIGNING_TAG,
126
+ TRANSPORT_BINDING_TAG,
127
+ TransportBindingStatement,
128
+ delegation_signing_payload,
129
+ link_signing_payload,
130
+ link_signing_payload_v2,
131
+ new_nonce,
132
+ policy_hash,
133
+ revocation_signing_payload,
134
+ sign_delegation_body,
135
+ sign_revocation_body,
136
+ sign_transport_binding,
137
+ transport_binding_message,
138
+ verify_root_sig,
139
+ verify_transport_binding,
140
+ )
141
+ from .sponsorship import DEFAULT_SPONSOR_CAPACITY, SponsorshipIndex, SponsorshipState
142
+ from .store import Query, SqliteEventStore
143
+ from .transport import Link, Transport, link_local
144
+ from .validity import CONTEXTUAL_CODES, Validation, Validity, classify_event, validate_event, validity_of
145
+ from .wallet import ExternalLinkVerifier, OfflineIdVerifier, WalletSignatureVerifier, WalletVerdict
146
+
147
+ __version__ = "0.2.0"
148
+
149
+ __all__ = [
150
+ "ACCOUNT_PREFIX", "ACTIVE_PREFIX", "ActiveBeacons", "ActiveOutcome", "ActiveStats", "AuthStatus",
151
+ "BucketName", "BucketPlacement", "CHILD_KINDS", "CONTEXTUAL_CODES", "DEFAULT_SPONSOR_CAPACITY",
152
+ "DELEGATION_SIGNING_TAG", "DelegationIndex", "DelegationRecord", "DeviceKeyPair", "Digest", "Envelope",
153
+ "ErrorCode", "IDENTITY_KINDS", "IndexAddResult", "Issue", "KINDS", "LIMITS", "LINK_SIGNING_TAG", "LINK_SIGNING_TAG_V2", "Link", "link_local", "WALLET_LINK_CHAIN_IDS", "is_wallet_link_chain",
154
+ "LinkAddResult", "LinkIndex", "LinkRecord", "MAX_SAFE_INTEGER", "OfflineEoaVerifier", "OfflineIdVerifier",
155
+ "OPERATOR_LABELS", "PROTOCOL_VERSION", "ProtocolError", "Query", "RECOMMENDED_LABELS", "RESERVED_SLUGS",
156
+ "REVOCATION_SIGNING_TAG", "SCOPES", "SponsorshipIndex", "SponsorshipState", "SqliteEventStore",
157
+ "TRANSPORT_BINDING_TAG", "Transport", "TransportBinding", "TransportBindingStatement", "Validation",
158
+ "Validity", "VerificationBudget", "VerifyResult", "WalletSignatureVerifier", "WalletVerdict",
159
+ "account_id_of", "account_root", "address_from_private_key", "address_from_public_key",
160
+ "base64url_decode", "base64url_encode", "batch_events", "beats", "bucket_of", "build_event",
161
+ "canonical_bytes", "canonicalize", "chunk_ids", "classify_event", "code_point_length",
162
+ "copresence_message", "create_event", "create_offline_eoa_verifier", "day_of", "day_start",
163
+ "decode_message", "delegation_signing_payload", "device_key_from_secret", "digest_of", "encode_message",
164
+ "encoded_size", "event_id", "expected_refs", "format_caip10_account", "generate_device_key",
165
+ "has_lone_surrogate", "is_account", "is_base64url", "is_board_slug", "is_caip10_account",
166
+ "is_ens_like_name", "is_event_id", "is_evm_address", "is_hash32", "is_http_url", "is_iso_day",
167
+ "is_label_slug", "is_offline_id_subject", "is_op_address", "is_proof_token", "is_public_key_text",
168
+ "is_signature_text", "is_strong_public_key", "is_tag", "is_tx_ref", "is_uint256_decimal",
169
+ "is_wallet_sig_hex", "issue", "keccak256", "link_signing_payload", "link_signing_payload_v2", "new_copresence_nonce", "new_nonce",
170
+ "parent_of", "parse_bucket", "parse_caip10_account", "parse_strict_json", "personal_message_hash",
171
+ "policy_hash", "recover_personal_message_signer", "required_scope", "revocation_signing_payload",
172
+ "sha256", "sign_bytes", "sign_copresence", "sign_delegation_body", "sign_event", "sign_personal_message",
173
+ "sign_revocation_body", "sign_transport_binding", "testing", "transport_binding_message",
174
+ "unsigned_bytes", "validate_body", "validate_event", "validity_of", "verify_bytes", "verify_copresence",
175
+ "verify_event", "verify_event_cached", "verify_root_sig", "verify_transport_binding",
176
+ # recovery statements (SPEC §2) and gossip hop metadata (SPEC §11 item 3)
177
+ "MAX_WIRE_HOPS", "RECOVERY_APPROVAL_TAG", "RECOVERY_CANCEL_TAG", "RECOVERY_PLAN_TAG", "REROOT_TAG",
178
+ "ROOT_SIGNED_KINDS", "guardian_key", "recovery_approval_message", "recovery_cancel_message",
179
+ "recovery_plan_id", "recovery_plan_message", "recovery_root_sig_problem", "reroot_id", "reroot_message",
180
+ "sign_recovery_cancel", "sign_recovery_plan", "sign_reroot",
181
+ # v1.9: external link types (SPEC §18.6) and the reaction, collection, subscribe and poll_vote kinds
182
+ "EXTERNAL_LINK_TYPES", "ExternalLinkVerifier", "LINK_TYPES", "REACTIONS", "SUBSCRIBE_TYPES",
183
+ "is_external_link_type", "is_external_proof", "is_external_ref", "is_gist_id",
184
+ ]
pow_core/active.py ADDED
@@ -0,0 +1,311 @@
1
+ """The one-``active``-per-(account, day) rule (SPEC §5.2, §12) for a Python node.
2
+
3
+ The TypeScript gossip engine applies this rule inside ``GossipEngine``
4
+ (``ingestActive`` / ``settleStoredActive``). The Python port has no gossip
5
+ engine yet, so the same rule lives here, over any store that has ``put``,
6
+ ``query`` and ``remove`` (:class:`~pow_core.store.SqliteEventStore` does):
7
+
8
+ - only beacons that classify ``valid`` compete. An ``unlinked`` beacon is
9
+ neither stored nor forwarded; it is held in memory (bounded, FIFO) and
10
+ re-ingested when a delegation for its author arrives, so no stranger's key
11
+ can take an account's day;
12
+ - among valid beacons for one (account, day) the smallest ``created_at`` wins,
13
+ ties broken by the smaller ``id`` (string order). Arrival order never matters;
14
+ - admitting the winner removes stored losers, and losers are refused
15
+ afterwards, so ``active/<day>`` digests converge across nodes;
16
+ - the rule also applies to beacons other code wrote straight into the store:
17
+ when the store has ``subscribe`` (SqliteEventStore does) and ``watch_store``
18
+ is left on, every new ``active`` event the store reports is settled
19
+ automatically, as the TypeScript engine's ``onStoreEvent`` does; otherwise
20
+ callers that write beacons directly must call
21
+ :meth:`ActiveBeacons.settle_stored` themselves.
22
+
23
+ Background work (store settles, re-ingests after a delegation arrives) runs on
24
+ the event loop that is running when it is triggered; a delegation added with
25
+ no loop running is noted and its beacons are re-ingested at the next
26
+ ``ingest``/``settle_stored`` call (or by :meth:`ActiveBeacons.retry_held`).
27
+ Use one ``ActiveBeacons`` from one event loop.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import asyncio
33
+ import contextlib
34
+ from collections import OrderedDict
35
+ from dataclasses import dataclass, field
36
+ from typing import Any, AsyncIterator, Callable, Literal
37
+
38
+ from .day import day_start
39
+ from .delegation import DelegationIndex
40
+ from .links import LinkIndex
41
+ from .validity import classify_event, validate_event
42
+
43
+ Envelope = dict[str, Any]
44
+
45
+ #: What :meth:`ActiveBeacons.ingest` did with a beacon.
46
+ ActiveOutcome = Literal["new", "dup", "lost", "held", "invalid", "ignored"]
47
+
48
+ #: Unlinked beacons held in memory, as the TypeScript engine's MAX_HELD_ACTIVE.
49
+ MAX_HELD_ACTIVE = 4096
50
+ _DAY_MS = 86_400_000
51
+
52
+
53
+ def beats(x: Envelope, y: Envelope) -> bool:
54
+ """SPEC §5.2 tie-break: of two valid beacons for one (account, day), the smaller (created_at, id) wins."""
55
+ return bool(x["created_at"] < y["created_at"] or (x["created_at"] == y["created_at"] and x["id"] < y["id"]))
56
+
57
+
58
+ @dataclass
59
+ class ActiveStats:
60
+ """Counters, named after the TypeScript ``GossipStats`` fields."""
61
+
62
+ #: beacons that lost the tie-break: refused, or removed when the winner arrived
63
+ active_duplicates: int = 0
64
+ #: stored beacons removed because a winning beacon for their (account, day) arrived
65
+ active_replaced: int = 0
66
+ #: unlinked beacons held in memory until their delegation arrives
67
+ active_held: int = 0
68
+
69
+
70
+ @dataclass
71
+ class _KeyLock:
72
+ lock: asyncio.Lock = field(default_factory=asyncio.Lock)
73
+ users: int = 0
74
+
75
+
76
+ class ActiveBeacons:
77
+ """Applies the SPEC §5.2 activity rule to one store.
78
+
79
+ ``store`` needs ``put``, ``query`` and ``remove`` (a store without
80
+ ``remove`` cannot drop a losing beacon, so its ``active/`` digests would not
81
+ converge). ``now`` (ms) defaults to the system clock for the future bound.
82
+ ``watch_store`` (default on) settles beacons other code puts into a store
83
+ that has ``subscribe``. :meth:`close` detaches from the store and the index.
84
+ """
85
+
86
+ def __init__(
87
+ self,
88
+ store: Any,
89
+ delegations: DelegationIndex,
90
+ *,
91
+ links: LinkIndex | None = None,
92
+ max_held: int = MAX_HELD_ACTIVE,
93
+ now: Callable[[], int] | None = None,
94
+ watch_store: bool = True,
95
+ ) -> None:
96
+ if not callable(getattr(store, "remove", None)):
97
+ raise TypeError("the store needs remove(id): losing beacons must be dropped for active/ digests to converge")
98
+ self._store = store
99
+ self._delegations = delegations
100
+ self._links = links
101
+ self._max_held = max_held
102
+ self._now = now
103
+ self._held: OrderedDict[str, Envelope] = OrderedDict()
104
+ self._locks: dict[str, _KeyLock] = {}
105
+ self._tasks: set[asyncio.Task[Any]] = set()
106
+ # held beacons whose delegation arrived while no loop was running
107
+ self._ready: set[str] = set()
108
+ # ids this object is putting, so its own store writes are not re-settled
109
+ self._own_puts: set[str] = set()
110
+ self.stats = ActiveStats()
111
+ self._detach: list[Callable[[], None]] = [delegations.on_change(self._on_delegation)]
112
+ subscribe = getattr(store, "subscribe", None)
113
+ if watch_store and callable(subscribe):
114
+ self._detach.append(subscribe(self._on_store_event))
115
+
116
+ def close(self) -> None:
117
+ """Stops watching the store and the delegation index (scheduled work still finishes; see :meth:`drain`)."""
118
+ for off in self._detach:
119
+ off()
120
+ self._detach.clear()
121
+
122
+ @property
123
+ def lock_count(self) -> int:
124
+ """(account, day) decisions in progress or waiting; zero when idle."""
125
+ return len(self._locks)
126
+
127
+ @property
128
+ def held_count(self) -> int:
129
+ """Unlinked beacons held in memory."""
130
+ return len(self._held)
131
+
132
+ def is_held(self, eid: str) -> bool:
133
+ """True while a beacon is held waiting for its delegation."""
134
+ return eid in self._held
135
+
136
+ async def ingest(self, e: Any) -> ActiveOutcome:
137
+ """Validates a beacon and applies the rule; "new" means it was stored and should be forwarded."""
138
+ if not isinstance(e, dict) or e.get("kind") != "active":
139
+ return "ignored"
140
+ self._kick_ready()
141
+ v = await validate_event(e, delegations=self._delegations, links=self._links, now=self._now() if self._now else None)
142
+ if v.validity == "invalid":
143
+ return "invalid"
144
+ assert v.event is not None
145
+ if v.validity == "unlinked":
146
+ self._hold(v.event)
147
+ return "held"
148
+ return await self._admit(v.event)
149
+
150
+ async def _admit(self, e: Envelope) -> ActiveOutcome:
151
+ async with self._locked(e):
152
+ winner, losers = await self._contest(e)
153
+ if winner is not e:
154
+ self.stats.active_duplicates += 1
155
+ return "lost"
156
+ self._own_puts.add(e["id"])
157
+ try:
158
+ r = await self._store.put(e)
159
+ finally:
160
+ self._own_puts.discard(e["id"])
161
+ if r == "dup":
162
+ return "dup"
163
+ if r != "new":
164
+ return "invalid"
165
+ await self._drop(losers)
166
+ return "new"
167
+
168
+ async def settle_stored(self, e: Envelope) -> bool:
169
+ """The rule for a beacon other code wrote straight into the store.
170
+
171
+ An unlinked beacon is removed (and held); a losing one is removed; a
172
+ winner removes stored losers. True when ``e`` stays (and should be
173
+ forwarded). An invalid beacon is left alone and not forwarded.
174
+ """
175
+ if e.get("kind") != "active":
176
+ return True
177
+ self._kick_ready()
178
+ v = await validate_event(e, delegations=self._delegations, links=self._links, now=self._now() if self._now else None)
179
+ if v.validity == "invalid":
180
+ return False
181
+ if v.validity == "unlinked":
182
+ self._hold(e)
183
+ await self._store.remove(e["id"])
184
+ return False
185
+ async with self._locked(e):
186
+ winner, losers = await self._contest(e)
187
+ if winner is not e:
188
+ await self._drop([e["id"]])
189
+ return False
190
+ await self._drop(losers)
191
+ return True
192
+
193
+ async def _contest(self, e: Envelope) -> tuple[Envelope, list[str]]:
194
+ # The winner among e and the stored beacons for its (account, day), and
195
+ # the stored beacons that must go if e is admitted (losers, plus any
196
+ # that no longer classify valid, e.g. after a revocation).
197
+ day = e["body"]["day"]
198
+ start = day_start(day)
199
+ stored = await self._store.query(kinds=["active"], account=e["account"], since=start, until=start + _DAY_MS - 1)
200
+ same = [x for x in stored if x["id"] != e["id"] and x["body"]["day"] == day]
201
+ winner = e
202
+ for x in same:
203
+ if classify_event(x, self._delegations, self._links).validity == "valid" and beats(x, winner):
204
+ winner = x
205
+ return winner, ([x["id"] for x in same] if winner is e else [])
206
+
207
+ async def _drop(self, ids: list[str]) -> None:
208
+ for eid in ids:
209
+ if await self._store.remove(eid):
210
+ self.stats.active_replaced += 1
211
+ self.stats.active_duplicates += 1
212
+
213
+ @contextlib.asynccontextmanager
214
+ async def _locked(self, e: Envelope) -> AsyncIterator[None]:
215
+ # One decision at a time per (account, day). The entry is dropped once
216
+ # nobody holds or waits for it (as the TypeScript withActiveLock), so
217
+ # the map only ever holds keys with work in progress.
218
+ k = f"{e['account']}|{e['body']['day']}"
219
+ entry = self._locks.get(k)
220
+ if entry is None:
221
+ entry = self._locks[k] = _KeyLock()
222
+ entry.users += 1
223
+ try:
224
+ async with entry.lock:
225
+ yield
226
+ finally:
227
+ entry.users -= 1
228
+ if entry.users == 0 and self._locks.get(k) is entry:
229
+ del self._locks[k]
230
+
231
+ def _spawn(self, loop: asyncio.AbstractEventLoop, coro: Any) -> None:
232
+ # Keep a reference so the task is not garbage-collected mid-flight.
233
+ task = loop.create_task(coro)
234
+ self._tasks.add(task)
235
+ task.add_done_callback(self._tasks.discard)
236
+
237
+ def _on_store_event(self, e: Envelope) -> None:
238
+ # A beacon other code wrote into the store (SPEC §5.2: the rule covers
239
+ # local writes too). Our own puts were already decided under the lock.
240
+ if e.get("kind") != "active" or e.get("id") in self._own_puts:
241
+ return
242
+ try:
243
+ loop = asyncio.get_running_loop()
244
+ except RuntimeError:
245
+ return # store.put is a coroutine, so this only happens off-loop; settle_stored by hand
246
+ self._spawn(loop, self._settle_quietly(e))
247
+
248
+ async def _settle_quietly(self, e: Envelope) -> None:
249
+ try:
250
+ await self.settle_stored(e)
251
+ except Exception: # noqa: BLE001 - a background settle must never raise into the loop
252
+ pass
253
+
254
+ def _kick_ready(self) -> None:
255
+ # Re-ingest held beacons whose delegation arrived while no loop ran.
256
+ if not self._ready:
257
+ return
258
+ ids, self._ready = self._ready, set()
259
+ loop = asyncio.get_running_loop()
260
+ for eid in ids:
261
+ x = self._held.pop(eid, None)
262
+ if x is not None:
263
+ self._spawn(loop, self._reingest(x))
264
+
265
+ def _hold(self, e: Envelope) -> None:
266
+ if e["id"] in self._held:
267
+ return
268
+ if len(self._held) >= self._max_held:
269
+ # Evicted beacons are not lost: a later anti-entropy round lists them again.
270
+ evicted, _ = self._held.popitem(last=False)
271
+ self._ready.discard(evicted)
272
+ self._held[e["id"]] = e
273
+ self.stats.active_held += 1
274
+
275
+ def _on_delegation(self, d: Envelope) -> None:
276
+ if d.get("kind") != "delegation":
277
+ return
278
+ device = d["body"]["device"]
279
+ retry = [x for x in self._held.values() if x["account"] == d["account"] and x["author"] == device]
280
+ if not retry:
281
+ return
282
+ try:
283
+ loop = asyncio.get_running_loop()
284
+ except RuntimeError:
285
+ # No loop: the next ingest/settle_stored (or retry_held) re-ingests them.
286
+ self._ready.update(x["id"] for x in retry)
287
+ return
288
+ for x in retry:
289
+ self._held.pop(x["id"], None)
290
+ self._spawn(loop, self._reingest(x))
291
+
292
+ async def _reingest(self, e: Envelope) -> None:
293
+ try:
294
+ await self.ingest(e)
295
+ except Exception: # noqa: BLE001 - a background re-check must never raise into the loop
296
+ pass
297
+
298
+ async def retry_held(self) -> int:
299
+ """Re-ingests held beacons; returns how many were stored."""
300
+ stored = 0
301
+ self._ready.clear()
302
+ for e in list(self._held.values()):
303
+ self._held.pop(e["id"], None)
304
+ if await self.ingest(e) == "new":
305
+ stored += 1
306
+ return stored
307
+
308
+ async def drain(self) -> None:
309
+ """Waits for re-ingests scheduled by arriving delegations (tests and shutdown)."""
310
+ while self._tasks:
311
+ await asyncio.gather(*list(self._tasks), return_exceptions=True)
pow_core/base64url.py ADDED
@@ -0,0 +1,77 @@
1
+ """Strict unpadded base64url (RFC 4648 §5).
2
+
3
+ Decoding rejects padding, foreign characters, impossible lengths and non-zero
4
+ trailing bits, so every byte string has exactly one textual form; otherwise two
5
+ spellings of one key or id could both verify. The standard library decoder is
6
+ lenient on all of those, hence the hand-rolled codec.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .errors import ProtocolError
12
+
13
+ _ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
14
+ _LOOKUP = {c: i for i, c in enumerate(_ALPHABET)}
15
+
16
+
17
+ def base64url_encode(data: bytes) -> str:
18
+ """Encodes bytes as unpadded base64url."""
19
+ out: list[str] = []
20
+ n = len(data)
21
+ i = 0
22
+ while i + 2 < n:
23
+ v = (data[i] << 16) | (data[i + 1] << 8) | data[i + 2]
24
+ out.append(_ALPHABET[(v >> 18) & 63] + _ALPHABET[(v >> 12) & 63] + _ALPHABET[(v >> 6) & 63] + _ALPHABET[v & 63])
25
+ i += 3
26
+ rest = n - i
27
+ if rest == 1:
28
+ v = data[i] << 16
29
+ out.append(_ALPHABET[(v >> 18) & 63] + _ALPHABET[(v >> 12) & 63])
30
+ elif rest == 2:
31
+ v = (data[i] << 16) | (data[i + 1] << 8)
32
+ out.append(_ALPHABET[(v >> 18) & 63] + _ALPHABET[(v >> 12) & 63] + _ALPHABET[(v >> 6) & 63])
33
+ return "".join(out)
34
+
35
+
36
+ def base64url_decode(text: str) -> bytes:
37
+ """Decodes strict unpadded base64url.
38
+
39
+ Raises :class:`ProtocolError` with ``E_B64_LENGTH``, ``E_B64_CHAR`` or
40
+ ``E_B64_NONCANONICAL`` (checked in that order, as the TypeScript core does).
41
+ """
42
+ if len(text) % 4 == 1:
43
+ raise ProtocolError("E_B64_LENGTH", "impossible base64url length")
44
+ vals: list[int] = []
45
+ for i, c in enumerate(text):
46
+ v = _LOOKUP.get(c)
47
+ if v is None:
48
+ raise ProtocolError("E_B64_CHAR", f"invalid base64url character at {i}")
49
+ vals.append(v)
50
+ full, rest = divmod(len(vals), 4)
51
+ out = bytearray()
52
+ i = 0
53
+ for _ in range(full):
54
+ n = (vals[i] << 18) | (vals[i + 1] << 12) | (vals[i + 2] << 6) | vals[i + 3]
55
+ out += bytes(((n >> 16) & 255, (n >> 8) & 255, n & 255))
56
+ i += 4
57
+ if rest == 2:
58
+ if vals[i + 1] & 0x0F:
59
+ raise ProtocolError("E_B64_NONCANONICAL", "non-zero trailing bits")
60
+ out.append(((vals[i] << 2) | (vals[i + 1] >> 4)) & 255)
61
+ elif rest == 3:
62
+ if vals[i + 2] & 0x03:
63
+ raise ProtocolError("E_B64_NONCANONICAL", "non-zero trailing bits")
64
+ n = (vals[i] << 10) | (vals[i + 1] << 4) | (vals[i + 2] >> 2)
65
+ out += bytes(((n >> 8) & 255, n & 255))
66
+ return bytes(out)
67
+
68
+
69
+ def is_base64url(text: object, byte_length: int | None = None) -> bool:
70
+ """True when ``text`` is strict base64url, optionally of exactly ``byte_length`` bytes."""
71
+ if not isinstance(text, str):
72
+ return False
73
+ try:
74
+ b = base64url_decode(text)
75
+ except ProtocolError:
76
+ return False
77
+ return byte_length is None or len(b) == byte_length