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 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
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the distribution and wire user-agent version."""
2
+
3
+ __version__ = "0.5.2"
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)