forked from ChelseaKR/habitable
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathverify.py
More file actions
2022 lines (1824 loc) · 83.8 KB
/
Copy pathverify.py
File metadata and controls
2022 lines (1824 loc) · 83.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# SPDX-License-Identifier: AGPL-3.0-or-later OR Apache-2.0
# Copyright 2026 Chelsea Kelly-Reif
"""Independent verification of an evidence packet.
This is the module a skeptic runs. Given only a packet directory (and, optionally,
trusted TSA root certificates), it re-derives every hash, validates each timestamp
token, checks whether its authority chains to a caller-supplied trust root, verifies
the producer's signature over the whole bundle, validates packet-v3 timeline
commitments and links, and walks the chain of custody.
Those are deliberately separate claims. A packet can be structurally intact while
its timestamps are untrusted (or still absent), and neither state is silently
promoted to ``evidence_ready``. In particular, development timestamps are useful
for exercising the proof format but can never make evidence ready for review.
Licensing: this verifier, together with the pure modules it imports
(:mod:`habitable.canonical`, :mod:`habitable.crypto`, :mod:`habitable.evidence`,
:mod:`habitable.timeline`, :mod:`habitable.tsa`), is the "verification subset"
offered under Apache-2.0 as an additional permission (see NOTICE), so a court or
legal-aid group can embed and redistribute verification without the AGPL reaching
their code.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import stat
from collections.abc import Mapping
from contextlib import suppress
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import TYPE_CHECKING, BinaryIO
from .canonical import JSONValue, canonical_json, sha256_bytes
from .crypto import verify as verify_signature
from .errors import VerificationError
from .evidence import CustodyLog
from .timeline import EVENT_TYPES, SOURCES, normalize_occurred_at
from .tsa import TimestampInfo, TimestampToken, verify_archive_chain, verify_token
if TYPE_CHECKING:
from cryptography import x509
__all__ = ["ItemVerdict", "SealVerdict", "VerificationReport", "verify_packet"]
_BUNDLE = "bundle.json"
_SIGNATURE = "bundle.sig.json"
_MEDIA = "media"
_ORIGINALS = "originals"
_HASH_CHUNK = 1024 * 1024
_MAX_BUNDLE_BYTES = 256 * 1024 * 1024
_MAX_SIGNATURE_BYTES = 1024 * 1024
# Keep a hostile packet from turning verification into an unbounded read. This is
# deliberately much larger than the app and relay upload ceilings, so ordinary
# exported photos, recordings, and supported legacy packets retain ample headroom.
_MAX_REFERENCED_FILE_BYTES = 1024 * 1024 * 1024
# The newest packet format this verifier understands. The contract: every version
# from 1..SUPPORTED_PACKET_VERSION still verifies (guarded by the golden-packet
# corpus in tests/), and a newer-than-supported packet is rejected with a clear,
# non-crashing error rather than mis-verified.
SUPPORTED_PACKET_VERSION = 4
_ARTIFACT_TYPES = {
"repair_request",
"delivery_receipt",
"landlord_response",
"inspection_report",
"utility_notice",
"accommodation_request",
"supporting_letter",
"clinician_letter",
"expense_receipt",
"relocation_record",
"partner_export",
"other_document",
}
_RELATIONSHIP_TYPES = {
"documents_condition",
"sent_via",
"delivery_receipt_for",
"response_to",
"before_of",
"after_of",
"inspection_finding_for",
"repair_claim_for",
"expense_caused_by",
"supports",
}
_RELATIONSHIP_ENDPOINT_KINDS = {
"documents_condition": {
("capture", "issue"),
("artifact", "issue"),
("timeline", "issue"),
},
"sent_via": {("artifact", "artifact"), ("timeline", "timeline")},
"delivery_receipt_for": {
("artifact", "artifact"),
("artifact", "timeline"),
("timeline", "artifact"),
("timeline", "timeline"),
},
"response_to": {
("artifact", "artifact"),
("artifact", "timeline"),
("timeline", "artifact"),
("timeline", "timeline"),
},
"before_of": {("capture", "capture")},
"after_of": {("capture", "capture")},
"inspection_finding_for": {
("artifact", "issue"),
("artifact", "capture"),
("timeline", "issue"),
("timeline", "capture"),
},
"repair_claim_for": {
("artifact", "issue"),
("artifact", "capture"),
("timeline", "issue"),
("timeline", "capture"),
},
"expense_caused_by": {
("artifact", "issue"),
("artifact", "artifact"),
("artifact", "capture"),
("artifact", "timeline"),
},
"supports": {
(source, target)
for source in ("capture", "artifact", "timeline")
for target in ("issue", "capture", "artifact", "timeline")
},
}
# Referenced by name (not an inline `except (...)`) so the formatter cannot rewrite it
# to the parenthesis-free PEP 758 form, a SyntaxError on Python < 3.14. verify.py is
# the entry point of the Apache-2.0 verifier subset, kept portable for embedders who
# vendor it onto older interpreters (see docs/embedding-the-verifier.md).
_SIGNATURE_READ_ERRORS = (
json.JSONDecodeError,
UnicodeDecodeError,
ValueError,
OSError,
VerificationError,
)
_SUMMARY_TEXT = {
"en": {
"intact": "intact",
"not_intact": "NOT INTACT",
"trusted": "trusted",
"not_trusted": "NOT TRUSTED",
"ready": "READY",
"not_ready": "NOT READY",
"summary": (
"integrity: {integrity}; timestamp authority: {trust} "
"({trusted_items}/{total} items); evidence readiness: {readiness}"
),
"guidance_evidence_ready": (
"Technical evidence readiness does not decide admissibility or any legal outcome."
),
"guidance_integrity_failed": (
"Not evidence-ready: one or more packet integrity checks failed."
),
"guidance_no_items": "Not evidence-ready: the packet contains no evidence items.",
"guidance_timestamp_missing": (
"Not evidence-ready: one or more evidence items are awaiting a timestamp."
),
"guidance_timestamp_invalid": (
"Not evidence-ready: one or more attached timestamp tokens are invalid."
),
"guidance_timestamp_authority_untrusted": (
"Not evidence-ready: no certificate anchor was supplied, so authority trust "
"was not assessed. Rerun with --trusted-cert PEM for an authority you "
"independently trust. Development timestamps can never become trusted."
),
"guidance_timestamp_authority_did_not_chain": (
"Not evidence-ready: the certificate anchor(s) you supplied did not match or "
"issue this packet's timestamp certificate. This check is one hop, so supply "
"the certificate that issued the authority's responder certificate (or pin "
"the responder certificate itself) rather than a root above it; see each "
"item's note. This is not, by itself, a finding that the timestamps are "
"forged. Development timestamps can never become trusted."
),
},
"es": {
"intact": "íntegra",
"not_intact": "NO ÍNTEGRA",
"trusted": "confiable",
"not_trusted": "NO CONFIABLE",
"ready": "LISTA",
"not_ready": "NO LISTA",
"summary": (
"integridad: {integrity}; autoridad del sello de tiempo: {trust} "
"({trusted_items}/{total} elementos); preparación probatoria: {readiness}"
),
"guidance_evidence_ready": (
"La preparación técnica no determina la admisibilidad ni ningún resultado legal."
),
"guidance_integrity_failed": (
"No está lista como prueba: falló una o más comprobaciones de integridad."
),
"guidance_no_items": (
"No está lista como prueba: el expediente no contiene elementos probatorios."
),
"guidance_timestamp_missing": (
"No está lista como prueba: uno o más elementos esperan un sello de tiempo."
),
"guidance_timestamp_invalid": (
"No está lista como prueba: uno o más sellos de tiempo adjuntos no son válidos."
),
"guidance_timestamp_authority_untrusted": (
"No está lista como prueba: no se proporcionó ningún certificado de anclaje, "
"así que no se evaluó la confianza en la autoridad. Vuelva a ejecutar con "
"--trusted-cert PEM para una autoridad que usted confíe de forma "
"independiente. Los sellos de desarrollo nunca pueden volverse confiables."
),
"guidance_timestamp_authority_did_not_chain": (
"No está lista como prueba: el certificado o los certificados de anclaje que "
"usted proporcionó no coinciden con el certificado del sello de tiempo de este "
"expediente ni lo emitieron. Esta comprobación es de un solo paso: proporcione "
"el certificado que emitió el certificado de respuesta de la autoridad (o fije "
"ese mismo certificado de respuesta), no un certificado raíz por encima de él; "
"consulte la nota de cada elemento. Esto por sí solo no significa que los "
"sellos de tiempo sean falsos. Los sellos de desarrollo nunca pueden volverse "
"confiables."
),
},
}
_SEAL_TEXT = {
"en": {
"sealed": (
"authority seal: this packet's exact contents were countersigned by {tsa} at "
"{gen_time}, and that authority chains to a certificate you supplied"
),
"sealed_untrusted": (
"authority seal: present ({tsa}, {gen_time}), but its authority does not chain "
"to a certificate you supplied, so it anchors nothing you independently trust"
),
"broken": "authority seal: PRESENT BUT INVALID — it does not cover this packet",
"absent": (
"authority seal: none. Nothing binds this packet's contents as a whole, so a "
"rewritten packet re-signed with a fresh producer key is indistinguishable "
"from this one. Ask the producer to re-export while online."
),
},
"es": {
"sealed": (
"sello de la autoridad: el contenido exacto de este expediente fue refrendado "
"por {tsa} el {gen_time}, y esa autoridad se encadena a un certificado que "
"usted proporcionó"
),
"sealed_untrusted": (
"sello de la autoridad: presente ({tsa}, {gen_time}), pero su autoridad no se "
"encadena a ningún certificado que usted haya proporcionado, así que no ancla "
"nada en lo que usted confíe de forma independiente"
),
"broken": ("sello de la autoridad: PRESENTE PERO INVÁLIDO — no cubre este expediente"),
"absent": (
"sello de la autoridad: ninguno. Nada vincula el contenido de este expediente "
"en su conjunto, así que un expediente reescrito y refirmado con una clave de "
"productor nueva es indistinguible de este. Pida al productor que vuelva a "
"exportarlo con conexión."
),
},
}
_ITEM_DETAIL_TEXT = {
"en": {
"no_evidence": (
"no photo, recording, or file was included for this item — only its "
"content hash and timestamp"
),
"shared_media": "shared media is missing or does not match its recorded hash",
"custody_binding": "shared media is not bound to the original by custody",
"original_fixity": "embedded original does not match its recorded hash",
"timestamp_missing": "awaiting timestamp",
"timestamp_invalid": "attached timestamp is invalid",
"timestamp_untrusted": "timestamp is valid but its authority is not trusted",
"timestamp_dev": "development timestamp is untrusted and never evidence-ready",
"not_ready": "not evidence-ready",
},
"es": {
"no_evidence": (
"no se incluyó ninguna foto, grabación o archivo para este elemento — solo "
"su hash de contenido y sello de tiempo"
),
"shared_media": "falta el archivo compartido o no coincide con su hash registrado",
"custody_binding": "la custodia no vincula el archivo compartido con el original",
"original_fixity": "el original incluido no coincide con su hash registrado",
"timestamp_missing": "sello de tiempo pendiente",
"timestamp_invalid": "el sello de tiempo adjunto no es válido",
"timestamp_untrusted": ("el sello de tiempo es válido, pero su autoridad no es confiable"),
"timestamp_dev": (
"el sello de desarrollo no es confiable ni puede estar listo como prueba"
),
"not_ready": "no está listo como prueba",
},
}
@dataclass(frozen=True, slots=True)
class SealVerdict:
"""What an RFC 3161 authority countersigned about the packet *as a whole*.
Item timestamps bind one ``content_hash`` each, and in a default packet those
are the hashes of original bytes the packet does not ship. The seal is a token
over the SHA-256 of the exact ``bundle.json`` bytes, so it binds every field in
the bundle at once — including every ``shared_hash``, i.e. the photographs a
recipient can actually open, and the custody ``head_hash``.
Absence is a state, not a failure: a packet exported offline cannot be sealed.
It is reported rather than inferred, and a recipient who needs the guarantee
asserts it (``require_packet_seal``) instead of hoping for it. See
``docs/adr/0011-authority-seal-over-the-whole-packet.md``.
"""
present: bool = False
#: The token parses, its signature verifies, and its imprint is this bundle's digest.
verified: bool = False
#: The sealing authority chains to a caller-supplied certificate. ``dev`` never does.
trusted: bool = False
kind: str = ""
tsa_name: str = ""
gen_time: str = ""
#: True when the caller asserted the seal must be there (``require_packet_seal``).
required: bool = False
notes: tuple[str, ...] = field(default_factory=tuple)
@property
def ok(self) -> bool:
"""Present, cryptographically valid over this bundle, and authority-anchored."""
return self.present and self.verified and self.trusted
def statement(self, language: str = "en") -> str:
"""One localized sentence a human can act on, including when there is no seal."""
text = _SEAL_TEXT.get(language.lower().split("-", 1)[0], _SEAL_TEXT["en"])
if not self.present:
return text["absent"]
if not self.verified:
return text["broken"]
key = "sealed" if self.trusted else "sealed_untrusted"
return text[key].format(tsa=self.tsa_name or "?", gen_time=self.gen_time or "?")
@dataclass(frozen=True, slots=True)
class ItemVerdict:
"""The verification outcome for one media item."""
capture_id: str
content_hash: str
timestamp_verified: bool
gen_time: str
tsa_name: str
shared_media_ok: bool
custody_binding_ok: bool
original_fixity_ok: bool | None # None when the sealed original is not included
notes: tuple[str, ...] = field(default_factory=tuple)
verified_authorities: tuple[str, ...] = field(default_factory=tuple)
timestamp_authority_trusted: bool = False
trusted_authorities: tuple[str, ...] = field(default_factory=tuple)
timestamp_present: bool = False
timestamp_kind: str = ""
# True unless the item carries neither a recorded shared copy nor an
# embedded original (issue #158, decision 3): a content hash and a
# timestamp alone are not evidence a human can look at. Before this field
# existed, that state read as "nothing to check, therefore fine"; it now
# reads as "nothing was exported, therefore not intact." Defaults True so
# every pre-existing caller that builds an ItemVerdict without naming this
# field keeps its prior meaning.
evidence_present: bool = True
@property
def structurally_intact(self) -> bool:
"""Whether media, custody binding, any embedded original, and the mere
presence of checkable evidence bytes are all intact.
Timestamp presence, token validity, and authority trust are intentionally
excluded: those are separate claims and must not redefine byte integrity.
"""
return (
self.shared_media_ok
and self.custody_binding_ok
and self.original_fixity_ok is not False
and self.evidence_present
)
@property
def cryptographically_verified(self) -> bool:
"""Legacy proof check: integrity plus a valid token, regardless of trust root."""
return self.structurally_intact and self.timestamp_verified
@property
def evidence_ready(self) -> bool:
"""Whether this item passes integrity, token, and authority-trust checks."""
return self.cryptographically_verified and self.timestamp_authority_trusted
@property
def ok(self) -> bool:
"""Backward-compatible field name, tightened to mean ``evidence_ready``.
Older callers used ``ok`` for a valid token even when its authority was not
trusted. Keeping that meaning would continue the unsafe ambiguity this
report is designed to remove, so callers that only need the old mechanical
check should use :attr:`cryptographically_verified` explicitly.
"""
return self.evidence_ready
def human_detail(self, language: str = "en") -> str:
"""Return a localized, non-technical explanation of this item's failed checks."""
text = _item_detail_text(language)
reasons: list[str] = []
if not self.evidence_present:
reasons.append(text["no_evidence"])
if not self.shared_media_ok:
reasons.append(text["shared_media"])
if not self.custody_binding_ok:
reasons.append(text["custody_binding"])
if self.original_fixity_ok is False:
reasons.append(text["original_fixity"])
if not self.timestamp_verified:
reasons.append(
text["timestamp_invalid"] if self.timestamp_present else text["timestamp_missing"]
)
elif not self.timestamp_authority_trusted:
key = "timestamp_dev" if self.timestamp_kind == "dev" else "timestamp_untrusted"
reasons.append(text[key])
return "; ".join(reasons) or text["not_ready"]
@dataclass(frozen=True, slots=True)
class VerificationReport:
"""The overall verdict on a packet."""
packet_dir: Path
signature_ok: bool
custody_ok: bool
custody_length: int
items: tuple[ItemVerdict, ...]
problems: tuple[str, ...]
language: str = "en"
#: How many certificate anchors the caller supplied. Zero and non-zero are
#: different situations behind the same untrusted verdict: "authority trust
#: was never assessed" versus "the anchors you supplied did not chain".
#: Collapsing them told a reviewer who had just supplied a root to supply a
#: root (issue #159).
anchors_supplied: int = 0
#: Whether the caller pinned the producer's signing key out of band. False
#: means "not asserted", not "asserted and matched": ``signature_ok`` alone
#: only says the packet is internally consistent with the key sitting inside
#: it, so an unpinned verdict never distinguishes the real producer from
#: anyone who re-signed the bundle with a fresh key. See
#: ``docs/tamper-challenge.md``.
producer_key_pinned: bool = False
#: What an authority countersigned about the whole bundle, if anything. This is
#: the fourth claim alongside the three of ADR 0008 — deliberately reported
#: separately rather than folded into them, so no existing verdict changes
#: meaning. Seal *problems* do reach ``problems`` (and therefore
#: ``structurally_intact``); a merely absent, unasserted seal does not.
seal: SealVerdict = field(default_factory=SealVerdict)
@property
def structurally_intact(self) -> bool:
"""Whether packet structure, signature, custody, and media checks pass."""
return (
self.signature_ok
and self.custody_ok
and not self.problems
and all(item.structurally_intact for item in self.items)
)
@property
def timestamp_authority_trusted(self) -> bool:
"""Whether every evidence item has a valid token anchored to a trusted root."""
return bool(self.items) and all(item.timestamp_authority_trusted for item in self.items)
@property
def evidence_ready(self) -> bool:
"""Technical readiness: integrity plus trusted timestamp coverage for all items."""
return (
self.structurally_intact
and bool(self.items)
and all(item.evidence_ready for item in self.items)
)
@property
def ok(self) -> bool:
"""Backward-compatible field name, now a fail-closed alias for evidence readiness."""
return self.evidence_ready
@property
def verified_items(self) -> int:
"""Number of evidence-ready items (the historical field name is retained)."""
return sum(1 for item in self.items if item.evidence_ready)
@property
def cryptographically_verified_items(self) -> int:
"""Items with intact bytes and a valid token, whether or not its root is trusted."""
return sum(1 for item in self.items if item.cryptographically_verified)
@property
def trusted_timestamp_items(self) -> int:
return sum(1 for item in self.items if item.timestamp_authority_trusted)
@property
def status(self) -> str:
"""Stable machine-readable reason for the overall readiness result."""
if self.evidence_ready:
return "evidence_ready"
if not self.structurally_intact:
return "integrity_failed"
if not self.items:
return "no_items"
if not all(item.timestamp_verified for item in self.items):
# An attached-but-invalid proof is an alarm even if another item merely
# awaits a token; never let the calm missing state hide invalid material.
if any(item.timestamp_present and not item.timestamp_verified for item in self.items):
return "timestamp_invalid"
return "timestamp_missing"
return "timestamp_authority_untrusted"
def summary(self, language: str | None = None) -> str:
"""Return a localized, claim-separated human summary."""
text = _summary_text(language or self.language)
total = len(self.items)
return text["summary"].format(
integrity=text["intact"] if self.structurally_intact else text["not_intact"],
trust=(text["trusted"] if self.timestamp_authority_trusted else text["not_trusted"]),
trusted_items=self.trusted_timestamp_items,
total=total,
readiness=text["ready"] if self.evidence_ready else text["not_ready"],
)
def guidance(self, language: str | None = None) -> str:
"""Return localized next-step/caveat text for :attr:`status`.
The untrusted-authority case splits by whether anchors were supplied.
``status`` itself is unchanged — it is a machine-readable contract other
code branches on — but the sentence a human reads must not tell someone
who just passed ``--trusted-cert`` to pass ``--trusted-cert``.
"""
text = _summary_text(language or self.language)
if self.status == "timestamp_authority_untrusted" and self.anchors_supplied:
return text["guidance_timestamp_authority_did_not_chain"]
return text[f"guidance_{self.status}"]
def seal_statement(self, language: str | None = None) -> str:
"""Return the localized seal sentence — printed on every run, pass or fail.
Kept out of :meth:`summary` on purpose: ``summary``'s format string is a
stable contract other code and tests read. The seal is new information, so
it gets its own line rather than silently reshaping an old one.
"""
return self.seal.statement(language or self.language)
def _summary_text(language: str) -> dict[str, str]:
return _SUMMARY_TEXT.get(language.lower().split("-", 1)[0], _SUMMARY_TEXT["en"])
def _item_detail_text(language: str) -> dict[str, str]:
return _ITEM_DETAIL_TEXT.get(language.lower().split("-", 1)[0], _ITEM_DETAIL_TEXT["en"])
def verify_packet(
packet_dir: Path,
*,
trusted_certs: list[x509.Certificate] | None = None,
expected_producer_key: str | None = None,
require_packet_seal: bool = False,
seal_not_after: str | None = None,
) -> VerificationReport:
"""Verify a packet directory end to end and return a structured report.
``expected_producer_key`` is the base64 Ed25519 public key the recipient
obtained **out of band** (the ``sign_public`` value of a packet they already
trust). Supplying it turns a substituted signing key into a structural
failure. Omitting it leaves the signature self-attesting — see
``docs/tamper-challenge.md`` for exactly what that does and does not cover.
``require_packet_seal`` demands that an authority countersigned the whole
bundle (``docs/adr/0011-authority-seal-over-the-whole-packet.md``). Unlike the
pin it needs no secret: the anchor is the same ``trusted_certs`` the caller
already supplies. A present seal is *always* checked; this flag is what makes
an absent or unanchored one a failure rather than a note, and is therefore the
answer to an attacker who simply strips the seal out.
``seal_not_after`` is an ISO 8601 UTC instant the recipient names — typically
the moment they received the packet. A seal minted after it means the bundle's
bytes came into existence after the packet reached them, which no honest export
can do. It is the anchor every recipient already holds: their own calendar.
"""
packet_dir = Path(packet_dir)
bundle_bytes = _read_bundle_bytes(packet_dir)
bundle = _parse_bundle(bundle_bytes)
language = _s(bundle, "language") or "en"
pin_problem = _producer_pin_problem(packet_dir, expected_producer_key)
pinned = expected_producer_key is not None
seal, seal_problems = _verify_packet_seal(
packet_dir,
bundle_bytes,
trusted_certs=trusted_certs,
required=require_packet_seal,
not_after=seal_not_after,
)
# Enforce the version contract before trusting the rest of the structure.
version_problem = _check_packet_version(bundle)
if version_problem is not None:
return VerificationReport(
packet_dir=packet_dir,
signature_ok=_verify_signature(packet_dir, bundle_bytes),
custody_ok=False,
custody_length=0,
items=(),
problems=(
version_problem,
*([pin_problem] if pin_problem else []),
*seal_problems,
),
language=language,
anchors_supplied=len(trusted_certs or ()),
producer_key_pinned=pinned,
seal=seal,
)
signature_ok = _verify_signature(packet_dir, bundle_bytes)
custody_ok, custody_length, custody = _verify_custody(bundle)
bindings = _sharing_bindings(custody)
poster_bindings = _poster_bindings(custody)
problems: list[str] = [pin_problem] if pin_problem else []
problems.extend(seal_problems)
items: list[ItemVerdict] = []
for raw_item in _list(bundle, "items"):
if not isinstance(raw_item, dict):
problems.append("malformed item in bundle")
continue
items.append(
_verify_item(
raw_item,
packet_dir,
bindings,
poster_bindings,
trusted_certs,
inspect_references=signature_ok,
)
)
if bundle.get("packet_version") in {3, 4}:
problems.extend(_verify_v3_timeline(bundle, custody))
if bundle.get("packet_version") == 4:
problems.extend(_verify_v4_workflows(bundle, custody))
return VerificationReport(
packet_dir=packet_dir,
signature_ok=signature_ok,
custody_ok=custody_ok,
custody_length=custody_length,
items=tuple(items),
problems=tuple(problems),
language=language,
anchors_supplied=len(trusted_certs or ()),
producer_key_pinned=pinned,
seal=seal,
)
def _verify_item( # noqa: C901 -- P1-4 follow-up: extract per-check helpers; left alone for
# now rather than risk a regression in the standalone verifier under time pressure.
item: Mapping[str, JSONValue],
packet_dir: Path,
bindings: dict[str, set[tuple[str, str]]],
poster_bindings: dict[str, set[tuple[str, str]]],
trusted_certs: list[x509.Certificate] | None,
*,
inspect_references: bool = True,
) -> ItemVerdict:
capture_id = _s(item, "capture_id")
content_hash = _s(item, "content_hash")
media_type = _s(item, "media_type")
shared_name = _s(item, "shared_name")
shared_hash = _s(item, "shared_hash")
poster_name = _s(item, "poster_name")
poster_hash = _s(item, "poster_hash")
transcript = _s(item, "transcript")
has_original = item.get("has_original") is True
notes: list[str] = []
if not inspect_references and (shared_name or poster_name or has_original):
notes.append("bundle signature invalid; referenced packet files were not read")
# 1. Trusted timestamp(s) over the original content hash. The primary token plus any
# independent "additional" authorities give redundancy: the item counts as
# timestamped if AT LEAST ONE authority verifies, so the proof never rests on a
# single TSA (item R-16). With no additional tokens this is identical to before.
timestamp_verified = False
timestamp_authority_trusted = False
timestamp_kind = ""
gen_time = ""
tsa_name = ""
verified_authorities: list[str] = []
trusted_authorities: list[str] = []
token_raw = item.get("timestamp")
timestamp_present = isinstance(token_raw, dict)
if isinstance(token_raw, dict):
try:
token = TimestampToken.from_dict(token_raw)
info = verify_token(token, content_hash, trusted_certs=trusted_certs)
# Archive (re-)timestamps, if present, must chain back to this token.
archive_raw = item.get("archive_timestamps")
archives = (
[TimestampToken.from_dict(a) for a in archive_raw if isinstance(a, dict)]
if isinstance(archive_raw, list)
else []
)
if archives:
verify_archive_chain(content_hash, token, archives, trusted_certs=trusted_certs)
notes.append(f"archive-timestamped ({len(archives)} link(s))")
# Commit the primary verdict only after every attached archive link has
# passed. A broken attached archive is an invalid proof, not an ignorable
# decoration; a valid redundant authority below can still rescue the item.
timestamp_verified = True
timestamp_kind = info.kind
gen_time = info.gen_time
tsa_name = info.tsa_name
verified_authorities.append(info.tsa_name)
if info.trusted_chain:
timestamp_authority_trusted = True
trusted_authorities.append(info.tsa_name)
else:
notes.append(
info.note or "timestamp valid but authority not chained to a trusted root"
)
except Exception as exc:
# A failed primary does not, by itself, condemn the item if a redundant
# authority below still verifies the same content hash.
notes.append(f"primary timestamp check failed: {exc}")
else:
notes.append("awaiting timestamp")
# 1b. Independent redundant authorities over the same content hash.
additional_raw = item.get("additional_timestamps")
if isinstance(additional_raw, list):
for extra_raw in additional_raw:
if not isinstance(extra_raw, dict):
continue
timestamp_present = True
try:
extra = TimestampToken.from_dict(extra_raw)
extra_info = verify_token(extra, content_hash, trusted_certs=trusted_certs)
except Exception as exc:
notes.append(f"additional timestamp check failed: {exc}")
continue
verified_authorities.append(extra_info.tsa_name)
notes.append(f"also timestamped by {extra_info.tsa_name}")
if extra_info.trusted_chain:
timestamp_authority_trusted = True
trusted_authorities.append(extra_info.tsa_name)
else:
notes.append(
extra_info.note
or f"additional authority {extra_info.tsa_name} not chained to a trusted root"
)
if not timestamp_verified:
timestamp_verified = True
timestamp_kind = extra_info.kind
gen_time = extra_info.gen_time
tsa_name = extra_info.tsa_name
# 2. Shared media hashes to its recorded shared_hash.
shared_media_ok = True
if shared_name:
if not inspect_references:
shared_media_ok = False
else:
media_digest, media_problem = _hash_packet_reference(
packet_dir, _MEDIA, shared_name, label="shared media"
)
if media_problem is not None:
shared_media_ok = False
notes.append(media_problem)
elif media_digest != shared_hash:
shared_media_ok = False
notes.append("shared media does not match its recorded hash")
else:
notes.append("no shared media included for this item")
# 3. Custody binds the shared copy to the sealed original's content hash.
custody_binding_ok = True
if shared_name:
custody_binding_ok = (content_hash, shared_hash) in bindings.get(capture_id, set())
if not custody_binding_ok:
notes.append("no signed custody entry binds the shared copy to the original")
# 3b. Video's poster frame (EXP-07), if present, hashes and binds the same way.
if poster_name:
if not inspect_references:
shared_media_ok = False
else:
poster_digest, poster_problem = _hash_packet_reference(
packet_dir, _MEDIA, poster_name, label="poster frame"
)
if poster_problem is not None:
shared_media_ok = False
notes.append(poster_problem)
elif poster_digest != poster_hash:
shared_media_ok = False
notes.append("poster frame does not match its recorded hash")
elif (content_hash, poster_hash) not in poster_bindings.get(capture_id, set()):
custody_binding_ok = False
notes.append("no signed custody entry binds the poster frame to the original")
# 3c. Video/audio needs a transcript or poster frame to meet the accessibility
# gate (EXP-07 excellence bar); surfaced as a note, not a hard failure --
# this is a completeness signal, not a cryptographic integrity failure.
if media_type.startswith(("video/", "audio/")) and not transcript and not poster_name:
notes.append("no transcript or poster frame recorded for this item (accessibility gap)")
# 4. If the sealed original is embedded, re-derive its content hash.
original_fixity_ok: bool | None = None
if has_original:
if not inspect_references:
original_fixity_ok = False
else:
original_digest, original_problem = _hash_packet_reference(
packet_dir, _ORIGINALS, capture_id, label="embedded original"
)
if original_problem is not None:
original_fixity_ok = False
notes.append(original_problem)
else:
original_fixity_ok = original_digest == content_hash
if not original_fixity_ok:
notes.append("embedded original failed fixity")
# 5. An item is not structurally intact if it carries neither a recorded
# shared copy nor an embedded original -- i.e., no real, checkable
# evidence bytes at all (issue #158 decision 3). Before this check, that
# state read as "nothing to check, therefore fine" (both shared_media_ok
# and custody_binding_ok default True when shared_name is empty); it now
# reads as "nothing was exported, therefore not intact." Whether the
# included bytes go on to pass their own hash checks is judged
# separately, above, by shared_media_ok / original_fixity_ok.
evidence_present = bool(shared_name) or has_original
if not evidence_present:
notes.append(
"no shared media and no embedded original: this item carries no "
"checkable evidence bytes"
)
return ItemVerdict(
capture_id=capture_id,
content_hash=content_hash,
timestamp_verified=timestamp_verified,
gen_time=gen_time,
tsa_name=tsa_name,
shared_media_ok=shared_media_ok,
custody_binding_ok=custody_binding_ok,
original_fixity_ok=original_fixity_ok,
notes=tuple(notes),
verified_authorities=tuple(verified_authorities),
timestamp_authority_trusted=timestamp_authority_trusted,
trusted_authorities=tuple(trusted_authorities),
timestamp_present=timestamp_present,
timestamp_kind=timestamp_kind,
evidence_present=evidence_present,
)
def _hash_packet_reference( # noqa: C901 -- security checks are intentionally linear
packet_dir: Path, directory: str, reference: str, *, label: str
) -> tuple[str | None, str | None]:
"""Hash one strictly confined packet file, returning ``(digest, problem)``.
Bundle fields are attacker-controlled until proven otherwise. A file reference is
therefore one basename, never a path. The directory and file are lstat-checked,
resolved for containment, and rechecked after opening before any bytes are hashed.
``O_NOFOLLOW``/``O_NONBLOCK`` add final-component protection where the host exposes
them. Path-based check/open operations are not atomic, so a concurrent directory
replacement remains a documented residual race (see the embedding guide).
"""
name_problem = _reference_name_problem(reference, label)
if name_problem is not None:
return None, name_problem
try:
root_before = packet_dir.lstat()
except OSError:
return None, "packet directory could not be safely inspected"
if stat.S_ISLNK(root_before.st_mode):
return None, "packet directory must not be a symlink"
if not stat.S_ISDIR(root_before.st_mode):
return None, "packet path is not a directory"
directory_path = packet_dir / directory
try:
directory_before = directory_path.lstat()
except FileNotFoundError:
return None, f"{label} directory missing"
except OSError:
return None, f"{label} directory could not be safely inspected"
if stat.S_ISLNK(directory_before.st_mode):
return None, f"{label} directory must not be a symlink"
if not stat.S_ISDIR(directory_before.st_mode):
return None, f"{label} directory is not a regular directory"
candidate = directory_path / reference
try:
file_before = candidate.lstat()
except FileNotFoundError:
return None, f"{label} file missing"
except OSError:
return None, f"{label} file could not be safely inspected"
file_problem = _regular_file_problem(file_before, label)
if file_problem is not None:
return None, file_problem
try:
root_resolved = packet_dir.resolve(strict=True)
directory_resolved = directory_path.resolve(strict=True)
candidate_resolved = candidate.resolve(strict=True)
except OSError:
return None, f"{label} path could not be safely resolved"
if not directory_resolved.is_relative_to(root_resolved):
return None, f"{label} directory escapes the packet directory"
if not candidate_resolved.is_relative_to(directory_resolved):
return None, f"{label} path escapes its designated directory"
file_fd = -1
try:
flags = (
os.O_RDONLY
| getattr(os, "O_NOFOLLOW", 0)
| getattr(os, "O_NONBLOCK", 0)
| getattr(os, "O_CLOEXEC", 0)
)
file_fd = os.open(candidate, flags)
handle = os.fdopen(file_fd, "rb", closefd=True)
file_fd = -1 # ownership transferred to ``handle``
with handle:
opened = os.fstat(handle.fileno())
file_problem = _regular_file_problem(opened, label)
if file_problem is not None:
return None, file_problem
if _different_file(file_before, opened):
return None, f"{label} file changed during safety checks"
digest = _stream_sha256(handle)
after = os.fstat(handle.fileno())
except OSError:
return None, f"{label} file could not be safely read"
finally:
_close_fd(file_fd)
if digest is None:
return None, _oversized_problem(label)
if _file_changed_while_reading(opened, after):
return None, f"{label} file changed while it was hashed"
return digest, None
def _reference_name_problem(reference: str, label: str) -> str | None:
"""Reject every spelling that can be interpreted as more than one basename."""
windows = PureWindowsPath(reference)
posix = PurePosixPath(reference)
if (
not reference
or reference in {".", ".."}
or "\x00" in reference
or "/" in reference
or "\\" in reference
or posix.is_absolute()
or windows.is_absolute()
or bool(windows.drive)
):
return (
f"{label} reference must be one basename "
"(absolute paths, separators, drive names, and '..' are forbidden)"
)
return None
def _regular_file_problem(file_stat: os.stat_result, label: str) -> str | None:
if stat.S_ISLNK(file_stat.st_mode):
return f"{label} path must not be a symlink"
if not stat.S_ISREG(file_stat.st_mode):
return f"{label} path is not a regular file"
if file_stat.st_size > _MAX_REFERENCED_FILE_BYTES:
return _oversized_problem(label)
return None
def _stream_sha256(handle: BinaryIO) -> str | None:
"""Hash at most the configured ceiling, including files that grow while read."""