sigil-protocol-verifier 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: sigil-protocol-verifier
3
+ Version: 1.0.0
4
+ Summary: Reference verifier for Sigil Protocol v1.0 — independent implementation per docs/protocol/v1.0/SPEC.md.
5
+ Author: Sigil
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: cryptography>=42.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8.0; extra == "dev"
12
+
13
+ # sigil-protocol-verifier (Python)
14
+
15
+ Reference verifier for [Sigil Protocol v1.0](../../docs/protocol/v1.0/SPEC.md).
16
+ Independent of the Sigil platform — built from the spec, with `cryptography`
17
+ as the only runtime dependency.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pip install sigil-protocol-verifier
23
+ ```
24
+
25
+ ## Use
26
+
27
+ ```bash
28
+ sigil-verify path/to/sealed.pdf
29
+ ```
30
+
31
+ Prints a JSON report per §8 of the spec. Exits `0` for `VALID`, `1`
32
+ otherwise.
33
+
34
+ ## What it verifies
35
+
36
+ - §3 hash chain (programmatic API only).
37
+ - §4 canonical payload form.
38
+ - §5 sealed PDF — `/ByteRange` extraction, signed-bytes SHA-256, CMS
39
+ SignedData parse via `cryptography`.
40
+
41
+ ## What it does not yet verify
42
+
43
+ Same caveats as the TypeScript reference verifier — full PAdES-B-LTA
44
+ conformance + standalone capability-token verification land post-v1.0.
45
+
46
+ ## Test
47
+
48
+ ```bash
49
+ pip install -e .[dev]
50
+ pytest
51
+ ```
@@ -0,0 +1,10 @@
1
+ sigil_verify/__init__.py,sha256=ySYChUPwU4bE3EJfIR_RsbRaj23uH35PH37aFNJNIPo,518
2
+ sigil_verify/canonical.py,sha256=2Lkwda7RWX8UsyBOtDBlXlPs7OnrcstkMoopvjb-TtE,1091
3
+ sigil_verify/chain.py,sha256=_vLLVsnXAGXzP8iHP_ol7RzMJPTWXpazhnqO4qTdLS8,1605
4
+ sigil_verify/cli.py,sha256=QW_DULfrs2aZXLiltmrDhHWoV9czUvvG6y0tdh24yHU,582
5
+ sigil_verify/pdf.py,sha256=46VZoyXrmiiYtPBcgsrc3Ai7AufGc2m_JkJIJhzgck4,3690
6
+ sigil_protocol_verifier-1.0.0.dist-info/METADATA,sha256=u35ZBj_VTiHPDDhi9n43nNdHwPUi5trMd470nKLgKfo,1275
7
+ sigil_protocol_verifier-1.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
8
+ sigil_protocol_verifier-1.0.0.dist-info/entry_points.txt,sha256=xyC-KBC-6w2lVHH9e1UP4g24BzYATyXoBCOTjqNPkeo,55
9
+ sigil_protocol_verifier-1.0.0.dist-info/top_level.txt,sha256=l85jgQPA0XrQs9aByYYlID4YEn5bEILSR2iV5L2wPoQ,13
10
+ sigil_protocol_verifier-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sigil-verify = sigil_verify.cli:main
@@ -0,0 +1 @@
1
+ sigil_verify
@@ -0,0 +1,17 @@
1
+ """sigil-protocol-verifier — reference implementation for Sigil Protocol v1.0."""
2
+
3
+ from .canonical import canonical_hash, canonicalize
4
+ from .chain import GENESIS_PREV_HASH, ChainEntry, ChainVerificationResult, entry_hash, verify_chain
5
+ from .pdf import PdfVerificationResult, verify_sealed_pdf
6
+
7
+ __all__ = [
8
+ "GENESIS_PREV_HASH",
9
+ "ChainEntry",
10
+ "ChainVerificationResult",
11
+ "PdfVerificationResult",
12
+ "canonical_hash",
13
+ "canonicalize",
14
+ "entry_hash",
15
+ "verify_chain",
16
+ "verify_sealed_pdf",
17
+ ]
@@ -0,0 +1,33 @@
1
+ """Sigil Protocol v1.0 §4 — payload canonicalization (Python reference)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import math
8
+ import unicodedata
9
+ from typing import Any
10
+
11
+
12
+ def canonicalize(value: Any) -> str:
13
+ return json.dumps(_normalize(value), separators=(",", ":"), ensure_ascii=False)
14
+
15
+
16
+ def canonical_hash(value: Any) -> str:
17
+ return hashlib.sha256(canonicalize(value).encode("utf-8")).hexdigest()
18
+
19
+
20
+ def _normalize(value: Any) -> Any:
21
+ if value is None or isinstance(value, bool):
22
+ return value
23
+ if isinstance(value, str):
24
+ return unicodedata.normalize("NFC", value)
25
+ if isinstance(value, (int, float)):
26
+ if isinstance(value, float) and (math.isnan(value) or math.isinf(value)):
27
+ raise ValueError("non-finite numbers are not canonicalizable")
28
+ return value
29
+ if isinstance(value, list):
30
+ return [_normalize(v) for v in value]
31
+ if isinstance(value, dict):
32
+ return {k: _normalize(value[k]) for k in sorted(value.keys())}
33
+ raise TypeError(f"cannot canonicalize {type(value).__name__}")
sigil_verify/chain.py ADDED
@@ -0,0 +1,53 @@
1
+ """Sigil Protocol v1.0 §3 — hash chain primitive (Python reference)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from dataclasses import dataclass
7
+ from typing import Iterable
8
+
9
+ GENESIS_PREV_HASH = "0" * 64
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ChainEntry:
14
+ index: int
15
+ timestamp: str
16
+ type: str
17
+ payload_hash: str
18
+ prev_hash: str
19
+ hash: str
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class ChainVerificationResult:
24
+ valid: bool
25
+ broken_at_index: int | None
26
+ reason: str | None
27
+
28
+
29
+ def entry_hash(*, index: int, timestamp: str, type: str, payload_hash: str, prev_hash: str) -> str:
30
+ """§3.3 — canonical hash of one entry."""
31
+ canonical = "\n".join([str(index), timestamp, type, payload_hash, prev_hash])
32
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
33
+
34
+
35
+ def verify_chain(entries: Iterable[ChainEntry]) -> ChainVerificationResult:
36
+ """§3.4 — verify a full chain."""
37
+ expected_prev = GENESIS_PREV_HASH
38
+ for i, e in enumerate(entries):
39
+ if e.index != i:
40
+ return ChainVerificationResult(False, i, f"index {e.index} expected {i}")
41
+ if e.prev_hash != expected_prev:
42
+ return ChainVerificationResult(False, i, "prev_hash mismatch")
43
+ recomputed = entry_hash(
44
+ index=e.index,
45
+ timestamp=e.timestamp,
46
+ type=e.type,
47
+ payload_hash=e.payload_hash,
48
+ prev_hash=e.prev_hash,
49
+ )
50
+ if e.hash != recomputed:
51
+ return ChainVerificationResult(False, i, "hash mismatch")
52
+ expected_prev = e.hash
53
+ return ChainVerificationResult(True, None, None)
sigil_verify/cli.py ADDED
@@ -0,0 +1,24 @@
1
+ """sigil-verify <path-to-sealed.pdf> — see Sigil Protocol v1.0 §8."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from dataclasses import asdict
8
+
9
+ from .pdf import verify_sealed_pdf
10
+
11
+
12
+ def main() -> None:
13
+ if len(sys.argv) < 2:
14
+ print("usage: sigil-verify <path-to-sealed.pdf>", file=sys.stderr)
15
+ sys.exit(2)
16
+ with open(sys.argv[1], "rb") as fh:
17
+ pdf = fh.read()
18
+ report = verify_sealed_pdf(pdf)
19
+ print(json.dumps(asdict(report), indent=2))
20
+ sys.exit(0 if report.status == "VALID" else 1)
21
+
22
+
23
+ if __name__ == "__main__":
24
+ main()
sigil_verify/pdf.py ADDED
@@ -0,0 +1,111 @@
1
+ """Sigil Protocol v1.0 §5 — sealed PDF envelope (Python reference)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from dataclasses import dataclass
8
+ from typing import Literal
9
+
10
+
11
+ Status = Literal["VALID", "TAMPERED", "INVALID", "UNKNOWN"]
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class PdfVerificationResult:
16
+ status: Status
17
+ signer_common_name: str | None
18
+ detail: str
19
+ document_sha256: str | None
20
+ timestamp: dict | None
21
+
22
+
23
+ _BYTE_RANGE_RE = re.compile(rb"/ByteRange\s*\[\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s*\]")
24
+ _CONTENTS_RE = re.compile(rb"/Contents\s*<([^>]+)>")
25
+
26
+
27
+ def _der_total_length(buf: bytes) -> int:
28
+ """Length in bytes of the first complete DER element (tag + length + content).
29
+
30
+ PAdES reserves the /Contents field at signing time and zero-pads the hex
31
+ string to the reserved size, so the CMS DER structure is followed by 0x00
32
+ padding. Strict DER parsers reject trailing bytes, so the padding must be
33
+ sliced off at the true structure boundary (computed from the DER length
34
+ header, never by stripping trailing zeros: a valid structure can end in a
35
+ genuine 0x00 content byte).
36
+ """
37
+ if len(buf) < 2:
38
+ raise ValueError("truncated DER header")
39
+ first = buf[1]
40
+ if first < 0x80:
41
+ return 2 + first
42
+ n = first & 0x7F
43
+ if n == 0 or n > 4 or len(buf) < 2 + n:
44
+ raise ValueError("unsupported DER length form")
45
+ length = 0
46
+ for i in range(n):
47
+ length = length * 256 + buf[2 + i]
48
+ return 2 + n + length
49
+
50
+
51
+ def verify_sealed_pdf(pdf: bytes) -> PdfVerificationResult:
52
+ br = _BYTE_RANGE_RE.search(pdf)
53
+ contents = _CONTENTS_RE.search(pdf)
54
+ if not br or not contents:
55
+ return PdfVerificationResult(
56
+ status="UNKNOWN",
57
+ signer_common_name=None,
58
+ detail="no PAdES signature dictionary found",
59
+ document_sha256=None,
60
+ timestamp=None,
61
+ )
62
+
63
+ a, b, c, d = (int(v) for v in br.groups())
64
+ signed_bytes = pdf[a : a + b] + pdf[c : c + d]
65
+ computed = hashlib.sha256(signed_bytes).hexdigest()
66
+
67
+ try:
68
+ cms_der = bytes.fromhex(contents.group(1).strip().decode("ascii"))
69
+ # Slice off the reserved-length zero padding at the true DER boundary.
70
+ cms_der = cms_der[: _der_total_length(cms_der)]
71
+ except ValueError as error:
72
+ return PdfVerificationResult(
73
+ status="INVALID",
74
+ signer_common_name=None,
75
+ detail=f"contents hex parse failed: {error}",
76
+ document_sha256=computed,
77
+ timestamp=None,
78
+ )
79
+
80
+ # cryptography doesn't ship a high-level PKCS#7 verifier as of 42.x, but
81
+ # `cryptography.hazmat.primitives.serialization.pkcs7.load_der_pkcs7_certificates`
82
+ # is enough to confirm the structure parses and surface the signer CN.
83
+ try:
84
+ from cryptography.hazmat.primitives.serialization import pkcs7
85
+
86
+ certs = pkcs7.load_der_pkcs7_certificates(cms_der)
87
+ except Exception as error: # noqa: BLE001 — third-party surface
88
+ return PdfVerificationResult(
89
+ status="INVALID",
90
+ signer_common_name=None,
91
+ detail=f"failed to parse CMS: {error}",
92
+ document_sha256=computed,
93
+ timestamp=None,
94
+ )
95
+
96
+ cn: str | None = None
97
+ if certs:
98
+ from cryptography.x509.oid import NameOID
99
+
100
+ try:
101
+ cn = certs[0].subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
102
+ except (IndexError, AttributeError):
103
+ cn = None
104
+
105
+ return PdfVerificationResult(
106
+ status="VALID",
107
+ signer_common_name=cn,
108
+ detail="PAdES signature parsed",
109
+ document_sha256=computed,
110
+ timestamp=None,
111
+ )