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.
- sigil_protocol_verifier-1.0.0.dist-info/METADATA +51 -0
- sigil_protocol_verifier-1.0.0.dist-info/RECORD +10 -0
- sigil_protocol_verifier-1.0.0.dist-info/WHEEL +5 -0
- sigil_protocol_verifier-1.0.0.dist-info/entry_points.txt +2 -0
- sigil_protocol_verifier-1.0.0.dist-info/top_level.txt +1 -0
- sigil_verify/__init__.py +17 -0
- sigil_verify/canonical.py +33 -0
- sigil_verify/chain.py +53 -0
- sigil_verify/cli.py +24 -0
- sigil_verify/pdf.py +111 -0
|
@@ -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 @@
|
|
|
1
|
+
sigil_verify
|
sigil_verify/__init__.py
ADDED
|
@@ -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
|
+
)
|