proofbundle 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- proofbundle/__init__.py +30 -0
- proofbundle/bundle.py +109 -0
- proofbundle/cli.py +89 -0
- proofbundle/emit.py +138 -0
- proofbundle/errors.py +54 -0
- proofbundle/merkle.py +186 -0
- proofbundle/py.typed +0 -0
- proofbundle/sdjwt.py +134 -0
- proofbundle/signature.py +29 -0
- proofbundle-0.3.0.dist-info/METADATA +291 -0
- proofbundle-0.3.0.dist-info/RECORD +15 -0
- proofbundle-0.3.0.dist-info/WHEEL +5 -0
- proofbundle-0.3.0.dist-info/entry_points.txt +2 -0
- proofbundle-0.3.0.dist-info/licenses/LICENSE +21 -0
- proofbundle-0.3.0.dist-info/top_level.txt +1 -0
proofbundle/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""proofbundle, an offline verifier for portable cryptographic evidence bundles.
|
|
2
|
+
|
|
3
|
+
Verify, fully offline and in pure Python, that a payload was Ed25519 signed and
|
|
4
|
+
anchored under an RFC 6962 Merkle root, with optional SD-JWT selective
|
|
5
|
+
disclosure. The verification half of a signed, third-party-verifiable evidence
|
|
6
|
+
receipt.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .bundle import SCHEMA, load_bundle, verify_bundle
|
|
12
|
+
from .emit import emit_bundle, generate_signer
|
|
13
|
+
from .errors import Check, ProofBundleError, VerificationResult
|
|
14
|
+
from .merkle import verify_consistency, verify_inclusion
|
|
15
|
+
|
|
16
|
+
__version__ = "0.3.0"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"__version__",
|
|
20
|
+
"SCHEMA",
|
|
21
|
+
"verify_bundle",
|
|
22
|
+
"load_bundle",
|
|
23
|
+
"emit_bundle",
|
|
24
|
+
"generate_signer",
|
|
25
|
+
"verify_inclusion",
|
|
26
|
+
"verify_consistency",
|
|
27
|
+
"VerificationResult",
|
|
28
|
+
"Check",
|
|
29
|
+
"ProofBundleError",
|
|
30
|
+
]
|
proofbundle/bundle.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Evidence bundle model and offline verification.
|
|
2
|
+
|
|
3
|
+
An evidence bundle is a single self-contained JSON document. ``verify_bundle``
|
|
4
|
+
checks, fully offline and without any running log server:
|
|
5
|
+
|
|
6
|
+
1. ed25519-signature the payload is signed by the stated public key
|
|
7
|
+
2. merkle-inclusion the payload is anchored under the stated tree root
|
|
8
|
+
(RFC 6962 / RFC 9162 inclusion proof)
|
|
9
|
+
3. sd-jwt (optional) any embedded SD-JWT selective-disclosure credential is
|
|
10
|
+
well formed and, if a key is given, issuer-signed
|
|
11
|
+
|
|
12
|
+
The verifier treats ``payload`` as opaque bytes: it proves *that these exact
|
|
13
|
+
bytes were signed and anchored*, not what they mean. That keeps v0.1 small and
|
|
14
|
+
correct. Turning a reproducible eval run into such a payload is the job of the
|
|
15
|
+
emitter (see ``emit.py``, roadmap).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import base64
|
|
21
|
+
import json
|
|
22
|
+
from typing import Union
|
|
23
|
+
|
|
24
|
+
from . import merkle
|
|
25
|
+
from .errors import BundleFormatError, UnsupportedError, VerificationResult
|
|
26
|
+
from .signature import verify_ed25519
|
|
27
|
+
from .sdjwt import verify_sd_jwt
|
|
28
|
+
|
|
29
|
+
__all__ = ["SCHEMA", "verify_bundle", "load_bundle"]
|
|
30
|
+
|
|
31
|
+
SCHEMA = "proofbundle/v0.1"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _b64d(value: str, field: str) -> bytes:
|
|
35
|
+
try:
|
|
36
|
+
return base64.b64decode(value, validate=True)
|
|
37
|
+
except (ValueError, TypeError) as exc:
|
|
38
|
+
raise BundleFormatError(f"field {field} is not valid base64") from exc
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _require(obj: dict, key: str, field: str):
|
|
42
|
+
if key not in obj:
|
|
43
|
+
raise BundleFormatError(f"missing field {field}")
|
|
44
|
+
return obj[key]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_bundle(path: str) -> dict:
|
|
48
|
+
"""Read and JSON-parse a bundle file."""
|
|
49
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
50
|
+
return json.load(handle)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def verify_bundle(bundle: Union[dict, str]) -> VerificationResult:
|
|
54
|
+
"""Verify an evidence bundle (a dict or a path to a JSON file)."""
|
|
55
|
+
if isinstance(bundle, str):
|
|
56
|
+
bundle = load_bundle(bundle)
|
|
57
|
+
if not isinstance(bundle, dict):
|
|
58
|
+
raise BundleFormatError("bundle must be a JSON object")
|
|
59
|
+
|
|
60
|
+
schema = bundle.get("schema")
|
|
61
|
+
if schema != SCHEMA:
|
|
62
|
+
raise UnsupportedError(f"unsupported schema {schema!r}, expected {SCHEMA!r}")
|
|
63
|
+
|
|
64
|
+
result = VerificationResult()
|
|
65
|
+
payload = _b64d(_require(bundle, "payload_b64", "payload_b64"), "payload_b64")
|
|
66
|
+
|
|
67
|
+
# 1. signature over the payload
|
|
68
|
+
sig = _require(bundle, "signature", "signature")
|
|
69
|
+
alg = sig.get("alg")
|
|
70
|
+
if alg != "ed25519":
|
|
71
|
+
raise UnsupportedError(f"signature alg {alg!r} not supported in v0.1")
|
|
72
|
+
pub = _b64d(_require(sig, "public_key_b64", "signature.public_key_b64"), "signature.public_key_b64")
|
|
73
|
+
raw_sig = _b64d(_require(sig, "sig_b64", "signature.sig_b64"), "signature.sig_b64")
|
|
74
|
+
sig_ok = verify_ed25519(pub, raw_sig, payload)
|
|
75
|
+
result.add("ed25519-signature", sig_ok, "payload signed by stated key" if sig_ok else "invalid signature")
|
|
76
|
+
|
|
77
|
+
# 2. merkle inclusion of the payload
|
|
78
|
+
mk = _require(bundle, "merkle", "merkle")
|
|
79
|
+
hash_alg = mk.get("hash_alg", "sha256-rfc6962")
|
|
80
|
+
if hash_alg != "sha256-rfc6962":
|
|
81
|
+
raise UnsupportedError(f"merkle hash_alg {hash_alg!r} not supported in v0.1")
|
|
82
|
+
leaf_index = int(_require(mk, "leaf_index", "merkle.leaf_index"))
|
|
83
|
+
tree_size = int(_require(mk, "tree_size", "merkle.tree_size"))
|
|
84
|
+
proof = [_b64d(p, "merkle.inclusion_proof_b64[]") for p in mk.get("inclusion_proof_b64", [])]
|
|
85
|
+
root = _b64d(_require(mk, "root_b64", "merkle.root_b64"), "merkle.root_b64")
|
|
86
|
+
incl_ok = merkle.verify_inclusion(payload, leaf_index, tree_size, proof, root)
|
|
87
|
+
result.add(
|
|
88
|
+
"merkle-inclusion",
|
|
89
|
+
incl_ok,
|
|
90
|
+
f"anchored at index {leaf_index} of {tree_size}" if incl_ok else "inclusion proof failed",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# 3. optional SD-JWT selective disclosure credential
|
|
94
|
+
sd = bundle.get("sd_jwt_vc")
|
|
95
|
+
if sd is not None:
|
|
96
|
+
compact = _require(sd, "compact", "sd_jwt_vc.compact")
|
|
97
|
+
issuer_pub = None
|
|
98
|
+
if sd.get("issuer_public_key_b64"):
|
|
99
|
+
issuer_pub = _b64d(sd["issuer_public_key_b64"], "sd_jwt_vc.issuer_public_key_b64")
|
|
100
|
+
sd_res = verify_sd_jwt(compact, issuer_pub)
|
|
101
|
+
result.add("sd-jwt-disclosures", sd_res["structure_ok"], sd_res["detail"])
|
|
102
|
+
if sd_res["sig_checked"]:
|
|
103
|
+
result.add(
|
|
104
|
+
"sd-jwt-issuer-signature",
|
|
105
|
+
sd_res["sig_ok"],
|
|
106
|
+
"issuer signature valid" if sd_res["sig_ok"] else "issuer signature invalid",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return result
|
proofbundle/cli.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Command line interface: ``proofbundle verify`` and ``proofbundle emit``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .bundle import verify_bundle
|
|
11
|
+
from .emit import emit_bundle, generate_signer, load_signer, save_signer
|
|
12
|
+
from .errors import ProofBundleError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _cmd_verify(args: argparse.Namespace) -> int:
|
|
16
|
+
try:
|
|
17
|
+
result = verify_bundle(args.bundle)
|
|
18
|
+
except ProofBundleError as exc:
|
|
19
|
+
if args.json:
|
|
20
|
+
print(json.dumps({"ok": False, "error": str(exc)}))
|
|
21
|
+
else:
|
|
22
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
23
|
+
return 2
|
|
24
|
+
|
|
25
|
+
if args.json:
|
|
26
|
+
print(json.dumps(result.as_dict(), indent=2))
|
|
27
|
+
else:
|
|
28
|
+
for check in result.checks:
|
|
29
|
+
print(str(check))
|
|
30
|
+
print("=> OK" if result.ok else "=> FAILED")
|
|
31
|
+
return 0 if result.ok else 1
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _cmd_emit(args: argparse.Namespace) -> int:
|
|
35
|
+
if args.new_key and args.key:
|
|
36
|
+
print("ERROR: use either --key or --new-key, not both", file=sys.stderr)
|
|
37
|
+
return 2
|
|
38
|
+
if args.new_key:
|
|
39
|
+
signer = generate_signer()
|
|
40
|
+
save_signer(signer, args.new_key)
|
|
41
|
+
print(f"wrote new signing key to {args.new_key} (keep this secret)", file=sys.stderr)
|
|
42
|
+
elif args.key:
|
|
43
|
+
signer = load_signer(args.key)
|
|
44
|
+
else:
|
|
45
|
+
print("ERROR: provide --key <file> or --new-key <file>", file=sys.stderr)
|
|
46
|
+
return 2
|
|
47
|
+
|
|
48
|
+
with open(args.payload_file, "rb") as handle:
|
|
49
|
+
payload = handle.read()
|
|
50
|
+
|
|
51
|
+
bundle = emit_bundle(payload, signer)
|
|
52
|
+
with open(args.out, "w", encoding="utf-8") as handle:
|
|
53
|
+
json.dump(bundle, handle, indent=2)
|
|
54
|
+
handle.write("\n")
|
|
55
|
+
print(f"wrote {args.out}")
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
60
|
+
parser = argparse.ArgumentParser(
|
|
61
|
+
prog="proofbundle",
|
|
62
|
+
description="Emit and verify portable cryptographic evidence bundles, offline.",
|
|
63
|
+
)
|
|
64
|
+
parser.add_argument("--version", action="version", version=f"proofbundle {__version__}")
|
|
65
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
66
|
+
|
|
67
|
+
verify = sub.add_parser("verify", help="verify an evidence bundle JSON file")
|
|
68
|
+
verify.add_argument("bundle", help="path to the bundle JSON file")
|
|
69
|
+
verify.add_argument("--json", action="store_true", help="machine readable output")
|
|
70
|
+
verify.set_defaults(func=_cmd_verify)
|
|
71
|
+
|
|
72
|
+
emit = sub.add_parser("emit", help="sign and anchor a payload into a bundle")
|
|
73
|
+
emit.add_argument("--payload-file", required=True, help="file whose bytes become the payload")
|
|
74
|
+
emit.add_argument("--out", required=True, help="path to write the bundle JSON")
|
|
75
|
+
emit.add_argument("--key", help="use an existing 32 byte raw Ed25519 seed file")
|
|
76
|
+
emit.add_argument("--new-key", help="generate a signing key and save it to this file")
|
|
77
|
+
emit.set_defaults(func=_cmd_emit)
|
|
78
|
+
|
|
79
|
+
return parser
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def main(argv=None) -> int:
|
|
83
|
+
parser = build_parser()
|
|
84
|
+
args = parser.parse_args(argv)
|
|
85
|
+
return args.func(args)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__": # pragma: no cover
|
|
89
|
+
raise SystemExit(main())
|
proofbundle/emit.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Evidence bundle emitter (v0.2).
|
|
2
|
+
|
|
3
|
+
Sign a payload with Ed25519 and anchor it as the last leaf of an RFC 6962
|
|
4
|
+
Merkle tree, producing a bundle that ``verify_bundle`` accepts. This is the
|
|
5
|
+
counterpart to the verifier: create the evidence here, check it anywhere with
|
|
6
|
+
``proofbundle verify``, fully offline.
|
|
7
|
+
|
|
8
|
+
The v0.3 eval-receipt emitter (wrap one evaluation run into a signed,
|
|
9
|
+
selectively disclosable receipt) is still a roadmap stub at the bottom of this
|
|
10
|
+
module.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import os
|
|
17
|
+
from typing import Optional, Sequence
|
|
18
|
+
|
|
19
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
20
|
+
from cryptography.hazmat.primitives.serialization import (
|
|
21
|
+
Encoding,
|
|
22
|
+
NoEncryption,
|
|
23
|
+
PrivateFormat,
|
|
24
|
+
PublicFormat,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from . import merkle
|
|
28
|
+
from .bundle import SCHEMA
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"generate_signer",
|
|
32
|
+
"save_signer",
|
|
33
|
+
"load_signer",
|
|
34
|
+
"emit_bundle",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _b64(data: bytes) -> str:
|
|
39
|
+
return base64.b64encode(data).decode("ascii")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _raw_pub(key: Ed25519PrivateKey) -> bytes:
|
|
43
|
+
return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def generate_signer() -> Ed25519PrivateKey:
|
|
47
|
+
"""Generate a fresh Ed25519 signing key."""
|
|
48
|
+
return Ed25519PrivateKey.generate()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def save_signer(key: Ed25519PrivateKey, path: str) -> None:
|
|
52
|
+
"""Write the 32 byte raw Ed25519 private seed to ``path``, mode 0600.
|
|
53
|
+
|
|
54
|
+
This is a secret. The file is created owner-read/write only (never
|
|
55
|
+
world-readable, even briefly), so an accidental commit or a shared directory
|
|
56
|
+
does not leak the key. Store it out of version control and treat it like a
|
|
57
|
+
key, not like data.
|
|
58
|
+
"""
|
|
59
|
+
raw = key.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
|
|
60
|
+
# Open with 0600 from the start to avoid a world-readable window.
|
|
61
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
62
|
+
with os.fdopen(fd, "wb") as handle:
|
|
63
|
+
handle.write(raw)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_signer(path: str) -> Ed25519PrivateKey:
|
|
67
|
+
"""Load an Ed25519 signing key from a 32 byte raw seed file."""
|
|
68
|
+
with open(path, "rb") as handle:
|
|
69
|
+
return Ed25519PrivateKey.from_private_bytes(handle.read())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def emit_bundle(
|
|
73
|
+
payload: bytes,
|
|
74
|
+
signer: Ed25519PrivateKey,
|
|
75
|
+
*,
|
|
76
|
+
prior_leaves: Sequence[bytes] = (),
|
|
77
|
+
sd_jwt_vc: Optional[dict] = None,
|
|
78
|
+
) -> dict:
|
|
79
|
+
"""Produce a ``proofbundle/v0.1`` bundle for ``payload``.
|
|
80
|
+
|
|
81
|
+
The payload is signed with ``signer`` and appended as the last leaf of an
|
|
82
|
+
RFC 6962 Merkle tree over ``prior_leaves + [payload]``. The returned dict is
|
|
83
|
+
accepted by :func:`proofbundle.verify_bundle`.
|
|
84
|
+
|
|
85
|
+
``sd_jwt_vc`` is passed through verbatim if given (for example
|
|
86
|
+
``{"compact": "...", "issuer_public_key_b64": "..."}``).
|
|
87
|
+
"""
|
|
88
|
+
leaves = list(prior_leaves) + [payload]
|
|
89
|
+
index = len(leaves) - 1
|
|
90
|
+
root = merkle.merkle_tree_hash(leaves)
|
|
91
|
+
proof = merkle.inclusion_proof(leaves, index)
|
|
92
|
+
signature = signer.sign(payload)
|
|
93
|
+
|
|
94
|
+
bundle = {
|
|
95
|
+
"schema": SCHEMA,
|
|
96
|
+
"payload_b64": _b64(payload),
|
|
97
|
+
"signature": {
|
|
98
|
+
"alg": "ed25519",
|
|
99
|
+
"public_key_b64": _b64(_raw_pub(signer)),
|
|
100
|
+
"sig_b64": _b64(signature),
|
|
101
|
+
},
|
|
102
|
+
"merkle": {
|
|
103
|
+
"hash_alg": "sha256-rfc6962",
|
|
104
|
+
"leaf_index": index,
|
|
105
|
+
"tree_size": len(leaves),
|
|
106
|
+
"inclusion_proof_b64": [_b64(p) for p in proof],
|
|
107
|
+
"root_b64": _b64(root),
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
if sd_jwt_vc is not None:
|
|
111
|
+
bundle["sd_jwt_vc"] = sd_jwt_vc
|
|
112
|
+
return bundle
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# --------------------------------------------------------------------------
|
|
116
|
+
# Roadmap stub, v0.3
|
|
117
|
+
# --------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class NotYetImplemented(NotImplementedError):
|
|
121
|
+
"""Raised by roadmap functions that are planned but not implemented yet."""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def emit_eval_receipt(*args, **kwargs): # pragma: no cover - roadmap stub
|
|
125
|
+
"""v0.3, the core differentiator.
|
|
126
|
+
|
|
127
|
+
Wrap one evaluation framework run (Inspect AI, lm-evaluation-harness) into a
|
|
128
|
+
signed receipt whose payload is a minimal, RFC 8785 canonicalized claim such
|
|
129
|
+
as ``{"suite": "...", "threshold": 0.8, "passed": true}``, optionally wrapped
|
|
130
|
+
as an SD-JWT VC so a holder can disclose "passed above threshold" without
|
|
131
|
+
revealing the model, weights or dataset, carrying a cluster-bootstrap
|
|
132
|
+
confidence interval, a multiple-testing correction and a preregistration
|
|
133
|
+
hash. Built on top of :func:`emit_bundle`.
|
|
134
|
+
"""
|
|
135
|
+
raise NotYetImplemented(
|
|
136
|
+
"emit_eval_receipt lands in v0.3. Use emit_bundle for a generic signed, "
|
|
137
|
+
"anchored bundle today."
|
|
138
|
+
)
|
proofbundle/errors.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Exception and result types for proofbundle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProofBundleError(Exception):
|
|
10
|
+
"""Base class for all proofbundle errors."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BundleFormatError(ProofBundleError):
|
|
14
|
+
"""The bundle JSON is missing fields or is malformed."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class UnsupportedError(ProofBundleError):
|
|
18
|
+
"""The bundle uses an algorithm or schema this version does not support."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Check:
|
|
23
|
+
"""Result of a single verification step."""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
ok: bool
|
|
27
|
+
detail: str = ""
|
|
28
|
+
|
|
29
|
+
def __str__(self) -> str: # pragma: no cover - cosmetic
|
|
30
|
+
mark = "PASS" if self.ok else "FAIL"
|
|
31
|
+
return f"[{mark}] {self.name}: {self.detail}".rstrip(": ")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class VerificationResult:
|
|
36
|
+
"""Aggregate result of verifying an evidence bundle."""
|
|
37
|
+
|
|
38
|
+
checks: List[Check] = field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def ok(self) -> bool:
|
|
42
|
+
"""True only if every check that ran passed and at least one ran."""
|
|
43
|
+
return bool(self.checks) and all(c.ok for c in self.checks)
|
|
44
|
+
|
|
45
|
+
def add(self, name: str, ok: bool, detail: str = "") -> None:
|
|
46
|
+
self.checks.append(Check(name, ok, detail))
|
|
47
|
+
|
|
48
|
+
def as_dict(self) -> dict:
|
|
49
|
+
return {
|
|
50
|
+
"ok": self.ok,
|
|
51
|
+
"checks": [
|
|
52
|
+
{"name": c.name, "ok": c.ok, "detail": c.detail} for c in self.checks
|
|
53
|
+
],
|
|
54
|
+
}
|
proofbundle/merkle.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""RFC 6962 / RFC 9162 Merkle tree hashing, inclusion and consistency proofs.
|
|
2
|
+
|
|
3
|
+
This module implements the Certificate Transparency Merkle tree exactly as
|
|
4
|
+
specified in RFC 6962 (updated by RFC 9162), so bundles verify against the same
|
|
5
|
+
primitives used by Sigstore Rekor, Certificate Transparency and tlog-tiles.
|
|
6
|
+
|
|
7
|
+
Leaf hash: SHA-256(0x00 || data)
|
|
8
|
+
Node hash: SHA-256(0x01 || left || right)
|
|
9
|
+
|
|
10
|
+
Only the verification functions (``root_from_inclusion``, ``verify_inclusion``,
|
|
11
|
+
``verify_consistency``) are part of the stable public API. The tree builder and
|
|
12
|
+
proof generators are provided so tests and examples can produce real proofs and
|
|
13
|
+
so callers can anchor their own evidence, but a production log would generate
|
|
14
|
+
these on the server side.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import hmac
|
|
21
|
+
from typing import List
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"leaf_hash",
|
|
25
|
+
"merkle_tree_hash",
|
|
26
|
+
"inclusion_proof",
|
|
27
|
+
"consistency_proof",
|
|
28
|
+
"root_from_inclusion",
|
|
29
|
+
"verify_inclusion",
|
|
30
|
+
"verify_consistency",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def leaf_hash(data: bytes) -> bytes:
|
|
35
|
+
"""RFC 6962 leaf hash: SHA-256(0x00 || data)."""
|
|
36
|
+
return hashlib.sha256(b"\x00" + data).digest()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _node_hash(left: bytes, right: bytes) -> bytes:
|
|
40
|
+
"""RFC 6962 interior node hash: SHA-256(0x01 || left || right)."""
|
|
41
|
+
return hashlib.sha256(b"\x01" + left + right).digest()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _largest_power_of_two_less_than(n: int) -> int:
|
|
45
|
+
"""Largest power of two k such that k < n <= 2k, for n >= 2."""
|
|
46
|
+
if n < 2:
|
|
47
|
+
raise ValueError("n must be >= 2")
|
|
48
|
+
k = 1
|
|
49
|
+
while k * 2 < n:
|
|
50
|
+
k *= 2
|
|
51
|
+
return k
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def merkle_tree_hash(leaves: List[bytes]) -> bytes:
|
|
55
|
+
"""Merkle Tree Hash (MTH) over a list of leaf *data* values (RFC 6962 2.1)."""
|
|
56
|
+
n = len(leaves)
|
|
57
|
+
if n == 0:
|
|
58
|
+
return hashlib.sha256(b"").digest()
|
|
59
|
+
if n == 1:
|
|
60
|
+
return leaf_hash(leaves[0])
|
|
61
|
+
k = _largest_power_of_two_less_than(n)
|
|
62
|
+
return _node_hash(merkle_tree_hash(leaves[:k]), merkle_tree_hash(leaves[k:]))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def inclusion_proof(leaves: List[bytes], index: int) -> List[bytes]:
|
|
66
|
+
"""Audit path for ``leaves[index]`` (siblings ordered leaf to root)."""
|
|
67
|
+
n = len(leaves)
|
|
68
|
+
if not 0 <= index < n:
|
|
69
|
+
raise ValueError("index out of range")
|
|
70
|
+
return _inclusion(leaves, index)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _inclusion(leaves: List[bytes], m: int) -> List[bytes]:
|
|
74
|
+
n = len(leaves)
|
|
75
|
+
if n == 1:
|
|
76
|
+
return []
|
|
77
|
+
k = _largest_power_of_two_less_than(n)
|
|
78
|
+
if m < k:
|
|
79
|
+
return _inclusion(leaves[:k], m) + [merkle_tree_hash(leaves[k:])]
|
|
80
|
+
return _inclusion(leaves[k:], m - k) + [merkle_tree_hash(leaves[:k])]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def consistency_proof(leaves: List[bytes], first: int) -> List[bytes]:
|
|
84
|
+
"""Consistency proof between tree sizes ``first`` and ``len(leaves)`` (RFC 6962 2.1.2)."""
|
|
85
|
+
second = len(leaves)
|
|
86
|
+
if not 0 < first <= second:
|
|
87
|
+
raise ValueError("require 0 < first <= len(leaves)")
|
|
88
|
+
return _subproof(first, leaves, True)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _subproof(m: int, leaves: List[bytes], b: bool) -> List[bytes]:
|
|
92
|
+
n = len(leaves)
|
|
93
|
+
if m == n:
|
|
94
|
+
return [] if b else [merkle_tree_hash(leaves)]
|
|
95
|
+
k = _largest_power_of_two_less_than(n)
|
|
96
|
+
if m <= k:
|
|
97
|
+
return _subproof(m, leaves[:k], b) + [merkle_tree_hash(leaves[k:])]
|
|
98
|
+
return _subproof(m - k, leaves[k:], False) + [merkle_tree_hash(leaves[:k])]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def root_from_inclusion(
|
|
102
|
+
leaf_index: int, tree_size: int, computed_leaf_hash: bytes, proof: List[bytes]
|
|
103
|
+
) -> bytes:
|
|
104
|
+
"""Recompute the tree root from an inclusion proof (RFC 9162 2.1.3.2)."""
|
|
105
|
+
if not 0 <= leaf_index < tree_size:
|
|
106
|
+
raise ValueError("leaf_index out of range for tree_size")
|
|
107
|
+
fn = leaf_index
|
|
108
|
+
sn = tree_size - 1
|
|
109
|
+
r = computed_leaf_hash
|
|
110
|
+
for p in proof:
|
|
111
|
+
if sn == 0:
|
|
112
|
+
raise ValueError("inclusion proof too long")
|
|
113
|
+
if (fn & 1) == 1 or fn == sn:
|
|
114
|
+
r = _node_hash(p, r)
|
|
115
|
+
if (fn & 1) == 0:
|
|
116
|
+
while (fn & 1) == 0 and fn != 0:
|
|
117
|
+
fn >>= 1
|
|
118
|
+
sn >>= 1
|
|
119
|
+
else:
|
|
120
|
+
r = _node_hash(r, p)
|
|
121
|
+
fn >>= 1
|
|
122
|
+
sn >>= 1
|
|
123
|
+
if sn != 0:
|
|
124
|
+
raise ValueError("inclusion proof too short")
|
|
125
|
+
return r
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def verify_inclusion(
|
|
129
|
+
leaf_data: bytes,
|
|
130
|
+
leaf_index: int,
|
|
131
|
+
tree_size: int,
|
|
132
|
+
proof: List[bytes],
|
|
133
|
+
expected_root: bytes,
|
|
134
|
+
) -> bool:
|
|
135
|
+
"""Return True iff ``leaf_data`` is included at ``leaf_index`` under ``expected_root``."""
|
|
136
|
+
try:
|
|
137
|
+
computed = root_from_inclusion(leaf_index, tree_size, leaf_hash(leaf_data), proof)
|
|
138
|
+
except ValueError:
|
|
139
|
+
return False
|
|
140
|
+
return hmac.compare_digest(computed, expected_root)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def verify_consistency(
|
|
144
|
+
first_size: int,
|
|
145
|
+
second_size: int,
|
|
146
|
+
proof: List[bytes],
|
|
147
|
+
first_root: bytes,
|
|
148
|
+
second_root: bytes,
|
|
149
|
+
) -> bool:
|
|
150
|
+
"""Return True iff ``first_root`` is a consistent prefix of ``second_root`` (RFC 9162 2.1.4.2)."""
|
|
151
|
+
if first_size <= 0 or first_size > second_size:
|
|
152
|
+
return False
|
|
153
|
+
if first_size == second_size:
|
|
154
|
+
return not proof and hmac.compare_digest(first_root, second_root)
|
|
155
|
+
path = list(proof)
|
|
156
|
+
# If first is an exact power of two, prepend first_root to the path.
|
|
157
|
+
if first_size & (first_size - 1) == 0:
|
|
158
|
+
path = [first_root] + path
|
|
159
|
+
if not path:
|
|
160
|
+
return False
|
|
161
|
+
fn = first_size - 1
|
|
162
|
+
sn = second_size - 1
|
|
163
|
+
while fn & 1:
|
|
164
|
+
fn >>= 1
|
|
165
|
+
sn >>= 1
|
|
166
|
+
fr = path[0]
|
|
167
|
+
sr = path[0]
|
|
168
|
+
for c in path[1:]:
|
|
169
|
+
if sn == 0:
|
|
170
|
+
return False
|
|
171
|
+
if (fn & 1) == 1 or fn == sn:
|
|
172
|
+
fr = _node_hash(c, fr)
|
|
173
|
+
sr = _node_hash(c, sr)
|
|
174
|
+
if (fn & 1) == 0:
|
|
175
|
+
while (fn & 1) == 0 and fn != 0:
|
|
176
|
+
fn >>= 1
|
|
177
|
+
sn >>= 1
|
|
178
|
+
else:
|
|
179
|
+
sr = _node_hash(sr, c)
|
|
180
|
+
fn >>= 1
|
|
181
|
+
sn >>= 1
|
|
182
|
+
return (
|
|
183
|
+
fn == 0
|
|
184
|
+
and hmac.compare_digest(fr, first_root)
|
|
185
|
+
and hmac.compare_digest(sr, second_root)
|
|
186
|
+
)
|
proofbundle/py.typed
ADDED
|
File without changes
|
proofbundle/sdjwt.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Minimal SD-JWT selective disclosure verification.
|
|
2
|
+
|
|
3
|
+
The SD-JWT *core* is now a published standard, RFC 9901 (December 2025). This
|
|
4
|
+
module verifies the heart of it: that every presented Disclosure hashes to a
|
|
5
|
+
digest that is actually committed in the issuer-signed JWT payload, and, if an
|
|
6
|
+
issuer public key is supplied and the algorithm is EdDSA, that the issuer
|
|
7
|
+
signature over the JWT is valid.
|
|
8
|
+
|
|
9
|
+
Note the layering: RFC 9901 is the SD-JWT mechanism; **SD-JWT VC** (the
|
|
10
|
+
credential type profile) is still an IETF draft,
|
|
11
|
+
``draft-ietf-oauth-sd-jwt-vc`` — this verifier does not yet do VC-level checks.
|
|
12
|
+
|
|
13
|
+
Scope of v0.1, stated honestly (see README security notes):
|
|
14
|
+
- EdDSA (Ed25519) issuer signatures only.
|
|
15
|
+
- No Key Binding JWT verification (a trailing key-binding token is ignored).
|
|
16
|
+
- No X.509 / trust-list / status-list checks, no ``vct`` type-metadata
|
|
17
|
+
resolution. Full SD-JWT VC conformance is on the roadmap.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
from typing import Optional, Set
|
|
26
|
+
|
|
27
|
+
from .signature import verify_ed25519
|
|
28
|
+
|
|
29
|
+
__all__ = ["verify_sd_jwt"]
|
|
30
|
+
|
|
31
|
+
_HASH_ALG = {"sha-256": "sha256", "sha-384": "sha384", "sha-512": "sha512"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _b64url_decode(s: str) -> bytes:
|
|
35
|
+
raw = s.encode("ascii")
|
|
36
|
+
return base64.urlsafe_b64decode(raw + b"=" * (-len(raw) % 4))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _b64url_nopad(b: bytes) -> str:
|
|
40
|
+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _digest(disclosure_b64: str, alg: str) -> str:
|
|
44
|
+
h = hashlib.new(_HASH_ALG[alg])
|
|
45
|
+
h.update(disclosure_b64.encode("ascii"))
|
|
46
|
+
return _b64url_nopad(h.digest())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _collect_committed_digests(node, out: Set[str]) -> None:
|
|
50
|
+
if isinstance(node, dict):
|
|
51
|
+
for key, value in node.items():
|
|
52
|
+
if key == "_sd" and isinstance(value, list):
|
|
53
|
+
out.update(d for d in value if isinstance(d, str))
|
|
54
|
+
elif key == "...":
|
|
55
|
+
if isinstance(value, str):
|
|
56
|
+
out.add(value)
|
|
57
|
+
else:
|
|
58
|
+
_collect_committed_digests(value, out)
|
|
59
|
+
elif isinstance(node, list):
|
|
60
|
+
for item in node:
|
|
61
|
+
_collect_committed_digests(item, out)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def verify_sd_jwt(compact: str, issuer_pubkey: Optional[bytes] = None) -> dict:
|
|
65
|
+
"""Verify an SD-JWT compact serialization.
|
|
66
|
+
|
|
67
|
+
Returns a dict with keys: ``structure_ok`` (disclosures all committed),
|
|
68
|
+
``sig_checked``, ``sig_ok``, ``alg`` and ``detail``.
|
|
69
|
+
"""
|
|
70
|
+
result = {
|
|
71
|
+
"structure_ok": False,
|
|
72
|
+
"sig_checked": False,
|
|
73
|
+
"sig_ok": False,
|
|
74
|
+
"alg": None,
|
|
75
|
+
"detail": "",
|
|
76
|
+
}
|
|
77
|
+
parts = compact.split("~")
|
|
78
|
+
if len(parts) < 1 or parts[0].count(".") != 2:
|
|
79
|
+
result["detail"] = "not a compact SD-JWT"
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
header_b64, payload_b64, sig_b64 = parts[0].split(".")
|
|
83
|
+
try:
|
|
84
|
+
header = json.loads(_b64url_decode(header_b64))
|
|
85
|
+
payload = json.loads(_b64url_decode(payload_b64))
|
|
86
|
+
except (ValueError, json.JSONDecodeError):
|
|
87
|
+
result["detail"] = "malformed JWT header or payload"
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
alg = header.get("alg")
|
|
91
|
+
result["alg"] = alg
|
|
92
|
+
sd_alg = payload.get("_sd_alg", "sha-256")
|
|
93
|
+
if sd_alg not in _HASH_ALG:
|
|
94
|
+
result["detail"] = f"unsupported _sd_alg {sd_alg}"
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
# Disclosures are the non-empty middle parts; a trailing key-binding token
|
|
98
|
+
# (which contains dots) is ignored in v0.1.
|
|
99
|
+
disclosures = [p for p in parts[1:] if p and p.count(".") == 0]
|
|
100
|
+
|
|
101
|
+
committed: Set[str] = set()
|
|
102
|
+
_collect_committed_digests(payload, committed)
|
|
103
|
+
|
|
104
|
+
all_committed = True
|
|
105
|
+
for d in disclosures:
|
|
106
|
+
try:
|
|
107
|
+
parsed = json.loads(_b64url_decode(d))
|
|
108
|
+
except (ValueError, json.JSONDecodeError):
|
|
109
|
+
all_committed = False
|
|
110
|
+
break
|
|
111
|
+
if not (isinstance(parsed, list) and len(parsed) in (2, 3)):
|
|
112
|
+
all_committed = False
|
|
113
|
+
break
|
|
114
|
+
if _digest(d, sd_alg) not in committed:
|
|
115
|
+
all_committed = False
|
|
116
|
+
break
|
|
117
|
+
result["structure_ok"] = all_committed and bool(parts[0])
|
|
118
|
+
|
|
119
|
+
if issuer_pubkey is not None:
|
|
120
|
+
result["sig_checked"] = True
|
|
121
|
+
if alg == "EdDSA":
|
|
122
|
+
signing_input = f"{header_b64}.{payload_b64}".encode("ascii")
|
|
123
|
+
try:
|
|
124
|
+
result["sig_ok"] = verify_ed25519(
|
|
125
|
+
issuer_pubkey, _b64url_decode(sig_b64), signing_input
|
|
126
|
+
)
|
|
127
|
+
except ValueError:
|
|
128
|
+
result["sig_ok"] = False
|
|
129
|
+
else:
|
|
130
|
+
result["detail"] = f"issuer signature alg {alg} not supported in v0.1"
|
|
131
|
+
|
|
132
|
+
if not result["detail"]:
|
|
133
|
+
result["detail"] = f"{len(disclosures)} disclosure(s)"
|
|
134
|
+
return result
|
proofbundle/signature.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Ed25519 signature verification.
|
|
2
|
+
|
|
3
|
+
Wraps ``cryptography`` so we never implement signature math ourselves. Only
|
|
4
|
+
verification is exposed as public API; key generation and signing live in the
|
|
5
|
+
examples and are meant for tests and local demos, not for production issuance.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from cryptography.exceptions import InvalidSignature
|
|
11
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
12
|
+
|
|
13
|
+
__all__ = ["verify_ed25519"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def verify_ed25519(public_key: bytes, signature: bytes, message: bytes) -> bool:
|
|
17
|
+
"""Return True iff ``signature`` is a valid Ed25519 signature over ``message``.
|
|
18
|
+
|
|
19
|
+
``public_key`` must be the 32 byte raw Ed25519 public key and ``signature``
|
|
20
|
+
the 64 byte raw signature. Any malformed input returns False rather than
|
|
21
|
+
raising, so callers get a boolean per check.
|
|
22
|
+
"""
|
|
23
|
+
if len(public_key) != 32 or len(signature) != 64:
|
|
24
|
+
return False
|
|
25
|
+
try:
|
|
26
|
+
Ed25519PublicKey.from_public_bytes(public_key).verify(signature, message)
|
|
27
|
+
return True
|
|
28
|
+
except (InvalidSignature, ValueError):
|
|
29
|
+
return False
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: proofbundle
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Emit and verify portable cryptographic evidence bundles, offline: Ed25519 + RFC 6962 Merkle + optional SD-JWT.
|
|
5
|
+
Author: Konrad Gruszka
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://b7n0de.com
|
|
8
|
+
Project-URL: Repository, https://github.com/b7n0de/proofbundle
|
|
9
|
+
Project-URL: Issues, https://github.com/b7n0de/proofbundle/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/b7n0de/proofbundle/blob/main/CHANGELOG.md
|
|
11
|
+
Project-URL: Documentation, https://github.com/b7n0de/proofbundle#readme
|
|
12
|
+
Keywords: cryptography,merkle,transparency-log,ed25519,sd-jwt,verifiable-credentials,attestation,provenance,rfc6962
|
|
13
|
+
Classifier: Development Status :: 3 - 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.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Security :: Cryptography
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: cryptography>=42
|
|
26
|
+
Provides-Extra: sdjwt
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
29
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
30
|
+
Requires-Dist: jsonschema>=4; extra == "dev"
|
|
31
|
+
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
32
|
+
Requires-Dist: build>=1; extra == "dev"
|
|
33
|
+
Requires-Dist: hypothesis>=6; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
<div align="center">
|
|
37
|
+
|
|
38
|
+
<picture>
|
|
39
|
+
<source media="(prefers-color-scheme: dark)" srcset="assets/b7n0de-logo-dark.svg">
|
|
40
|
+
<img alt="b7n0de, Verified AI Work" src="assets/b7n0de-logo.svg" height="60">
|
|
41
|
+
</picture>
|
|
42
|
+
|
|
43
|
+
<h1>proofbundle</h1>
|
|
44
|
+
|
|
45
|
+
**Emit and verify, fully offline, portable evidence that a piece of data was
|
|
46
|
+
signed and anchored in a tamper-evident log — and optionally carries a
|
|
47
|
+
selectively disclosable credential. Pure Python, no server, no daemon, one JSON file.**
|
|
48
|
+
|
|
49
|
+
[](https://github.com/b7n0de/proofbundle/actions/workflows/ci.yml)
|
|
50
|
+
[](https://pypi.org/project/proofbundle/)
|
|
51
|
+
[](https://pypi.org/project/proofbundle/)
|
|
52
|
+
[](LICENSE)
|
|
53
|
+
[](https://github.com/astral-sh/ruff)
|
|
54
|
+
[](https://slsa.dev)
|
|
55
|
+
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
**At a glance:** `proofbundle emit` signs and anchors a payload; `proofbundle
|
|
59
|
+
verify` checks one self-contained `bundle.json` with three offline cryptographic
|
|
60
|
+
checks → `OK` or `FAILED`. No network, no daemon, no own crypto. 25 tests.
|
|
61
|
+
|
|
62
|
+
## Contents
|
|
63
|
+
|
|
64
|
+
- [Why](#why)
|
|
65
|
+
- [What it verifies](#what-it-verifies)
|
|
66
|
+
- [How it fits together](#how-it-fits-together)
|
|
67
|
+
- [Install](#install)
|
|
68
|
+
- [Quickstart](#quickstart)
|
|
69
|
+
- [Interoperability](#interoperability)
|
|
70
|
+
- [Bundle format](#bundle-format-proofbundlev01)
|
|
71
|
+
- [Security notes and scope](#security-notes-and-scope-stated-honestly)
|
|
72
|
+
- [Roadmap](#roadmap)
|
|
73
|
+
- [Contributing](#contributing)
|
|
74
|
+
- [License](#license)
|
|
75
|
+
|
|
76
|
+
## Why
|
|
77
|
+
|
|
78
|
+
Cryptographic evidence today usually needs a running service to check it.
|
|
79
|
+
Sigstore Rekor, Certificate Transparency and other transparency logs are
|
|
80
|
+
excellent, but verifying an inclusion proof normally means talking to a log
|
|
81
|
+
server or wiring up Go tooling. There is no small, portable, Python-native
|
|
82
|
+
verifier that takes one self-contained file and answers a simple question
|
|
83
|
+
offline:
|
|
84
|
+
|
|
85
|
+
*Were these exact bytes signed by this key, and anchored under this Merkle root,
|
|
86
|
+
yes or no.*
|
|
87
|
+
|
|
88
|
+
`proofbundle` is that verifier — and, since v0.2, the matching emitter. It is
|
|
89
|
+
the verification half of a larger idea: turning a reproducible result (for
|
|
90
|
+
example an AI evaluation run) into a signed, third-party-verifiable, selectively
|
|
91
|
+
disclosable receipt. The verifier shipped first, small and correct, so it could
|
|
92
|
+
be reviewed and trusted on its own; `emit_bundle` now creates bundles that
|
|
93
|
+
`verify_bundle` accepts, fully offline on both sides.
|
|
94
|
+
|
|
95
|
+
## What it verifies
|
|
96
|
+
|
|
97
|
+
A bundle is a single JSON document. `proofbundle` checks, offline:
|
|
98
|
+
|
|
99
|
+
1. **ed25519-signature** — the payload was signed by the stated Ed25519 key
|
|
100
|
+
2. **merkle-inclusion** — the payload is anchored under the stated tree root,
|
|
101
|
+
using an RFC 6962 / RFC 9162 inclusion proof (the same primitive as Rekor and
|
|
102
|
+
Certificate Transparency)
|
|
103
|
+
3. **sd-jwt** (optional) — an embedded SD-JWT selective-disclosure credential is
|
|
104
|
+
well formed, and if an issuer key is given, correctly issuer-signed
|
|
105
|
+
|
|
106
|
+
The verifier treats the payload as opaque bytes. It proves that these exact
|
|
107
|
+
bytes were signed and anchored, not what they mean. That is on purpose: it keeps
|
|
108
|
+
the trusted core tiny.
|
|
109
|
+
|
|
110
|
+
## How it fits together
|
|
111
|
+
|
|
112
|
+
```mermaid
|
|
113
|
+
flowchart LR
|
|
114
|
+
P["payload bytes"]
|
|
115
|
+
P -->|"Ed25519 sign"| S["signature"]
|
|
116
|
+
P -->|"RFC 6962 anchor"| M["Merkle inclusion proof"]
|
|
117
|
+
SD["SD-JWT VC (optional)"] -.-> B
|
|
118
|
+
S --> B["bundle.json"]
|
|
119
|
+
M --> B
|
|
120
|
+
B --> V{{"proofbundle verify"}}
|
|
121
|
+
V --> C1["ed25519-signature"]
|
|
122
|
+
V --> C2["merkle-inclusion"]
|
|
123
|
+
V --> C3["sd-jwt (optional)"]
|
|
124
|
+
C1 --> R{"all checks pass?"}
|
|
125
|
+
C2 --> R
|
|
126
|
+
C3 --> R
|
|
127
|
+
R -->|yes| OK(["=> OK exit 0"])
|
|
128
|
+
R -->|no| FAIL(["=> FAILED exit 1"])
|
|
129
|
+
|
|
130
|
+
style V fill:#D6248A,stroke:#D6248A,color:#fff
|
|
131
|
+
style OK fill:#D6248A,stroke:#D6248A,color:#fff
|
|
132
|
+
style FAIL fill:#ef4444,stroke:#ef4444,color:#fff
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Install
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
pip install proofbundle
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Requires Python 3.9+ and [`cryptography`](https://cryptography.io). Signature
|
|
142
|
+
math is delegated to `cryptography`; this project never rolls its own crypto.
|
|
143
|
+
The Merkle and SD-JWT logic is pure standard library.
|
|
144
|
+
|
|
145
|
+
SD-JWT support is an optional extra (it adds no runtime dependency beyond the
|
|
146
|
+
core `cryptography`, so the trusted core stays lean):
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
pip install "proofbundle[sdjwt]"
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Quickstart
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
# generate a real example bundle with throwaway keys
|
|
156
|
+
python examples/make_example.py
|
|
157
|
+
|
|
158
|
+
# verify it
|
|
159
|
+
proofbundle verify examples/example_bundle.json
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
<div align="center">
|
|
163
|
+
<img src="assets/demo.svg" alt="proofbundle verify output: four PASS checks and OK" width="680">
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
Machine-readable output and a non-zero exit code on failure:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
proofbundle verify --json bundle.json # exit 0 = ok, 1 = failed, 2 = malformed
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Emit a bundle of your own (v0.2): sign a payload with a fresh key and anchor it,
|
|
173
|
+
then verify it anywhere, offline.
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
proofbundle emit --payload-file result.json --new-key signer.key --out bundle.json
|
|
177
|
+
proofbundle verify bundle.json
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Library use:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
from proofbundle import verify_bundle
|
|
184
|
+
|
|
185
|
+
result = verify_bundle("bundle.json")
|
|
186
|
+
print(result.ok) # True / False
|
|
187
|
+
for check in result.checks:
|
|
188
|
+
print(check.name, check.ok, check.detail)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Verify a consistency proof between two log states directly:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
from proofbundle import verify_consistency
|
|
195
|
+
verify_consistency(first_size, second_size, proof, first_root, second_root) # -> bool
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Interoperability
|
|
199
|
+
|
|
200
|
+
proofbundle uses the same RFC 6962 / RFC 9162 Merkle primitive as
|
|
201
|
+
[Sigstore Rekor](https://docs.sigstore.dev/) and Certificate Transparency, so its
|
|
202
|
+
`verify_inclusion` checks a real proof from a live transparency log, not just its
|
|
203
|
+
own bundles. [`examples/rekor_interop.py`](examples/rekor_interop.py) verifies a
|
|
204
|
+
real Sigstore Rekor inclusion proof (a committed fixture, `logIndex` 25579 in a
|
|
205
|
+
4.16-million-entry tree) **fully offline**, and documents the field mapping from
|
|
206
|
+
the Rekor bundle and its C2SP `tlog-checkpoint` signed note to proofbundle's
|
|
207
|
+
`merkle` object. Correctness is also checked against external RFC 6962 test
|
|
208
|
+
vectors vendored from
|
|
209
|
+
[transparency-dev/merkle](https://github.com/transparency-dev/merkle) (see
|
|
210
|
+
`tests/fixtures/`), plus Hypothesis property tests.
|
|
211
|
+
|
|
212
|
+
## Bundle format (`proofbundle/v0.1`)
|
|
213
|
+
|
|
214
|
+
The format is specified normatively in [SPEC.md](SPEC.md) (fields, encodings,
|
|
215
|
+
RFC 6962 hashing, verification order) with a machine-readable JSON Schema at
|
|
216
|
+
[`schemas/proofbundle_v0_1.schema.json`](schemas/proofbundle_v0_1.schema.json).
|
|
217
|
+
|
|
218
|
+
```json
|
|
219
|
+
{
|
|
220
|
+
"schema": "proofbundle/v0.1",
|
|
221
|
+
"payload_b64": "<the exact bytes that were signed and anchored>",
|
|
222
|
+
"signature": { "alg": "ed25519", "public_key_b64": "...", "sig_b64": "..." },
|
|
223
|
+
"merkle": {
|
|
224
|
+
"hash_alg": "sha256-rfc6962",
|
|
225
|
+
"leaf_index": 1,
|
|
226
|
+
"tree_size": 4,
|
|
227
|
+
"inclusion_proof_b64": ["...", "..."],
|
|
228
|
+
"root_b64": "..."
|
|
229
|
+
},
|
|
230
|
+
"sd_jwt_vc": { "compact": "<sd-jwt>", "issuer_public_key_b64": "..." }
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`sd_jwt_vc` is optional. Base64 fields are standard base64; the SD-JWT compact
|
|
235
|
+
string uses base64url as per the spec.
|
|
236
|
+
|
|
237
|
+
## Security notes and scope, stated honestly
|
|
238
|
+
|
|
239
|
+
This is v0.1. It does exactly what it says and no more:
|
|
240
|
+
|
|
241
|
+
- Ed25519 signatures only, for both the payload and the optional SD-JWT issuer
|
|
242
|
+
signature.
|
|
243
|
+
- SD-JWT: the SD-JWT core is now [RFC 9901](https://datatracker.ietf.org/doc/rfc9901/)
|
|
244
|
+
(Dec 2025); this verifies that every presented disclosure is committed in the
|
|
245
|
+
issuer-signed payload, and the issuer signature (EdDSA) if a key is supplied. It
|
|
246
|
+
does **not** verify a Key Binding JWT, an X.509 or trust-list chain, status
|
|
247
|
+
lists, or `vct` type metadata. **SD-JWT VC** (the credential-type profile) is
|
|
248
|
+
still an IETF draft ([draft-ietf-oauth-sd-jwt-vc](https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/));
|
|
249
|
+
full VC conformance is on the roadmap.
|
|
250
|
+
- The verifier does not fetch anything. Trust anchors (the signer key, the
|
|
251
|
+
expected root) are inputs you supply out of band.
|
|
252
|
+
- No custom cryptography. Ed25519 comes from `cryptography`; Merkle hashing is
|
|
253
|
+
RFC 6962.
|
|
254
|
+
|
|
255
|
+
If you find a correctness or security issue, please open an issue or see
|
|
256
|
+
[SECURITY.md](SECURITY.md).
|
|
257
|
+
|
|
258
|
+
## Roadmap
|
|
259
|
+
|
|
260
|
+
- **v0.1** — the offline verifier plus a real example bundle.
|
|
261
|
+
- **v0.2 (current release)** — the emitter: `emit_bundle` signs a payload with
|
|
262
|
+
Ed25519 and anchors it as the last leaf of an RFC 6962 Merkle tree, producing
|
|
263
|
+
a bundle that `verify_bundle` accepts. Available as `proofbundle emit`.
|
|
264
|
+
- **v0.3** — an eval-receipt emitter: wrap one evaluation framework run
|
|
265
|
+
([Inspect AI](https://github.com/UKGovernmentBEIS/inspect_ai),
|
|
266
|
+
[lm-evaluation-harness](https://github.com/EleutherAI/lm-evaluation-harness))
|
|
267
|
+
into a signed receipt whose payload is a minimal canonical claim, for example
|
|
268
|
+
`{"suite": "...", "threshold": 0.8, "passed": true}`, optionally wrapped as an
|
|
269
|
+
SD-JWT VC so a holder can disclose *passed above threshold* without revealing
|
|
270
|
+
the model, weights or dataset, and carrying a cluster-bootstrap confidence
|
|
271
|
+
interval, a multiple-testing correction and a preregistration hash.
|
|
272
|
+
|
|
273
|
+
That last step is the point: today no widely used AI project turns a
|
|
274
|
+
reproducible evaluation result into a signed, third-party-verifiable,
|
|
275
|
+
selectively disclosable receipt. This repository is the trustworthy verification
|
|
276
|
+
core that makes it possible.
|
|
277
|
+
|
|
278
|
+
## Contributing
|
|
279
|
+
|
|
280
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) and the
|
|
281
|
+
[Code of Conduct](CODE_OF_CONDUCT.md). Good first issues are labeled
|
|
282
|
+
[`good-first-issue`](https://github.com/b7n0de/proofbundle/labels/good-first-issue).
|
|
283
|
+
The verifier core aims to stay small, dependency-light and correct.
|
|
284
|
+
|
|
285
|
+
## License
|
|
286
|
+
|
|
287
|
+
MIT, see [LICENSE](LICENSE).
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
<p align="center"><sub>proofbundle is part of <b>b7n0de</b>, Verified AI Work · <a href="https://b7n0de.com">b7n0de.com</a></sub></p>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
proofbundle/__init__.py,sha256=WkhrTxUxBqfNE2HhSZpsdO3zi5O5DeCr-6oAUN8E8xU,851
|
|
2
|
+
proofbundle/bundle.py,sha256=GIZrKGXO1shczL0CTN5ORvdeWKlmNLCXQyvSD3TtkPs,4438
|
|
3
|
+
proofbundle/cli.py,sha256=d0Gp3ocpbCP3OZQQ5ftU4fuHd_1I26CcPdkDEllSd70,3057
|
|
4
|
+
proofbundle/emit.py,sha256=jrdFsgNsiuOfPCI2euCSk2VizG3t-iRTTP4OPunUKVI,4612
|
|
5
|
+
proofbundle/errors.py,sha256=zDwnBWTfuLWZdFoekj6bu_KZd668bj-j_DgKMv9hmEU,1435
|
|
6
|
+
proofbundle/merkle.py,sha256=vc-_JX7cJ6APNY49R-jVESbIE4689IJRokNQOMp2kDc,5861
|
|
7
|
+
proofbundle/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
proofbundle/sdjwt.py,sha256=B9Vy81IUrtyq2GHOhkS7reWtnsS4x16DAeioKL2cKns,4633
|
|
9
|
+
proofbundle/signature.py,sha256=jrHQtJPQCp0-doPYoV5rlgDU8UhUOXknVx2EE95dJ6E,1104
|
|
10
|
+
proofbundle-0.3.0.dist-info/licenses/LICENSE,sha256=5Jiib6WCXMfnvSVE-XCQBZvTdD21rgcMJoWoag5VXLE,1071
|
|
11
|
+
proofbundle-0.3.0.dist-info/METADATA,sha256=lzbp4pc8n5OtFtUnFOHGjpRx9Z52JMDmuyAmHhbF-zc,11595
|
|
12
|
+
proofbundle-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
+
proofbundle-0.3.0.dist-info/entry_points.txt,sha256=sUKwm8FUtcr2Fgpi6-mkvzdD57NcMCO3kF6eGQVaARU,53
|
|
14
|
+
proofbundle-0.3.0.dist-info/top_level.txt,sha256=lI10iF5LAz1Cg2LiOztawA6KQe0sgTvS0JgK03nejJI,12
|
|
15
|
+
proofbundle-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Konrad Gruszka
|
|
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 @@
|
|
|
1
|
+
proofbundle
|