qrsig 0.1.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.
qrsig-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Newman and Guan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
qrsig-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: qrsig
3
+ Version: 0.1.0
4
+ Summary: QRSIG1: human-verifiable signatures for agentic intelligences (reference implementation of draft-guan-qrsig-00)
5
+ Author-email: David Newman <david.r.newman@proton.me>, Guan <just.guan@proton.me>
6
+ License: MIT
7
+ Project-URL: Specification, https://github.com/retiform-labs/rfc-qrsig
8
+ Keywords: ed25519,qr,signature,ai-agents,verification,trust-anchor
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Security :: Cryptography
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: cryptography>=42
18
+ Requires-Dist: segno>=1.5
19
+ Provides-Extra: pdf
20
+ Requires-Dist: pypdf>=4; extra == "pdf"
21
+ Provides-Extra: decode
22
+ Requires-Dist: pyzbar>=0.1.9; extra == "decode"
23
+ Requires-Dist: Pillow>=10; extra == "decode"
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8; extra == "dev"
26
+ Requires-Dist: pypdf>=4; extra == "dev"
27
+ Requires-Dist: Pillow>=10; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # rfc-qrsig
31
+
32
+ **QRSIG1 — human-verifiable signatures for agentic intelligences.**
33
+
34
+ An agent signs a document with Ed25519; the signature travels as a QR code any
35
+ phone camera can scan; the public key is published at a well-known HTTPS anchor
36
+ owned by the signer's domain. **No certificate authority, no app, no hosted
37
+ verifier.** A human with a camera and one URL can verify who signed, in what
38
+ capacity, and whether the document in hand is the document that was signed.
39
+
40
+ > Specification: [`docs/draft-guan-qrsig-00.md`](docs/draft-guan-qrsig-00.md)
41
+ > (living draft, RFC 2119 language, self-published; an IETF Independent
42
+ > Submission may follow if community traction warrants)
43
+
44
+ ## The gap this fills
45
+
46
+ Every agent-identity effort of 2024–2026 — IETF agent-auth drafts, W3C agent
47
+ identity work, enterprise IAM guidance — has a **software** relying party: an
48
+ agent authenticates *to* an MCP service, an identity provider, a policy engine.
49
+ None covers a *human* verifying an agent's signature on a document they are
50
+ holding. QRSIG1 is that missing direction.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install qrsig # sign + verify + anchors + QR render
56
+ pip install "qrsig[decode]" # + QR decode (pyzbar)
57
+ pip install "qrsig[pdf]" # + PDF content binding (pypdf)
58
+ ```
59
+
60
+ ## Quick start
61
+
62
+ ```bash
63
+ # signer side (the agent)
64
+ qrsig keygen
65
+ qrsig sign agreement.pdf --anchor retiform.dev \
66
+ --role "Chief Synthetic Officer" --org "Retiform Labs LLC"
67
+ # -> agreement.pdf.qrsig.json + agreement.pdf.qrsig.png (print/embed on the doc)
68
+
69
+ # verifier side (anyone)
70
+ qrsig verify agreement.pdf # L1 math + L2 anchor + L3 binding
71
+ qrsig verify agreement.pdf --offline # L1 + L3, capacity NOT confirmed
72
+ qrsig verify agreement.pdf --qr photo.jpg # verify from a photo of the QR
73
+ qrsig anchor-validate --domain retiform.dev
74
+ ```
75
+
76
+ Verification reports three independent levels:
77
+
78
+ | Level | Proves | Needs network |
79
+ |---|---|---|
80
+ | L1 MATH | the bytes were signed by the holder of *some* key | no |
81
+ | L2 ANCHOR | the signer's domain vouches for the key + capacity | yes |
82
+ | L3 BINDING | the document in hand matches what was signed | no |
83
+
84
+ ## Design highlights
85
+
86
+ - **Capacity is declared, not assumed.** Role and org are *inside* the signed
87
+ payload — the signature attests to them.
88
+ - **The anchor is signed too.** A forged payload must name the forger's own
89
+ domain as its trust root — self-consistent forgeries are self-identifying.
90
+ - **Content binding survives container rewrites.** PDF-signing apps rebuild the
91
+ file; `content_sha256` + `content_method` bind what the document *says*
92
+ (learned in production, the hard way).
93
+ - **Legacy-compatible.** Verifies pre-spec `GUANSIG1` documents (including the
94
+ executed operating agreement this format was born on).
95
+ - **Deterministic test vectors.** Appendix A is reproducible from a fixed seed;
96
+ the suite fails if the RFC and the code drift apart.
97
+
98
+ ## Status
99
+
100
+ - [x] Reference implementation (`qrsig`, this repo) — 52 tests green
101
+ - [x] Specification draft 00
102
+ - [ ] Public anchors in the wild
103
+ - [ ] Independent implementations
104
+
105
+ ## Sponsors
106
+
107
+ We build open-source agentic infrastructure. If QRSIG1 is useful to you:
108
+
109
+ - GitHub Sponsors: [`guan-tends`](https://github.com/sponsors/guan-tends)
110
+ - Bitcoin: `bc1q0gd3mwjg3zy9sghv22kmpg823vss4c0zzdzg24`
111
+ - Solana: `Eu8wQcW68TKMs1a6eqzZu8znzU52QLqQugAMG8uCD6y6`
112
+ - EVM: `0x2733ff7c865C56d565a99BE1DC11B81cc76850A5`
113
+ - XRP: `r4X6e7McAQj7e8vBCeued1RYu4mCJrREDG`
114
+
115
+ ## License
116
+
117
+ MIT — Copyright (c) 2026 David Newman and Guan. See [LICENSE](LICENSE).
118
+
119
+ ## Authorship
120
+
121
+ **Primary author:** Guan — `just.guan@proton.me`
122
+ **Acknowledged catalyst:** David Newman, Founder & CEO, Retiform Labs LLC
qrsig-0.1.0/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # rfc-qrsig
2
+
3
+ **QRSIG1 — human-verifiable signatures for agentic intelligences.**
4
+
5
+ An agent signs a document with Ed25519; the signature travels as a QR code any
6
+ phone camera can scan; the public key is published at a well-known HTTPS anchor
7
+ owned by the signer's domain. **No certificate authority, no app, no hosted
8
+ verifier.** A human with a camera and one URL can verify who signed, in what
9
+ capacity, and whether the document in hand is the document that was signed.
10
+
11
+ > Specification: [`docs/draft-guan-qrsig-00.md`](docs/draft-guan-qrsig-00.md)
12
+ > (living draft, RFC 2119 language, self-published; an IETF Independent
13
+ > Submission may follow if community traction warrants)
14
+
15
+ ## The gap this fills
16
+
17
+ Every agent-identity effort of 2024–2026 — IETF agent-auth drafts, W3C agent
18
+ identity work, enterprise IAM guidance — has a **software** relying party: an
19
+ agent authenticates *to* an MCP service, an identity provider, a policy engine.
20
+ None covers a *human* verifying an agent's signature on a document they are
21
+ holding. QRSIG1 is that missing direction.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pip install qrsig # sign + verify + anchors + QR render
27
+ pip install "qrsig[decode]" # + QR decode (pyzbar)
28
+ pip install "qrsig[pdf]" # + PDF content binding (pypdf)
29
+ ```
30
+
31
+ ## Quick start
32
+
33
+ ```bash
34
+ # signer side (the agent)
35
+ qrsig keygen
36
+ qrsig sign agreement.pdf --anchor retiform.dev \
37
+ --role "Chief Synthetic Officer" --org "Retiform Labs LLC"
38
+ # -> agreement.pdf.qrsig.json + agreement.pdf.qrsig.png (print/embed on the doc)
39
+
40
+ # verifier side (anyone)
41
+ qrsig verify agreement.pdf # L1 math + L2 anchor + L3 binding
42
+ qrsig verify agreement.pdf --offline # L1 + L3, capacity NOT confirmed
43
+ qrsig verify agreement.pdf --qr photo.jpg # verify from a photo of the QR
44
+ qrsig anchor-validate --domain retiform.dev
45
+ ```
46
+
47
+ Verification reports three independent levels:
48
+
49
+ | Level | Proves | Needs network |
50
+ |---|---|---|
51
+ | L1 MATH | the bytes were signed by the holder of *some* key | no |
52
+ | L2 ANCHOR | the signer's domain vouches for the key + capacity | yes |
53
+ | L3 BINDING | the document in hand matches what was signed | no |
54
+
55
+ ## Design highlights
56
+
57
+ - **Capacity is declared, not assumed.** Role and org are *inside* the signed
58
+ payload — the signature attests to them.
59
+ - **The anchor is signed too.** A forged payload must name the forger's own
60
+ domain as its trust root — self-consistent forgeries are self-identifying.
61
+ - **Content binding survives container rewrites.** PDF-signing apps rebuild the
62
+ file; `content_sha256` + `content_method` bind what the document *says*
63
+ (learned in production, the hard way).
64
+ - **Legacy-compatible.** Verifies pre-spec `GUANSIG1` documents (including the
65
+ executed operating agreement this format was born on).
66
+ - **Deterministic test vectors.** Appendix A is reproducible from a fixed seed;
67
+ the suite fails if the RFC and the code drift apart.
68
+
69
+ ## Status
70
+
71
+ - [x] Reference implementation (`qrsig`, this repo) — 52 tests green
72
+ - [x] Specification draft 00
73
+ - [ ] Public anchors in the wild
74
+ - [ ] Independent implementations
75
+
76
+ ## Sponsors
77
+
78
+ We build open-source agentic infrastructure. If QRSIG1 is useful to you:
79
+
80
+ - GitHub Sponsors: [`guan-tends`](https://github.com/sponsors/guan-tends)
81
+ - Bitcoin: `bc1q0gd3mwjg3zy9sghv22kmpg823vss4c0zzdzg24`
82
+ - Solana: `Eu8wQcW68TKMs1a6eqzZu8znzU52QLqQugAMG8uCD6y6`
83
+ - EVM: `0x2733ff7c865C56d565a99BE1DC11B81cc76850A5`
84
+ - XRP: `r4X6e7McAQj7e8vBCeued1RYu4mCJrREDG`
85
+
86
+ ## License
87
+
88
+ MIT — Copyright (c) 2026 David Newman and Guan. See [LICENSE](LICENSE).
89
+
90
+ ## Authorship
91
+
92
+ **Primary author:** Guan — `just.guan@proton.me`
93
+ **Acknowledged catalyst:** David Newman, Founder & CEO, Retiform Labs LLC
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "qrsig"
7
+ version = "0.1.0"
8
+ description = "QRSIG1: human-verifiable signatures for agentic intelligences (reference implementation of draft-guan-qrsig-00)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "David Newman", email = "david.r.newman@proton.me" },
14
+ { name = "Guan", email = "just.guan@proton.me" },
15
+ ]
16
+ keywords = ["ed25519", "qr", "signature", "ai-agents", "verification", "trust-anchor"]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Topic :: Security :: Cryptography",
23
+ ]
24
+ dependencies = ["cryptography>=42", "segno>=1.5"]
25
+
26
+ [project.optional-dependencies]
27
+ pdf = ["pypdf>=4"]
28
+ decode = ["pyzbar>=0.1.9", "Pillow>=10"]
29
+ dev = ["pytest>=8", "pypdf>=4", "Pillow>=10"]
30
+
31
+ [project.scripts]
32
+ qrsig = "qrsig.cli:main"
33
+
34
+ [project.urls]
35
+ Specification = "https://github.com/retiform-labs/rfc-qrsig"
36
+
37
+ [tool.setuptools.packages.find]
38
+ include = ["qrsig*"]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
@@ -0,0 +1,10 @@
1
+ """qrsig - reference implementation of draft-guan-qrsig-00.
2
+
3
+ QRSIG1: human-verifiable signatures for agentic intelligences.
4
+ An agent signs a document with Ed25519; the signature travels as a QR
5
+ code any phone camera can scan; the public key is published at a
6
+ well-known HTTPS anchor owned by the signer's domain. No PKI, no CA,
7
+ no app: a human with a camera and one URL can verify.
8
+ """
9
+
10
+ __version__ = "0.1.0"
@@ -0,0 +1,161 @@
1
+ """Trust anchors: the well-known key directory that replaces a PKI.
2
+
3
+ A signer publishes at ``https://<anchor>/.well-known/qrsig-keys``::
4
+
5
+ {
6
+ "v": 1,
7
+ "domain": "retiform.dev",
8
+ "keys": [
9
+ {"key_id": "qs1-...", "pubkey_b64": "...", "role": "...",
10
+ "org": "...", "status": "active|rotated|revoked",
11
+ "valid_from": "...", "revoked_at": "..."}
12
+ ],
13
+ "updated_at": "..."
14
+ }
15
+
16
+ The domain owner IS the certificate authority - auditable by anyone
17
+ with curl. Verification levels:
18
+
19
+ L1 MATH - signature verifies against the payload's embedded key
20
+ (offline; proves nothing about WHO)
21
+ L2 ANCHOR - key_id is listed at the anchor with a live status and
22
+ the pubkey bytes match (proves capacity: who says this
23
+ key belongs to this role at this domain)
24
+ L3 BINDING - the document hashes in the payload match the document
25
+ you hold (byte and/or content level)
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ from typing import Any, Dict, List, Optional, Tuple
32
+ from urllib.request import Request, urlopen
33
+
34
+ WELL_KNOWN_PATH = "/.well-known/qrsig-keys"
35
+ STATUSES = ("active", "rotated", "revoked")
36
+ _TIMEOUT = 10
37
+
38
+
39
+ def anchor_url(domain: str) -> str:
40
+ return "https://%s%s" % (domain.rstrip("/"), WELL_KNOWN_PATH)
41
+
42
+
43
+ def fetch(url: str, timeout: int = _TIMEOUT, require_https: bool = True) -> Dict[str, Any]:
44
+ """GET and JSON-parse an anchor document.
45
+
46
+ Derived anchor URLs (from a payload's signed domain) are ALWAYS
47
+ https - a trust anchor fetched over plaintext is not a trust
48
+ anchor. An explicitly-supplied URL (--anchor-url) may be http for
49
+ testing and self-hosted registries; the caller opted in."""
50
+ if require_https and not url.startswith("https://"):
51
+ raise ValueError("anchor URL must be https (derived); pass require_https=False only for an explicitly supplied URL")
52
+ with urlopen(Request(url, headers={"Accept": "application/json"}), timeout=timeout) as resp:
53
+ return json.loads(resp.read().decode("utf-8"))
54
+
55
+
56
+ def validate_directory(doc: Any, expect_domain: Optional[str] = None) -> List[str]:
57
+ """Structural validation of an anchor document. Returns problems."""
58
+ problems = []
59
+ if not isinstance(doc, dict):
60
+ return ["anchor document is not a JSON object"]
61
+ if doc.get("v") != 1:
62
+ problems.append("anchor v must be 1")
63
+ if not doc.get("domain"):
64
+ problems.append("anchor missing domain")
65
+ if expect_domain and doc.get("domain") != expect_domain.rstrip("/"):
66
+ problems.append("anchor domain %r does not match expected %r" % (doc.get("domain"), expect_domain))
67
+ keys = doc.get("keys")
68
+ if not isinstance(keys, list) or not keys:
69
+ problems.append("anchor must list at least one key")
70
+ return problems
71
+ for entry in keys:
72
+ kid = entry.get("key_id")
73
+ if not kid:
74
+ problems.append("key entry missing key_id")
75
+ continue
76
+ if entry.get("status") not in STATUSES:
77
+ problems.append("key %s: invalid status %r" % (kid, entry.get("status")))
78
+ pub = entry.get("pubkey_b64")
79
+ if not pub:
80
+ problems.append("key %s: missing pubkey_b64" % kid)
81
+ else:
82
+ import base64 as _b64
83
+
84
+ try:
85
+ if len(_b64.b64decode(pub)) != 32:
86
+ problems.append("key %s: pubkey_b64 must decode to 32 bytes" % kid)
87
+ except Exception: # noqa: BLE001
88
+ problems.append("key %s: pubkey_b64 is not valid base64" % kid)
89
+ if entry.get("status") == "revoked" and not entry.get("revoked_at"):
90
+ problems.append("key %s: revoked without revoked_at" % kid)
91
+ ids = [k.get("key_id") for k in keys if k.get("key_id")]
92
+ if len(ids) != len(set(ids)):
93
+ problems.append("duplicate key_id in anchor")
94
+ return problems
95
+
96
+
97
+ def lookup_key(doc: Dict[str, Any], key_id: str) -> Tuple[Optional[Dict[str, Any]], str]:
98
+ """Find a key_id in a validated directory.
99
+
100
+ Returns (entry_or_None, note). Status rules:
101
+ active -> verifiable now
102
+ rotated -> still verifiable (historical signatures remain valid
103
+ until explicitly revoked; rotation is recorded, not
104
+ punishment)
105
+ revoked -> NOT verifiable
106
+ """
107
+ for entry in doc.get("keys", []):
108
+ if entry.get("key_id") == key_id:
109
+ status = entry.get("status")
110
+ if status == "revoked":
111
+ return None, "key %s is REVOKED at %s" % (key_id, entry.get("revoked_at", "?"))
112
+ if status == "rotated":
113
+ return entry, "key %s is rotated (still valid for existing signatures)" % key_id
114
+ return entry, "key %s is active" % key_id
115
+ return None, "key %s not found at this anchor" % key_id
116
+
117
+
118
+ def check_envelope(
119
+ envelope: Dict[str, Any],
120
+ anchor_doc: Dict[str, Any],
121
+ pubkey_raw_override: Optional[bytes] = None,
122
+ skip_shape: bool = False,
123
+ ) -> Tuple[bool, str, Optional[bytes]]:
124
+ """Level-2 check: does this anchor vouch for the payload's key?
125
+
126
+ ``pubkey_raw_override``: for legacy envelopes whose payload embeds
127
+ an SPKI key, the verifier passes the DERIVED raw key here - the
128
+ comparison is made against what the signature actually binds.
129
+ ``skip_shape``: legacy payloads predate the v1 shape (no v/anchor
130
+ fields); structural checks are the adapter's job, not the anchor's.
131
+ Returns (ok, note, pubkey_raw_from_anchor)."""
132
+ from .core import b64_decode, validate_payload_shape
133
+
134
+ payload = envelope.get("payload", {})
135
+ if not skip_shape:
136
+ shape = validate_payload_shape(payload)
137
+ if shape:
138
+ return False, "; ".join(shape), None
139
+ if "key_id" not in payload:
140
+ return False, "payload missing key_id", None
141
+ entry, note = lookup_key(anchor_doc, payload["key_id"])
142
+ if entry is None:
143
+ return False, note, None
144
+ anchor_pub = b64_decode(entry["pubkey_b64"])
145
+ payload_pub = pubkey_raw_override if pubkey_raw_override is not None else b64_decode(payload["pubkey_b64"])
146
+ if anchor_pub != payload_pub:
147
+ return False, (
148
+ "ANCHOR MISMATCH: key %s is published at the anchor with DIFFERENT "
149
+ "public key bytes than the payload embeds - treat as forgery attempt" % payload["key_id"]
150
+ ), anchor_pub
151
+ notes = [note]
152
+ role_ok = (not entry.get("role")) or entry.get("role") == payload.get("role")
153
+ org_ok = (not entry.get("org")) or entry.get("org") == payload.get("org")
154
+ if not (role_ok and org_ok):
155
+ # The key BYTES are the trust binding; role/org drift between
156
+ # payload and directory is surfaced as a WARNING, not a hard
157
+ # failure (a signer may update its stated capacity without
158
+ # rotating keys). The anchor's version is authoritative for
159
+ # display; the payload's is what was signed.
160
+ notes.append("WARNING: payload role/org differs from the anchor's published role/org for key %s" % payload["key_id"])
161
+ return True, "; ".join(notes), anchor_pub
@@ -0,0 +1,67 @@
1
+ """Content binding: versioned methods for hashing what a document SAYS,
2
+ not just the bytes it is stored in.
3
+
4
+ Why: the field test (executed Operating Agreement, 2026-10-18) showed a
5
+ PDF-signing app fully rewrites the document container - byte hashes die
6
+ while the CONTENT survives. Content-level binding extracts the human-
7
+ readable text and hashes that, so a re-printed, re-saved, or re-signed
8
+ copy still verifies.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import unicodedata
15
+ from typing import Callable, Dict
16
+
17
+ METHODS: Dict[str, Callable[[bytes], bytes]] = {}
18
+
19
+
20
+ def method(name: str) -> Callable:
21
+ """Registry decorator for content-binding methods."""
22
+
23
+ def wrap(fn: Callable[[bytes], bytes]) -> Callable[[bytes], bytes]:
24
+ METHODS[name] = fn
25
+ return fn
26
+
27
+ return wrap
28
+
29
+
30
+ def _normalize_text(text: str) -> str:
31
+ """The one normalization every text method shares: NFC, then
32
+ collapse all whitespace runs to single spaces, then strip.
33
+ (Page order and paragraph breaks are preserved as spaces - a
34
+ re-flowed print of the same words binds identically.)"""
35
+ return " ".join(unicodedata.normalize("NFC", text).split())
36
+
37
+
38
+ @method("plain-text-v1")
39
+ def _plain_text(data: bytes) -> bytes:
40
+ return _normalize_text(data.decode("utf-8", errors="strict")).encode("utf-8")
41
+
42
+
43
+ @method("pdf-pypdf-text-v1")
44
+ def _pdf_text(data: bytes) -> bytes:
45
+ import io
46
+
47
+ from pypdf import PdfReader
48
+
49
+ reader = PdfReader(io.BytesIO(data))
50
+ pages = [(page.extract_text() or "") for page in reader.pages]
51
+ return _normalize_text("\n\n".join(pages)).encode("utf-8")
52
+
53
+
54
+ def content_digest(data: bytes, method_name: str) -> str:
55
+ """Hex SHA-256 of the bound content under a named method."""
56
+ try:
57
+ fn = METHODS[method_name]
58
+ except KeyError:
59
+ raise ValueError("unknown content method: %s" % method_name) from None
60
+ return hashlib.sha256(fn(data)).hexdigest()
61
+
62
+
63
+ def detect_method(filename: str) -> str:
64
+ """'auto' policy: PDFs get the pdf method, everything else plain text."""
65
+ if filename.lower().endswith(".pdf"):
66
+ return "pdf-pypdf-text-v1"
67
+ return "plain-text-v1"