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.
@@ -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
@@ -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
+ [![CI](https://github.com/b7n0de/proofbundle/actions/workflows/ci.yml/badge.svg)](https://github.com/b7n0de/proofbundle/actions/workflows/ci.yml)
50
+ [![PyPI](https://img.shields.io/pypi/v/proofbundle.svg?color=D6248A)](https://pypi.org/project/proofbundle/)
51
+ [![Python](https://img.shields.io/pypi/pyversions/proofbundle.svg?color=D6248A)](https://pypi.org/project/proofbundle/)
52
+ [![License: MIT](https://img.shields.io/badge/license-MIT-D6248A.svg)](LICENSE)
53
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
54
+ [![SLSA build provenance](https://img.shields.io/badge/SLSA-build_provenance-D6248A.svg)](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 &nbsp; exit 0"])
128
+ R -->|no| FAIL(["=> FAILED &nbsp; 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 &middot; <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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ proofbundle = proofbundle.cli:main
@@ -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