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,178 @@
|
|
|
1
|
+
"""L3 — Reputation (§6.3 of SPECIFICATION.md).
|
|
2
|
+
|
|
3
|
+
Implements Definition 3 of the paper:
|
|
4
|
+
- Reputation record: signed observation.
|
|
5
|
+
- Score: fixed point of an EigenTrust-style iteration, computed in exact
|
|
6
|
+
arithmetic with a fixed iteration count K and a canonical ordering.
|
|
7
|
+
- The score is a view over S, not a stored value.
|
|
8
|
+
|
|
9
|
+
Out of scope (v2.0.0):
|
|
10
|
+
- Whether reputation may influence admission (policy decision).
|
|
11
|
+
- Per-task-class reputation (open question Q10).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from fractions import Fraction
|
|
17
|
+
from typing import Iterable
|
|
18
|
+
|
|
19
|
+
from aetheridentity.records import (
|
|
20
|
+
ReputationRecord,
|
|
21
|
+
canonical_bytes,
|
|
22
|
+
)
|
|
23
|
+
from aetheridentity.signer import Signer
|
|
24
|
+
|
|
25
|
+
K = 100
|
|
26
|
+
DAMPING = Fraction(1, 5) # a = 0.2
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def record_observation(
|
|
30
|
+
signer: Signer,
|
|
31
|
+
observer: str,
|
|
32
|
+
subject: str,
|
|
33
|
+
evidence: str,
|
|
34
|
+
outcome: int,
|
|
35
|
+
created_at: int = 0,
|
|
36
|
+
) -> ReputationRecord:
|
|
37
|
+
"""Create and sign a reputation record."""
|
|
38
|
+
if outcome not in (-1, 0, 1):
|
|
39
|
+
raise ValueError("outcome must be -1, 0, or +1")
|
|
40
|
+
record = ReputationRecord(
|
|
41
|
+
record_type="reputation",
|
|
42
|
+
signer_id=observer,
|
|
43
|
+
created_at=created_at,
|
|
44
|
+
observer=observer,
|
|
45
|
+
subject=subject,
|
|
46
|
+
evidence=evidence,
|
|
47
|
+
outcome=outcome,
|
|
48
|
+
t=created_at,
|
|
49
|
+
)
|
|
50
|
+
sig = signer.sign(canonical_bytes(record))
|
|
51
|
+
return ReputationRecord(**{**record.__dict__, "signature": sig})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _build_local_trust(
|
|
55
|
+
records: Iterable[ReputationRecord],
|
|
56
|
+
observers: list[str],
|
|
57
|
+
subjects: list[str],
|
|
58
|
+
) -> list[list[Fraction]]:
|
|
59
|
+
"""Build the row-stochastic matrix C from reputation records.
|
|
60
|
+
|
|
61
|
+
C[i][j] = normalized positive outcome from observers[i] about subjects[j].
|
|
62
|
+
"""
|
|
63
|
+
n = len(observers)
|
|
64
|
+
m = len(subjects)
|
|
65
|
+
raw = [[0 for _ in range(m)] for _ in range(n)]
|
|
66
|
+
|
|
67
|
+
# Accumulate positive outcomes only (v > 0), as in the paper's definition.
|
|
68
|
+
for r in records:
|
|
69
|
+
if r.record_type != "reputation":
|
|
70
|
+
continue
|
|
71
|
+
if r.observer not in observers or r.subject not in subjects:
|
|
72
|
+
continue
|
|
73
|
+
i = observers.index(r.observer)
|
|
74
|
+
j = subjects.index(r.subject)
|
|
75
|
+
if r.outcome > 0:
|
|
76
|
+
raw[i][j] += r.outcome
|
|
77
|
+
|
|
78
|
+
# Normalize each row. If a row is all zeros, use the uniform distribution.
|
|
79
|
+
C = [[Fraction(0) for _ in range(m)] for _ in range(n)]
|
|
80
|
+
for i in range(n):
|
|
81
|
+
total = sum(raw[i])
|
|
82
|
+
if total == 0:
|
|
83
|
+
for j in range(m):
|
|
84
|
+
C[i][j] = Fraction(1, m)
|
|
85
|
+
else:
|
|
86
|
+
for j in range(m):
|
|
87
|
+
C[i][j] = Fraction(raw[i][j], total)
|
|
88
|
+
return C
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _anchor_distribution(anchors: list[str], subjects: list[str]) -> list[Fraction]:
|
|
92
|
+
"""Uniform distribution over the anchor set Γ₀."""
|
|
93
|
+
m = len(subjects)
|
|
94
|
+
if not anchors:
|
|
95
|
+
return [Fraction(1, m) for _ in range(m)]
|
|
96
|
+
p = [Fraction(0) for _ in range(m)]
|
|
97
|
+
for a in anchors:
|
|
98
|
+
if a in subjects:
|
|
99
|
+
p[subjects.index(a)] = Fraction(1, len(anchors))
|
|
100
|
+
return p
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _iterate(
|
|
104
|
+
C: list[list[Fraction]],
|
|
105
|
+
p: list[Fraction],
|
|
106
|
+
a: Fraction,
|
|
107
|
+
K: int,
|
|
108
|
+
) -> list[Fraction]:
|
|
109
|
+
"""Iterate t ← (1 − a) · Cᵀ t + a · p for K steps.
|
|
110
|
+
|
|
111
|
+
Initial t is the uniform distribution.
|
|
112
|
+
"""
|
|
113
|
+
m = len(p)
|
|
114
|
+
t = [Fraction(1, m) for _ in range(m)]
|
|
115
|
+
one_minus_a = Fraction(1) - a
|
|
116
|
+
|
|
117
|
+
for _ in range(K):
|
|
118
|
+
# Compute Cᵀ t: for each j, sum_i C[i][j] * t[i].
|
|
119
|
+
ct = [Fraction(0) for _ in range(m)]
|
|
120
|
+
for i in range(len(C)):
|
|
121
|
+
ti = t[i]
|
|
122
|
+
if ti == 0:
|
|
123
|
+
continue
|
|
124
|
+
row = C[i]
|
|
125
|
+
for j in range(m):
|
|
126
|
+
ct[j] += row[j] * ti
|
|
127
|
+
t = [one_minus_a * ct[j] + a * p[j] for j in range(m)]
|
|
128
|
+
return t
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def reputation_of(
|
|
132
|
+
records: Iterable[ReputationRecord],
|
|
133
|
+
anchors: list[str] | None = None,
|
|
134
|
+
) -> dict[str, Fraction]:
|
|
135
|
+
"""Compute the reputation vector over all subjects in `records`.
|
|
136
|
+
|
|
137
|
+
- Observers and subjects are sorted canonically (lexicographic by id).
|
|
138
|
+
- Iteration count K = 100.
|
|
139
|
+
- Damping a = 1/5.
|
|
140
|
+
- Exact arithmetic (Fraction).
|
|
141
|
+
"""
|
|
142
|
+
records = list(records)
|
|
143
|
+
observers_set = {r.observer for r in records if r.record_type == "reputation"}
|
|
144
|
+
subjects_set = {r.subject for r in records if r.record_type == "reputation"}
|
|
145
|
+
observers = sorted(observers_set)
|
|
146
|
+
subjects = sorted(subjects_set)
|
|
147
|
+
|
|
148
|
+
if not subjects:
|
|
149
|
+
return {}
|
|
150
|
+
|
|
151
|
+
C = _build_local_trust(records, observers, subjects)
|
|
152
|
+
p = _anchor_distribution(anchors or [], subjects)
|
|
153
|
+
t = _iterate(C, p, DAMPING, K)
|
|
154
|
+
return {subjects[j]: t[j] for j in range(len(subjects))}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def reputation_residual(
|
|
158
|
+
records: Iterable[ReputationRecord],
|
|
159
|
+
anchors: list[str] | None = None,
|
|
160
|
+
) -> Fraction:
|
|
161
|
+
"""‖T(t_K) − t_K‖₁, for the convergence check (T06)."""
|
|
162
|
+
records = list(records)
|
|
163
|
+
observers = sorted({r.observer for r in records if r.record_type == "reputation"})
|
|
164
|
+
subjects = sorted({r.subject for r in records if r.record_type == "reputation"})
|
|
165
|
+
if not subjects:
|
|
166
|
+
return Fraction(0)
|
|
167
|
+
|
|
168
|
+
C = _build_local_trust(records, observers, subjects)
|
|
169
|
+
p = _anchor_distribution(anchors or [], subjects)
|
|
170
|
+
t_K = _iterate(C, p, DAMPING, K)
|
|
171
|
+
# One more iteration.
|
|
172
|
+
m = len(p)
|
|
173
|
+
ct = [Fraction(0) for _ in range(m)]
|
|
174
|
+
for i in range(len(C)):
|
|
175
|
+
for j in range(m):
|
|
176
|
+
ct[j] += C[i][j] * t_K[i]
|
|
177
|
+
t_next = [(Fraction(1) - DAMPING) * ct[j] + DAMPING * p[j] for j in range(m)]
|
|
178
|
+
return sum(abs(t_next[j] - t_K[j]) for j in range(m))
|
aetheridentity/signer.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Abstract Signer interface (§4 of SPECIFICATION.md).
|
|
2
|
+
|
|
3
|
+
Two implementations:
|
|
4
|
+
- FakeSigner: for unit tests and scenarios. Not cryptographic.
|
|
5
|
+
- Ed25519Signer: real Ed25519 via the `cryptography` library.
|
|
6
|
+
|
|
7
|
+
A signature produced by one is not accepted by the other.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
from cryptography.exceptions import InvalidSignature
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
16
|
+
Ed25519PrivateKey,
|
|
17
|
+
Ed25519PublicKey,
|
|
18
|
+
)
|
|
19
|
+
from cryptography.hazmat.primitives.serialization import (
|
|
20
|
+
Encoding,
|
|
21
|
+
PublicFormat,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Signer(Protocol):
|
|
26
|
+
"""Abstract signer interface."""
|
|
27
|
+
|
|
28
|
+
def sign(self, message: bytes) -> bytes: ...
|
|
29
|
+
def verify(self, message: bytes, signature: bytes, public_key: bytes) -> bool: ...
|
|
30
|
+
def public_key(self) -> bytes: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FakeSigner:
|
|
34
|
+
"""Non-cryptographic signer for tests.
|
|
35
|
+
|
|
36
|
+
The signature is the SHA-256 of the message prefixed with a fixed tag.
|
|
37
|
+
This is NOT secure and MUST NOT be used outside tests.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
TAG = b"FAKE-SIGNER:"
|
|
41
|
+
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
self._pk = b"fake-pk"
|
|
44
|
+
|
|
45
|
+
def sign(self, message: bytes) -> bytes:
|
|
46
|
+
import hashlib
|
|
47
|
+
|
|
48
|
+
return self.TAG + hashlib.sha256(message).digest()
|
|
49
|
+
|
|
50
|
+
def verify(self, message: bytes, signature: bytes, public_key: bytes) -> bool:
|
|
51
|
+
if not signature.startswith(self.TAG):
|
|
52
|
+
return False
|
|
53
|
+
return self.sign(message) == signature
|
|
54
|
+
|
|
55
|
+
def public_key(self) -> bytes:
|
|
56
|
+
return self._pk
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Ed25519Signer:
|
|
60
|
+
"""Real Ed25519 signer using the `cryptography` library."""
|
|
61
|
+
|
|
62
|
+
def __init__(self) -> None:
|
|
63
|
+
self._sk = Ed25519PrivateKey.generate()
|
|
64
|
+
self._pk = self._sk.public_key()
|
|
65
|
+
|
|
66
|
+
def sign(self, message: bytes) -> bytes:
|
|
67
|
+
return self._sk.sign(message)
|
|
68
|
+
|
|
69
|
+
def verify(self, message: bytes, signature: bytes, public_key: bytes) -> bool:
|
|
70
|
+
try:
|
|
71
|
+
pub = Ed25519PublicKey.from_public_bytes(public_key)
|
|
72
|
+
pub.verify(signature, message)
|
|
73
|
+
return True
|
|
74
|
+
except (InvalidSignature, ValueError):
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
def public_key(self) -> bytes:
|
|
78
|
+
return self._pk.public_bytes(Encoding.Raw, PublicFormat.Raw)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Minimal independent simulator of replicated state S (§8 of SPECIFICATION.md).
|
|
2
|
+
|
|
3
|
+
S is a flat list of admitted records. Admission is sequential. No
|
|
4
|
+
concurrency, no causal delivery, no fork resolution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from aetheridentity.records import AnyRecord, record_hash
|
|
10
|
+
from aetheridentity.signer import Signer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Simulator:
|
|
14
|
+
"""Replicated state S as a flat list of records."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, signer: Signer) -> None:
|
|
17
|
+
self._signer = signer
|
|
18
|
+
self._records: list[AnyRecord] = []
|
|
19
|
+
self._hashes: set[str] = set()
|
|
20
|
+
|
|
21
|
+
def admit(self, record: AnyRecord) -> bool:
|
|
22
|
+
"""Admit a record. Returns True if admitted, False if duplicate.
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
ValueError: if the record's signature does not verify.
|
|
26
|
+
"""
|
|
27
|
+
# 1. Structural validation is the responsibility of the record class
|
|
28
|
+
# (its __post_init__). A malformed record cannot be constructed.
|
|
29
|
+
# 2. Signature verification.
|
|
30
|
+
if record.signature:
|
|
31
|
+
from aetheridentity.records import canonical_bytes
|
|
32
|
+
|
|
33
|
+
if not self._signer.verify(canonical_bytes(record), record.signature, b""):
|
|
34
|
+
raise ValueError("signature verification failed")
|
|
35
|
+
|
|
36
|
+
# 3. Deduplication by canonical hash.
|
|
37
|
+
h = record_hash(record)
|
|
38
|
+
if h in self._hashes:
|
|
39
|
+
return False
|
|
40
|
+
self._hashes.add(h)
|
|
41
|
+
self._records.append(record)
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
def records(self) -> list[AnyRecord]:
|
|
45
|
+
"""Return a copy of S. The caller cannot mutate internal state."""
|
|
46
|
+
return list(self._records)
|
|
47
|
+
|
|
48
|
+
def __len__(self) -> int:
|
|
49
|
+
return len(self._records)
|
aetheridentity/sybil.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""L4 — Sybil Resistance (§6.4 of SPECIFICATION.md).
|
|
2
|
+
|
|
3
|
+
Implements:
|
|
4
|
+
- SybilClaim: signed, evidence-backed claim of shared control.
|
|
5
|
+
- Cluster formation: claims signed by at least tau distinct anchored attesters.
|
|
6
|
+
- cluster_weight_of: divide each member's weight by cluster size.
|
|
7
|
+
- detection_rates: separate false-positive and false-negative rates.
|
|
8
|
+
|
|
9
|
+
Out of scope (v2.0.0):
|
|
10
|
+
- Detection guarantees (none are claimed).
|
|
11
|
+
- Admissible evidence (open question Q13).
|
|
12
|
+
- Threshold tau (fixed at 2 in INVARIANTS.md).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Iterable
|
|
18
|
+
|
|
19
|
+
from aetheridentity.records import SybilClaim, canonical_bytes
|
|
20
|
+
from aetheridentity.signer import Signer
|
|
21
|
+
|
|
22
|
+
TAU = 2 # threshold for cluster formation (INVARIANTS.md, O4)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def issue_sybil_claim(
|
|
26
|
+
signer: Signer,
|
|
27
|
+
attester: str,
|
|
28
|
+
target: str,
|
|
29
|
+
evidence: str,
|
|
30
|
+
created_at: int = 0,
|
|
31
|
+
) -> SybilClaim:
|
|
32
|
+
"""Create and sign a Sybil claim."""
|
|
33
|
+
record = SybilClaim(
|
|
34
|
+
record_type="sybil_claim",
|
|
35
|
+
signer_id=attester,
|
|
36
|
+
created_at=created_at,
|
|
37
|
+
attester=attester,
|
|
38
|
+
target=target,
|
|
39
|
+
evidence=evidence,
|
|
40
|
+
t=created_at,
|
|
41
|
+
)
|
|
42
|
+
sig = signer.sign(canonical_bytes(record))
|
|
43
|
+
return SybilClaim(**{**record.__dict__, "signature": sig})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _cluster_of(
|
|
47
|
+
records: Iterable[SybilClaim],
|
|
48
|
+
anchored: set[str],
|
|
49
|
+
tau: int = TAU,
|
|
50
|
+
) -> dict[str, set[str]]:
|
|
51
|
+
"""Build clusters from claims.
|
|
52
|
+
|
|
53
|
+
A cluster is a set of identities linked by at least `tau` distinct
|
|
54
|
+
anchored attesters. The evidence string is used as a grouping key:
|
|
55
|
+
claims about the same evidence are considered to describe the same
|
|
56
|
+
cluster.
|
|
57
|
+
"""
|
|
58
|
+
# Group claims by evidence.
|
|
59
|
+
by_evidence: dict[str, list[SybilClaim]] = {}
|
|
60
|
+
for r in records:
|
|
61
|
+
if r.record_type != "sybil_claim":
|
|
62
|
+
continue
|
|
63
|
+
by_evidence.setdefault(r.evidence, []).append(r)
|
|
64
|
+
|
|
65
|
+
clusters: dict[str, set[str]] = {}
|
|
66
|
+
for evidence, claims in by_evidence.items():
|
|
67
|
+
distinct_attesters = {c.attester for c in claims if c.attester in anchored}
|
|
68
|
+
if len(distinct_attesters) < tau:
|
|
69
|
+
continue
|
|
70
|
+
members: set[str] = set()
|
|
71
|
+
for c in claims:
|
|
72
|
+
members.add(c.attester)
|
|
73
|
+
members.add(c.target)
|
|
74
|
+
for m in members:
|
|
75
|
+
clusters[m] = members
|
|
76
|
+
return clusters
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def cluster_weight_of(
|
|
80
|
+
records: Iterable[SybilClaim],
|
|
81
|
+
ident: str,
|
|
82
|
+
anchored: set[str] | None = None,
|
|
83
|
+
tau: int = TAU,
|
|
84
|
+
) -> float:
|
|
85
|
+
"""Return the weight of `ident` in its cluster (1/m), or 1.0 if not in a cluster."""
|
|
86
|
+
anchored = anchored or set()
|
|
87
|
+
clusters = _cluster_of(records, anchored, tau)
|
|
88
|
+
if ident not in clusters:
|
|
89
|
+
return 1.0
|
|
90
|
+
return 1.0 / len(clusters[ident])
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def detection_rates(
|
|
94
|
+
predicted: set[str],
|
|
95
|
+
ground_truth: set[str],
|
|
96
|
+
honest_operators: set[str],
|
|
97
|
+
) -> dict[str, float]:
|
|
98
|
+
"""Compute false-positive and false-negative rates.
|
|
99
|
+
|
|
100
|
+
- False positive rate: fraction of honest operators wrongly flagged.
|
|
101
|
+
- False negative rate: fraction of true Sybils not flagged.
|
|
102
|
+
- Detection rate: fraction of true Sybils flagged.
|
|
103
|
+
"""
|
|
104
|
+
true_positives = predicted & ground_truth
|
|
105
|
+
false_positives = (predicted - ground_truth) & honest_operators
|
|
106
|
+
false_negatives = ground_truth - predicted
|
|
107
|
+
|
|
108
|
+
fp_rate = len(false_positives) / len(honest_operators) if honest_operators else 0.0
|
|
109
|
+
fn_rate = len(false_negatives) / len(ground_truth) if ground_truth else 0.0
|
|
110
|
+
detection_rate = len(true_positives) / len(ground_truth) if ground_truth else 0.0
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
"detection_rate": detection_rate,
|
|
114
|
+
"false_positive_rate": fp_rate,
|
|
115
|
+
"false_negative_rate": fn_rate,
|
|
116
|
+
"true_positives": len(true_positives),
|
|
117
|
+
"false_positives": len(false_positives),
|
|
118
|
+
"false_negatives": len(false_negatives),
|
|
119
|
+
}
|