fix: make receipt export bounded and evidence-honest

#7
killinchu_receipt_export.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bounded, evidence-honest Killinchu receipt export contract.
2
+
3
+ This module is deliberately pure stdlib so the export state can be tested
4
+ without importing the full FastAPI application. A transport-success response
5
+ does not imply that a receipt or signature exists: callers must inspect
6
+ ``export_state``, ``receipt_available`` and ``verification.state``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import hashlib
13
+ from datetime import datetime, timezone
14
+ from typing import Any, Mapping, Sequence
15
+
16
+
17
+ SCHEMA = "szl.killinchu.receipt-export/v1"
18
+ DEFAULT_PUBLIC_KEY_URL = "https://szlholdings-killinchu.hf.space/cosign.pub"
19
+
20
+
21
+ def _now_utc() -> str:
22
+ return datetime.now(timezone.utc).isoformat()
23
+
24
+
25
+ def _base(doctrine: str, public_key_url: str) -> dict[str, Any]:
26
+ return {
27
+ "schema": SCHEMA,
28
+ "ok": True,
29
+ "doctrine": doctrine,
30
+ "generated_at": _now_utc(),
31
+ "public_key_url": public_key_url,
32
+ }
33
+
34
+
35
+ def build_receipt_export(
36
+ dag: Sequence[Mapping[str, Any]],
37
+ *,
38
+ index: int = -1,
39
+ doctrine: str = "v11",
40
+ dsse_module: Any = None,
41
+ khipu_root: str | None = None,
42
+ public_key_url: str = DEFAULT_PUBLIC_KEY_URL,
43
+ ) -> tuple[dict[str, Any], int]:
44
+ """Return ``(body, status_code)`` for the public receipt export route.
45
+
46
+ The empty ledger is a valid, explicit runtime state and therefore returns
47
+ HTTP 200 with no receipt and no signature claim. A non-empty export is
48
+ marked signed only when the configured DSSE verifier validates it.
49
+ Out-of-range indexes are rejected rather than silently selecting a
50
+ different receipt.
51
+ """
52
+
53
+ out = _base(doctrine, public_key_url)
54
+ ledger_size = len(dag)
55
+
56
+ if ledger_size == 0:
57
+ out.update(
58
+ {
59
+ "export_state": "EMPTY",
60
+ "receipt_available": False,
61
+ "ledger_size": 0,
62
+ "node_index": None,
63
+ "node_digest": None,
64
+ "khipu_root": None,
65
+ "dsse": None,
66
+ "payload_b64": None,
67
+ "signed": False,
68
+ "keyid": None,
69
+ "verification": {
70
+ "state": "NOT_APPLICABLE",
71
+ "verified": False,
72
+ "reason": "no receipt exists in the in-memory ledger",
73
+ },
74
+ "verify_offline": [],
75
+ "limits": [
76
+ {
77
+ "code": "NO_RECEIPTS",
78
+ "detail": "No receipt has been emitted since this runtime started.",
79
+ },
80
+ {
81
+ "code": "EPHEMERAL_LEDGER",
82
+ "detail": "The in-memory Khipu ledger resets on Space restart.",
83
+ },
84
+ ],
85
+ "honesty": (
86
+ "The export route is reachable, but no receipt exists. "
87
+ "This response is not a receipt and carries no signature."
88
+ ),
89
+ }
90
+ )
91
+ return out, 200
92
+
93
+ if index < -ledger_size or index >= ledger_size:
94
+ out.update(
95
+ {
96
+ "ok": False,
97
+ "export_state": "INDEX_OUT_OF_RANGE",
98
+ "receipt_available": False,
99
+ "ledger_size": ledger_size,
100
+ "requested_index": index,
101
+ "valid_index": {"min": -ledger_size, "max": ledger_size - 1},
102
+ "signed": False,
103
+ "verification": {
104
+ "state": "NOT_APPLICABLE",
105
+ "verified": False,
106
+ "reason": "requested index is outside the bounded ledger",
107
+ },
108
+ "limits": [
109
+ {
110
+ "code": "INDEX_OUT_OF_RANGE",
111
+ "detail": "Select an index inside valid_index; no fallback receipt was substituted.",
112
+ }
113
+ ],
114
+ }
115
+ )
116
+ return out, 422
117
+
118
+ node = dag[index]
119
+ receipt_obj = node.get("receipt")
120
+ raw_dsse = node.get("dsse")
121
+ dsse: dict[str, Any] = dict(raw_dsse) if isinstance(raw_dsse, Mapping) else {}
122
+ raw_signatures = dsse.get("signatures")
123
+ signatures = [dict(s) for s in raw_signatures or [] if isinstance(s, Mapping)]
124
+
125
+ # A literal placeholder is an honesty marker, never a signature. Do not
126
+ # expose it in the DSSE signatures array where generic clients may mistake
127
+ # presence for cryptographic proof.
128
+ has_placeholder = any(
129
+ s.get("keyid") in (None, "PENDING")
130
+ or "PLACEHOLDER" in str(s.get("sig", "")).upper()
131
+ for s in signatures
132
+ )
133
+ if has_placeholder:
134
+ signatures = []
135
+ dsse["signatures"] = signatures
136
+
137
+ reconstruction_error: str | None = None
138
+ payload_b64: str | None = None
139
+ pae_sha256: str | None = None
140
+ if receipt_obj is None:
141
+ reconstruction_error = "receipt object missing from ledger node"
142
+ elif dsse_module is None:
143
+ reconstruction_error = "DSSE module unavailable in this runtime"
144
+ else:
145
+ try:
146
+ body = dsse_module.canonical_json(receipt_obj)
147
+ payload_type = dsse.get("payloadType", "application/vnd.szl.receipt+json")
148
+ payload_b64 = base64.b64encode(body).decode("ascii")
149
+ pae_sha256 = hashlib.sha256(dsse_module.pae(payload_type, body)).hexdigest()
150
+ dsse.update(
151
+ {
152
+ "payloadType": payload_type,
153
+ "payload": payload_b64,
154
+ "_dsse": "DSSEv1",
155
+ "_pae_sha256": pae_sha256,
156
+ }
157
+ )
158
+ except Exception:
159
+ reconstruction_error = "DSSE payload reconstruction failed"
160
+
161
+ verification: dict[str, Any]
162
+ if not signatures:
163
+ verification = {
164
+ "state": "UNSIGNED",
165
+ "verified": False,
166
+ "reason": "no genuine signature is present",
167
+ }
168
+ elif reconstruction_error:
169
+ verification = {
170
+ "state": "UNAVAILABLE",
171
+ "verified": None,
172
+ "reason": reconstruction_error,
173
+ }
174
+ elif not hasattr(dsse_module, "verify_envelope"):
175
+ verification = {
176
+ "state": "UNAVAILABLE",
177
+ "verified": None,
178
+ "reason": "DSSE verifier unavailable in this runtime",
179
+ }
180
+ else:
181
+ try:
182
+ verdict = dsse_module.verify_envelope(dsse)
183
+ verified = bool(isinstance(verdict, Mapping) and verdict.get("verified") is True)
184
+ verification = {
185
+ "state": "VERIFIED" if verified else "FAILED",
186
+ "verified": verified,
187
+ "reason": (
188
+ "signature verified against the published public key"
189
+ if verified
190
+ else str(verdict.get("reason", "signature verification failed"))
191
+ ),
192
+ "pae_sha256": verdict.get("pae_sha256", pae_sha256),
193
+ }
194
+ except Exception:
195
+ verification = {
196
+ "state": "UNAVAILABLE",
197
+ "verified": None,
198
+ "reason": "DSSE verifier raised; no signed claim emitted",
199
+ }
200
+
201
+ signed = verification.get("verified") is True
202
+ dsse["signed"] = signed
203
+ export_state = (
204
+ "SIGNED_VERIFIED"
205
+ if signed
206
+ else "UNSIGNED"
207
+ if not signatures
208
+ else "SIGNATURE_UNVERIFIED"
209
+ )
210
+ keyid = signatures[0].get("keyid") if signatures else None
211
+ limits = [
212
+ {
213
+ "code": "EPHEMERAL_LEDGER",
214
+ "detail": "The in-memory Khipu ledger resets on Space restart.",
215
+ }
216
+ ]
217
+ if not signed:
218
+ limits.append(
219
+ {
220
+ "code": "NO_VERIFIED_SIGNATURE",
221
+ "detail": verification["reason"],
222
+ }
223
+ )
224
+
225
+ verify_offline = []
226
+ if signed:
227
+ verify_offline = [
228
+ f"curl -s {public_key_url} -o cosign.pub",
229
+ "base64 -d payload.b64 > payload.bin",
230
+ "cosign verify-blob --key cosign.pub --signature sig.b64 payload.bin",
231
+ ]
232
+
233
+ out.update(
234
+ {
235
+ "export_state": export_state,
236
+ "receipt_available": True,
237
+ "ledger_size": ledger_size,
238
+ "node_index": node.get("index", index % ledger_size),
239
+ "node_digest": node.get("digest"),
240
+ "khipu_root": khipu_root,
241
+ "dsse": dsse,
242
+ "payload_b64": payload_b64,
243
+ "signed": signed,
244
+ "keyid": keyid,
245
+ "verification": verification,
246
+ "verify_offline": verify_offline,
247
+ "limits": limits,
248
+ "honesty": (
249
+ "The DSSE signature was verified against the published public key."
250
+ if signed
251
+ else "A receipt exists, but this response makes no verified-signature claim."
252
+ ),
253
+ }
254
+ )
255
+ return out, 200
256
+
serve.py CHANGED
@@ -41,6 +41,7 @@ from fastapi.staticfiles import StaticFiles
41
  from starlette.middleware.cors import CORSMiddleware
