sigil-protocol-verifier 1.0.0__tar.gz
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/PKG-INFO +51 -0
- sigil_protocol_verifier-1.0.0/README.md +39 -0
- sigil_protocol_verifier-1.0.0/pyproject.toml +25 -0
- sigil_protocol_verifier-1.0.0/setup.cfg +4 -0
- sigil_protocol_verifier-1.0.0/sigil_protocol_verifier.egg-info/PKG-INFO +51 -0
- sigil_protocol_verifier-1.0.0/sigil_protocol_verifier.egg-info/SOURCES.txt +16 -0
- sigil_protocol_verifier-1.0.0/sigil_protocol_verifier.egg-info/dependency_links.txt +1 -0
- sigil_protocol_verifier-1.0.0/sigil_protocol_verifier.egg-info/entry_points.txt +2 -0
- sigil_protocol_verifier-1.0.0/sigil_protocol_verifier.egg-info/requires.txt +4 -0
- sigil_protocol_verifier-1.0.0/sigil_protocol_verifier.egg-info/top_level.txt +1 -0
- sigil_protocol_verifier-1.0.0/sigil_verify/__init__.py +17 -0
- sigil_protocol_verifier-1.0.0/sigil_verify/canonical.py +33 -0
- sigil_protocol_verifier-1.0.0/sigil_verify/chain.py +53 -0
- sigil_protocol_verifier-1.0.0/sigil_verify/cli.py +24 -0
- sigil_protocol_verifier-1.0.0/sigil_verify/pdf.py +111 -0
- sigil_protocol_verifier-1.0.0/tests/test_canonical.py +19 -0
- sigil_protocol_verifier-1.0.0/tests/test_chain.py +34 -0
- sigil_protocol_verifier-1.0.0/tests/test_pdf.py +46 -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,39 @@
|
|
|
1
|
+
# sigil-protocol-verifier (Python)
|
|
2
|
+
|
|
3
|
+
Reference verifier for [Sigil Protocol v1.0](../../docs/protocol/v1.0/SPEC.md).
|
|
4
|
+
Independent of the Sigil platform — built from the spec, with `cryptography`
|
|
5
|
+
as the only runtime dependency.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install sigil-protocol-verifier
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Use
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
sigil-verify path/to/sealed.pdf
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Prints a JSON report per §8 of the spec. Exits `0` for `VALID`, `1`
|
|
20
|
+
otherwise.
|
|
21
|
+
|
|
22
|
+
## What it verifies
|
|
23
|
+
|
|
24
|
+
- §3 hash chain (programmatic API only).
|
|
25
|
+
- §4 canonical payload form.
|
|
26
|
+
- §5 sealed PDF — `/ByteRange` extraction, signed-bytes SHA-256, CMS
|
|
27
|
+
SignedData parse via `cryptography`.
|
|
28
|
+
|
|
29
|
+
## What it does not yet verify
|
|
30
|
+
|
|
31
|
+
Same caveats as the TypeScript reference verifier — full PAdES-B-LTA
|
|
32
|
+
conformance + standalone capability-token verification land post-v1.0.
|
|
33
|
+
|
|
34
|
+
## Test
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install -e .[dev]
|
|
38
|
+
pytest
|
|
39
|
+
```
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sigil-protocol-verifier"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Reference verifier for Sigil Protocol v1.0 — independent implementation per docs/protocol/v1.0/SPEC.md."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Sigil" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"cryptography>=42.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = ["pytest>=8.0"]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
sigil-verify = "sigil_verify.cli:main"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
where = ["."]
|
|
25
|
+
include = ["sigil_verify*"]
|
|
@@ -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,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
sigil_protocol_verifier.egg-info/PKG-INFO
|
|
4
|
+
sigil_protocol_verifier.egg-info/SOURCES.txt
|
|
5
|
+
sigil_protocol_verifier.egg-info/dependency_links.txt
|
|
6
|
+
sigil_protocol_verifier.egg-info/entry_points.txt
|
|
7
|
+
sigil_protocol_verifier.egg-info/requires.txt
|
|
8
|
+
sigil_protocol_verifier.egg-info/top_level.txt
|
|
9
|
+
sigil_verify/__init__.py
|
|
10
|
+
sigil_verify/canonical.py
|
|
11
|
+
sigil_verify/chain.py
|
|
12
|
+
sigil_verify/cli.py
|
|
13
|
+
sigil_verify/pdf.py
|
|
14
|
+
tests/test_canonical.py
|
|
15
|
+
tests/test_chain.py
|
|
16
|
+
tests/test_pdf.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -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__}")
|
|
@@ -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)
|
|
@@ -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()
|
|
@@ -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
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from sigil_verify import canonical_hash, canonicalize
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_object_keys_sorted():
|
|
5
|
+
assert canonicalize({"b": 1, "a": 2}) == '{"a":2,"b":1}'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_array_order_preserved():
|
|
9
|
+
assert canonicalize([3, 1, 2]) == "[3,1,2]"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_nfc_normalization():
|
|
13
|
+
composed = "é"
|
|
14
|
+
decomposed = "é"
|
|
15
|
+
assert canonical_hash(composed) == canonical_hash(decomposed)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_equivalent_objects_same_hash():
|
|
19
|
+
assert canonical_hash({"a": 1, "b": 2}) == canonical_hash({"b": 2, "a": 1})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from sigil_verify import GENESIS_PREV_HASH, ChainEntry, entry_hash, verify_chain
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def seal(previous: ChainEntry | None, *, type: str, timestamp: str, payload_hash: str) -> ChainEntry:
|
|
5
|
+
index = 0 if previous is None else previous.index + 1
|
|
6
|
+
prev = GENESIS_PREV_HASH if previous is None else previous.hash
|
|
7
|
+
h = entry_hash(index=index, timestamp=timestamp, type=type, payload_hash=payload_hash, prev_hash=prev)
|
|
8
|
+
return ChainEntry(
|
|
9
|
+
index=index, timestamp=timestamp, type=type, payload_hash=payload_hash, prev_hash=prev, hash=h
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_clean_chain_verifies():
|
|
14
|
+
a = seal(None, type="ENVELOPE_SENT", timestamp="2026-05-24T12:00:00Z", payload_hash="a" * 64)
|
|
15
|
+
b = seal(a, type="RECIPIENT_COMPLETED", timestamp="2026-05-24T12:05:00Z", payload_hash="b" * 64)
|
|
16
|
+
result = verify_chain([a, b])
|
|
17
|
+
assert result.valid is True
|
|
18
|
+
assert result.broken_at_index is None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_tampered_chain_detected():
|
|
22
|
+
a = seal(None, type="ENVELOPE_SENT", timestamp="2026-05-24T12:00:00Z", payload_hash="a" * 64)
|
|
23
|
+
b = seal(a, type="RECIPIENT_COMPLETED", timestamp="2026-05-24T12:05:00Z", payload_hash="b" * 64)
|
|
24
|
+
tampered = ChainEntry(
|
|
25
|
+
index=b.index,
|
|
26
|
+
timestamp="2026-05-25T00:00:00Z",
|
|
27
|
+
type=b.type,
|
|
28
|
+
payload_hash=b.payload_hash,
|
|
29
|
+
prev_hash=b.prev_hash,
|
|
30
|
+
hash=b.hash,
|
|
31
|
+
)
|
|
32
|
+
result = verify_chain([a, tampered])
|
|
33
|
+
assert result.valid is False
|
|
34
|
+
assert result.broken_at_index == 1
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from sigil_verify import verify_sealed_pdf
|
|
4
|
+
from sigil_verify.pdf import _der_total_length
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_unknown_for_non_sigil_pdf():
|
|
8
|
+
not_a_seal = b"%PDF-1.4\n1 0 obj <</Type /Catalog>> endobj\n%%EOF\n"
|
|
9
|
+
report = verify_sealed_pdf(not_a_seal)
|
|
10
|
+
assert report.status == "UNKNOWN"
|
|
11
|
+
assert report.document_sha256 is None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_invalid_when_contents_hex_malformed():
|
|
15
|
+
malformed = (
|
|
16
|
+
b"%PDF-1.4\n"
|
|
17
|
+
b"1 0 obj <</ByteRange [0 10 100 50] /Contents <ZZZZ> >> endobj\n"
|
|
18
|
+
b"%%EOF\n"
|
|
19
|
+
+ b"\x00" * 200
|
|
20
|
+
)
|
|
21
|
+
report = verify_sealed_pdf(malformed)
|
|
22
|
+
assert report.status == "INVALID"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Regression: real Sigil-sealed PDFs zero-pad /Contents to the reserved
|
|
26
|
+
# length, and feeding the padding into the DER parser tripped cryptography's
|
|
27
|
+
# BER fallback warning (and would become a hard error in a future release).
|
|
28
|
+
def test_der_total_length_short_form_ignores_padding():
|
|
29
|
+
der = bytes([0x30, 0x03, 0x01, 0x02, 0x03, 0x00, 0x00, 0x00])
|
|
30
|
+
assert _der_total_length(der) == 5
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_der_total_length_long_form():
|
|
34
|
+
header = bytes([0x30, 0x82, 0x01, 0x02])
|
|
35
|
+
assert _der_total_length(header) == 4 + 258
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_der_total_length_keeps_trailing_zero_content_byte():
|
|
39
|
+
# Stripping trailing zeros naively would eat the final content byte.
|
|
40
|
+
der = bytes([0x30, 0x02, 0x05, 0x00, 0x00, 0x00])
|
|
41
|
+
assert _der_total_length(der) == 4
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_der_total_length_rejects_truncated_header():
|
|
45
|
+
with pytest.raises(ValueError):
|
|
46
|
+
_der_total_length(bytes([0x30]))
|