aetheridentity 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aetheridentity/__init__.py +42 -0
- aetheridentity/continuity.py +119 -0
- aetheridentity/delegation.py +251 -0
- aetheridentity/identity.py +53 -0
- aetheridentity/records.py +138 -0
- aetheridentity/reputation.py +178 -0
- aetheridentity/signer.py +78 -0
- aetheridentity/simulator.py +49 -0
- aetheridentity/sybil.py +119 -0
- aetheridentity-2.0.0.dist-info/METADATA +432 -0
- aetheridentity-2.0.0.dist-info/RECORD +15 -0
- aetheridentity-2.0.0.dist-info/WHEEL +5 -0
- aetheridentity-2.0.0.dist-info/licenses/AUTHORS.md +33 -0
- aetheridentity-2.0.0.dist-info/licenses/LICENSE +29 -0
- aetheridentity-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""AetherIdentity v2.0.0 — Reference Implementation.
|
|
2
|
+
|
|
3
|
+
This package implements the specification in SPECIFICATION.md and the
|
|
4
|
+
invariants in INVARIANTS.md. It is a research artifact, not a production
|
|
5
|
+
system.
|
|
6
|
+
|
|
7
|
+
Public API:
|
|
8
|
+
records — Record types and canonical serialization
|
|
9
|
+
signer — Abstract Signer, FakeSigner, Ed25519Signer
|
|
10
|
+
simulator — Replicated state S as a flat list
|
|
11
|
+
identity — L1 Identity
|
|
12
|
+
continuity — L2 Continuity
|
|
13
|
+
reputation — L3 Reputation
|
|
14
|
+
sybil — L4 Sybil resistance
|
|
15
|
+
delegation — L5 Delegation
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
__version__ = "2.0.0"
|
|
19
|
+
__paper__ = "10.5281/zenodo.24102739"
|
|
20
|
+
__osf__ = "10.17605/OSF.IO/B6AEN"
|
|
21
|
+
|
|
22
|
+
from aetheridentity import (
|
|
23
|
+
continuity,
|
|
24
|
+
delegation,
|
|
25
|
+
identity,
|
|
26
|
+
records,
|
|
27
|
+
reputation,
|
|
28
|
+
signer,
|
|
29
|
+
simulator,
|
|
30
|
+
sybil,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"continuity",
|
|
35
|
+
"delegation",
|
|
36
|
+
"identity",
|
|
37
|
+
"records",
|
|
38
|
+
"reputation",
|
|
39
|
+
"signer",
|
|
40
|
+
"simulator",
|
|
41
|
+
"sybil",
|
|
42
|
+
]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""L2 — Continuity (§6.2 of SPECIFICATION.md).
|
|
2
|
+
|
|
3
|
+
Implements Definition 2 of the paper:
|
|
4
|
+
- Continuity chain with pre-committed next keys.
|
|
5
|
+
- Rotation requires the next key, kept offline.
|
|
6
|
+
- Fork detection: two valid rotations with the same predecessor.
|
|
7
|
+
|
|
8
|
+
Out of scope (v2.0.0):
|
|
9
|
+
- Fork resolution.
|
|
10
|
+
- Recovery after total key loss.
|
|
11
|
+
- Single-instance enforcement.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
from typing import Iterable
|
|
18
|
+
|
|
19
|
+
from aetheridentity.records import (
|
|
20
|
+
InceptionRecord,
|
|
21
|
+
RotationRecord,
|
|
22
|
+
canonical_bytes,
|
|
23
|
+
)
|
|
24
|
+
from aetheridentity.signer import Signer
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def hash_key(pk: bytes) -> bytes:
|
|
28
|
+
"""Hash a public key for pre-commitment (H(pk))."""
|
|
29
|
+
return hashlib.sha256(pk).digest()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def create_inception(
|
|
33
|
+
signer: Signer,
|
|
34
|
+
label: str,
|
|
35
|
+
next_pk: bytes,
|
|
36
|
+
alg: str = "ed25519",
|
|
37
|
+
created_at: int = 0,
|
|
38
|
+
) -> InceptionRecord:
|
|
39
|
+
"""Create an inception record that commits to the next key.
|
|
40
|
+
|
|
41
|
+
The inception record itself has no `next_pk` field in Definition 1,
|
|
42
|
+
but the commitment is stored as the first rotation's `commitment`
|
|
43
|
+
field when the first rotation is created. To keep the record types
|
|
44
|
+
simple, we store the commitment implicitly: the caller must pass
|
|
45
|
+
`next_pk` when creating the first rotation.
|
|
46
|
+
"""
|
|
47
|
+
from aetheridentity.identity import identifier
|
|
48
|
+
|
|
49
|
+
pk = signer.public_key()
|
|
50
|
+
signer_id = identifier(alg, pk, label)
|
|
51
|
+
record = InceptionRecord(
|
|
52
|
+
record_type="inception",
|
|
53
|
+
signer_id=signer_id,
|
|
54
|
+
created_at=created_at,
|
|
55
|
+
alg=alg,
|
|
56
|
+
pk=pk,
|
|
57
|
+
label=label,
|
|
58
|
+
t_c=created_at,
|
|
59
|
+
)
|
|
60
|
+
sig = signer.sign(canonical_bytes(record))
|
|
61
|
+
return InceptionRecord(**{**record.__dict__, "signature": sig})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def create_rotation(
|
|
65
|
+
signer: Signer,
|
|
66
|
+
ident: str,
|
|
67
|
+
epoch: int,
|
|
68
|
+
new_pk: bytes,
|
|
69
|
+
next_pk: bytes,
|
|
70
|
+
prev_hash: str,
|
|
71
|
+
created_at: int = 0,
|
|
72
|
+
) -> RotationRecord:
|
|
73
|
+
"""Create a rotation record that reveals `new_pk` and commits to `next_pk`."""
|
|
74
|
+
commitment = hash_key(next_pk)
|
|
75
|
+
record = RotationRecord(
|
|
76
|
+
record_type="rotation",
|
|
77
|
+
signer_id=ident,
|
|
78
|
+
created_at=created_at,
|
|
79
|
+
id=ident,
|
|
80
|
+
epoch=epoch,
|
|
81
|
+
new_pk=new_pk,
|
|
82
|
+
commitment=commitment,
|
|
83
|
+
prev_hash=prev_hash,
|
|
84
|
+
signing_pk=signer.public_key(),
|
|
85
|
+
)
|
|
86
|
+
sig = signer.sign(canonical_bytes(record))
|
|
87
|
+
return RotationRecord(**{**record.__dict__, "signature": sig})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def verify_rotation(
|
|
91
|
+
rotation: RotationRecord,
|
|
92
|
+
previous_commitment: bytes,
|
|
93
|
+
) -> bool:
|
|
94
|
+
"""Verify that a rotation reveals the pre-committed key."""
|
|
95
|
+
return hash_key(rotation.new_pk) == previous_commitment
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def detect_fork(rotations: Iterable[RotationRecord]) -> bool:
|
|
99
|
+
"""A fork is two valid rotations with the same predecessor and different content."""
|
|
100
|
+
seen: dict[str, bytes] = {}
|
|
101
|
+
for r in rotations:
|
|
102
|
+
if r.record_type != "rotation":
|
|
103
|
+
continue
|
|
104
|
+
content = canonical_bytes(r)
|
|
105
|
+
if r.prev_hash in seen:
|
|
106
|
+
if seen[r.prev_hash] != content:
|
|
107
|
+
return True
|
|
108
|
+
else:
|
|
109
|
+
seen[r.prev_hash] = content
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def continuity_chain(
|
|
114
|
+
records: Iterable,
|
|
115
|
+
ident: str,
|
|
116
|
+
) -> list[RotationRecord]:
|
|
117
|
+
"""Return the continuity chain for `ident`, ordered by epoch."""
|
|
118
|
+
chain = [r for r in records if isinstance(r, RotationRecord) and r.id == ident]
|
|
119
|
+
return sorted(chain, key=lambda r: r.epoch)
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""L5 — Delegation (§6.5 of SPECIFICATION.md).
|
|
2
|
+
|
|
3
|
+
Implements Definition 4 of the paper:
|
|
4
|
+
- Delegation record: (i, j, S_d, e_d, p_d).
|
|
5
|
+
- Validity: S_d ⊆ S_p, e_d ≤ e_p, parent valid.
|
|
6
|
+
- Effective scope: intersection. Effective expiry: minimum.
|
|
7
|
+
- Revocation of any link invalidates downstream links.
|
|
8
|
+
|
|
9
|
+
Out of scope (v2.0.0):
|
|
10
|
+
- Expressive scopes (open question Q16).
|
|
11
|
+
- Retroactive vs. prospective revocation (open question Q17).
|
|
12
|
+
- Whether L5 is separable from AetherGovern (tested via governance_overlap).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Iterable
|
|
19
|
+
|
|
20
|
+
from aetheridentity.records import DelegationRecord, canonical_bytes
|
|
21
|
+
from aetheridentity.signer import Signer
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Scope:
|
|
26
|
+
"""A scope is a set of permissions (strings)."""
|
|
27
|
+
|
|
28
|
+
permissions: frozenset[str]
|
|
29
|
+
|
|
30
|
+
def is_subset_of(self, other: "Scope") -> bool:
|
|
31
|
+
return self.permissions.issubset(other.permissions)
|
|
32
|
+
|
|
33
|
+
def intersect(self, other: "Scope") -> "Scope":
|
|
34
|
+
return Scope(self.permissions & other.permissions)
|
|
35
|
+
|
|
36
|
+
def __str__(self) -> str:
|
|
37
|
+
return "|".join(sorted(self.permissions))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_scope(s: str) -> Scope:
|
|
41
|
+
"""Parse a scope string ('p1|p2|...') into a Scope."""
|
|
42
|
+
if not s:
|
|
43
|
+
return Scope(frozenset())
|
|
44
|
+
return Scope(frozenset(s.split("|")))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def delegate(
|
|
48
|
+
signer: Signer,
|
|
49
|
+
delegator: str,
|
|
50
|
+
delegatee: str,
|
|
51
|
+
scope: Scope,
|
|
52
|
+
expiry: int,
|
|
53
|
+
parent: str = "",
|
|
54
|
+
created_at: int = 0,
|
|
55
|
+
) -> DelegationRecord:
|
|
56
|
+
"""Create and sign a delegation record."""
|
|
57
|
+
record = DelegationRecord(
|
|
58
|
+
record_type="delegation",
|
|
59
|
+
signer_id=delegator,
|
|
60
|
+
created_at=created_at,
|
|
61
|
+
delegator=delegator,
|
|
62
|
+
delegatee=delegatee,
|
|
63
|
+
scope=str(scope),
|
|
64
|
+
expiry=expiry,
|
|
65
|
+
parent=parent,
|
|
66
|
+
)
|
|
67
|
+
sig = signer.sign(canonical_bytes(record))
|
|
68
|
+
return DelegationRecord(**{**record.__dict__, "signature": sig})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _find_parent(
|
|
72
|
+
records: Iterable[DelegationRecord],
|
|
73
|
+
parent_id: str,
|
|
74
|
+
) -> DelegationRecord | None:
|
|
75
|
+
"""Find a parent delegation by its signature hash (acting as id)."""
|
|
76
|
+
if not parent_id:
|
|
77
|
+
return None
|
|
78
|
+
for r in records:
|
|
79
|
+
if r.record_type != "delegation":
|
|
80
|
+
continue
|
|
81
|
+
# Use the canonical hash as the delegation's identifier.
|
|
82
|
+
from aetheridentity.records import record_hash
|
|
83
|
+
|
|
84
|
+
if record_hash(r) == parent_id:
|
|
85
|
+
return r
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def validate_delegation(
|
|
90
|
+
records: Iterable[DelegationRecord],
|
|
91
|
+
child: DelegationRecord,
|
|
92
|
+
) -> bool:
|
|
93
|
+
"""A delegation is valid iff:
|
|
94
|
+
- its scope is a subset of the parent's scope,
|
|
95
|
+
- its expiry is ≤ the parent's expiry,
|
|
96
|
+
- the parent is valid (recursively).
|
|
97
|
+
A root delegation (no parent) is valid iff its scope is non-empty and
|
|
98
|
+
its expiry is positive.
|
|
99
|
+
"""
|
|
100
|
+
child_scope = _parse_scope(child.scope)
|
|
101
|
+
|
|
102
|
+
if not child.parent:
|
|
103
|
+
return len(child_scope.permissions) > 0 and child.expiry > 0
|
|
104
|
+
|
|
105
|
+
parent = _find_parent(records, child.parent)
|
|
106
|
+
if parent is None:
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
parent_scope = _parse_scope(parent.scope)
|
|
110
|
+
if not child_scope.is_subset_of(parent_scope):
|
|
111
|
+
return False
|
|
112
|
+
if child.expiry > parent.expiry:
|
|
113
|
+
return False
|
|
114
|
+
return validate_delegation(records, parent)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def effective_scope(
|
|
118
|
+
records: Iterable[DelegationRecord],
|
|
119
|
+
leaf: DelegationRecord,
|
|
120
|
+
) -> Scope:
|
|
121
|
+
"""The effective scope of a chain is the intersection of its scopes."""
|
|
122
|
+
records = list(records)
|
|
123
|
+
chain = _chain_of(records, leaf)
|
|
124
|
+
result = _parse_scope(chain[0].scope)
|
|
125
|
+
for r in chain[1:]:
|
|
126
|
+
result = result.intersect(_parse_scope(r.scope))
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def effective_expiry(
|
|
131
|
+
records: Iterable[DelegationRecord],
|
|
132
|
+
leaf: DelegationRecord,
|
|
133
|
+
) -> int:
|
|
134
|
+
"""The effective expiry of a chain is the minimum of its expiries."""
|
|
135
|
+
records = list(records)
|
|
136
|
+
chain = _chain_of(records, leaf)
|
|
137
|
+
return min(r.expiry for r in chain)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _chain_of(
|
|
141
|
+
records: list[DelegationRecord],
|
|
142
|
+
leaf: DelegationRecord,
|
|
143
|
+
) -> list[DelegationRecord]:
|
|
144
|
+
"""Return the chain from root to leaf."""
|
|
145
|
+
chain = [leaf]
|
|
146
|
+
current = leaf
|
|
147
|
+
while current.parent:
|
|
148
|
+
parent = _find_parent(records, current.parent)
|
|
149
|
+
if parent is None:
|
|
150
|
+
break
|
|
151
|
+
chain.append(parent)
|
|
152
|
+
current = parent
|
|
153
|
+
return list(reversed(chain))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def revoke(
|
|
157
|
+
signer: Signer,
|
|
158
|
+
target_delegation: DelegationRecord,
|
|
159
|
+
created_at: int = 0,
|
|
160
|
+
) -> DelegationRecord:
|
|
161
|
+
"""Create and sign a revocation record for a delegation.
|
|
162
|
+
|
|
163
|
+
A revocation is represented as a delegation with an empty scope and
|
|
164
|
+
expiry 0, targeting the original delegation via `parent`.
|
|
165
|
+
"""
|
|
166
|
+
from aetheridentity.records import record_hash
|
|
167
|
+
|
|
168
|
+
target_id = record_hash(target_delegation)
|
|
169
|
+
record = DelegationRecord(
|
|
170
|
+
record_type="delegation",
|
|
171
|
+
signer_id=target_delegation.delegator,
|
|
172
|
+
created_at=created_at,
|
|
173
|
+
delegator=target_delegation.delegator,
|
|
174
|
+
delegatee=target_delegation.delegatee,
|
|
175
|
+
scope="",
|
|
176
|
+
expiry=0,
|
|
177
|
+
parent=target_id,
|
|
178
|
+
)
|
|
179
|
+
sig = signer.sign(canonical_bytes(record))
|
|
180
|
+
return DelegationRecord(**{**record.__dict__, "signature": sig})
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def is_revoked(
|
|
184
|
+
records: Iterable[DelegationRecord],
|
|
185
|
+
delegation: DelegationRecord,
|
|
186
|
+
) -> bool:
|
|
187
|
+
"""A delegation is revoked if any link in its chain has a revocation record."""
|
|
188
|
+
from aetheridentity.records import record_hash
|
|
189
|
+
|
|
190
|
+
records = list(records)
|
|
191
|
+
target_id = record_hash(delegation)
|
|
192
|
+
for r in records:
|
|
193
|
+
if r.record_type != "delegation":
|
|
194
|
+
continue
|
|
195
|
+
if r.scope == "" and r.expiry == 0 and r.parent == target_id:
|
|
196
|
+
return True
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def is_valid_for_action(
|
|
201
|
+
records: Iterable[DelegationRecord],
|
|
202
|
+
delegation: DelegationRecord,
|
|
203
|
+
action_time: int,
|
|
204
|
+
required_permission: str,
|
|
205
|
+
) -> bool:
|
|
206
|
+
"""Check that a delegation authorizes an action.
|
|
207
|
+
|
|
208
|
+
- The chain must be valid (validate_delegation).
|
|
209
|
+
- The action_time must be ≤ the effective expiry.
|
|
210
|
+
- The required permission must be in the effective scope.
|
|
211
|
+
- No link in the chain may be revoked.
|
|
212
|
+
"""
|
|
213
|
+
records = list(records)
|
|
214
|
+
if not validate_delegation(records, delegation):
|
|
215
|
+
return False
|
|
216
|
+
if action_time > effective_expiry(records, delegation):
|
|
217
|
+
return False
|
|
218
|
+
if required_permission not in effective_scope(records, delegation).permissions:
|
|
219
|
+
return False
|
|
220
|
+
chain = _chain_of(records, delegation)
|
|
221
|
+
for link in chain:
|
|
222
|
+
if is_revoked(records, link):
|
|
223
|
+
return False
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def governance_overlap(
|
|
228
|
+
records: Iterable[DelegationRecord],
|
|
229
|
+
authority_chains: dict[str, frozenset[str]],
|
|
230
|
+
) -> float:
|
|
231
|
+
"""Measure the fraction of L5 decisions that can be re-expressed as
|
|
232
|
+
AetherGovern decisions (F3, tested in S6).
|
|
233
|
+
|
|
234
|
+
For each delegation in `records`, check whether its (delegator, delegatee)
|
|
235
|
+
pair already appears in the `authority_chains` mapping, where
|
|
236
|
+
authority_chains[delegator] is the set of principals authorized to act
|
|
237
|
+
on behalf of `delegator` under AetherGovern.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Fraction in [0, 1]. 1.0 means every L5 decision is already an
|
|
241
|
+
AetherGovern decision (F3 falsified if ≥ the threshold 0.80).
|
|
242
|
+
"""
|
|
243
|
+
records = [r for r in records if r.record_type == "delegation"]
|
|
244
|
+
if not records:
|
|
245
|
+
return 0.0
|
|
246
|
+
overlaps = 0
|
|
247
|
+
for r in records:
|
|
248
|
+
authorized = authority_chains.get(r.delegator, frozenset())
|
|
249
|
+
if r.delegatee in authorized:
|
|
250
|
+
overlaps += 1
|
|
251
|
+
return overlaps / len(records)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""L1 — Identity (§6.1 of SPECIFICATION.md)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from typing import Iterable
|
|
7
|
+
|
|
8
|
+
from aetheridentity.records import InceptionRecord, canonical_bytes
|
|
9
|
+
from aetheridentity.signer import Signer
|
|
10
|
+
|
|
11
|
+
DOMAIN_TAG = b"AETHERIDENTITY/v2"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def identifier(alg: str, pk: bytes, label: str) -> str:
|
|
15
|
+
"""Definition 1: id(t) = H(tag ‖ alg ‖ pk ‖ λ)."""
|
|
16
|
+
h = hashlib.sha256()
|
|
17
|
+
h.update(DOMAIN_TAG)
|
|
18
|
+
h.update(b"\x00")
|
|
19
|
+
h.update(alg.encode("utf-8"))
|
|
20
|
+
h.update(b"\x00")
|
|
21
|
+
h.update(pk)
|
|
22
|
+
h.update(b"\x00")
|
|
23
|
+
h.update(label.encode("utf-8"))
|
|
24
|
+
return h.hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def create_identity(signer: Signer, label: str, alg: str = "ed25519", created_at: int = 0) -> InceptionRecord:
|
|
28
|
+
"""Create an inception record and sign it."""
|
|
29
|
+
pk = signer.public_key()
|
|
30
|
+
signer_id = identifier(alg, pk, label)
|
|
31
|
+
record = InceptionRecord(
|
|
32
|
+
record_type="inception",
|
|
33
|
+
signer_id=signer_id,
|
|
34
|
+
created_at=created_at,
|
|
35
|
+
alg=alg,
|
|
36
|
+
pk=pk,
|
|
37
|
+
label=label,
|
|
38
|
+
t_c=created_at,
|
|
39
|
+
)
|
|
40
|
+
sig = signer.sign(canonical_bytes(record))
|
|
41
|
+
return InceptionRecord(
|
|
42
|
+
**{**record.__dict__, "signature": sig}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def verify_identity(records: Iterable[InceptionRecord], ident: str) -> bool:
|
|
47
|
+
"""Check that the identifier recomputes from the record's fields."""
|
|
48
|
+
for r in records:
|
|
49
|
+
if r.record_type != "inception":
|
|
50
|
+
continue
|
|
51
|
+
if identifier(r.alg, r.pk, r.label) == ident:
|
|
52
|
+
return True
|
|
53
|
+
return False
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Record types for AetherIdentity.
|
|
2
|
+
|
|
3
|
+
Implements §3 of SPECIFICATION.md:
|
|
4
|
+
- Five record types (InceptionRecord, RotationRecord, ReputationRecord,
|
|
5
|
+
DelegationRecord, SybilClaim).
|
|
6
|
+
- Canonical JSON serialization (§3.3).
|
|
7
|
+
- Record hashing (SHA-256 over canonical JSON).
|
|
8
|
+
|
|
9
|
+
No signatures are verified here. Signature verification belongs to signer.py.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
from dataclasses import dataclass, field, asdict
|
|
17
|
+
from typing import Any, Literal
|
|
18
|
+
|
|
19
|
+
RecordType = Literal["inception", "rotation", "reputation", "delegation", "sybil_claim"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _canonical_json(obj: dict[str, Any]) -> bytes:
|
|
23
|
+
"""Canonical JSON serialization (§3.3 of SPECIFICATION.md).
|
|
24
|
+
|
|
25
|
+
- Keys sorted lexicographically.
|
|
26
|
+
- No whitespace.
|
|
27
|
+
- UTF-8 encoding.
|
|
28
|
+
- Bytes encoded as lowercase hex.
|
|
29
|
+
"""
|
|
30
|
+
return json.dumps(
|
|
31
|
+
obj,
|
|
32
|
+
sort_keys=True,
|
|
33
|
+
separators=(",", ":"),
|
|
34
|
+
ensure_ascii=False,
|
|
35
|
+
default=_json_default,
|
|
36
|
+
).encode("utf-8")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _json_default(obj: Any) -> Any:
|
|
40
|
+
if isinstance(obj, bytes):
|
|
41
|
+
return obj.hex()
|
|
42
|
+
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def canonical_bytes(record: "BaseRecord") -> bytes:
|
|
46
|
+
"""Canonical serialization of a record, without its signature field."""
|
|
47
|
+
data = asdict(record)
|
|
48
|
+
data.pop("signature", None)
|
|
49
|
+
return _canonical_json(data)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def record_hash(record: "BaseRecord") -> str:
|
|
53
|
+
"""SHA-256 hash of the canonical serialization (without signature)."""
|
|
54
|
+
return hashlib.sha256(canonical_bytes(record)).hexdigest()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class BaseRecord:
|
|
59
|
+
"""Common fields for all record types (§3.2 of SPECIFICATION.md)."""
|
|
60
|
+
|
|
61
|
+
record_type: RecordType
|
|
62
|
+
signer_id: str
|
|
63
|
+
created_at: int
|
|
64
|
+
signature: bytes = field(default=b"", compare=False)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class InceptionRecord(BaseRecord):
|
|
69
|
+
"""Identity creation (Definition 1 of the paper)."""
|
|
70
|
+
|
|
71
|
+
alg: str = ""
|
|
72
|
+
pk: bytes = field(default=b"", compare=False)
|
|
73
|
+
label: str = ""
|
|
74
|
+
t_c: int = 0
|
|
75
|
+
|
|
76
|
+
def __post_init__(self) -> None:
|
|
77
|
+
object.__setattr__(self, "record_type", "inception")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class RotationRecord(BaseRecord):
|
|
82
|
+
"""Key rotation (Definition 2 of the paper)."""
|
|
83
|
+
|
|
84
|
+
id: str = ""
|
|
85
|
+
epoch: int = 0
|
|
86
|
+
new_pk: bytes = field(default=b"", compare=False)
|
|
87
|
+
commitment: bytes = field(default=b"", compare=False)
|
|
88
|
+
prev_hash: str = ""
|
|
89
|
+
signing_pk: bytes = field(default=b"", compare=False)
|
|
90
|
+
|
|
91
|
+
def __post_init__(self) -> None:
|
|
92
|
+
object.__setattr__(self, "record_type", "rotation")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class ReputationRecord(BaseRecord):
|
|
97
|
+
"""Signed observation (Definition 3 of the paper)."""
|
|
98
|
+
|
|
99
|
+
observer: str = ""
|
|
100
|
+
subject: str = ""
|
|
101
|
+
evidence: str = ""
|
|
102
|
+
outcome: int = 0
|
|
103
|
+
t: int = 0
|
|
104
|
+
|
|
105
|
+
def __post_init__(self) -> None:
|
|
106
|
+
object.__setattr__(self, "record_type", "reputation")
|
|
107
|
+
if self.outcome not in (-1, 0, 1):
|
|
108
|
+
raise ValueError("outcome must be -1, 0, or +1")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class DelegationRecord(BaseRecord):
|
|
113
|
+
"""Scoped authority (Definition 4 of the paper)."""
|
|
114
|
+
|
|
115
|
+
delegator: str = ""
|
|
116
|
+
delegatee: str = ""
|
|
117
|
+
scope: str = ""
|
|
118
|
+
expiry: int = 0
|
|
119
|
+
parent: str = ""
|
|
120
|
+
|
|
121
|
+
def __post_init__(self) -> None:
|
|
122
|
+
object.__setattr__(self, "record_type", "delegation")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class SybilClaim(BaseRecord):
|
|
127
|
+
"""Evidence-backed claim of shared control (§6.4 of SPECIFICATION.md)."""
|
|
128
|
+
|
|
129
|
+
attester: str = ""
|
|
130
|
+
target: str = ""
|
|
131
|
+
evidence: str = ""
|
|
132
|
+
t: int = 0
|
|
133
|
+
|
|
134
|
+
def __post_init__(self) -> None:
|
|
135
|
+
object.__setattr__(self, "record_type", "sybil_claim")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
AnyRecord = InceptionRecord | RotationRecord | ReputationRecord | DelegationRecord | SybilClaim
|