42
 
43
  import killinchu_protocols as kp
 
44
 
45
  _APP_ROOT = Path(os.environ.get("KILLINCHU_ROOT", "/app"))
46
  STATIC_DIR = _APP_ROOT / "static"
@@ -2217,64 +2218,20 @@ async def cosign_pub() -> Response:
2217
 
2218
  @app.get("/api/killinchu/v1/receipt/export")
2219
  async def receipt_export(index: int = -1) -> JSONResponse:
2220
- """Export ONE signed receipt + step-by-step offline verify instructions.
2221
 
2222
- The on-stage 'verify it yourself' artifact: gives the judge the DSSE
2223
- envelope, the public-key URL, and the exact two commands to verify the
2224
- signature with cosign no trust in killinchu's infrastructure required.
2225
  """
2226
- if not _KHIPU_DAG:
2227
- return JSONResponse({"ok": False, "error": "no receipts yet — run a /beyond demo first"},
2228
- status_code=404)
2229
- try:
2230
- node = _KHIPU_DAG[index]
2231
- except IndexError:
2232
- node = _KHIPU_DAG[-1]
2233
- dsse = dict(node.get("dsse") or {})
2234
- sigs = dsse.get("signatures") or []
2235
- signed = bool(sigs and sigs[0].get("keyid") not in (None, "PENDING"))
2236
- # RECONSTRUCT the full verifiable DSSE envelope. The signature was computed by
2237
- # szl_dsse.sign_payload over canonical_json(node["receipt"]); the stored node
2238
- # kept only the signatures, so re-derive the exact base64 payload here. Without
2239
- # this, an offline `cosign verify-blob` would have nothing to verify against.
2240
- payload_b64 = None
2241
- pae_sha256 = None
2242
- receipt_obj = node.get("receipt")
2243
- if receipt_obj is not None and _szl_dsse is not None:
2244
- try:
2245
- import base64 as _b64_exp
2246
- body = _szl_dsse.canonical_json(receipt_obj)
2247
- payload_b64 = _b64_exp.b64encode(body).decode("ascii")
2248
- ptype = dsse.get("payloadType", "application/vnd.szl.receipt+json")
2249
- pae_sha256 = hashlib.sha256(_szl_dsse.pae(ptype, body)).hexdigest()
2250
- dsse["payload"] = payload_b64
2251
- dsse["_dsse"] = "DSSEv1"
2252
- dsse["_pae_sha256"] = pae_sha256
2253
- except Exception:
2254
- pass
2255
- return JSONResponse({
2256
- "ok": True,
2257
- "node_index": node.get("index"),
2258
- "node_digest": node.get("digest"),
2259
- "khipu_root": _khipu_root(),
2260
- "dsse": dsse,
2261
- "payload_b64": payload_b64,
2262
- "signed": signed,
2263
- "keyid": (sigs[0].get("keyid") if sigs else None),
2264
- "public_key_url": "https://szlholdings-killinchu.hf.space/cosign.pub",
2265
- "verify_offline": [
2266
- "# 1. Save the public key",
2267
- "curl -s https://szlholdings-killinchu.hf.space/cosign.pub -o cosign.pub",
2268
- "# 2. Save this DSSE envelope's payload + signature, then:",
2269
- "cosign verify-blob --key cosign.pub --signature sig.b64 payload.bin",
2270
- "# OR verify the whole DAG via the live endpoint:",
2271
- "curl -s -X POST https://szlholdings-killinchu.hf.space/khipu/verify -H 'Content-Type: application/json' -d @receipt.json",
2272
- ],
2273
- "honesty": ("REAL ECDSA-P256-SHA256 DSSE when signed=true (keyid szlholdings-cosign); "
2274
- "an honest UNSIGNED placeholder otherwise. The public key is the same one "
2275
- "published at szl-holdings/.github/cosign.pub — verify without trusting us."),
2276
- "doctrine": DOCTRINE,
2277
- })
2278
 
