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 +21 -0
- qrsig-0.1.0/PKG-INFO +122 -0
- qrsig-0.1.0/README.md +93 -0
- qrsig-0.1.0/pyproject.toml +41 -0
- qrsig-0.1.0/qrsig/__init__.py +10 -0
- qrsig-0.1.0/qrsig/anchor.py +161 -0
- qrsig-0.1.0/qrsig/binding.py +67 -0
- qrsig-0.1.0/qrsig/cli.py +305 -0
- qrsig-0.1.0/qrsig/core.py +200 -0
- qrsig-0.1.0/qrsig/legacy.py +57 -0
- qrsig-0.1.0/qrsig/qrcode.py +81 -0
- qrsig-0.1.0/qrsig.egg-info/PKG-INFO +122 -0
- qrsig-0.1.0/qrsig.egg-info/SOURCES.txt +21 -0
- qrsig-0.1.0/qrsig.egg-info/dependency_links.txt +1 -0
- qrsig-0.1.0/qrsig.egg-info/entry_points.txt +2 -0
- qrsig-0.1.0/qrsig.egg-info/requires.txt +14 -0
- qrsig-0.1.0/qrsig.egg-info/top_level.txt +1 -0
- qrsig-0.1.0/setup.cfg +4 -0
- qrsig-0.1.0/tests/test_binding_anchor.py +203 -0
- qrsig-0.1.0/tests/test_core.py +163 -0
- qrsig-0.1.0/tests/test_e2e.py +185 -0
- qrsig-0.1.0/tests/test_legacy.py +109 -0
- qrsig-0.1.0/tests/test_vectors.py +77 -0
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"
|