ithuriel 0.5.2__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.
- ithuriel/__init__.py +93 -0
- ithuriel/_version.py +3 -0
- ithuriel/acceptance.py +436 -0
- ithuriel/api.py +76 -0
- ithuriel/cli.py +690 -0
- ithuriel/conformance/__init__.py +10 -0
- ithuriel/conformance/ithuriel1-ed25519-vector.json +45 -0
- ithuriel/crypto.py +87 -0
- ithuriel/enrollment.py +429 -0
- ithuriel/envelope.py +230 -0
- ithuriel/errors.py +32 -0
- ithuriel/federation.py +119 -0
- ithuriel/http.py +196 -0
- ithuriel/jsoncanon.py +90 -0
- ithuriel/limits.py +44 -0
- ithuriel/py.typed +0 -0
- ithuriel/relay.py +623 -0
- ithuriel/replay.py +82 -0
- ithuriel/resolver.py +307 -0
- ithuriel/schema.py +13 -0
- ithuriel/schemas/__init__.py +1 -0
- ithuriel/schemas/ithuriel-envelope-v1.schema.json +30 -0
- ithuriel/server.py +267 -0
- ithuriel/snapshot.py +38 -0
- ithuriel/transport.py +92 -0
- ithuriel/trust.py +250 -0
- ithuriel/util.py +82 -0
- ithuriel/verify.py +247 -0
- ithuriel-0.5.2.dist-info/METADATA +344 -0
- ithuriel-0.5.2.dist-info/RECORD +34 -0
- ithuriel-0.5.2.dist-info/WHEEL +5 -0
- ithuriel-0.5.2.dist-info/entry_points.txt +2 -0
- ithuriel-0.5.2.dist-info/licenses/LICENSE +21 -0
- ithuriel-0.5.2.dist-info/top_level.txt +1 -0
ithuriel/__init__.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Vendor-neutral reference implementation of ITHURIEL/1."""
|
|
2
|
+
|
|
3
|
+
from .acceptance import AcceptanceStore, AcceptedRecord, AdmissionReservation
|
|
4
|
+
from .api import create_signal, send_signal, verify_signal
|
|
5
|
+
from .crypto import generate_keypair, key_id, load_private, public_record
|
|
6
|
+
from .enrollment import EnrollmentInvite, EnrollmentRequest, EnrollmentStore, create_enrollment_request, create_enrollment_server
|
|
7
|
+
from .federation import RelayDiscovery, discover_relay, discovery_url, recipient_domain
|
|
8
|
+
from .envelope import CanonicalRef, IthurielEnvelope, digest_file
|
|
9
|
+
from .errors import ResolveError, TransportError, ValidationError, VerificationError, IthurielError
|
|
10
|
+
from .replay import ReplayGuard
|
|
11
|
+
from .relay import RelayConfig, RelayStore, create_relay_server, relay_send, token_digest
|
|
12
|
+
from .resolver import (
|
|
13
|
+
DEFAULT_MAX_CANONICAL_BYTES,
|
|
14
|
+
ResolverRegistry,
|
|
15
|
+
command_resolver,
|
|
16
|
+
default_resolvers,
|
|
17
|
+
http_resolver,
|
|
18
|
+
local_resolvers,
|
|
19
|
+
restricted_file_resolver,
|
|
20
|
+
restricted_http_resolver,
|
|
21
|
+
unsafe_file_resolver,
|
|
22
|
+
)
|
|
23
|
+
from .snapshot import VerifiedSnapshot
|
|
24
|
+
from .transport import TransportRegistry, default_transports
|
|
25
|
+
from .trust import JsonTrustStore, MappingTrustStore, SqliteTrustStore, TrustStore, TrustedKey, load_trust
|
|
26
|
+
from .verify import (
|
|
27
|
+
SignatureInspectionResult,
|
|
28
|
+
VerificationPolicy,
|
|
29
|
+
VerificationResult,
|
|
30
|
+
inspect_envelope_signature,
|
|
31
|
+
verify_envelope,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
from ._version import __version__
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"AcceptanceStore",
|
|
38
|
+
"AcceptedRecord",
|
|
39
|
+
"AdmissionReservation",
|
|
40
|
+
"CanonicalRef",
|
|
41
|
+
"DEFAULT_MAX_CANONICAL_BYTES",
|
|
42
|
+
"EnrollmentInvite",
|
|
43
|
+
"EnrollmentRequest",
|
|
44
|
+
"EnrollmentStore",
|
|
45
|
+
"JsonTrustStore",
|
|
46
|
+
"MappingTrustStore",
|
|
47
|
+
"ReplayGuard",
|
|
48
|
+
"RelayConfig",
|
|
49
|
+
"RelayDiscovery",
|
|
50
|
+
"RelayStore",
|
|
51
|
+
"ResolveError",
|
|
52
|
+
"ResolverRegistry",
|
|
53
|
+
"SignatureInspectionResult",
|
|
54
|
+
"SqliteTrustStore",
|
|
55
|
+
"TransportError",
|
|
56
|
+
"TransportRegistry",
|
|
57
|
+
"TrustStore",
|
|
58
|
+
"TrustedKey",
|
|
59
|
+
"ValidationError",
|
|
60
|
+
"VerificationError",
|
|
61
|
+
"VerificationPolicy",
|
|
62
|
+
"VerificationResult",
|
|
63
|
+
"VerifiedSnapshot",
|
|
64
|
+
"IthurielEnvelope",
|
|
65
|
+
"IthurielError",
|
|
66
|
+
"command_resolver",
|
|
67
|
+
"create_enrollment_request",
|
|
68
|
+
"create_enrollment_server",
|
|
69
|
+
"create_relay_server",
|
|
70
|
+
"create_signal",
|
|
71
|
+
"default_resolvers",
|
|
72
|
+
"default_transports",
|
|
73
|
+
"digest_file",
|
|
74
|
+
"discover_relay",
|
|
75
|
+
"discovery_url",
|
|
76
|
+
"generate_keypair",
|
|
77
|
+
"http_resolver",
|
|
78
|
+
"inspect_envelope_signature",
|
|
79
|
+
"key_id",
|
|
80
|
+
"load_private",
|
|
81
|
+
"load_trust",
|
|
82
|
+
"local_resolvers",
|
|
83
|
+
"public_record",
|
|
84
|
+
"recipient_domain",
|
|
85
|
+
"relay_send",
|
|
86
|
+
"send_signal",
|
|
87
|
+
"token_digest",
|
|
88
|
+
"verify_envelope",
|
|
89
|
+
"verify_signal",
|
|
90
|
+
"restricted_file_resolver",
|
|
91
|
+
"restricted_http_resolver",
|
|
92
|
+
"unsafe_file_resolver",
|
|
93
|
+
]
|
ithuriel/_version.py
ADDED
ithuriel/acceptance.py
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
import threading
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .envelope import IthurielEnvelope
|
|
11
|
+
from .errors import VerificationError
|
|
12
|
+
from .snapshot import VerifiedSnapshot
|
|
13
|
+
from .util import epoch_microseconds, rfc3339, sha256_digest, utc_now
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class AcceptedRecord:
|
|
18
|
+
status: str
|
|
19
|
+
envelope_id: str
|
|
20
|
+
nonce: str
|
|
21
|
+
packet_fingerprint: str
|
|
22
|
+
canonical_digest: str
|
|
23
|
+
canonical_uri: str
|
|
24
|
+
accepted_at: str
|
|
25
|
+
envelope_json: bytes
|
|
26
|
+
canonical_bytes: bytes
|
|
27
|
+
expires_at: str | None = None
|
|
28
|
+
consumed_at: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AdmissionReservation:
|
|
32
|
+
"""In-memory logical-payload and record reservation held during resolution."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, store: "AcceptanceStore", bytes_reserved: int, records_reserved: int = 1):
|
|
35
|
+
self._store = store
|
|
36
|
+
self.bytes_reserved = bytes_reserved
|
|
37
|
+
self.records_reserved = records_reserved
|
|
38
|
+
self._active = True
|
|
39
|
+
|
|
40
|
+
def release(self) -> None:
|
|
41
|
+
self._store._release_reservation(self)
|
|
42
|
+
|
|
43
|
+
def __enter__(self) -> "AdmissionReservation":
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
47
|
+
self.release()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AcceptanceStore:
|
|
51
|
+
"""Durable receipt identity plus separately purged payload bytes.
|
|
52
|
+
|
|
53
|
+
``max_total_bytes`` is logical payload accounting for retained envelope and
|
|
54
|
+
canonical bytes. It is not a physical disk quota: SQLite WAL files, indexes,
|
|
55
|
+
logs, temporary files, and SQLite maintenance require separate deployment
|
|
56
|
+
limits.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
SCHEMA_VERSION = 2
|
|
60
|
+
|
|
61
|
+
def __init__(self, path: Path, *, max_records: int = 10_000, max_tombstones: int = 100_000):
|
|
62
|
+
if max_records <= 0 or max_tombstones <= 0:
|
|
63
|
+
raise ValueError("record and tombstone limits must be positive")
|
|
64
|
+
self.path = path
|
|
65
|
+
self.max_records = max_records
|
|
66
|
+
self.max_tombstones = max_tombstones
|
|
67
|
+
self.lock = threading.RLock()
|
|
68
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
self.conn = sqlite3.connect(path, check_same_thread=False, isolation_level=None)
|
|
70
|
+
self.conn.execute("PRAGMA journal_mode=WAL")
|
|
71
|
+
self.conn.execute("PRAGMA synchronous=FULL")
|
|
72
|
+
self._reserved_bytes = 0
|
|
73
|
+
self._reserved_records = 0
|
|
74
|
+
self._migrate_or_initialize()
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _table_exists(conn: sqlite3.Connection, name: str) -> bool:
|
|
78
|
+
return conn.execute(
|
|
79
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", (name,)
|
|
80
|
+
).fetchone() is not None
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def _create_schema_locked(cls, conn: sqlite3.Connection) -> None:
|
|
84
|
+
conn.execute(
|
|
85
|
+
"CREATE TABLE receipt_identity ("
|
|
86
|
+
"nonce TEXT PRIMARY KEY, envelope_id TEXT UNIQUE NOT NULL, packet_fingerprint TEXT NOT NULL, "
|
|
87
|
+
"expires_at TEXT NOT NULL, expires_at_us INTEGER NOT NULL, "
|
|
88
|
+
"accepted_at TEXT NOT NULL, accepted_at_us INTEGER NOT NULL, "
|
|
89
|
+
"status TEXT NOT NULL CHECK(status IN ('pending','consumed','purged')), "
|
|
90
|
+
"consumed_at TEXT, consumed_at_us INTEGER, canonical_digest TEXT NOT NULL, canonical_uri TEXT NOT NULL)"
|
|
91
|
+
)
|
|
92
|
+
conn.execute(
|
|
93
|
+
"CREATE TABLE receipt_payload ("
|
|
94
|
+
"nonce TEXT PRIMARY KEY, envelope_json BLOB NOT NULL, canonical_bytes BLOB NOT NULL, "
|
|
95
|
+
"FOREIGN KEY(nonce) REFERENCES receipt_identity(nonce) ON DELETE CASCADE)"
|
|
96
|
+
)
|
|
97
|
+
conn.execute("CREATE INDEX receipt_identity_expires_idx ON receipt_identity(expires_at_us)")
|
|
98
|
+
conn.execute("CREATE INDEX receipt_identity_consumed_idx ON receipt_identity(consumed_at_us)")
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def _timestamp(value: str | None, *, field: str) -> tuple[str | None, int | None]:
|
|
102
|
+
if value is None:
|
|
103
|
+
return None, None
|
|
104
|
+
try:
|
|
105
|
+
return value, epoch_microseconds(value)
|
|
106
|
+
except (TypeError, ValueError) as exc:
|
|
107
|
+
raise ValueError(f"invalid {field} timestamp during acceptance migration") from exc
|
|
108
|
+
|
|
109
|
+
def _migrate_or_initialize(self) -> None:
|
|
110
|
+
with self.lock:
|
|
111
|
+
self.conn.execute("BEGIN IMMEDIATE")
|
|
112
|
+
try:
|
|
113
|
+
has_new = self._table_exists(self.conn, "receipt_identity")
|
|
114
|
+
has_payload = self._table_exists(self.conn, "receipt_payload")
|
|
115
|
+
if has_new != has_payload:
|
|
116
|
+
raise ValueError("incomplete acceptance schema")
|
|
117
|
+
if has_new:
|
|
118
|
+
required = {
|
|
119
|
+
"nonce", "envelope_id", "packet_fingerprint", "expires_at", "expires_at_us",
|
|
120
|
+
"accepted_at", "accepted_at_us", "status", "consumed_at", "consumed_at_us",
|
|
121
|
+
"canonical_digest", "canonical_uri",
|
|
122
|
+
}
|
|
123
|
+
actual = {row[1] for row in self.conn.execute("PRAGMA table_info(receipt_identity)")}
|
|
124
|
+
payload_actual = {row[1] for row in self.conn.execute("PRAGMA table_info(receipt_payload)")}
|
|
125
|
+
if required - actual or {"nonce", "envelope_json", "canonical_bytes"} - payload_actual:
|
|
126
|
+
raise ValueError("unsupported acceptance schema")
|
|
127
|
+
else:
|
|
128
|
+
self._migrate_legacy_locked()
|
|
129
|
+
self.conn.execute(f"PRAGMA user_version = {self.SCHEMA_VERSION}")
|
|
130
|
+
self.conn.execute("COMMIT")
|
|
131
|
+
except Exception:
|
|
132
|
+
if self.conn.in_transaction:
|
|
133
|
+
self.conn.execute("ROLLBACK")
|
|
134
|
+
raise
|
|
135
|
+
|
|
136
|
+
def _migrate_legacy_locked(self) -> None:
|
|
137
|
+
legacy_rows: list[tuple[str, tuple[Any, ...], bool]] = []
|
|
138
|
+
if self._table_exists(self.conn, "accepted"):
|
|
139
|
+
columns = {row[1] for row in self.conn.execute("PRAGMA table_info(accepted)")}
|
|
140
|
+
required = {
|
|
141
|
+
"nonce", "envelope_id", "packet_fingerprint", "expires_at", "accepted_at",
|
|
142
|
+
"canonical_digest", "canonical_uri", "envelope_json", "canonical_bytes",
|
|
143
|
+
}
|
|
144
|
+
if required - columns:
|
|
145
|
+
raise ValueError("accepted table is missing migration columns")
|
|
146
|
+
query = (
|
|
147
|
+
"SELECT nonce, envelope_id, packet_fingerprint, expires_at, accepted_at, "
|
|
148
|
+
+ ("status, " if "status" in columns else "'pending', ")
|
|
149
|
+
+ ("consumed_at, " if "consumed_at" in columns else "NULL, ")
|
|
150
|
+
+ "canonical_digest, canonical_uri, envelope_json, canonical_bytes FROM accepted"
|
|
151
|
+
)
|
|
152
|
+
for row in self.conn.execute(query):
|
|
153
|
+
legacy_rows.append(("accepted", row, True))
|
|
154
|
+
if self._table_exists(self.conn, "accepted_tombstones"):
|
|
155
|
+
columns = {row[1] for row in self.conn.execute("PRAGMA table_info(accepted_tombstones)")}
|
|
156
|
+
required = {
|
|
157
|
+
"nonce", "envelope_id", "packet_fingerprint", "expires_at", "accepted_at",
|
|
158
|
+
"canonical_digest", "canonical_uri",
|
|
159
|
+
}
|
|
160
|
+
if required - columns:
|
|
161
|
+
raise ValueError("accepted_tombstones table is missing migration columns")
|
|
162
|
+
query = (
|
|
163
|
+
"SELECT nonce, envelope_id, packet_fingerprint, expires_at, accepted_at, "
|
|
164
|
+
+ ("consumed_at, " if "consumed_at" in columns else "NULL, ")
|
|
165
|
+
+ "canonical_digest, canonical_uri FROM accepted_tombstones"
|
|
166
|
+
)
|
|
167
|
+
for row in self.conn.execute(query):
|
|
168
|
+
legacy_rows.append(("tombstone", row, False))
|
|
169
|
+
|
|
170
|
+
self._create_schema_locked(self.conn)
|
|
171
|
+
seen_nonces: set[str] = set()
|
|
172
|
+
seen_ids: set[str] = set()
|
|
173
|
+
for _source, row, has_payload in legacy_rows:
|
|
174
|
+
if has_payload:
|
|
175
|
+
(
|
|
176
|
+
nonce, envelope_id, fingerprint, expires_at, accepted_at, status,
|
|
177
|
+
consumed_at, digest, uri, envelope_json, canonical_bytes,
|
|
178
|
+
) = row
|
|
179
|
+
else:
|
|
180
|
+
nonce, envelope_id, fingerprint, expires_at, accepted_at, consumed_at, digest, uri = row
|
|
181
|
+
status = "purged"
|
|
182
|
+
envelope_json = None
|
|
183
|
+
canonical_bytes = None
|
|
184
|
+
if nonce in seen_nonces or envelope_id in seen_ids:
|
|
185
|
+
raise ValueError("conflicting duplicate acceptance identity during migration")
|
|
186
|
+
if not all(isinstance(value, str) and value for value in (nonce, envelope_id, fingerprint, expires_at, accepted_at, digest, uri)):
|
|
187
|
+
raise ValueError("invalid acceptance identity during migration")
|
|
188
|
+
if status not in {"pending", "consumed", "purged"}:
|
|
189
|
+
raise ValueError("invalid acceptance lifecycle state during migration")
|
|
190
|
+
expires_text, expires_us = self._timestamp(expires_at, field="expires_at")
|
|
191
|
+
accepted_text, accepted_us = self._timestamp(accepted_at, field="accepted_at")
|
|
192
|
+
consumed_text, consumed_us = self._timestamp(consumed_at, field="consumed_at")
|
|
193
|
+
self.conn.execute(
|
|
194
|
+
"INSERT INTO receipt_identity(nonce, envelope_id, packet_fingerprint, expires_at, expires_at_us, "
|
|
195
|
+
"accepted_at, accepted_at_us, status, consumed_at, consumed_at_us, canonical_digest, canonical_uri) "
|
|
196
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
197
|
+
(
|
|
198
|
+
nonce, envelope_id, fingerprint, expires_text, expires_us,
|
|
199
|
+
accepted_text, accepted_us, status, consumed_text, consumed_us, digest, uri,
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
if has_payload:
|
|
203
|
+
if envelope_json is None or canonical_bytes is None:
|
|
204
|
+
raise ValueError("accepted payload is null during migration")
|
|
205
|
+
self.conn.execute(
|
|
206
|
+
"INSERT INTO receipt_payload(nonce, envelope_json, canonical_bytes) VALUES (?, ?, ?)",
|
|
207
|
+
(nonce, bytes(envelope_json), bytes(canonical_bytes)),
|
|
208
|
+
)
|
|
209
|
+
seen_nonces.add(nonce)
|
|
210
|
+
seen_ids.add(envelope_id)
|
|
211
|
+
if self._table_exists(self.conn, "accepted"):
|
|
212
|
+
self.conn.execute("DROP TABLE accepted")
|
|
213
|
+
if self._table_exists(self.conn, "accepted_tombstones"):
|
|
214
|
+
self.conn.execute("DROP TABLE accepted_tombstones")
|
|
215
|
+
|
|
216
|
+
@staticmethod
|
|
217
|
+
def _record(row: tuple[Any, ...]) -> AcceptedRecord:
|
|
218
|
+
return AcceptedRecord(
|
|
219
|
+
status=row[0], envelope_id=row[1], nonce=row[2], packet_fingerprint=row[3],
|
|
220
|
+
canonical_digest=row[4], canonical_uri=row[5], accepted_at=row[6],
|
|
221
|
+
envelope_json=b"" if row[7] is None else bytes(row[7]),
|
|
222
|
+
canonical_bytes=b"" if row[8] is None else bytes(row[8]),
|
|
223
|
+
expires_at=row[9], consumed_at=row[10],
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def _select_base(self, suffix: str, value: str) -> tuple[Any, ...] | None:
|
|
227
|
+
if suffix not in {"nonce", "envelope_id"}:
|
|
228
|
+
raise ValueError("invalid acceptance lookup column")
|
|
229
|
+
return self.conn.execute(
|
|
230
|
+
"SELECT i.status, i.envelope_id, i.nonce, i.packet_fingerprint, i.canonical_digest, i.canonical_uri, "
|
|
231
|
+
"i.accepted_at, p.envelope_json, p.canonical_bytes, i.expires_at, i.consumed_at "
|
|
232
|
+
"FROM receipt_identity i LEFT JOIN receipt_payload p ON p.nonce = i.nonce "
|
|
233
|
+
f"WHERE i.{suffix} = ?",
|
|
234
|
+
(value,),
|
|
235
|
+
).fetchone()
|
|
236
|
+
|
|
237
|
+
def _lookup_locked(self, nonce: str) -> AcceptedRecord | None:
|
|
238
|
+
row = self._select_base("nonce", nonce)
|
|
239
|
+
return None if row is None else self._record(row)
|
|
240
|
+
|
|
241
|
+
def lookup(self, nonce: str) -> AcceptedRecord | None:
|
|
242
|
+
with self.lock:
|
|
243
|
+
return self._lookup_locked(nonce)
|
|
244
|
+
|
|
245
|
+
def reserve(self, *, max_total_bytes: int, bytes_reserved: int) -> AdmissionReservation:
|
|
246
|
+
if max_total_bytes <= 0 or bytes_reserved <= 0:
|
|
247
|
+
raise ValueError("capacity and reservation must be positive")
|
|
248
|
+
with self.lock:
|
|
249
|
+
current = self.conn.execute(
|
|
250
|
+
"SELECT coalesce(sum(length(p.envelope_json) + length(p.canonical_bytes)), 0) "
|
|
251
|
+
"FROM receipt_payload p JOIN receipt_identity i ON i.nonce = p.nonce WHERE i.status != 'purged'"
|
|
252
|
+
).fetchone()[0]
|
|
253
|
+
retained = self.conn.execute(
|
|
254
|
+
"SELECT count(*) FROM receipt_identity WHERE status != 'purged'"
|
|
255
|
+
).fetchone()[0]
|
|
256
|
+
if current + self._reserved_bytes + bytes_reserved > max_total_bytes:
|
|
257
|
+
raise VerificationError("accepted inbox capacity exceeded", code="inbox-capacity-exceeded")
|
|
258
|
+
if retained + self._reserved_records + 1 > self.max_records:
|
|
259
|
+
raise VerificationError("accepted inbox record limit exceeded", code="inbox-record-limit-exceeded")
|
|
260
|
+
self._reserved_bytes += bytes_reserved
|
|
261
|
+
self._reserved_records += 1
|
|
262
|
+
return AdmissionReservation(self, bytes_reserved)
|
|
263
|
+
|
|
264
|
+
def _release_reservation(self, reservation: AdmissionReservation) -> None:
|
|
265
|
+
with self.lock:
|
|
266
|
+
if reservation._store is not self:
|
|
267
|
+
raise ValueError("reservation belongs to another acceptance store")
|
|
268
|
+
if not reservation._active:
|
|
269
|
+
return
|
|
270
|
+
if reservation.bytes_reserved > self._reserved_bytes or reservation.records_reserved > self._reserved_records:
|
|
271
|
+
raise RuntimeError("acceptance reservation accounting invariant violated")
|
|
272
|
+
self._reserved_bytes -= reservation.bytes_reserved
|
|
273
|
+
self._reserved_records -= reservation.records_reserved
|
|
274
|
+
reservation._active = False
|
|
275
|
+
|
|
276
|
+
def accept(
|
|
277
|
+
self,
|
|
278
|
+
env: IthurielEnvelope,
|
|
279
|
+
snapshot: VerifiedSnapshot,
|
|
280
|
+
*,
|
|
281
|
+
max_total_bytes: int | None = None,
|
|
282
|
+
reservation: AdmissionReservation | None = None,
|
|
283
|
+
max_records: int | None = None,
|
|
284
|
+
) -> AcceptedRecord:
|
|
285
|
+
env.validate()
|
|
286
|
+
if snapshot.uri != env.canonical.uri or snapshot.digest != env.canonical.digest:
|
|
287
|
+
raise VerificationError("snapshot does not match envelope canonical reference", code="snapshot-envelope-mismatch")
|
|
288
|
+
if sha256_digest(snapshot.content) != env.canonical.digest:
|
|
289
|
+
raise VerificationError("snapshot bytes do not match envelope digest", code="snapshot-envelope-mismatch")
|
|
290
|
+
fingerprint = env.packet_fingerprint()
|
|
291
|
+
envelope_json = env.dumps().encode("utf-8")
|
|
292
|
+
accepted_at = rfc3339(utc_now())
|
|
293
|
+
accepted_at_us = epoch_microseconds(accepted_at)
|
|
294
|
+
record_limit = self.max_records if max_records is None else max_records
|
|
295
|
+
if record_limit <= 0:
|
|
296
|
+
raise ValueError("max_records must be positive")
|
|
297
|
+
with self.lock:
|
|
298
|
+
self.conn.execute("BEGIN IMMEDIATE")
|
|
299
|
+
try:
|
|
300
|
+
prior = self._lookup_locked(env.nonce)
|
|
301
|
+
if prior is None:
|
|
302
|
+
existing_id = self._select_base("envelope_id", env.id)
|
|
303
|
+
if existing_id is not None:
|
|
304
|
+
raise VerificationError("envelope id was previously used by a different packet", code="envelope-id-conflict")
|
|
305
|
+
if prior is not None:
|
|
306
|
+
if prior.envelope_id != env.id or prior.packet_fingerprint != fingerprint:
|
|
307
|
+
raise VerificationError("nonce was previously used by a different packet", code="nonce-conflict")
|
|
308
|
+
self.conn.execute("COMMIT")
|
|
309
|
+
return AcceptedRecord(
|
|
310
|
+
status="purged" if prior.status == "purged" else "already_accepted",
|
|
311
|
+
envelope_id=prior.envelope_id,
|
|
312
|
+
nonce=prior.nonce,
|
|
313
|
+
packet_fingerprint=prior.packet_fingerprint,
|
|
314
|
+
canonical_digest=prior.canonical_digest,
|
|
315
|
+
canonical_uri=prior.canonical_uri,
|
|
316
|
+
accepted_at=prior.accepted_at,
|
|
317
|
+
envelope_json=prior.envelope_json,
|
|
318
|
+
canonical_bytes=prior.canonical_bytes,
|
|
319
|
+
expires_at=prior.expires_at,
|
|
320
|
+
consumed_at=prior.consumed_at,
|
|
321
|
+
)
|
|
322
|
+
if max_total_bytes is not None:
|
|
323
|
+
if max_total_bytes <= 0:
|
|
324
|
+
raise ValueError("max_total_bytes must be positive")
|
|
325
|
+
current = self.conn.execute(
|
|
326
|
+
"SELECT coalesce(sum(length(p.envelope_json) + length(p.canonical_bytes)), 0) "
|
|
327
|
+
"FROM receipt_payload p JOIN receipt_identity i ON i.nonce = p.nonce WHERE i.status != 'purged'"
|
|
328
|
+
).fetchone()[0]
|
|
329
|
+
reserved_other = self._reserved_bytes
|
|
330
|
+
reserved_records_other = self._reserved_records
|
|
331
|
+
if reservation is not None and reservation._store is self and reservation._active:
|
|
332
|
+
reserved_other -= reservation.bytes_reserved
|
|
333
|
+
reserved_records_other -= reservation.records_reserved
|
|
334
|
+
if current + reserved_other + len(envelope_json) + len(snapshot.content) > max_total_bytes:
|
|
335
|
+
raise VerificationError("accepted inbox capacity exceeded", code="inbox-capacity-exceeded")
|
|
336
|
+
retained = self.conn.execute(
|
|
337
|
+
"SELECT count(*) FROM receipt_identity WHERE status != 'purged'"
|
|
338
|
+
).fetchone()[0]
|
|
339
|
+
reserved_records_other = self._reserved_records
|
|
340
|
+
if reservation is not None and reservation._store is self and reservation._active:
|
|
341
|
+
reserved_records_other -= reservation.records_reserved
|
|
342
|
+
if retained + reserved_records_other + 1 > record_limit:
|
|
343
|
+
raise VerificationError("accepted inbox record limit exceeded", code="inbox-record-limit-exceeded")
|
|
344
|
+
|
|
345
|
+
self.conn.execute(
|
|
346
|
+
"INSERT INTO receipt_identity(nonce, envelope_id, packet_fingerprint, expires_at, expires_at_us, "
|
|
347
|
+
"accepted_at, accepted_at_us, status, canonical_digest, canonical_uri) "
|
|
348
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)",
|
|
349
|
+
(
|
|
350
|
+
env.nonce, env.id, fingerprint, env.expires_at, epoch_microseconds(env.expires_at),
|
|
351
|
+
accepted_at, accepted_at_us, snapshot.digest, snapshot.uri,
|
|
352
|
+
),
|
|
353
|
+
)
|
|
354
|
+
self.conn.execute(
|
|
355
|
+
"INSERT INTO receipt_payload(nonce, envelope_json, canonical_bytes) VALUES (?, ?, ?)",
|
|
356
|
+
(env.nonce, envelope_json, snapshot.content),
|
|
357
|
+
)
|
|
358
|
+
self.conn.execute("COMMIT")
|
|
359
|
+
return AcceptedRecord(
|
|
360
|
+
status="accepted", envelope_id=env.id, nonce=env.nonce, packet_fingerprint=fingerprint,
|
|
361
|
+
canonical_digest=snapshot.digest, canonical_uri=snapshot.uri, accepted_at=accepted_at,
|
|
362
|
+
envelope_json=envelope_json, canonical_bytes=snapshot.content, expires_at=env.expires_at,
|
|
363
|
+
)
|
|
364
|
+
except Exception:
|
|
365
|
+
if self.conn.in_transaction:
|
|
366
|
+
self.conn.execute("ROLLBACK")
|
|
367
|
+
raise
|
|
368
|
+
|
|
369
|
+
def get_by_id(self, envelope_id: str) -> AcceptedRecord | None:
|
|
370
|
+
with self.lock:
|
|
371
|
+
row = self._select_base("envelope_id", envelope_id)
|
|
372
|
+
return None if row is None else self._record(row)
|
|
373
|
+
|
|
374
|
+
def list_metadata(self, limit: int = 100) -> list[dict[str, Any]]:
|
|
375
|
+
if limit <= 0:
|
|
376
|
+
raise ValueError("limit must be positive")
|
|
377
|
+
with self.lock:
|
|
378
|
+
rows = self.conn.execute(
|
|
379
|
+
"SELECT i.envelope_id, i.nonce, i.status, i.canonical_digest, i.canonical_uri, i.accepted_at, "
|
|
380
|
+
"i.expires_at, i.consumed_at, length(p.canonical_bytes) "
|
|
381
|
+
"FROM receipt_identity i LEFT JOIN receipt_payload p ON p.nonce = i.nonce "
|
|
382
|
+
"WHERE i.status != 'purged' ORDER BY i.accepted_at_us DESC LIMIT ?", (limit,)
|
|
383
|
+
).fetchall()
|
|
384
|
+
return [
|
|
385
|
+
{
|
|
386
|
+
"envelope_id": row[0], "nonce": row[1], "status": row[2], "canonical_digest": row[3],
|
|
387
|
+
"canonical_uri": row[4], "accepted_at": row[5], "expires_at": row[6],
|
|
388
|
+
"consumed_at": row[7], "canonical_bytes": row[8] or 0,
|
|
389
|
+
}
|
|
390
|
+
for row in rows
|
|
391
|
+
]
|
|
392
|
+
|
|
393
|
+
def mark_consumed(self, envelope_id: str) -> bool:
|
|
394
|
+
consumed_at = rfc3339(utc_now())
|
|
395
|
+
with self.lock:
|
|
396
|
+
cur = self.conn.execute(
|
|
397
|
+
"UPDATE receipt_identity SET status = 'consumed', consumed_at = ?, consumed_at_us = ? "
|
|
398
|
+
"WHERE envelope_id = ? AND status = 'pending'",
|
|
399
|
+
(consumed_at, epoch_microseconds(consumed_at), envelope_id),
|
|
400
|
+
)
|
|
401
|
+
return cur.rowcount > 0
|
|
402
|
+
|
|
403
|
+
def gc(self, *, retain_seconds: int = 86_400, limit: int = 1000) -> int:
|
|
404
|
+
"""Purge payloads while retaining every receipt identity for replay/conflict checks."""
|
|
405
|
+
if retain_seconds < 0 or limit <= 0:
|
|
406
|
+
raise ValueError("retain_seconds must be non-negative and limit must be positive")
|
|
407
|
+
now = utc_now()
|
|
408
|
+
now_us = epoch_microseconds(now)
|
|
409
|
+
cutoff_us = epoch_microseconds(now - timedelta(seconds=retain_seconds))
|
|
410
|
+
with self.lock:
|
|
411
|
+
self.conn.execute("BEGIN IMMEDIATE")
|
|
412
|
+
try:
|
|
413
|
+
tombstones = self.conn.execute(
|
|
414
|
+
"SELECT count(*) FROM receipt_identity WHERE status = 'purged'"
|
|
415
|
+
).fetchone()[0]
|
|
416
|
+
if tombstones >= self.max_tombstones:
|
|
417
|
+
raise VerificationError("acceptance tombstone limit exceeded", code="tombstone-limit-exceeded")
|
|
418
|
+
rows = self.conn.execute(
|
|
419
|
+
"SELECT nonce FROM receipt_identity WHERE status != 'purged' AND "
|
|
420
|
+
"(expires_at_us <= ? OR (status = 'consumed' AND consumed_at_us <= ?)) "
|
|
421
|
+
"ORDER BY accepted_at_us ASC LIMIT ?",
|
|
422
|
+
(now_us, cutoff_us, min(limit, self.max_tombstones - tombstones)),
|
|
423
|
+
).fetchall()
|
|
424
|
+
for (nonce,) in rows:
|
|
425
|
+
self.conn.execute("DELETE FROM receipt_payload WHERE nonce = ?", (nonce,))
|
|
426
|
+
self.conn.execute("UPDATE receipt_identity SET status = 'purged' WHERE nonce = ?", (nonce,))
|
|
427
|
+
self.conn.execute("COMMIT")
|
|
428
|
+
return len(rows)
|
|
429
|
+
except Exception:
|
|
430
|
+
if self.conn.in_transaction:
|
|
431
|
+
self.conn.execute("ROLLBACK")
|
|
432
|
+
raise
|
|
433
|
+
|
|
434
|
+
def close(self) -> None:
|
|
435
|
+
with self.lock:
|
|
436
|
+
self.conn.close()
|
ithuriel/api.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Mapping
|
|
4
|
+
|
|
5
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
6
|
+
|
|
7
|
+
from .envelope import IthurielEnvelope
|
|
8
|
+
from .replay import ReplayGuard
|
|
9
|
+
from .resolver import ResolverRegistry, default_resolvers
|
|
10
|
+
from .transport import TransportRegistry, default_transports
|
|
11
|
+
from .trust import TrustStore
|
|
12
|
+
from .util import sha256_digest
|
|
13
|
+
from .verify import VerificationPolicy, VerificationResult, verify_envelope
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_signal(
|
|
17
|
+
*,
|
|
18
|
+
sender: str,
|
|
19
|
+
recipient: str,
|
|
20
|
+
kind: str,
|
|
21
|
+
object_id: str,
|
|
22
|
+
canonical_uri: str,
|
|
23
|
+
private_key: Ed25519PrivateKey,
|
|
24
|
+
canonical_digest: str | None = None,
|
|
25
|
+
ttl_seconds: int = 600,
|
|
26
|
+
resolvers: ResolverRegistry | None = None,
|
|
27
|
+
max_canonical_bytes: int = 8 * 1024 * 1024,
|
|
28
|
+
) -> IthurielEnvelope:
|
|
29
|
+
"""Create a signed ITHURIEL signal.
|
|
30
|
+
|
|
31
|
+
If canonical_digest is omitted, callers must explicitly supply a resolver
|
|
32
|
+
registry. The core never implicitly grants file/network/plugin access.
|
|
33
|
+
"""
|
|
34
|
+
if canonical_digest is None:
|
|
35
|
+
if resolvers is None:
|
|
36
|
+
raise ValueError("canonical_digest or an explicit resolver registry is required")
|
|
37
|
+
content = resolvers.resolve(canonical_uri, max_bytes=max_canonical_bytes)
|
|
38
|
+
canonical_digest = sha256_digest(content)
|
|
39
|
+
return IthurielEnvelope.create(
|
|
40
|
+
sender=sender,
|
|
41
|
+
recipient=recipient,
|
|
42
|
+
kind=kind,
|
|
43
|
+
object_id=object_id,
|
|
44
|
+
uri=canonical_uri,
|
|
45
|
+
digest=canonical_digest,
|
|
46
|
+
private_key=private_key,
|
|
47
|
+
ttl_seconds=ttl_seconds,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def verify_signal(
|
|
52
|
+
envelope: IthurielEnvelope,
|
|
53
|
+
*,
|
|
54
|
+
trust: TrustStore | Mapping[str, object],
|
|
55
|
+
policy: VerificationPolicy,
|
|
56
|
+
replay_guard: ReplayGuard | None = None,
|
|
57
|
+
resolvers: ResolverRegistry | None = None,
|
|
58
|
+
) -> VerificationResult:
|
|
59
|
+
return verify_envelope(
|
|
60
|
+
envelope,
|
|
61
|
+
trust=trust,
|
|
62
|
+
policy=policy,
|
|
63
|
+
replay_guard=replay_guard,
|
|
64
|
+
resolvers=resolvers,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def send_signal(
|
|
69
|
+
envelope: IthurielEnvelope,
|
|
70
|
+
endpoint: str,
|
|
71
|
+
*,
|
|
72
|
+
transports: TransportRegistry | None = None,
|
|
73
|
+
timeout: float = 15.0,
|
|
74
|
+
) -> bytes:
|
|
75
|
+
payload = envelope.dumps().encode("utf-8")
|
|
76
|
+
return (transports or default_transports()).send(endpoint, payload, timeout)
|