2279
 
2280
  @app.get("/api/killinchu/v1/lambda")
 
41
  from starlette.middleware.cors import CORSMiddleware
42
 
43
  import killinchu_protocols as kp
44
+ from killinchu_receipt_export import build_receipt_export
45
 
46
  _APP_ROOT = Path(os.environ.get("KILLINCHU_ROOT", "/app"))
47
  STATIC_DIR = _APP_ROOT / "static"
 
2218
 
2219
  @app.get("/api/killinchu/v1/receipt/export")
2220
  async def receipt_export(index: int = -1) -> JSONResponse:
2221
+ """Export one bounded receipt or an explicit, typed empty state.
2222
 
2223
+ ``signed`` is true only after the reconstructed DSSE envelope verifies
2224
+ against the public key. Missing receipts or signing capability are never
2225
+ represented by a fabricated envelope.
2226
  """
2227
+ body, status_code = build_receipt_export(
2228
+ _KHIPU_DAG,
2229
+ index=index,
2230
+ doctrine=DOCTRINE,
2231
+ dsse_module=_szl_dsse,
2232
+ khipu_root=_khipu_root(),
2233
+ )
2234
+ return JSONResponse(body, status_code=status_code)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2235
 
2236
 
2237
  @app.get("/api/killinchu/v1/lambda")
