bluefox-edge 0.0.1__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.
- bluefox_edge-0.0.1/LICENSE +21 -0
- bluefox_edge-0.0.1/PKG-INFO +76 -0
- bluefox_edge-0.0.1/README.md +52 -0
- bluefox_edge-0.0.1/pyproject.toml +39 -0
- bluefox_edge-0.0.1/setup.cfg +4 -0
- bluefox_edge-0.0.1/src/bluefox_edge/__init__.py +43 -0
- bluefox_edge-0.0.1/src/bluefox_edge/_cli.py +27 -0
- bluefox_edge-0.0.1/src/bluefox_edge/verify_receipt.py +501 -0
- bluefox_edge-0.0.1/src/bluefox_edge.egg-info/PKG-INFO +76 -0
- bluefox_edge-0.0.1/src/bluefox_edge.egg-info/SOURCES.txt +11 -0
- bluefox_edge-0.0.1/src/bluefox_edge.egg-info/dependency_links.txt +1 -0
- bluefox_edge-0.0.1/src/bluefox_edge.egg-info/entry_points.txt +2 -0
- bluefox_edge-0.0.1/src/bluefox_edge.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Format Dynamics, Inc. (DBA BlueFox)
|
|
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.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bluefox-edge
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: BlueFox Edge for Python: today, the published offline receipt checker as an installable module; the API client follows.
|
|
5
|
+
Author-email: BlueFox AI <ethan@bluefoxedge.ai>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://www.bluefoxedge.ai
|
|
8
|
+
Project-URL: Documentation, https://www.bluefoxedge.ai/docs/sdk/python
|
|
9
|
+
Project-URL: Verify a receipt, https://www.bluefoxedge.ai/docs/verify-a-receipt
|
|
10
|
+
Project-URL: Published checker, https://api.bluefoxedge.ai/verify_receipt.py
|
|
11
|
+
Project-URL: Repository, https://github.com/ethanholien/bluefox-edge
|
|
12
|
+
Keywords: bluefox,receipt,verification,ed25519,rfc8785,jcs,offline,agents
|
|
13
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Topic :: Security :: Cryptography
|
|
20
|
+
Requires-Python: >=3.8
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
<!-- DRAFT-FOR-CEO-REVIEW · staged dark by ff115 (SS115/D006) · NOT PUBLISHED · every sentence below is proposed public text -->
|
|
26
|
+
# bluefox-edge (Python)
|
|
27
|
+
|
|
28
|
+
**0.0.1 — a first, small, genuine release.** It installs the published BlueFox Edge
|
|
29
|
+
offline receipt checker so you can pin it with pip instead of fetching a file:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install bluefox-edge
|
|
33
|
+
bluefox-edge-verify-receipt my-receipt.json
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
That command runs the exact bytes served at
|
|
37
|
+
<https://api.bluefoxedge.ai/verify_receipt.py> (sha256 `50dd2124…711d8`, 24,801 B —
|
|
38
|
+
compare with <https://api.bluefoxedge.ai/verify_receipt.py.sha256> any time). Same
|
|
39
|
+
steps, same order, same one-sentence verdicts. Exit 0 means the check passed.
|
|
40
|
+
|
|
41
|
+
## What it checks
|
|
42
|
+
|
|
43
|
+
A BlueFox Edge receipt is a signed record of one API answer: the envelope is
|
|
44
|
+
canonicalized (RFC 8785 / JCS), hashed (SHA-256), chained, and signed (Ed25519) with a
|
|
45
|
+
published key. The checker recomputes every signed field from the file's own bytes and
|
|
46
|
+
refuses — in a sentence, never a traceback — on any disagreement. It reads the key set
|
|
47
|
+
from the published JWKS, or fully offline from a file you saved:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
curl -sSo pinned-jwks.json https://api.bluefoxedge.ai/.well-known/jwks.json
|
|
51
|
+
bluefox-edge-verify-receipt --jwks pinned-jwks.json my-receipt.json
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The receipt never names its own key source; the `--jwks` flag is the operator's hand.
|
|
55
|
+
How to read what a receipt does and does not say: <https://www.bluefoxedge.ai/docs/verify-a-receipt>.
|
|
56
|
+
|
|
57
|
+
## What is coming
|
|
58
|
+
|
|
59
|
+
The BlueFox Edge API client for Python (`BlueFoxClient`, sync and async) is written and
|
|
60
|
+
tested in the repository at version 0.6.0. It arrives on PyPI under this name once its
|
|
61
|
+
handling of the keyless x402 payment quote (HTTP 402) is cured — until then a 0.0.x
|
|
62
|
+
release does not talk to the API for you. Docs: <https://www.bluefoxedge.ai/docs/sdk/python>.
|
|
63
|
+
|
|
64
|
+
## Why a 0.0.1 at all
|
|
65
|
+
|
|
66
|
+
Fifteen files in the repository and both SDK READMEs already print
|
|
67
|
+
`pip install bluefox-edge`. Publishing a real, working checker under the name closes
|
|
68
|
+
the gap between what the docs say and what the registry has, without pretending the
|
|
69
|
+
client is here.
|
|
70
|
+
|
|
71
|
+
## Also on npm
|
|
72
|
+
|
|
73
|
+
The JavaScript twin of the checker is `npx bluefox-verify-receipt`, and `bluefox-edge`
|
|
74
|
+
on npm re-exports it.
|
|
75
|
+
|
|
76
|
+
License: MIT. Author: BlueFox AI (Format Dynamics, Inc.).
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<!-- DRAFT-FOR-CEO-REVIEW · staged dark by ff115 (SS115/D006) · NOT PUBLISHED · every sentence below is proposed public text -->
|
|
2
|
+
# bluefox-edge (Python)
|
|
3
|
+
|
|
4
|
+
**0.0.1 — a first, small, genuine release.** It installs the published BlueFox Edge
|
|
5
|
+
offline receipt checker so you can pin it with pip instead of fetching a file:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install bluefox-edge
|
|
9
|
+
bluefox-edge-verify-receipt my-receipt.json
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
That command runs the exact bytes served at
|
|
13
|
+
<https://api.bluefoxedge.ai/verify_receipt.py> (sha256 `50dd2124…711d8`, 24,801 B —
|
|
14
|
+
compare with <https://api.bluefoxedge.ai/verify_receipt.py.sha256> any time). Same
|
|
15
|
+
steps, same order, same one-sentence verdicts. Exit 0 means the check passed.
|
|
16
|
+
|
|
17
|
+
## What it checks
|
|
18
|
+
|
|
19
|
+
A BlueFox Edge receipt is a signed record of one API answer: the envelope is
|
|
20
|
+
canonicalized (RFC 8785 / JCS), hashed (SHA-256), chained, and signed (Ed25519) with a
|
|
21
|
+
published key. The checker recomputes every signed field from the file's own bytes and
|
|
22
|
+
refuses — in a sentence, never a traceback — on any disagreement. It reads the key set
|
|
23
|
+
from the published JWKS, or fully offline from a file you saved:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
curl -sSo pinned-jwks.json https://api.bluefoxedge.ai/.well-known/jwks.json
|
|
27
|
+
bluefox-edge-verify-receipt --jwks pinned-jwks.json my-receipt.json
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The receipt never names its own key source; the `--jwks` flag is the operator's hand.
|
|
31
|
+
How to read what a receipt does and does not say: <https://www.bluefoxedge.ai/docs/verify-a-receipt>.
|
|
32
|
+
|
|
33
|
+
## What is coming
|
|
34
|
+
|
|
35
|
+
The BlueFox Edge API client for Python (`BlueFoxClient`, sync and async) is written and
|
|
36
|
+
tested in the repository at version 0.6.0. It arrives on PyPI under this name once its
|
|
37
|
+
handling of the keyless x402 payment quote (HTTP 402) is cured — until then a 0.0.x
|
|
38
|
+
release does not talk to the API for you. Docs: <https://www.bluefoxedge.ai/docs/sdk/python>.
|
|
39
|
+
|
|
40
|
+
## Why a 0.0.1 at all
|
|
41
|
+
|
|
42
|
+
Fifteen files in the repository and both SDK READMEs already print
|
|
43
|
+
`pip install bluefox-edge`. Publishing a real, working checker under the name closes
|
|
44
|
+
the gap between what the docs say and what the registry has, without pretending the
|
|
45
|
+
client is here.
|
|
46
|
+
|
|
47
|
+
## Also on npm
|
|
48
|
+
|
|
49
|
+
The JavaScript twin of the checker is `npx bluefox-verify-receipt`, and `bluefox-edge`
|
|
50
|
+
on npm re-exports it.
|
|
51
|
+
|
|
52
|
+
License: MIT. Author: BlueFox AI (Format Dynamics, Inc.).
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# DRAFT-FOR-CEO-REVIEW -- staged dark by ff115 (SS115/D006), NOT published. Every public-facing string in this
|
|
2
|
+
# file and in README.md is a proposal for the CEO's review before any claim ceremony.
|
|
3
|
+
[build-system]
|
|
4
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
5
|
+
build-backend = "setuptools.build_meta"
|
|
6
|
+
|
|
7
|
+
[project]
|
|
8
|
+
name = "bluefox-edge"
|
|
9
|
+
version = "0.0.1"
|
|
10
|
+
description = "BlueFox Edge for Python: today, the published offline receipt checker as an installable module; the API client follows."
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
license = {text = "MIT"}
|
|
13
|
+
requires-python = ">=3.8"
|
|
14
|
+
authors = [{name = "BlueFox AI", email = "ethan@bluefoxedge.ai"}]
|
|
15
|
+
keywords = ["bluefox", "receipt", "verification", "ed25519", "rfc8785", "jcs", "offline", "agents"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Topic :: Security :: Cryptography",
|
|
24
|
+
]
|
|
25
|
+
dependencies = []
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://www.bluefoxedge.ai"
|
|
29
|
+
Documentation = "https://www.bluefoxedge.ai/docs/sdk/python"
|
|
30
|
+
"Verify a receipt" = "https://www.bluefoxedge.ai/docs/verify-a-receipt"
|
|
31
|
+
"Published checker" = "https://api.bluefoxedge.ai/verify_receipt.py"
|
|
32
|
+
Repository = "https://github.com/ethanholien/bluefox-edge"
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
bluefox-edge-verify-receipt = "bluefox_edge._cli:main"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
where = ["src"]
|
|
39
|
+
include = ["bluefox_edge*"]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""bluefox-edge 0.0.1 -- DRAFT-FOR-CEO-REVIEW, staged dark, not published.
|
|
2
|
+
|
|
3
|
+
What this release IS: the published BlueFox Edge offline receipt checker
|
|
4
|
+
(https://api.bluefoxedge.ai/verify_receipt.py), shipped byte-for-byte as
|
|
5
|
+
``bluefox_edge.verify_receipt`` so it can be pip-installed and pinned instead of
|
|
6
|
+
fetched. Run it as ``bluefox-edge-verify-receipt <receipt.json>`` or
|
|
7
|
+
``python -m bluefox_edge.verify_receipt <receipt.json>``.
|
|
8
|
+
|
|
9
|
+
What this release IS NOT: the API client. That client exists in the BlueFox Edge
|
|
10
|
+
repository (sdk/python, version 0.6.0) and lands here under a later version once
|
|
11
|
+
its keyless-402 handling is cured. Nothing in 0.0.x talks to the API on your
|
|
12
|
+
behalf; the checker only fetches the published key set, and ``--jwks`` makes even
|
|
13
|
+
that offline.
|
|
14
|
+
"""
|
|
15
|
+
import hashlib as _hashlib
|
|
16
|
+
import os as _os
|
|
17
|
+
|
|
18
|
+
__version__ = "0.0.1"
|
|
19
|
+
|
|
20
|
+
DOCS_URL = "https://www.bluefoxedge.ai/docs/sdk/python"
|
|
21
|
+
VERIFY_DOCS_URL = "https://www.bluefoxedge.ai/docs/verify-a-receipt"
|
|
22
|
+
PUBLISHED_CHECKER_URL = "https://api.bluefoxedge.ai/verify_receipt.py"
|
|
23
|
+
JWKS_URL = "https://api.bluefoxedge.ai/.well-known/jwks.json"
|
|
24
|
+
|
|
25
|
+
#: sha256 of the checker bytes vendored in this release -- the same bytes the
|
|
26
|
+
#: street served at PUBLISHED_CHECKER_URL on 2026-09-12 (24,801 B).
|
|
27
|
+
CHECKER_SHA256 = "50dd2124e8023cd7e7a3d83bccfab8627b645d83f785e082fa1edf38373711d8"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def checker_path():
|
|
31
|
+
"""Absolute path of the vendored checker file."""
|
|
32
|
+
return _os.path.join(_os.path.dirname(__file__), "verify_receipt.py")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def checker_sha256():
|
|
36
|
+
"""sha256 of the vendored checker as installed -- compare with CHECKER_SHA256 and
|
|
37
|
+
with https://api.bluefoxedge.ai/verify_receipt.py.sha256 before you trust it."""
|
|
38
|
+
with open(checker_path(), "rb") as f:
|
|
39
|
+
return _hashlib.sha256(f.read()).hexdigest()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = ["__version__", "DOCS_URL", "VERIFY_DOCS_URL", "PUBLISHED_CHECKER_URL",
|
|
43
|
+
"JWKS_URL", "CHECKER_SHA256", "checker_path", "checker_sha256"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Console entry for ``bluefox-edge-verify-receipt``.
|
|
2
|
+
|
|
3
|
+
The vendored checker is a script, not a library: it reads ``sys.argv`` at import and
|
|
4
|
+
exits with a one-sentence verdict. Running it through ``runpy`` keeps the served bytes
|
|
5
|
+
untouched (so its sha256 stays comparable with the street's) while giving pip users
|
|
6
|
+
a command on PATH.
|
|
7
|
+
"""
|
|
8
|
+
import runpy
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv=None):
|
|
13
|
+
if argv is None:
|
|
14
|
+
argv = sys.argv[1:]
|
|
15
|
+
if argv and argv[0] in ("-h", "--help"):
|
|
16
|
+
print("usage: bluefox-edge-verify-receipt [--jwks KEYS.json|https://...] [RECEIPT.json]\n"
|
|
17
|
+
"Checks a BlueFox Edge signed receipt. With no file it checks ./specimen-receipt.json.\n"
|
|
18
|
+
"Exit 0 = the check passes; any other exit prints one sentence saying why not.\n"
|
|
19
|
+
"Same bytes as https://api.bluefoxedge.ai/verify_receipt.py (see bluefox_edge.CHECKER_SHA256).")
|
|
20
|
+
return 0
|
|
21
|
+
sys.argv = ["verify_receipt.py"] + list(argv)
|
|
22
|
+
runpy.run_module("bluefox_edge.verify_receipt", run_name="__main__", alter_sys=True)
|
|
23
|
+
return 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
if __name__ == "__main__":
|
|
27
|
+
sys.exit(main())
|
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import base64, hashlib, json, sys, urllib.request
|
|
2
|
+
|
|
3
|
+
# DEPENDENCIES: none. Python 3.8+ standard library only. If the third-party
|
|
4
|
+
# cryptography package happens to be installed, its Ed25519 verifier is used;
|
|
5
|
+
# otherwise the self-contained RFC 8032 verifier further down checks the same
|
|
6
|
+
# bytes and reaches the same answer. Nothing here needs pip.
|
|
7
|
+
#
|
|
8
|
+
# python3 verify_receipt.py checks specimen-receipt.json
|
|
9
|
+
# python3 verify_receipt.py my-receipt.json checks the file you name
|
|
10
|
+
# python3 verify_receipt.py --jwks saved-keys.json my-receipt.json
|
|
11
|
+
# checks fully offline against keys you saved
|
|
12
|
+
#
|
|
13
|
+
# Exit 0 means the check passed. Any non-zero exit means it did not, and the
|
|
14
|
+
# reason is one sentence -- never a stack trace.
|
|
15
|
+
#
|
|
16
|
+
# TWO KEY FAMILIES, AND THEY NEVER CROSS. Receipts are signed by the receipt
|
|
17
|
+
# key. The transparency log signs its checkpoints with a DIFFERENT key. Both are
|
|
18
|
+
# published in the same key set, and every entry declares which family it
|
|
19
|
+
# belongs to in a bfx:purpose member. DO NOT SELECT A KEY BY POSITION: keys[0]
|
|
20
|
+
# is the receipt key, so a checker that indexes the list instead of reading the
|
|
21
|
+
# label is one reordering away from checking the wrong thing. This file checks a
|
|
22
|
+
# RECEIPT, so it requires a purpose of "receipt" and refuses a receipt that
|
|
23
|
+
# names the log's key. Checkpoints are the other script's job, and it makes the
|
|
24
|
+
# mirror-image check: https://api.bluefoxedge.ai/verify_inclusion.py
|
|
25
|
+
|
|
26
|
+
# Pin the key set out of band -- never read a jwks_url out of the receipt.
|
|
27
|
+
# That field is unsigned: a forger can point it at keys they control, and a
|
|
28
|
+
# checker that follows it will bless the forgery. The explicit User-Agent is
|
|
29
|
+
# deliberate too: some edge firewalls refuse the default urllib signature.
|
|
30
|
+
#
|
|
31
|
+
# --jwks moves the pin FURTHER out of band, never less: name a key-set file
|
|
32
|
+
# you saved earlier and the whole check runs offline against those pinned
|
|
33
|
+
# bytes -- nothing is fetched at all -- or name an https address you trust
|
|
34
|
+
# and keys are fetched from there instead of the default below. Either way
|
|
35
|
+
# the choice is the operator's hand, never the receipt's: no field in the
|
|
36
|
+
# file being judged can pick its own judge.
|
|
37
|
+
JWKS_URL = "https://api.bluefoxedge.ai/.well-known/jwks.json"
|
|
38
|
+
PURPOSE_CLAIM = "bfx:purpose"
|
|
39
|
+
RECEIPT_PURPOSE = "receipt"
|
|
40
|
+
|
|
41
|
+
def unb64url(s):
|
|
42
|
+
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
|
43
|
+
|
|
44
|
+
def b64url(raw):
|
|
45
|
+
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
|
46
|
+
|
|
47
|
+
def sha256s(raw):
|
|
48
|
+
return "sha256:" + hashlib.sha256(raw).hexdigest()
|
|
49
|
+
|
|
50
|
+
# RFC 8785 (JCS) canonical JSON, standard library only.
|
|
51
|
+
#
|
|
52
|
+
# Reproduced from BRIDIE's construction -- a stranger who, sent at this estate
|
|
53
|
+
# on 2026-08-15 under a no-install rule, rebuilt this in 25 lines of stdlib and
|
|
54
|
+
# got output BYTE-IDENTICAL to the rfc8785 package on the published specimen
|
|
55
|
+
# (1,324 bytes from both). Her point stands and is the reason this file no
|
|
56
|
+
# longer asks you to install anything: the spec is complete enough to
|
|
57
|
+
# reimplement, so the checker should not need a package to prove it.
|
|
58
|
+
#
|
|
59
|
+
# Three rules do all the work. Object keys sort by UTF-16 code unit -- which is
|
|
60
|
+
# what encoding each key to UTF-16 big-endian and comparing those bytes gives
|
|
61
|
+
# you, surrogate pairs included. There is no insignificant whitespace. And a
|
|
62
|
+
# number that is integral is spelled without its fraction: JCS spells 0.0 as 0,
|
|
63
|
+
# and one character breaks a hash. That trap is live in the flagship artifact --
|
|
64
|
+
# the specimen's confidence_score is 0.0.
|
|
65
|
+
#
|
|
66
|
+
# The number domain this accepts is the one the signer itself enforces before
|
|
67
|
+
# hashing (finite floats, integers within the JCS safe-integer range). Outside
|
|
68
|
+
# that, and in the narrow band where a shortest-round-trip float would have to
|
|
69
|
+
# be spelled with an exponent, this REFUSES rather than guessing -- a wrong
|
|
70
|
+
# canonical form is a wrong hash, and a wrong hash silently accuses an honest
|
|
71
|
+
# receipt. Reject, never normalise.
|
|
72
|
+
def jcs(value):
|
|
73
|
+
if value is True:
|
|
74
|
+
return b"true"
|
|
75
|
+
if value is False:
|
|
76
|
+
return b"false"
|
|
77
|
+
if value is None:
|
|
78
|
+
return b"null"
|
|
79
|
+
if isinstance(value, str):
|
|
80
|
+
try:
|
|
81
|
+
return json.dumps(value, ensure_ascii=False,
|
|
82
|
+
separators=(",", ":")).encode("utf-8")
|
|
83
|
+
except UnicodeEncodeError:
|
|
84
|
+
raise SystemExit("this receipt carries text that is not encodable as "
|
|
85
|
+
"UTF-8 (a lone surrogate) -- it cannot be canonicalised")
|
|
86
|
+
if isinstance(value, int):
|
|
87
|
+
if abs(value) > 2 ** 53 - 1:
|
|
88
|
+
raise SystemExit("this receipt carries an integer outside the JCS "
|
|
89
|
+
"safe-integer range -- refusing rather than hashing a "
|
|
90
|
+
"value two implementations would spell differently")
|
|
91
|
+
return repr(value).encode("ascii")
|
|
92
|
+
if isinstance(value, float):
|
|
93
|
+
if value != value or value in (float("inf"), float("-inf")):
|
|
94
|
+
raise SystemExit("this receipt carries a non-finite number, which has "
|
|
95
|
+
"no JSON canonical form")
|
|
96
|
+
text = repr(value)
|
|
97
|
+
if "e" in text or "E" in text:
|
|
98
|
+
raise SystemExit("this receipt carries a number whose canonical "
|
|
99
|
+
"spelling needs an exponent, which this stdlib "
|
|
100
|
+
"canonicaliser does not attempt -- refusing rather "
|
|
101
|
+
"than risking a wrong hash")
|
|
102
|
+
if value.is_integer():
|
|
103
|
+
return repr(int(value)).encode("ascii")
|
|
104
|
+
return text.encode("ascii")
|
|
105
|
+
if isinstance(value, list):
|
|
106
|
+
return b"[" + b",".join(jcs(item) for item in value) + b"]"
|
|
107
|
+
if isinstance(value, dict):
|
|
108
|
+
pairs = []
|
|
109
|
+
for key in sorted(value, key=lambda k: k.encode("utf-16-be")):
|
|
110
|
+
if not isinstance(key, str):
|
|
111
|
+
raise SystemExit("this receipt has a non-text object key -- it is "
|
|
112
|
+
"not canonicalisable JSON")
|
|
113
|
+
pairs.append(jcs(key) + b":" + jcs(value[key]))
|
|
114
|
+
return b"{" + b",".join(pairs) + b"}"
|
|
115
|
+
raise SystemExit("this receipt carries a value of a kind JSON has no "
|
|
116
|
+
"canonical form for -- it is not a receipt this recipe checks")
|
|
117
|
+
|
|
118
|
+
def copies(node, key): # every value a receipt's own subtree carries under key
|
|
119
|
+
if isinstance(node, dict):
|
|
120
|
+
for k, v in node.items():
|
|
121
|
+
if k == key:
|
|
122
|
+
yield v
|
|
123
|
+
yield from copies(v, key)
|
|
124
|
+
elif isinstance(node, list):
|
|
125
|
+
for item in node:
|
|
126
|
+
yield from copies(item, key)
|
|
127
|
+
|
|
128
|
+
# The file to check: the one you name, or the published specimen if you name
|
|
129
|
+
# nothing. Every way this can go wrong below is a sentence and a non-zero exit,
|
|
130
|
+
# because handing a stranger a traceback for a missing file is not a verdict --
|
|
131
|
+
# it is the vendor's stack frames in place of an answer. Flag parsing is spelled
|
|
132
|
+
# out rather than imported: argparse prints its own usage paragraphs and exits 2,
|
|
133
|
+
# and this file's contract is one sentence and its own exit, every time.
|
|
134
|
+
jwks_source = None
|
|
135
|
+
names = []
|
|
136
|
+
args = list(sys.argv[1:])
|
|
137
|
+
while args:
|
|
138
|
+
arg = args.pop(0)
|
|
139
|
+
if arg == "--jwks" or arg.startswith("--jwks="):
|
|
140
|
+
if jwks_source is not None:
|
|
141
|
+
raise SystemExit("--jwks is named twice -- one verdict means one "
|
|
142
|
+
"pinned key set, so name exactly one")
|
|
143
|
+
if arg == "--jwks":
|
|
144
|
+
value = args.pop(0) if args else ""
|
|
145
|
+
else:
|
|
146
|
+
value = arg[len("--jwks="):]
|
|
147
|
+
if not value:
|
|
148
|
+
raise SystemExit("--jwks needs a value -- the path of a key-set "
|
|
149
|
+
"file you saved earlier, or an https address to "
|
|
150
|
+
"fetch the keys from")
|
|
151
|
+
jwks_source = value
|
|
152
|
+
else:
|
|
153
|
+
names.append(arg)
|
|
154
|
+
if len(names) > 1:
|
|
155
|
+
raise SystemExit("usage: python3 verify_receipt.py [PATH-TO-RECEIPT.json] -- "
|
|
156
|
+
"one file at a time, so one verdict means one receipt")
|
|
157
|
+
path = names[0] if names else "specimen-receipt.json"
|
|
158
|
+
try:
|
|
159
|
+
with open(path, encoding="utf-8") as handle:
|
|
160
|
+
specimen = json.load(handle)
|
|
161
|
+
except FileNotFoundError:
|
|
162
|
+
raise SystemExit("there is no file at " + path + " -- name the receipt you "
|
|
163
|
+
"want checked, or save it as specimen-receipt.json and pass "
|
|
164
|
+
"nothing")
|
|
165
|
+
except IsADirectoryError:
|
|
166
|
+
raise SystemExit(path + " is a directory, not a receipt file")
|
|
167
|
+
except PermissionError:
|
|
168
|
+
raise SystemExit(path + " cannot be read (permission denied)")
|
|
169
|
+
except UnicodeDecodeError:
|
|
170
|
+
raise SystemExit(path + " is not UTF-8 text, so it is not a receipt this "
|
|
171
|
+
"recipe can read")
|
|
172
|
+
except ValueError as exc:
|
|
173
|
+
raise SystemExit(path + " is not valid JSON (" + str(exc) + ")")
|
|
174
|
+
except OSError as exc:
|
|
175
|
+
raise SystemExit(path + " could not be read (" + str(exc) + ")")
|
|
176
|
+
if not isinstance(specimen, dict):
|
|
177
|
+
raise SystemExit(path + " holds a " + type(specimen).__name__ + ", not a JSON "
|
|
178
|
+
"object -- a receipt is an object")
|
|
179
|
+
|
|
180
|
+
# The signed surface is the envelope: the one dict that carries the chain
|
|
181
|
+
# block. Its home differs by door -- data.receipt in an x402 mint response,
|
|
182
|
+
# data.envelope in a read-API response or the published specimen, one per
|
|
183
|
+
# entry of an export bundle -- so this finds it instead of indexing a path.
|
|
184
|
+
#
|
|
185
|
+
# WHICH envelope, when a file offers more than one, is a security question,
|
|
186
|
+
# not a convenience one. Every door here answers with a "data" mapping and
|
|
187
|
+
# puts the envelope inside it, so if the file HAS that mapping, ITS slot
|
|
188
|
+
# decides and nothing found elsewhere in the file can speak for it. That is
|
|
189
|
+
# what stops a doctored receipt at the slot you read from being excused by a
|
|
190
|
+
# genuine envelope tucked somewhere else in the same file. Only a file with no
|
|
191
|
+
# such mapping -- an export bundle, or something you assembled yourself --
|
|
192
|
+
# falls back to searching, and then you are reading whatever the search found:
|
|
193
|
+
# run this once per entry and read each answer. Either way a pass is a
|
|
194
|
+
# statement about ONE envelope, never about the file. And a file that HAS a
|
|
195
|
+
# data block but names no receipt in it is refused outright rather than
|
|
196
|
+
# searched, because that is not a shape any of these doors answers with.
|
|
197
|
+
#
|
|
198
|
+
# Both routes also return the dict CARRYING the envelope -- data, or the entry
|
|
199
|
+
# an envelope was found under -- because that is where the unsigned convenience
|
|
200
|
+
# copies live, and it is the fence that keeps the copy check inside ONE receipt,
|
|
201
|
+
# so a two-receipt bundle never compares entry A's hashes against entry B's.
|
|
202
|
+
# An envelope found directly inside a list has no carrier to name, and then the
|
|
203
|
+
# envelope fences itself: only its own copies are checked.
|
|
204
|
+
def signed(node):
|
|
205
|
+
chain = node.get("chain") if isinstance(node, dict) else None
|
|
206
|
+
return isinstance(chain, dict) and "signature" in chain
|
|
207
|
+
|
|
208
|
+
def slot_in_data(doc):
|
|
209
|
+
data = doc.get("data") if isinstance(doc, dict) else None
|
|
210
|
+
if not isinstance(data, dict):
|
|
211
|
+
return None # no data mapping: search, below
|
|
212
|
+
named = [s for s in ("receipt", "envelope") if s in data]
|
|
213
|
+
if not named:
|
|
214
|
+
# A "data" mapping is how these doors answer, so a file that has one
|
|
215
|
+
# and names no receipt inside it is not a receipt response -- and the
|
|
216
|
+
# rest of the file does not get to volunteer a substitute.
|
|
217
|
+
raise SystemExit("this file has a data block with no receipt in it -- "
|
|
218
|
+
"refusing to hunt elsewhere for something to check")
|
|
219
|
+
# From here the file HAS named a receipt, so that name binds and the
|
|
220
|
+
# search never runs. Naming one and then failing to be one is a refusal,
|
|
221
|
+
# not a licence to go looking for something else in the file to bless.
|
|
222
|
+
if len(named) > 1:
|
|
223
|
+
raise SystemExit("this file fills both data.receipt and data.envelope "
|
|
224
|
+
"-- name one receipt, or check them one at a time")
|
|
225
|
+
slot = named[0]
|
|
226
|
+
if not signed(data[slot]):
|
|
227
|
+
raise SystemExit("data." + slot + " is not a signed envelope -- "
|
|
228
|
+
"refusing to check something else in its place")
|
|
229
|
+
return data, data[slot] # data is the carrier, as below
|
|
230
|
+
|
|
231
|
+
def find_envelope(node, parent=None):
|
|
232
|
+
if isinstance(node, dict):
|
|
233
|
+
if signed(node):
|
|
234
|
+
return parent, node
|
|
235
|
+
for child in node.values():
|
|
236
|
+
hit = find_envelope(child, node)
|
|
237
|
+
if hit is not None:
|
|
238
|
+
return hit
|
|
239
|
+
elif isinstance(node, list):
|
|
240
|
+
for child in node:
|
|
241
|
+
hit = find_envelope(child, None)
|
|
242
|
+
if hit is not None:
|
|
243
|
+
return hit
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
found = slot_in_data(specimen) or find_envelope(specimen)
|
|
247
|
+
if found is None:
|
|
248
|
+
raise SystemExit("no signed envelope found -- not a signed receipt (yet?)")
|
|
249
|
+
scope, envelope = found
|
|
250
|
+
if scope is None:
|
|
251
|
+
scope = envelope
|
|
252
|
+
chain = envelope["chain"]
|
|
253
|
+
|
|
254
|
+
# Everything the recipe is about to read, present AND of the right kind
|
|
255
|
+
# before it is read. A receipt missing one of these -- or carrying null or a
|
|
256
|
+
# number where text belongs -- is refused in a sentence, never with a
|
|
257
|
+
# traceback: handing a stranger a stack trace is the failure this whole
|
|
258
|
+
# recipe was rewritten to stop. (key_id must only be PRESENT: a keyless
|
|
259
|
+
# receipt carries it as null and reaches its own honest exit further down.)
|
|
260
|
+
def needs(field, home, text=True):
|
|
261
|
+
if field not in home:
|
|
262
|
+
raise SystemExit("this envelope has no " + field + " -- it is not a "
|
|
263
|
+
"receipt of the shape this recipe checks")
|
|
264
|
+
if text and not isinstance(home[field], str):
|
|
265
|
+
raise SystemExit("this envelope's " + field + " is not text -- it is "
|
|
266
|
+
"not a receipt of the shape this recipe checks")
|
|
267
|
+
|
|
268
|
+
needs("chain_prev", chain)
|
|
269
|
+
needs("key_id", chain, text=False)
|
|
270
|
+
needs("schema_version", envelope)
|
|
271
|
+
|
|
272
|
+
# 1. content_digest recomputes from the envelope's own bytes: sha256 over the
|
|
273
|
+
# RFC 8785 canonical form of the envelope with the whole chain block
|
|
274
|
+
# removed. JCS, not json.dumps -- JCS spells 0.0 as 0, and one character
|
|
275
|
+
# breaks a hash.
|
|
276
|
+
body = {k: v for k, v in envelope.items() if k != "chain"}
|
|
277
|
+
digest = sha256s(jcs(body))
|
|
278
|
+
|
|
279
|
+
# 2. chain_self recomputes: sha256 over digest|chain_prev|schema_version,
|
|
280
|
+
# joined with literal pipes, hashed as UTF-8. chain_prev is the previous
|
|
281
|
+
# receipt's chain_self, or the literal "GENESIS" at a chain head.
|
|
282
|
+
head = sha256s("|".join(
|
|
283
|
+
(digest, chain["chain_prev"], body["schema_version"])).encode("utf-8"))
|
|
284
|
+
|
|
285
|
+
# 3. Every copy of the two hashes in THIS receipt's subtree agrees with the
|
|
286
|
+
# recomputation -- the chain block's and the top-level mirrors' alike.
|
|
287
|
+
# Presence is checked before agreement: all() is True over an empty
|
|
288
|
+
# iterator, so a receipt carrying NO copy of a hash would otherwise sail
|
|
289
|
+
# through this step without it ever being compared to anything. A detected
|
|
290
|
+
# forgery is a verdict, so it exits in a sentence, never a stack trace.
|
|
291
|
+
for key, want in (("content_digest", digest), ("chain_self", head)):
|
|
292
|
+
carried = list(copies(scope, key))
|
|
293
|
+
if not carried:
|
|
294
|
+
raise SystemExit(key + " is absent -- there is nothing here to check")
|
|
295
|
+
if not all(got == want for got in carried):
|
|
296
|
+
raise SystemExit(
|
|
297
|
+
key + " does not recompute from the receipt's own bytes")
|
|
298
|
+
|
|
299
|
+
if not chain.get("signature"):
|
|
300
|
+
raise SystemExit("keyless receipt: steps 1-3 hold, but with key_id and "
|
|
301
|
+
"signature null there is no signature to check")
|
|
302
|
+
|
|
303
|
+
# 4. The signing key is published, and its name is a derived fact:
|
|
304
|
+
# kid = sha256 of the raw 32-byte Ed25519 public key. The key is selected by
|
|
305
|
+
# kid and then checked against its declared family -- selecting by kid alone
|
|
306
|
+
# would let a checkpoint key verify a receipt if the two ever crossed, and
|
|
307
|
+
# the whole reason there are two keys is that they must not.
|
|
308
|
+
#
|
|
309
|
+
# With --jwks the same selection runs against the keys YOU pinned. A saved
|
|
310
|
+
# file is read here with no network at all; an https address you named is
|
|
311
|
+
# fetched in place of the default. The kid check below is what makes a
|
|
312
|
+
# pinned file trustworthy to hold: the key's name is derived from the key's
|
|
313
|
+
# own bytes, so a swapped key cannot keep its old name.
|
|
314
|
+
key_set_word = "published" if jwks_source is None else "pinned"
|
|
315
|
+
if jwks_source is None or jwks_source.startswith("https://"):
|
|
316
|
+
key_set_url = JWKS_URL if jwks_source is None else jwks_source
|
|
317
|
+
req = urllib.request.Request(key_set_url, headers={"User-Agent": "bluefox-verify/1"})
|
|
318
|
+
try:
|
|
319
|
+
jwks = json.load(urllib.request.urlopen(req, timeout=30))
|
|
320
|
+
except Exception as exc:
|
|
321
|
+
raise SystemExit("the " + key_set_word + " key set at " + key_set_url +
|
|
322
|
+
" could not be "
|
|
323
|
+
"fetched (" + type(exc).__name__ + ") -- this checker will "
|
|
324
|
+
"not fall back to any address inside the receipt, so it "
|
|
325
|
+
"stops here")
|
|
326
|
+
elif jwks_source.startswith("http://"):
|
|
327
|
+
raise SystemExit("--jwks " + jwks_source + " is plaintext http -- keys "
|
|
328
|
+
"fetched without TLS can be swapped in flight, so save the "
|
|
329
|
+
"key set to a file and name that instead, or use https")
|
|
330
|
+
else:
|
|
331
|
+
try:
|
|
332
|
+
with open(jwks_source, encoding="utf-8") as handle:
|
|
333
|
+
jwks = json.load(handle)
|
|
334
|
+
except FileNotFoundError:
|
|
335
|
+
raise SystemExit("there is no key-set file at " + jwks_source + " -- "
|
|
336
|
+
"save the published keys first (curl -sSo "
|
|
337
|
+
"pinned-jwks.json " + JWKS_URL + ") and name that file")
|
|
338
|
+
except IsADirectoryError:
|
|
339
|
+
raise SystemExit(jwks_source + " is a directory, not a key-set file")
|
|
340
|
+
except PermissionError:
|
|
341
|
+
raise SystemExit(jwks_source + " cannot be read (permission denied)")
|
|
342
|
+
except UnicodeDecodeError:
|
|
343
|
+
raise SystemExit(jwks_source + " is not UTF-8 text, so it is not a key "
|
|
344
|
+
"set this recipe can read")
|
|
345
|
+
except ValueError:
|
|
346
|
+
raise SystemExit(jwks_source + " is not valid JSON, so no key can be "
|
|
347
|
+
"pinned from it")
|
|
348
|
+
except OSError:
|
|
349
|
+
raise SystemExit(jwks_source + " could not be read, so no key can be "
|
|
350
|
+
"pinned from it")
|
|
351
|
+
if not isinstance(jwks, dict) or not isinstance(jwks.get("keys"), list):
|
|
352
|
+
raise SystemExit("the " + key_set_word + " key set has no keys array, so no "
|
|
353
|
+
"key can be pinned")
|
|
354
|
+
key = next((k for k in jwks["keys"]
|
|
355
|
+
if isinstance(k, dict) and k.get("kid") == chain["key_id"]), None)
|
|
356
|
+
if key is None:
|
|
357
|
+
if jwks_source is None:
|
|
358
|
+
raise SystemExit("key_id is not in the published key set")
|
|
359
|
+
raise SystemExit("key_id is not in the pinned key set at " + jwks_source +
|
|
360
|
+
" -- if that copy predates this receipt, refresh it from " +
|
|
361
|
+
JWKS_URL + " and pin the new file")
|
|
362
|
+
purpose = key.get(PURPOSE_CLAIM)
|
|
363
|
+
# ABSENT is tolerated on purpose; only a POSITIVE wrong designation refuses.
|
|
364
|
+
# The published set gained bfx:purpose at the 2026-08-18 FIRE, so every key-set
|
|
365
|
+
# file pinned before that day lacks the member -- and the --jwks door exists
|
|
366
|
+
# precisely for files saved earlier. Same philosophy as the settlement lane's
|
|
367
|
+
# network guard: refuse a wrong answer, never punish an absent one. (H98 read
|
|
368
|
+
# the fail-closed suggestion and refuted it against the repo's own genuine
|
|
369
|
+
# 2026-08-08 pinned fixture; a stricter world needs a worded tolerance-sunset.)
|
|
370
|
+
if isinstance(purpose, str) and purpose != RECEIPT_PURPOSE:
|
|
371
|
+
raise SystemExit("this receipt names the " + key_set_word + " key " + chain["key_id"] +
|
|
372
|
+
", which is designated " + repr(purpose) + " and not a "
|
|
373
|
+
"receipt signer -- the two key families never cross, so "
|
|
374
|
+
"this is refused rather than verified")
|
|
375
|
+
raw_key = unb64url(key["x"])
|
|
376
|
+
if hashlib.sha256(raw_key).hexdigest() != chain["key_id"]:
|
|
377
|
+
raise SystemExit("kid is not sha256 of the raw public key")
|
|
378
|
+
|
|
379
|
+
# 5. The carried signature string is the one canonical spelling of its bytes
|
|
380
|
+
# (base64url, padding stripped) -- flip even a slack bit and this fails.
|
|
381
|
+
needs("signature", chain)
|
|
382
|
+
try:
|
|
383
|
+
sig = unb64url(chain["signature"])
|
|
384
|
+
except Exception:
|
|
385
|
+
raise SystemExit("this envelope's signature is not base64url text -- it "
|
|
386
|
+
"is not a receipt of the shape this recipe checks")
|
|
387
|
+
if b64url(sig) != chain["signature"]:
|
|
388
|
+
raise SystemExit("signature encoding is not canonical")
|
|
389
|
+
|
|
390
|
+
# Ed25519 verification (RFC 8032), self-contained, so this file runs on a bare
|
|
391
|
+
# Python. You never need to read this block: it is the arithmetic, not the
|
|
392
|
+
# recipe. It is here only so that "no install required" is literally true, and
|
|
393
|
+
# it is skipped entirely when the cryptography package is present.
|
|
394
|
+
P = 2 ** 255 - 19
|
|
395
|
+
L = 2 ** 252 + 27742317777372353535851937790883648493
|
|
396
|
+
|
|
397
|
+
def inv(x):
|
|
398
|
+
return pow(x, P - 2, P)
|
|
399
|
+
|
|
400
|
+
D = (-121665 * inv(121666)) % P
|
|
401
|
+
SQRT_M1 = pow(2, (P - 1) // 4, P)
|
|
402
|
+
|
|
403
|
+
def x_recover(y):
|
|
404
|
+
xx = ((y * y - 1) * inv(D * y * y + 1)) % P
|
|
405
|
+
x = pow(xx, (P + 3) // 8, P)
|
|
406
|
+
if (x * x - xx) % P != 0:
|
|
407
|
+
x = (x * SQRT_M1) % P
|
|
408
|
+
if (x * x - xx) % P != 0:
|
|
409
|
+
return None
|
|
410
|
+
return P - x if x % 2 != 0 else x
|
|
411
|
+
|
|
412
|
+
def point_add(pt1, pt2):
|
|
413
|
+
x1, y1 = pt1
|
|
414
|
+
x2, y2 = pt2
|
|
415
|
+
prod = (D * x1 * x2 * y1 * y2) % P
|
|
416
|
+
return (((x1 * y2 + x2 * y1) * inv(1 + prod)) % P,
|
|
417
|
+
((y1 * y2 + x1 * x2) * inv(1 - prod)) % P)
|
|
418
|
+
|
|
419
|
+
def scalar_mult(pt, scalar):
|
|
420
|
+
acc = (0, 1)
|
|
421
|
+
while scalar > 0:
|
|
422
|
+
if scalar & 1:
|
|
423
|
+
acc = point_add(acc, pt)
|
|
424
|
+
pt = point_add(pt, pt)
|
|
425
|
+
scalar >>= 1
|
|
426
|
+
return acc
|
|
427
|
+
|
|
428
|
+
BASE_Y = (4 * inv(5)) % P
|
|
429
|
+
BASE = (x_recover(BASE_Y), BASE_Y)
|
|
430
|
+
|
|
431
|
+
def on_curve(pt):
|
|
432
|
+
x, y = pt
|
|
433
|
+
return (-x * x + y * y - 1 - D * x * x * y * y) % P == 0
|
|
434
|
+
|
|
435
|
+
def decode_point(blob):
|
|
436
|
+
value = int.from_bytes(blob, "little")
|
|
437
|
+
y = value & ((1 << 255) - 1)
|
|
438
|
+
sign = value >> 255
|
|
439
|
+
if y >= P:
|
|
440
|
+
return None
|
|
441
|
+
x = x_recover(y)
|
|
442
|
+
if x is None:
|
|
443
|
+
return None
|
|
444
|
+
if x & 1 != sign:
|
|
445
|
+
x = P - x
|
|
446
|
+
pt = (x, y)
|
|
447
|
+
return pt if on_curve(pt) else None
|
|
448
|
+
|
|
449
|
+
def ed25519_verify_pure(public_raw, signature, message):
|
|
450
|
+
if len(public_raw) != 32 or len(signature) != 64:
|
|
451
|
+
return False
|
|
452
|
+
point_a = decode_point(public_raw)
|
|
453
|
+
point_r = decode_point(signature[:32])
|
|
454
|
+
if point_a is None or point_r is None:
|
|
455
|
+
return False
|
|
456
|
+
scalar_s = int.from_bytes(signature[32:], "little")
|
|
457
|
+
if scalar_s >= L:
|
|
458
|
+
return False
|
|
459
|
+
challenge = int.from_bytes(
|
|
460
|
+
hashlib.sha512(signature[:32] + public_raw + message).digest(),
|
|
461
|
+
"little") % L
|
|
462
|
+
return scalar_mult(BASE, scalar_s) == point_add(
|
|
463
|
+
point_r, scalar_mult(point_a, challenge))
|
|
464
|
+
|
|
465
|
+
def ed25519_verify(public_raw, signature, message):
|
|
466
|
+
try:
|
|
467
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
468
|
+
Ed25519PublicKey)
|
|
469
|
+
except ImportError:
|
|
470
|
+
return ed25519_verify_pure(public_raw, signature, message)
|
|
471
|
+
try:
|
|
472
|
+
Ed25519PublicKey.from_public_bytes(public_raw).verify(signature, message)
|
|
473
|
+
return True
|
|
474
|
+
except Exception:
|
|
475
|
+
return False
|
|
476
|
+
|
|
477
|
+
# 6. The Ed25519 signature verifies over the RECOMPUTED chain head -- so the
|
|
478
|
+
# signature commits to every envelope byte hashed in step 1, not merely to
|
|
479
|
+
# a hash string the file happened to carry. A failure HERE is the verdict
|
|
480
|
+
# this whole recipe exists to deliver, so it too is a sentence.
|
|
481
|
+
if not ed25519_verify(raw_key, sig, head.encode("utf-8")):
|
|
482
|
+
raise SystemExit("the signature does not verify over the recomputed chain "
|
|
483
|
+
"head -- these are not the bytes the published key signed")
|
|
484
|
+
|
|
485
|
+
# 7. Unsigned convenience mirrors: the dict carrying the envelope repeats some
|
|
486
|
+
# signed fields for readers, and nothing above checked those copies -- they
|
|
487
|
+
# sit outside the signature. Mirrors spell the exact bytes of the envelope's
|
|
488
|
+
# values (produced_at included: same Z-form, same instant), so a copy that
|
|
489
|
+
# disagrees is a REFUSAL, not a warning. The signature held -- but the file
|
|
490
|
+
# in your hand is showing a reader different bytes than the ones it signed,
|
|
491
|
+
# and a disagreeing mirror is exactly how a doctored copy rides a check
|
|
492
|
+
# that reads only the envelope. A warning here asked you to notice what
|
|
493
|
+
# this script exists to notice for you, so it stops instead.
|
|
494
|
+
if scope is not envelope:
|
|
495
|
+
for key, value in body.items():
|
|
496
|
+
if key in scope and scope[key] != value:
|
|
497
|
+
raise SystemExit("unsigned top-level " + key + " does not match the "
|
|
498
|
+
"signed envelope's " + key + " -- the signature held, "
|
|
499
|
+
"but this file presents altered bytes where a reader "
|
|
500
|
+
"reads, and that is a tampered file, not a footnote")
|
|
501
|
+
print("the check passes: every signed field recomputed from the file's own bytes")
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bluefox-edge
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: BlueFox Edge for Python: today, the published offline receipt checker as an installable module; the API client follows.
|
|
5
|
+
Author-email: BlueFox AI <ethan@bluefoxedge.ai>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://www.bluefoxedge.ai
|
|
8
|
+
Project-URL: Documentation, https://www.bluefoxedge.ai/docs/sdk/python
|
|
9
|
+
Project-URL: Verify a receipt, https://www.bluefoxedge.ai/docs/verify-a-receipt
|
|
10
|
+
Project-URL: Published checker, https://api.bluefoxedge.ai/verify_receipt.py
|
|
11
|
+
Project-URL: Repository, https://github.com/ethanholien/bluefox-edge
|
|
12
|
+
Keywords: bluefox,receipt,verification,ed25519,rfc8785,jcs,offline,agents
|
|
13
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Topic :: Security :: Cryptography
|
|
20
|
+
Requires-Python: >=3.8
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
<!-- DRAFT-FOR-CEO-REVIEW · staged dark by ff115 (SS115/D006) · NOT PUBLISHED · every sentence below is proposed public text -->
|
|
26
|
+
# bluefox-edge (Python)
|
|
27
|
+
|
|
28
|
+
**0.0.1 — a first, small, genuine release.** It installs the published BlueFox Edge
|
|
29
|
+
offline receipt checker so you can pin it with pip instead of fetching a file:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install bluefox-edge
|
|
33
|
+
bluefox-edge-verify-receipt my-receipt.json
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
That command runs the exact bytes served at
|
|
37
|
+
<https://api.bluefoxedge.ai/verify_receipt.py> (sha256 `50dd2124…711d8`, 24,801 B —
|
|
38
|
+
compare with <https://api.bluefoxedge.ai/verify_receipt.py.sha256> any time). Same
|
|
39
|
+
steps, same order, same one-sentence verdicts. Exit 0 means the check passed.
|
|
40
|
+
|
|
41
|
+
## What it checks
|
|
42
|
+
|
|
43
|
+
A BlueFox Edge receipt is a signed record of one API answer: the envelope is
|
|
44
|
+
canonicalized (RFC 8785 / JCS), hashed (SHA-256), chained, and signed (Ed25519) with a
|
|
45
|
+
published key. The checker recomputes every signed field from the file's own bytes and
|
|
46
|
+
refuses — in a sentence, never a traceback — on any disagreement. It reads the key set
|
|
47
|
+
from the published JWKS, or fully offline from a file you saved:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
curl -sSo pinned-jwks.json https://api.bluefoxedge.ai/.well-known/jwks.json
|
|
51
|
+
bluefox-edge-verify-receipt --jwks pinned-jwks.json my-receipt.json
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The receipt never names its own key source; the `--jwks` flag is the operator's hand.
|
|
55
|
+
How to read what a receipt does and does not say: <https://www.bluefoxedge.ai/docs/verify-a-receipt>.
|
|
56
|
+
|
|
57
|
+
## What is coming
|
|
58
|
+
|
|
59
|
+
The BlueFox Edge API client for Python (`BlueFoxClient`, sync and async) is written and
|
|
60
|
+
tested in the repository at version 0.6.0. It arrives on PyPI under this name once its
|
|
61
|
+
handling of the keyless x402 payment quote (HTTP 402) is cured — until then a 0.0.x
|
|
62
|
+
release does not talk to the API for you. Docs: <https://www.bluefoxedge.ai/docs/sdk/python>.
|
|
63
|
+
|
|
64
|
+
## Why a 0.0.1 at all
|
|
65
|
+
|
|
66
|
+
Fifteen files in the repository and both SDK READMEs already print
|
|
67
|
+
`pip install bluefox-edge`. Publishing a real, working checker under the name closes
|
|
68
|
+
the gap between what the docs say and what the registry has, without pretending the
|
|
69
|
+
client is here.
|
|
70
|
+
|
|
71
|
+
## Also on npm
|
|
72
|
+
|
|
73
|
+
The JavaScript twin of the checker is `npx bluefox-verify-receipt`, and `bluefox-edge`
|
|
74
|
+
on npm re-exports it.
|
|
75
|
+
|
|
76
|
+
License: MIT. Author: BlueFox AI (Format Dynamics, Inc.).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/bluefox_edge/__init__.py
|
|
5
|
+
src/bluefox_edge/_cli.py
|
|
6
|
+
src/bluefox_edge/verify_receipt.py
|
|
7
|
+
src/bluefox_edge.egg-info/PKG-INFO
|
|
8
|
+
src/bluefox_edge.egg-info/SOURCES.txt
|
|
9
|
+
src/bluefox_edge.egg-info/dependency_links.txt
|
|
10
|
+
src/bluefox_edge.egg-info/entry_points.txt
|
|
11
|
+
src/bluefox_edge.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bluefox_edge
|