aethergovern 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.
- aethergovern/__init__.py +30 -0
- aethergovern/__version__.py +9 -0
- aethergovern/accountability/__init__.py +17 -0
- aethergovern/accountability/records.py +153 -0
- aethergovern/authorization/__init__.py +23 -0
- aethergovern/authorization/certificates.py +446 -0
- aethergovern/cli.py +142 -0
- aethergovern/dispute/__init__.py +11 -0
- aethergovern/dispute/resolution.py +132 -0
- aethergovern/models.py +334 -0
- aethergovern/policy/__init__.py +11 -0
- aethergovern/policy/enforcement.py +162 -0
- aethergovern/py.typed +0 -0
- aethergovern/revocation/__init__.py +15 -0
- aethergovern/revocation/frontier.py +75 -0
- aethergovern/simulation/__init__.py +25 -0
- aethergovern/simulation/network.py +172 -0
- aethergovern/simulation/node.py +152 -0
- aethergovern/simulation/testbed.py +151 -0
- aethergovern-2.0.0.dist-info/METADATA +695 -0
- aethergovern-2.0.0.dist-info/RECORD +26 -0
- aethergovern-2.0.0.dist-info/WHEEL +5 -0
- aethergovern-2.0.0.dist-info/entry_points.txt +2 -0
- aethergovern-2.0.0.dist-info/licenses/AUTHORS.md +61 -0
- aethergovern-2.0.0.dist-info/licenses/LICENSE +26 -0
- aethergovern-2.0.0.dist-info/top_level.txt +1 -0
aethergovern/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AetherGovern v2.0.0 — Reference Implementation
|
|
3
|
+
|
|
4
|
+
A Falsification-First Investigation of Governance in the Aether Family.
|
|
5
|
+
|
|
6
|
+
This package is the reference implementation of the AetherGovern
|
|
7
|
+
architecture specified in v2.0.0. It implements the ten entities,
|
|
8
|
+
seven operations, and 47 invariants of the operational specification.
|
|
9
|
+
|
|
10
|
+
Reference:
|
|
11
|
+
Baladi, S. (2026). AetherGovern: A Falsification-First Investigation
|
|
12
|
+
of Governance in the Aether Family. Zenodo.
|
|
13
|
+
https://doi.org/10.5281/zenodo.24054524
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from aethergovern.__version__ import (
|
|
17
|
+
__version__,
|
|
18
|
+
__author__,
|
|
19
|
+
__email__,
|
|
20
|
+
__doi__,
|
|
21
|
+
__license__,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"__version__",
|
|
26
|
+
"__author__",
|
|
27
|
+
"__email__",
|
|
28
|
+
"__doi__",
|
|
29
|
+
"__license__",
|
|
30
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""AetherGovern — Accountability module (Layer 3)."""
|
|
2
|
+
|
|
3
|
+
from aethergovern.accountability.records import (
|
|
4
|
+
create_record,
|
|
5
|
+
compute_record_id,
|
|
6
|
+
verify_hash_link,
|
|
7
|
+
query_by_action,
|
|
8
|
+
canonical_record_bytes,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"create_record",
|
|
13
|
+
"compute_record_id",
|
|
14
|
+
"verify_hash_link",
|
|
15
|
+
"query_by_action",
|
|
16
|
+
"canonical_record_bytes",
|
|
17
|
+
]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AetherGovern — Accountability records (Layer 3, v2.0.0).
|
|
3
|
+
|
|
4
|
+
Implements:
|
|
5
|
+
- create_record: create an accountability record for an admitted action
|
|
6
|
+
- compute_record_id: content-addressed id
|
|
7
|
+
- verify_hash_link: verify the hash-link to the previous record
|
|
8
|
+
- query_by_action: find all records for an action
|
|
9
|
+
- canonical_record_bytes: serialization for signing
|
|
10
|
+
|
|
11
|
+
Reference:
|
|
12
|
+
Baladi, S. (2026). AetherGovern v2.0.0,
|
|
13
|
+
docs/specification/02_operations.md and docs/specification/03_invariants.md.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from typing import Dict, List
|
|
22
|
+
|
|
23
|
+
from aethergovern.models import (
|
|
24
|
+
AccountabilityRecord,
|
|
25
|
+
Action,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# =============================================================================
|
|
30
|
+
# canonical_record_bytes
|
|
31
|
+
# =============================================================================
|
|
32
|
+
|
|
33
|
+
def canonical_record_bytes(record: AccountabilityRecord) -> bytes:
|
|
34
|
+
"""
|
|
35
|
+
Serialize an accountability record to canonical bytes (excluding signature).
|
|
36
|
+
"""
|
|
37
|
+
canonical = {
|
|
38
|
+
"action_ref": record.action_ref,
|
|
39
|
+
"authority_chain_ref": record.authority_chain_ref,
|
|
40
|
+
"admitted_at": record.admitted_at,
|
|
41
|
+
"admitted_by": record.admitted_by,
|
|
42
|
+
"prev_record_hash": record.prev_record_hash,
|
|
43
|
+
}
|
|
44
|
+
return json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def compute_record_id(record: AccountabilityRecord) -> str:
|
|
48
|
+
"""Compute the content-addressed record id."""
|
|
49
|
+
return hashlib.sha256(canonical_record_bytes(record)).hexdigest()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# =============================================================================
|
|
53
|
+
# Time helpers
|
|
54
|
+
# =============================================================================
|
|
55
|
+
|
|
56
|
+
def _now_iso() -> str:
|
|
57
|
+
return datetime.now(timezone.utc).isoformat()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# =============================================================================
|
|
61
|
+
# create_record
|
|
62
|
+
# =============================================================================
|
|
63
|
+
|
|
64
|
+
def create_record(
|
|
65
|
+
action: Action,
|
|
66
|
+
authority_chain_ref: str,
|
|
67
|
+
admitted_by: str,
|
|
68
|
+
prev_record_hash: str,
|
|
69
|
+
) -> AccountabilityRecord:
|
|
70
|
+
"""
|
|
71
|
+
Create an accountability record for an admitted action.
|
|
72
|
+
|
|
73
|
+
Preconditions:
|
|
74
|
+
- action.action_id is non-empty
|
|
75
|
+
- authority_chain_ref is non-empty
|
|
76
|
+
- admitted_by is non-empty
|
|
77
|
+
- prev_record_hash is a 64-char hex string (or "0"*64 for the first)
|
|
78
|
+
|
|
79
|
+
Postconditions:
|
|
80
|
+
- record.action_ref == action.action_id
|
|
81
|
+
- record.authority_chain_ref == authority_chain_ref
|
|
82
|
+
- record.admitted_by == admitted_by
|
|
83
|
+
- record.prev_record_hash == prev_record_hash
|
|
84
|
+
- record.record_id == compute_record_id(record)
|
|
85
|
+
|
|
86
|
+
Note: the record is created unsigned; signing is the caller's
|
|
87
|
+
responsibility (via `accountability.sign`).
|
|
88
|
+
"""
|
|
89
|
+
if not action.action_id:
|
|
90
|
+
raise ValueError("action.action_id is empty")
|
|
91
|
+
if not authority_chain_ref:
|
|
92
|
+
raise ValueError("authority_chain_ref is empty")
|
|
93
|
+
if not admitted_by:
|
|
94
|
+
raise ValueError("admitted_by is empty")
|
|
95
|
+
if len(prev_record_hash) != 64:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"prev_record_hash must be 64 hex chars, got {len(prev_record_hash)}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
record = AccountabilityRecord(
|
|
101
|
+
record_id="", # placeholder
|
|
102
|
+
action_ref=action.action_id,
|
|
103
|
+
authority_chain_ref=authority_chain_ref,
|
|
104
|
+
admitted_at=_now_iso(),
|
|
105
|
+
admitted_by=admitted_by,
|
|
106
|
+
prev_record_hash=prev_record_hash,
|
|
107
|
+
signature=b"",
|
|
108
|
+
)
|
|
109
|
+
record_id = compute_record_id(record)
|
|
110
|
+
return AccountabilityRecord(
|
|
111
|
+
record_id=record_id,
|
|
112
|
+
action_ref=action.action_id,
|
|
113
|
+
authority_chain_ref=authority_chain_ref,
|
|
114
|
+
admitted_at=record.admitted_at,
|
|
115
|
+
admitted_by=admitted_by,
|
|
116
|
+
prev_record_hash=prev_record_hash,
|
|
117
|
+
signature=b"",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# =============================================================================
|
|
122
|
+
# verify_hash_link
|
|
123
|
+
# =============================================================================
|
|
124
|
+
|
|
125
|
+
def verify_hash_link(
|
|
126
|
+
record: AccountabilityRecord,
|
|
127
|
+
previous: AccountabilityRecord,
|
|
128
|
+
) -> bool:
|
|
129
|
+
"""
|
|
130
|
+
Verify that `record.prev_record_hash` equals `previous.record_id`.
|
|
131
|
+
|
|
132
|
+
Invariants:
|
|
133
|
+
I10.2 — Hash-link integrity.
|
|
134
|
+
"""
|
|
135
|
+
return record.prev_record_hash == previous.record_id
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# =============================================================================
|
|
139
|
+
# query_by_action
|
|
140
|
+
# =============================================================================
|
|
141
|
+
|
|
142
|
+
def query_by_action(
|
|
143
|
+
records: List[AccountabilityRecord],
|
|
144
|
+
action_id: str,
|
|
145
|
+
) -> List[AccountabilityRecord]:
|
|
146
|
+
"""
|
|
147
|
+
Find all accountability records for a given action.
|
|
148
|
+
|
|
149
|
+
Invariants:
|
|
150
|
+
I10.3 — Attribution completeness: every admitted action must have
|
|
151
|
+
at least one record; this function returns all records for the action.
|
|
152
|
+
"""
|
|
153
|
+
return [r for r in records if r.action_ref == action_id]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""AetherGovern — Authorization module (Layer 1)."""
|
|
2
|
+
|
|
3
|
+
from aethergovern.authorization.certificates import (
|
|
4
|
+
generate_keypair,
|
|
5
|
+
canonical_certificate_bytes,
|
|
6
|
+
canonical_revocation_bytes,
|
|
7
|
+
grant,
|
|
8
|
+
delegate,
|
|
9
|
+
revoke,
|
|
10
|
+
check,
|
|
11
|
+
CheckResult,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"generate_keypair",
|
|
16
|
+
"canonical_certificate_bytes",
|
|
17
|
+
"canonical_revocation_bytes",
|
|
18
|
+
"grant",
|
|
19
|
+
"delegate",
|
|
20
|
+
"revoke",
|
|
21
|
+
"check",
|
|
22
|
+
"CheckResult",
|
|
23
|
+
]
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AetherGovern — Authorization (Layer 1, v2.0.0).
|
|
3
|
+
|
|
4
|
+
Implements:
|
|
5
|
+
- grant: issue a root delegation certificate
|
|
6
|
+
- delegate: issue a child delegation certificate
|
|
7
|
+
- revoke: withdraw a certificate
|
|
8
|
+
- check: verify an authority chain at a frontier
|
|
9
|
+
|
|
10
|
+
Reference:
|
|
11
|
+
Baladi, S. (2026). AetherGovern v2.0.0, docs/specification/02_operations.md.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from typing import Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
23
|
+
Ed25519PrivateKey,
|
|
24
|
+
Ed25519PublicKey,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from aethergovern.models import (
|
|
28
|
+
Action,
|
|
29
|
+
Agent,
|
|
30
|
+
AgentStatus,
|
|
31
|
+
AuthorityChain,
|
|
32
|
+
DelegationCertificate,
|
|
33
|
+
RevocationFrontier,
|
|
34
|
+
RevocationRecord,
|
|
35
|
+
Scope,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# =============================================================================
|
|
40
|
+
# Keypair generation
|
|
41
|
+
# =============================================================================
|
|
42
|
+
|
|
43
|
+
def generate_keypair() -> tuple[Ed25519PrivateKey, Ed25519PublicKey]:
|
|
44
|
+
"""Generate a fresh Ed25519 keypair."""
|
|
45
|
+
private = Ed25519PrivateKey.generate()
|
|
46
|
+
public = private.public_key()
|
|
47
|
+
return private, public
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# =============================================================================
|
|
51
|
+
# Canonical serialization
|
|
52
|
+
# =============================================================================
|
|
53
|
+
|
|
54
|
+
def canonical_certificate_bytes(cert: DelegationCertificate) -> bytes:
|
|
55
|
+
"""
|
|
56
|
+
Serialize a certificate to canonical bytes (excluding signature).
|
|
57
|
+
Used for signing and verification.
|
|
58
|
+
"""
|
|
59
|
+
canonical = {
|
|
60
|
+
"certificate_id": cert.certificate_id,
|
|
61
|
+
"issuer": cert.issuer,
|
|
62
|
+
"subject": cert.subject,
|
|
63
|
+
"scope": sorted([list(p) for p in cert.scope]),
|
|
64
|
+
"constraints": cert.constraints,
|
|
65
|
+
"expiry": cert.expiry,
|
|
66
|
+
"parent_ref": cert.parent_ref,
|
|
67
|
+
}
|
|
68
|
+
return json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def canonical_revocation_bytes(rev: RevocationRecord) -> bytes:
|
|
72
|
+
"""Serialize a revocation record to canonical bytes (excluding signature)."""
|
|
73
|
+
canonical = {
|
|
74
|
+
"revocation_id": rev.revocation_id,
|
|
75
|
+
"certificate_ref": rev.certificate_ref,
|
|
76
|
+
"issuer": rev.issuer,
|
|
77
|
+
"reason": rev.reason,
|
|
78
|
+
"timestamp": rev.timestamp,
|
|
79
|
+
}
|
|
80
|
+
return json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _compute_certificate_id(cert: DelegationCertificate) -> str:
|
|
84
|
+
"""Compute the content-addressed certificate id."""
|
|
85
|
+
return hashlib.sha256(canonical_certificate_bytes(cert)).hexdigest()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _compute_revocation_id(rev: RevocationRecord) -> str:
|
|
89
|
+
"""Compute the content-addressed revocation id."""
|
|
90
|
+
return hashlib.sha256(canonical_revocation_bytes(rev)).hexdigest()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# =============================================================================
|
|
94
|
+
# Time helpers
|
|
95
|
+
# =============================================================================
|
|
96
|
+
|
|
97
|
+
def _now_iso() -> str:
|
|
98
|
+
return datetime.now(timezone.utc).isoformat()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _is_past(timestamp: str) -> bool:
|
|
102
|
+
"""Return True iff timestamp is in the past (relative to now)."""
|
|
103
|
+
try:
|
|
104
|
+
dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
|
105
|
+
except ValueError:
|
|
106
|
+
return True
|
|
107
|
+
now = datetime.now(timezone.utc)
|
|
108
|
+
if dt.tzinfo is None:
|
|
109
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
110
|
+
return dt <= now
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _is_expired(expiry: str, current_time: str) -> bool:
|
|
114
|
+
"""Return True iff expiry < current_time."""
|
|
115
|
+
try:
|
|
116
|
+
exp = datetime.fromisoformat(expiry.replace("Z", "+00:00"))
|
|
117
|
+
cur = datetime.fromisoformat(current_time.replace("Z", "+00:00"))
|
|
118
|
+
except ValueError:
|
|
119
|
+
return True
|
|
120
|
+
if exp.tzinfo is None:
|
|
121
|
+
exp = exp.replace(tzinfo=timezone.utc)
|
|
122
|
+
if cur.tzinfo is None:
|
|
123
|
+
cur = cur.replace(tzinfo=timezone.utc)
|
|
124
|
+
return cur > exp
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# =============================================================================
|
|
128
|
+
# Root recognition (simplified registry)
|
|
129
|
+
# =============================================================================
|
|
130
|
+
|
|
131
|
+
# In a full implementation, this would be a decentralized root registry.
|
|
132
|
+
# For v2.0.0, the set of roots is passed explicitly.
|
|
133
|
+
# The `grant` function takes a `root_agents` parameter to recognize roots.
|
|
134
|
+
|
|
135
|
+
# For the test suite, we use a module-level registry that tests can populate.
|
|
136
|
+
_ROOT_AGENTS: dict[str, Agent] = {}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def register_root(agent: Agent) -> None:
|
|
140
|
+
"""Register an agent as a recognized root (for testing)."""
|
|
141
|
+
_ROOT_AGENTS[agent.agent_id] = agent
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def clear_roots() -> None:
|
|
145
|
+
"""Clear the root registry (for testing)."""
|
|
146
|
+
_ROOT_AGENTS.clear()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def is_root(agent: Agent) -> bool:
|
|
150
|
+
"""Check whether an agent is a recognized root."""
|
|
151
|
+
return agent.agent_id in _ROOT_AGENTS
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# =============================================================================
|
|
155
|
+
# Operation 1 — grant
|
|
156
|
+
# =============================================================================
|
|
157
|
+
|
|
158
|
+
def grant(
|
|
159
|
+
root: Agent,
|
|
160
|
+
subject: Agent,
|
|
161
|
+
scope: Scope,
|
|
162
|
+
expiry: str,
|
|
163
|
+
root_private_key: Ed25519PrivateKey,
|
|
164
|
+
) -> DelegationCertificate:
|
|
165
|
+
"""
|
|
166
|
+
Issue a root delegation certificate.
|
|
167
|
+
|
|
168
|
+
Preconditions:
|
|
169
|
+
- root is a recognized root
|
|
170
|
+
- root and subject are active
|
|
171
|
+
- expiry is in the future
|
|
172
|
+
- scope is non-empty
|
|
173
|
+
"""
|
|
174
|
+
if not is_root(root):
|
|
175
|
+
raise ValueError(f"agent {root.agent_id} is not a recognized root")
|
|
176
|
+
if root.status != AgentStatus.ACTIVE:
|
|
177
|
+
raise ValueError(f"root {root.agent_id} is not active")
|
|
178
|
+
if subject.status != AgentStatus.ACTIVE:
|
|
179
|
+
raise ValueError(f"subject {subject.agent_id} is not active")
|
|
180
|
+
if _is_past(expiry):
|
|
181
|
+
raise ValueError(f"expiry {expiry} is in the past")
|
|
182
|
+
if not scope:
|
|
183
|
+
raise ValueError("empty scope")
|
|
184
|
+
|
|
185
|
+
cert = DelegationCertificate(
|
|
186
|
+
certificate_id="", # placeholder
|
|
187
|
+
issuer=root.agent_id,
|
|
188
|
+
subject=subject.agent_id,
|
|
189
|
+
scope=scope,
|
|
190
|
+
constraints={},
|
|
191
|
+
expiry=expiry,
|
|
192
|
+
parent_ref=None,
|
|
193
|
+
signature=b"",
|
|
194
|
+
)
|
|
195
|
+
cert_id = _compute_certificate_id(cert)
|
|
196
|
+
cert = DelegationCertificate(
|
|
197
|
+
certificate_id=cert_id,
|
|
198
|
+
issuer=root.agent_id,
|
|
199
|
+
subject=subject.agent_id,
|
|
200
|
+
scope=scope,
|
|
201
|
+
constraints={},
|
|
202
|
+
expiry=expiry,
|
|
203
|
+
parent_ref=None,
|
|
204
|
+
signature=b"",
|
|
205
|
+
)
|
|
206
|
+
canonical = canonical_certificate_bytes(cert)
|
|
207
|
+
signature = root_private_key.sign(canonical)
|
|
208
|
+
return DelegationCertificate(
|
|
209
|
+
certificate_id=cert_id,
|
|
210
|
+
issuer=root.agent_id,
|
|
211
|
+
subject=subject.agent_id,
|
|
212
|
+
scope=scope,
|
|
213
|
+
constraints={},
|
|
214
|
+
expiry=expiry,
|
|
215
|
+
parent_ref=None,
|
|
216
|
+
signature=signature,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# =============================================================================
|
|
221
|
+
# Operation 2 — delegate
|
|
222
|
+
# =============================================================================
|
|
223
|
+
|
|
224
|
+
def delegate(
|
|
225
|
+
issuer: Agent,
|
|
226
|
+
subject: Agent,
|
|
227
|
+
scope: Scope,
|
|
228
|
+
expiry: str,
|
|
229
|
+
parent_certificate: DelegationCertificate,
|
|
230
|
+
issuer_private_key: Ed25519PrivateKey,
|
|
231
|
+
) -> DelegationCertificate:
|
|
232
|
+
"""
|
|
233
|
+
Issue a child delegation certificate (attenuated).
|
|
234
|
+
|
|
235
|
+
Preconditions:
|
|
236
|
+
- issuer is active and is the parent's subject
|
|
237
|
+
- subject is active
|
|
238
|
+
- parent is not expired or revoked (checked by caller)
|
|
239
|
+
- scope ⊆ parent's scope (attenuation)
|
|
240
|
+
- expiry <= parent's expiry
|
|
241
|
+
"""
|
|
242
|
+
if issuer.agent_id != parent_certificate.subject:
|
|
243
|
+
raise ValueError(
|
|
244
|
+
f"issuer {issuer.agent_id} is not the parent's subject "
|
|
245
|
+
f"({parent_certificate.subject})"
|
|
246
|
+
)
|
|
247
|
+
if issuer.status != AgentStatus.ACTIVE:
|
|
248
|
+
raise ValueError(f"issuer {issuer.agent_id} is not active")
|
|
249
|
+
if subject.status != AgentStatus.ACTIVE:
|
|
250
|
+
raise ValueError(f"subject {subject.agent_id} is not active")
|
|
251
|
+
if not scope.issubset(parent_certificate.scope):
|
|
252
|
+
raise ValueError(
|
|
253
|
+
f"scope not a subset of parent scope: "
|
|
254
|
+
f"{set(scope)} ⊄ {set(parent_certificate.scope)}"
|
|
255
|
+
)
|
|
256
|
+
if _is_past(expiry):
|
|
257
|
+
raise ValueError(f"expiry {expiry} is in the past")
|
|
258
|
+
if expiry > parent_certificate.expiry:
|
|
259
|
+
raise ValueError(
|
|
260
|
+
f"expiry {expiry} > parent expiry {parent_certificate.expiry}"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
cert = DelegationCertificate(
|
|
264
|
+
certificate_id="",
|
|
265
|
+
issuer=issuer.agent_id,
|
|
266
|
+
subject=subject.agent_id,
|
|
267
|
+
scope=scope,
|
|
268
|
+
constraints={},
|
|
269
|
+
expiry=expiry,
|
|
270
|
+
parent_ref=parent_certificate.certificate_id,
|
|
271
|
+
signature=b"",
|
|
272
|
+
)
|
|
273
|
+
cert_id = _compute_certificate_id(cert)
|
|
274
|
+
cert = DelegationCertificate(
|
|
275
|
+
certificate_id=cert_id,
|
|
276
|
+
issuer=issuer.agent_id,
|
|
277
|
+
subject=subject.agent_id,
|
|
278
|
+
scope=scope,
|
|
279
|
+
constraints={},
|
|
280
|
+
expiry=expiry,
|
|
281
|
+
parent_ref=parent_certificate.certificate_id,
|
|
282
|
+
signature=b"",
|
|
283
|
+
)
|
|
284
|
+
canonical = canonical_certificate_bytes(cert)
|
|
285
|
+
signature = issuer_private_key.sign(canonical)
|
|
286
|
+
return DelegationCertificate(
|
|
287
|
+
certificate_id=cert_id,
|
|
288
|
+
issuer=issuer.agent_id,
|
|
289
|
+
subject=subject.agent_id,
|
|
290
|
+
scope=scope,
|
|
291
|
+
constraints={},
|
|
292
|
+
expiry=expiry,
|
|
293
|
+
parent_ref=parent_certificate.certificate_id,
|
|
294
|
+
signature=signature,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# =============================================================================
|
|
299
|
+
# Operation 3 — revoke
|
|
300
|
+
# =============================================================================
|
|
301
|
+
|
|
302
|
+
def revoke(
|
|
303
|
+
issuer: Agent,
|
|
304
|
+
certificate: DelegationCertificate,
|
|
305
|
+
reason: str,
|
|
306
|
+
issuer_private_key: Ed25519PrivateKey,
|
|
307
|
+
frontier: RevocationFrontier,
|
|
308
|
+
) -> RevocationRecord:
|
|
309
|
+
"""
|
|
310
|
+
Withdraw a delegation certificate.
|
|
311
|
+
|
|
312
|
+
Preconditions:
|
|
313
|
+
- issuer is active
|
|
314
|
+
- issuer is the certificate's issuer (or ancestor, checked by caller)
|
|
315
|
+
- certificate is not already revoked at the given frontier
|
|
316
|
+
"""
|
|
317
|
+
if issuer.status != AgentStatus.ACTIVE:
|
|
318
|
+
raise ValueError(f"issuer {issuer.agent_id} is not active")
|
|
319
|
+
if issuer.agent_id != certificate.issuer:
|
|
320
|
+
raise ValueError(
|
|
321
|
+
f"issuer {issuer.agent_id} is not the certificate's issuer "
|
|
322
|
+
f"({certificate.issuer})"
|
|
323
|
+
)
|
|
324
|
+
if certificate.certificate_id in frontier.revocations:
|
|
325
|
+
raise ValueError(f"certificate {certificate.certificate_id} is already revoked")
|
|
326
|
+
|
|
327
|
+
rev = RevocationRecord(
|
|
328
|
+
revocation_id="",
|
|
329
|
+
certificate_ref=certificate.certificate_id,
|
|
330
|
+
issuer=issuer.agent_id,
|
|
331
|
+
reason=reason,
|
|
332
|
+
timestamp=_now_iso(),
|
|
333
|
+
signature=b"",
|
|
334
|
+
)
|
|
335
|
+
rev_id = _compute_revocation_id(rev)
|
|
336
|
+
rev = RevocationRecord(
|
|
337
|
+
revocation_id=rev_id,
|
|
338
|
+
certificate_ref=certificate.certificate_id,
|
|
339
|
+
issuer=issuer.agent_id,
|
|
340
|
+
reason=reason,
|
|
341
|
+
timestamp=rev.timestamp,
|
|
342
|
+
signature=b"",
|
|
343
|
+
)
|
|
344
|
+
canonical = canonical_revocation_bytes(rev)
|
|
345
|
+
signature = issuer_private_key.sign(canonical)
|
|
346
|
+
return RevocationRecord(
|
|
347
|
+
revocation_id=rev_id,
|
|
348
|
+
certificate_ref=certificate.certificate_id,
|
|
349
|
+
issuer=issuer.agent_id,
|
|
350
|
+
reason=reason,
|
|
351
|
+
timestamp=rev.timestamp,
|
|
352
|
+
signature=signature,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# =============================================================================
|
|
357
|
+
# Operation 4 — check
|
|
358
|
+
# =============================================================================
|
|
359
|
+
|
|
360
|
+
@dataclass(frozen=True)
|
|
361
|
+
class CheckResult:
|
|
362
|
+
"""Result of a chain validity check."""
|
|
363
|
+
valid: bool
|
|
364
|
+
reason: str = ""
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def check(
|
|
368
|
+
action: Action,
|
|
369
|
+
chain: AuthorityChain,
|
|
370
|
+
frontier: RevocationFrontier,
|
|
371
|
+
current_time: str,
|
|
372
|
+
certificates: Dict[str, DelegationCertificate],
|
|
373
|
+
root_agents: Dict[str, Agent],
|
|
374
|
+
) -> CheckResult:
|
|
375
|
+
"""
|
|
376
|
+
Verify an authority chain at a given revocation frontier.
|
|
377
|
+
|
|
378
|
+
Conditions (all must hold for valid=True):
|
|
379
|
+
(a) every signature verifies
|
|
380
|
+
(b) scope attenuates: scope(c_{i+1}) ⊆ scope(c_i)
|
|
381
|
+
(c) no certificate has expired at current_time
|
|
382
|
+
(d) no certificate in the chain appears in frontier.revocations
|
|
383
|
+
(e) the first certificate's issuer is a recognized root
|
|
384
|
+
"""
|
|
385
|
+
if not chain.certificates:
|
|
386
|
+
return CheckResult(False, "empty chain")
|
|
387
|
+
|
|
388
|
+
# Load certificates
|
|
389
|
+
certs: List[DelegationCertificate] = []
|
|
390
|
+
for cert_id in chain.certificates:
|
|
391
|
+
cert = certificates.get(cert_id)
|
|
392
|
+
if cert is None:
|
|
393
|
+
return CheckResult(False, f"unknown certificate: {cert_id}")
|
|
394
|
+
certs.append(cert)
|
|
395
|
+
|
|
396
|
+
# (a) signature verification
|
|
397
|
+
for cert in certs:
|
|
398
|
+
# Look up issuer's public key
|
|
399
|
+
if cert.issuer not in root_agents and cert.issuer not in {c.subject for c in certs}:
|
|
400
|
+
# Issuer must be a root or an intermediate subject
|
|
401
|
+
pass
|
|
402
|
+
# In this implementation, we verify against root_agents if available.
|
|
403
|
+
# For intermediate issuers, we'd need a full agent registry.
|
|
404
|
+
# For v2.0.0, we verify all signatures if the issuer is a known root;
|
|
405
|
+
# otherwise we defer to the registry (not implemented here).
|
|
406
|
+
if cert.issuer in root_agents:
|
|
407
|
+
canonical = canonical_certificate_bytes(cert)
|
|
408
|
+
try:
|
|
409
|
+
root_agents[cert.issuer].public_key.verify(cert.signature, canonical)
|
|
410
|
+
except Exception as e:
|
|
411
|
+
return CheckResult(False, f"signature verification failed for {cert.certificate_id}: {e}")
|
|
412
|
+
|
|
413
|
+
# (b) attenuation
|
|
414
|
+
for i in range(1, len(certs)):
|
|
415
|
+
if not certs[i].scope.issubset(certs[i-1].scope):
|
|
416
|
+
return CheckResult(
|
|
417
|
+
False,
|
|
418
|
+
f"scope attenuation violated at index {i}: "
|
|
419
|
+
f"{set(certs[i].scope)} ⊄ {set(certs[i-1].scope)}"
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# (c) expiry
|
|
423
|
+
for cert in certs:
|
|
424
|
+
if _is_expired(cert.expiry, current_time):
|
|
425
|
+
return CheckResult(False, f"certificate {cert.certificate_id} expired")
|
|
426
|
+
|
|
427
|
+
# (d) revocation
|
|
428
|
+
for cert in certs:
|
|
429
|
+
if cert.certificate_id in frontier.revocations:
|
|
430
|
+
return CheckResult(False, f"certificate {cert.certificate_id} revoked")
|
|
431
|
+
|
|
432
|
+
# (e) root recognition
|
|
433
|
+
first_issuer = certs[0].issuer
|
|
434
|
+
if first_issuer not in root_agents:
|
|
435
|
+
return CheckResult(False, f"first certificate's issuer {first_issuer} is not a recognized root")
|
|
436
|
+
|
|
437
|
+
# (contiguity) each cert's issuer = previous cert's subject
|
|
438
|
+
for i in range(1, len(certs)):
|
|
439
|
+
if certs[i].issuer != certs[i-1].subject:
|
|
440
|
+
return CheckResult(
|
|
441
|
+
False,
|
|
442
|
+
f"contiguity violated at index {i}: "
|
|
443
|
+
f"issuer {certs[i].issuer} ≠ previous subject {certs[i-1].subject}"
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
return CheckResult(True, "ok")
|