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 +184 -0
- pow_core/active.py +311 -0
- pow_core/base64url.py +77 -0
- pow_core/bodies.py +627 -0
- pow_core/buckets.py +162 -0
- pow_core/budget.py +73 -0
- pow_core/canonical.py +191 -0
- pow_core/copresence.py +46 -0
- pow_core/crypto.py +97 -0
- pow_core/day.py +19 -0
- pow_core/delegation.py +308 -0
- pow_core/ed25519.py +227 -0
- pow_core/eip191.py +123 -0
- pow_core/errors.py +123 -0
- pow_core/event.py +360 -0
- pow_core/formats.py +303 -0
- pow_core/jsvalue.py +213 -0
- pow_core/limits.py +115 -0
- pow_core/links.py +444 -0
- pow_core/messages.py +230 -0
- pow_core/py.typed +0 -0
- pow_core/recovery.py +151 -0
- pow_core/schema.py +154 -0
- pow_core/signing.py +164 -0
- pow_core/sponsorship.py +156 -0
- pow_core/store/__init__.py +5 -0
- pow_core/store/sqlite.py +413 -0
- pow_core/testing.py +164 -0
- pow_core/transport.py +76 -0
- pow_core/validity.py +131 -0
- pow_core/wallet.py +86 -0
- pow_core-0.2.0.dist-info/METADATA +79 -0
- pow_core-0.2.0.dist-info/RECORD +35 -0
- pow_core-0.2.0.dist-info/WHEEL +4 -0
- pow_core-0.2.0.dist-info/licenses/LICENSE +661 -0
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
|