tests/test_receipt_export_contract.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import hashlib
3
+ import hmac
4
+ import json
5
+ import unittest
6
+
7
+ from killinchu_receipt_export import SCHEMA, build_receipt_export
8
+
9
+
10
+ class _TestDSSE:
11
+ """Deterministic verifier used to test the route contract without secrets."""
12
+
13
+ secret = b"test-only-key"
14
+
15
+ @staticmethod
16
+ def canonical_json(obj):
17
+ return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode("utf-8")
18
+
19
+ @staticmethod
20
+ def pae(payload_type, body):
21
+ t = payload_type.encode("utf-8")
22
+ return b"DSSEv1 " + str(len(t)).encode() + b" " + t + b" " + str(len(body)).encode() + b" " + body
23
+
24
+ @classmethod
25
+ def signature(cls, payload_type, receipt):
26
+ body = cls.canonical_json(receipt)
27
+ return base64.b64encode(hmac.new(cls.secret, cls.pae(payload_type, body), hashlib.sha256).digest()).decode()
28
+
29
+ @classmethod
30
+ def verify_envelope(cls, env):
31
+ body = base64.b64decode(env["payload"])
32
+ expected = hmac.new(cls.secret, cls.pae(env["payloadType"], body), hashlib.sha256).digest()
33
+ actual = base64.b64decode(env["signatures"][0]["sig"])
34
+ verified = hmac.compare_digest(actual, expected)
35
+ return {
36
+ "verified": verified,
37
+ "reason": "test signature mismatch" if not verified else "verified",
38
+ "pae_sha256": hashlib.sha256(cls.pae(env["payloadType"], body)).hexdigest(),
39
+ }
40
+
41
+
42
+ def _node(signature):
43
+ receipt = {
44
+ "schema": "szl.killinchu.receipt/v1",
45
+ "kind": "contract-test",
46
+ "payload": {"bounded": True},
47
+ "doctrine": "v11",
48
+ "ts_utc": "2026-07-11T00:00:00Z",
49
+ }
50
+ return receipt, {
51
+ "index": 0,
52
+ "digest": "a" * 64,
53
+ "receipt": receipt,
54
+ "dsse": {
55
+ "payloadType": "application/vnd.szl.receipt+json",
56
+ "signatures": [{"keyid": "test-key", "sig": signature}],
57
+ },
58
+ }
59
+
60
+
61
+ class ReceiptExportContractTests(unittest.TestCase):
62
+ def test_empty_ledger_is_typed_200_with_explicit_limit(self):
63
+ body, status = build_receipt_export([], dsse_module=_TestDSSE)
64
+ self.assertEqual(status, 200)
65
+ self.assertEqual(body["schema"], SCHEMA)
66
+ self.assertEqual(body["export_state"], "EMPTY")
67
+ self.assertFalse(body["receipt_available"])
68
+ self.assertFalse(body["signed"])
69
+ self.assertIsNone(body["dsse"])
70
+ self.assertEqual(body["verification"]["state"], "NOT_APPLICABLE")
71
+ self.assertIn("NO_RECEIPTS", {item["code"] for item in body["limits"]})
72
+
73
+ def test_valid_signature_is_exported_only_after_verification(self):
74
+ receipt, _ = _node("")
75
+ sig = _TestDSSE.signature("application/vnd.szl.receipt+json", receipt)
76
+ _, node = _node(sig)
77
+ body, status = build_receipt_export([node], dsse_module=_TestDSSE, khipu_root=node["digest"])
78
+ self.assertEqual(status, 200)
79
+ self.assertEqual(body["export_state"], "SIGNED_VERIFIED")
80
+ self.assertTrue(body["signed"])
81
+ self.assertEqual(body["verification"]["state"], "VERIFIED")
82
+ self.assertTrue(body["dsse"]["payload"])
83
+ self.assertTrue(body["verify_offline"])
84
+
85
+ def test_invalid_signature_never_becomes_signed(self):
86
+ _, node = _node(base64.b64encode(b"not-a-valid-signature").decode())
87
+ body, status = build_receipt_export([node], dsse_module=_TestDSSE)
88
+ self.assertEqual(status, 200)
89
+ self.assertEqual(body["export_state"], "SIGNATURE_UNVERIFIED")
90
+ self.assertFalse(body["signed"])
91
+ self.assertEqual(body["verification"]["state"], "FAILED")
92
+ self.assertEqual(body["verify_offline"], [])
93
+ self.assertIn("NO_VERIFIED_SIGNATURE", {item["code"] for item in body["limits"]})
94
+
95
+ def test_placeholder_is_not_exposed_as_a_signature(self):
96
+ _, node = _node("PLACEHOLDER - not a signature")
97
+ node["dsse"]["signatures"][0]["keyid"] = "PENDING"
98
+ body, status = build_receipt_export([node], dsse_module=_TestDSSE)
99
+ self.assertEqual(status, 200)
100
+ self.assertEqual(body["export_state"], "UNSIGNED")
101
+ self.assertFalse(body["signed"])
102
+ self.assertEqual(body["dsse"]["signatures"], [])
103
+ self.assertEqual(body["verification"]["state"], "UNSIGNED")
104
+
105
+ def test_out_of_range_index_is_rejected_without_fallback(self):
106
+ _, node = _node(base64.b64encode(b"x").decode())
107
+ body, status = build_receipt_export([node], index=3, dsse_module=_TestDSSE)
108
+ self.assertEqual(status, 422)
109
+ self.assertFalse(body["ok"])
110
+ self.assertEqual(body["export_state"], "INDEX_OUT_OF_RANGE")
111
+ self.assertEqual(body["requested_index"], 3)
112
+
113
+
114
+ if __name__ == "__main__":
115
+ unittest.main()
116
+