@zanii/core 0.5.1 → 0.5.3

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.
package/src/receipt.ts CHANGED
@@ -1,308 +1,340 @@
1
- import { ed25519 } from '@noble/curves/ed25519';
2
- import { sha256 } from '@noble/hashes/sha256';
3
- import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils';
4
- import { canonicalBytes, decodeSig, encodeSig, jcsHash } from './encoding.js';
5
- import { publicKeyFromDid } from './keys.js';
6
- import { scopesAllow, verifyChain, type DelegationCert } from './delegation.js';
7
-
8
- export interface Receipt {
9
- v: 1;
10
- agent_id: string;
11
- delegation: DelegationCert[];
12
- action: string;
13
- target: string;
14
- payload_hash: string;
15
- ts: string;
16
- prev: string | null;
17
- /** Optional provenance (SPEC §3). Present when set; covered by the signature. */
18
- runtime_hash?: string;
19
- model_id?: string;
20
- /** Hash of the product's tool/scope manifest that governed this action. */
21
- manifest_hash?: string;
22
- /** Optional pseudonymous data-subject tag (SPEC §3) — lets the end user the action was
23
- * about find + audit their own slice (`@zanii/subject`). Covered by the signature. */
24
- subject_tag?: string;
25
- /**
26
- * How `payload_hash` committed to the payload (SPEC §3). `'jcs'` means the payload was
27
- * encoded with `payloadBytes` (RFC 8785) before hashing — the only value written since
28
- * `@zanii/sdk` 0.5.0. ABSENT means a pre-0.5.0 receipt, whose bytes came from the SDK's
29
- * own `JSON.stringify`/`json.dumps` and therefore differ between the two SDKs; those
30
- * are checked with `verifyPayloadLegacy`, never with `verifyPayload`.
31
- */
32
- payload_encoding?: 'jcs';
33
- sig: string;
34
- }
35
-
36
- export interface VerifyResult {
37
- ok: boolean;
38
- error?: string;
39
- }
40
-
41
- export function hashPayload(payload: Uint8Array | string): string {
42
- const bytes = typeof payload === 'string' ? new TextEncoder().encode(payload) : payload;
43
- return `sha256:${bytesToHex(sha256(bytes))}`;
44
- }
45
-
46
- /**
47
- * Reject anything whose canonical form the two SDKs would not agree on. Mirrors
48
- * `_check_jcs_safe` in `zanii.encoding`: non-integer numbers are the one place JSON
49
- * serialisers legitimately disagree, so they are refused rather than hashed into a
50
- * receipt no Python verifier can reproduce. Use integer minor units (`@zanii/payments`).
51
- */
52
- function assertPayloadCanonicalizable(v: unknown, path = 'payload'): void {
53
- if (v === null || typeof v === 'string' || typeof v === 'boolean') return;
54
- if (typeof v === 'number') {
55
- if (!Number.isFinite(v)) throw new Error(`${path}: ${v} has no JSON representation`);
56
- if (!Number.isInteger(v)) {
57
- throw new Error(
58
- `${path}: non-integer number ${v} cannot be committed to a payload hash — the two SDKs ` +
59
- 'would encode it differently. Use integer minor units (see @zanii/payments) or a string.',
60
- );
61
- }
62
- return;
63
- }
64
- if (Array.isArray(v)) {
65
- v.forEach((x, i) => assertPayloadCanonicalizable(x, `${path}[${i}]`));
66
- return;
67
- }
68
- if (typeof v === 'object') {
69
- for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
70
- // An undefined member is DROPPED, per JSON.stringify: `{a:1,b:undefined}` encodes
71
- // as `{"a":1}`, which is byte-identical to what Python produces for the equivalent
72
- // input (the key simply absent). A Python `None` means JSON null, a different
73
- // value, and correctly hashes differently. So this needs no special case.
74
- if (val === undefined) continue;
75
- assertPayloadCanonicalizable(val, `${path}.${k}`);
76
- }
77
- return;
78
- }
79
- throw new Error(`${path}: ${typeof v} is not canonicalizable JSON`);
80
- }
81
-
82
- /**
83
- * The bytes a `payload_hash` commits to (SPEC §3): RFC 8785 JCS, UTF-8.
84
- *
85
- * This exists because "hash the payload" was under-specified: each SDK reached for its
86
- * own serialiser, so the same object hashed differently in TypeScript and Python and
87
- * `verifyPayload` returned false across languages for honest data. One encoder, both
88
- * languages, byte-identical — pinned by the cross-language vectors.
89
- *
90
- * A top-level `undefined` (or `null`) encodes as `null`, which is what both SDKs already
91
- * hashed, so null payloads are unaffected by the change.
92
- */
93
- export function payloadBytes(payload: unknown): Uint8Array {
94
- const value = payload === undefined ? null : payload;
95
- assertPayloadCanonicalizable(value);
96
- return canonicalBytes(value);
97
- }
98
-
99
- /**
100
- * Privacy-preserving payload commitment: `sha256(nonce ‖ payload)`. A bare
101
- * `hashPayload` of a low-entropy payload ("phone X, amount 500") is dictionary-
102
- * attackable from the public anchored log, and a linkable hash can itself be
103
- * personal data (PDPL). The per-receipt `nonce` is kept operator-side (NOT in the
104
- * receipt), so the on-chain value reveals nothing; keep it to later prove the
105
- * payload with `verifyPayload`. If `nonce` is omitted a fresh 16-byte one is drawn.
106
- */
107
- export function saltedPayloadHash(
108
- payload: Uint8Array | string,
109
- nonce?: string,
110
- ): { payload_hash: string; nonce: string } {
111
- const nonceHex = nonce ?? bytesToHex(randomBytes(16));
112
- const nonceBytes = hexToBytes(nonceHex);
113
- const payloadBytes = typeof payload === 'string' ? new TextEncoder().encode(payload) : payload;
114
- const combined = new Uint8Array(nonceBytes.length + payloadBytes.length);
115
- combined.set(nonceBytes, 0);
116
- combined.set(payloadBytes, nonceBytes.length);
117
- return { payload_hash: `sha256:${bytesToHex(sha256(combined))}`, nonce: nonceHex };
118
- }
119
-
120
- /** Prove a payload matches a salted `payload_hash`, given the nonce kept at record time. */
121
- export function verifyPayload(payload: Uint8Array | string, nonce: string, payloadHash: string): boolean {
122
- try {
123
- return saltedPayloadHash(payload, nonce).payload_hash === payloadHash;
124
- } catch {
125
- return false;
126
- }
127
- }
128
-
129
- /** Which pre-0.5.0 SDK's serialiser reproduced a legacy `payload_hash`. */
130
- export type PayloadDialect = 'jcs' | 'legacy-ts' | 'legacy-py';
131
-
132
- /** Old `@zanii/sdk`: `JSON.stringify`, insertion order, no spaces, raw UTF-8. */
133
- function legacyTsEncode(payload: unknown): string | undefined {
134
- return JSON.stringify(payload) ?? 'null';
135
- }
136
-
137
- /** Old `zanii` (Python): `json.dumps(sort_keys=True)` — `, ` / `: ` separators, \uXXXX escapes. */
138
- function legacyPyEncode(value: unknown): string {
139
- const esc = (str: string): string => {
140
- let out = '';
141
- for (const ch of str) {
142
- const c = ch.codePointAt(0)!;
143
- if (ch === '"') out += '\\"';
144
- else if (ch === '\\') out += '\\\\';
145
- else if (ch === '\n') out += '\\n';
146
- else if (ch === '\r') out += '\\r';
147
- else if (ch === '\t') out += '\\t';
148
- else if (c < 0x20) out += `\\u${c.toString(16).padStart(4, '0')}`;
149
- else if (c < 0x7f) out += ch;
150
- else if (c > 0xffff) {
151
- // Python escapes astral characters as a surrogate pair
152
- const u = c - 0x10000;
153
- out += `\\u${(0xd800 + (u >> 10)).toString(16)}\\u${(0xdc00 + (u & 0x3ff)).toString(16)}`;
154
- } else out += `\\u${c.toString(16).padStart(4, '0')}`;
155
- }
156
- return `"${out}"`;
157
- };
158
- const enc = (v: unknown): string => {
159
- if (v === null || v === undefined) return 'null';
160
- if (typeof v === 'boolean') return v ? 'true' : 'false';
161
- if (typeof v === 'number') return Number.isInteger(v) ? String(v) : String(v);
162
- if (typeof v === 'string') return esc(v);
163
- if (Array.isArray(v)) return `[${v.map(enc).join(', ')}]`;
164
- const o = v as Record<string, unknown>;
165
- const keys = Object.keys(o).sort();
166
- return `{${keys.map((k) => `${esc(k)}: ${enc(o[k])}`).join(', ')}}`;
167
- };
168
- return enc(value);
169
- }
170
-
171
- /**
172
- * Verify a payload against a receipt recorded BEFORE the JCS rule (no `payload_encoding`
173
- * field). Such a receipt does not record which SDK wrote it, so this tries each old
174
- * dialect and reports which one matched.
175
- *
176
- * Honest limits: the `legacy-ts` dialect depends on the object's original KEY INSERTION
177
- * ORDER, which is not recoverable from a re-parsed payload, so it only matches when the
178
- * caller reconstructs the object in the order it was recorded. Float formatting is
179
- * likewise best-effort. A `null` result means no dialect reproduced the hash; it does not
180
- * prove the payload wrong.
181
- */
182
- export function verifyPayloadLegacy(
183
- payload: unknown,
184
- nonce: string,
185
- payloadHash: string,
186
- ): { ok: boolean; dialect: PayloadDialect | null } {
187
- const attempts: Array<[PayloadDialect, () => Uint8Array | string]> = [
188
- ['jcs', () => payloadBytes(payload)],
189
- ['legacy-ts', () => legacyTsEncode(payload)!],
190
- ['legacy-py', () => legacyPyEncode(payload)],
191
- ];
192
- for (const [dialect, encode] of attempts) {
193
- try {
194
- if (saltedPayloadHash(encode(), nonce).payload_hash === payloadHash) return { ok: true, dialect };
195
- } catch {
196
- /* this dialect cannot encode it — try the next */
197
- }
198
- }
199
- return { ok: false, dialect: null };
200
- }
201
-
202
- export function createReceipt(
203
- fields: {
204
- agentId: string;
205
- delegation: DelegationCert[];
206
- action: string;
207
- target: string;
208
- payloadHash: string;
209
- ts: string;
210
- prev: string | null;
211
- /** Optional provenance — included in the signed receipt only when provided. */
212
- runtimeHash?: string;
213
- modelId?: string;
214
- manifestHash?: string;
215
- /** Optional pseudonymous data-subject tag — per-subject auditability (`@zanii/subject`). */
216
- subjectTag?: string;
217
- /** How `payloadHash` committed to the payload. Set `'jcs'` when it came from `payloadBytes`. */
218
- payloadEncoding?: 'jcs';
219
- },
220
- agentPrivateKey: Uint8Array,
221
- ): Receipt {
222
- if (Number.isNaN(Date.parse(fields.ts))) throw new Error(`invalid ts timestamp: ${fields.ts}`);
223
- const unsigned = {
224
- v: 1 as const,
225
- agent_id: fields.agentId,
226
- delegation: fields.delegation,
227
- action: fields.action,
228
- target: fields.target,
229
- payload_hash: fields.payloadHash,
230
- ts: fields.ts,
231
- prev: fields.prev,
232
- ...(fields.runtimeHash ? { runtime_hash: fields.runtimeHash } : {}),
233
- ...(fields.modelId ? { model_id: fields.modelId } : {}),
234
- ...(fields.manifestHash ? { manifest_hash: fields.manifestHash } : {}),
235
- ...(fields.subjectTag ? { subject_tag: fields.subjectTag } : {}),
236
- ...(fields.payloadEncoding ? { payload_encoding: fields.payloadEncoding } : {}),
237
- };
238
- const sig = ed25519.sign(canonicalBytes(unsigned), agentPrivateKey);
239
- return { ...unsigned, sig: encodeSig(sig) };
240
- }
241
-
242
- /** Hash of the full receipt (incl. sig) — what the next receipt's `prev` points to. */
243
- export function receiptHash(receipt: Receipt): string {
244
- return jcsHash(receipt);
245
- }
246
-
247
- export function verifyReceiptSignature(receipt: Receipt): boolean {
248
- const pub = publicKeyFromDid(receipt.agent_id);
249
- const sig = decodeSig(receipt.sig);
250
- if (!pub || !sig) return false;
251
- const { sig: _omitted, ...unsigned } = receipt;
252
- try {
253
- return ed25519.verify(sig, canonicalBytes(unsigned), pub);
254
- } catch {
255
- return false;
256
- }
257
- }
258
-
259
- /**
260
- * Full single-receipt verification per SPEC §4:
261
- * signature → delegation chain (at receipt time) → scope covers target → prev linkage.
262
- */
263
- export function verifyReceipt(
264
- receipt: Receipt,
265
- opts: { expectedOwner?: string; revoked?: Set<string>; expectedPrev?: string | null } = {},
266
- ): VerifyResult {
267
- if (receipt.v !== 1) return { ok: false, error: 'unsupported receipt version' };
268
- if (!verifyReceiptSignature(receipt)) return { ok: false, error: 'bad receipt signature' };
269
-
270
- const ts = new Date(receipt.ts);
271
- if (Number.isNaN(ts.getTime())) return { ok: false, error: 'invalid ts' };
272
-
273
- const chain = verifyChain(receipt.delegation, {
274
- agentId: receipt.agent_id,
275
- expectedRoot: opts.expectedOwner,
276
- revoked: opts.revoked,
277
- at: ts,
278
- });
279
- if (!chain.ok) return { ok: false, error: `delegation: ${chain.error}` };
280
-
281
- if (!scopesAllow(chain.effectiveScopes!, receipt.target)) {
282
- return { ok: false, error: `target ${receipt.target} outside effective scopes` };
283
- }
284
-
285
- if (opts.expectedPrev !== undefined && receipt.prev !== opts.expectedPrev) {
286
- return { ok: false, error: 'prev hash does not match preceding receipt (chain broken)' };
287
- }
288
-
289
- return { ok: true };
290
- }
291
-
292
- /**
293
- * Verify an agent's full receipt sequence: every receipt valid and hash-linked
294
- * to the one before it. Returns the index of the first bad receipt on failure.
295
- */
296
- export function verifyReceiptChain(
297
- receipts: Receipt[],
298
- opts: { expectedOwner?: string; revoked?: Set<string> } = {},
299
- ): VerifyResult & { failedIndex?: number } {
300
- let expectedPrev: string | null = null;
301
- for (let i = 0; i < receipts.length; i++) {
302
- const r = receipts[i]!;
303
- const result = verifyReceipt(r, { ...opts, expectedPrev });
304
- if (!result.ok) return { ok: false, error: `receipt ${i}: ${result.error}`, failedIndex: i };
305
- expectedPrev = receiptHash(r);
306
- }
307
- return { ok: true };
308
- }
1
+ import { ed25519 } from '@noble/curves/ed25519';
2
+ import { sha256 } from '@noble/hashes/sha256';
3
+ import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils';
4
+ import { canonicalBytes, decodeSig, encodeSig, jcsHash } from './encoding.js';
5
+ import { publicKeyFromDid } from './keys.js';
6
+ import { scopesAllow, verifyChain, type DelegationCert } from './delegation.js';
7
+
8
+ /**
9
+ * The target of a session-close receipt (SPEC §15). An agent records one when it ends a
10
+ * session deliberately, so a verifier can tell a run that finished from one that was cut
11
+ * off. The per-agent `prev` chain already proves nothing is missing between receipts;
12
+ * this marks where a session was declared over.
13
+ */
14
+ export const SESSION_CLOSE_TARGET = 'zanii.session.close';
15
+
16
+ /** True for a solo receipt that closes a session. */
17
+ export function isSessionClose(receipt: unknown): boolean {
18
+ const r = receipt as { type?: string; target?: string; action?: string } | null;
19
+ return !!r && r.type === undefined && r.target === SESSION_CLOSE_TARGET && r.action === 'session.close';
20
+ }
21
+
22
+ /** What is at stake in an action (SPEC §3). A label the recorder sets, signed with the receipt: useful for policy and triage, and a control only where something independent of the agent enforces it. */
23
+ export const RISK_LEVELS = ['low', 'medium', 'high', 'critical'] as const;
24
+ export type RiskLevel = (typeof RISK_LEVELS)[number];
25
+ export function isRiskLevel(v: unknown): v is RiskLevel {
26
+ return typeof v === 'string' && (RISK_LEVELS as readonly string[]).includes(v);
27
+ }
28
+
29
+ export interface Receipt {
30
+ v: 1;
31
+ agent_id: string;
32
+ delegation: DelegationCert[];
33
+ action: string;
34
+ target: string;
35
+ payload_hash: string;
36
+ ts: string;
37
+ prev: string | null;
38
+ /** Optional provenance (SPEC §3). Present when set; covered by the signature. */
39
+ runtime_hash?: string;
40
+ model_id?: string;
41
+ /** Hash of the product's tool/scope manifest that governed this action. */
42
+ manifest_hash?: string;
43
+ /** Optional pseudonymous data-subject tag (SPEC §3) — lets the end user the action was
44
+ * about find + audit their own slice (`@zanii/subject`). Covered by the signature. */
45
+ subject_tag?: string;
46
+ /**
47
+ * How `payload_hash` committed to the payload (SPEC §3). `'jcs'` means the payload was
48
+ * encoded with `payloadBytes` (RFC 8785) before hashing — the only value written since
49
+ * `@zanii/sdk` 0.5.0. ABSENT means a pre-0.5.0 receipt, whose bytes came from the SDK's
50
+ * own `JSON.stringify`/`json.dumps` and therefore differ between the two SDKs; those
51
+ * are checked with `verifyPayloadLegacy`, never with `verifyPayload`.
52
+ */
53
+ payload_encoding?: 'jcs';
54
+ /** What is at stake (SPEC §3). Signed; absent means the recorder did not say. */
55
+ risk?: RiskLevel;
56
+ sig: string;
57
+ }
58
+
59
+ export interface VerifyResult {
60
+ ok: boolean;
61
+ error?: string;
62
+ }
63
+
64
+ export function hashPayload(payload: Uint8Array | string): string {
65
+ const bytes = typeof payload === 'string' ? new TextEncoder().encode(payload) : payload;
66
+ return `sha256:${bytesToHex(sha256(bytes))}`;
67
+ }
68
+
69
+ /**
70
+ * Reject anything whose canonical form the two SDKs would not agree on. Mirrors
71
+ * `_check_jcs_safe` in `zanii.encoding`: non-integer numbers are the one place JSON
72
+ * serialisers legitimately disagree, so they are refused rather than hashed into a
73
+ * receipt no Python verifier can reproduce. Use integer minor units (`@zanii/payments`).
74
+ */
75
+ function assertPayloadCanonicalizable(v: unknown, path = 'payload'): void {
76
+ if (v === null || typeof v === 'string' || typeof v === 'boolean') return;
77
+ if (typeof v === 'number') {
78
+ if (!Number.isFinite(v)) throw new Error(`${path}: ${v} has no JSON representation`);
79
+ // A non-integer encodes as RFC 8785 §3.2.2.3 (ECMAScript's own form, which Python's
80
+ // SDK reproduces byte for byte since protocol 1.3), so it needs no check here.
81
+ if (Number.isInteger(v) && !Number.isSafeInteger(v)) {
82
+ // JavaScript has already rounded it by now; another SDK would keep it exact.
83
+ throw new Error(
84
+ `${path}: integer ${v} is outside +/-(2^53-1): not every JSON implementation can ` +
85
+ 'represent it exactly, so the SDKs would disagree on its bytes. Pass it as a string.',
86
+ );
87
+ }
88
+ return;
89
+ }
90
+ if (Array.isArray(v)) {
91
+ v.forEach((x, i) => assertPayloadCanonicalizable(x, `${path}[${i}]`));
92
+ return;
93
+ }
94
+ if (typeof v === 'object') {
95
+ for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
96
+ // An undefined member is DROPPED, per JSON.stringify: `{a:1,b:undefined}` encodes
97
+ // as `{"a":1}`, which is byte-identical to what Python produces for the equivalent
98
+ // input (the key simply absent). A Python `None` means JSON null, a different
99
+ // value, and correctly hashes differently. So this needs no special case.
100
+ if (val === undefined) continue;
101
+ assertPayloadCanonicalizable(val, `${path}.${k}`);
102
+ }
103
+ return;
104
+ }
105
+ throw new Error(`${path}: ${typeof v} is not canonicalizable JSON`);
106
+ }
107
+
108
+ /**
109
+ * The bytes a `payload_hash` commits to (SPEC §3): RFC 8785 JCS, UTF-8.
110
+ *
111
+ * This exists because "hash the payload" was under-specified: each SDK reached for its
112
+ * own serialiser, so the same object hashed differently in TypeScript and Python and
113
+ * `verifyPayload` returned false across languages for honest data. One encoder, both
114
+ * languages, byte-identical — pinned by the cross-language vectors.
115
+ *
116
+ * A top-level `undefined` (or `null`) encodes as `null`, which is what both SDKs already
117
+ * hashed, so null payloads are unaffected by the change.
118
+ */
119
+ export function payloadBytes(payload: unknown): Uint8Array {
120
+ const value = payload === undefined ? null : payload;
121
+ assertPayloadCanonicalizable(value);
122
+ return canonicalBytes(value);
123
+ }
124
+
125
+ /**
126
+ * Privacy-preserving payload commitment: `sha256(nonce ‖ payload)`. A bare
127
+ * `hashPayload` of a low-entropy payload ("phone X, amount 500") is dictionary-
128
+ * attackable from the public anchored log, and a linkable hash can itself be
129
+ * personal data (PDPL). The per-receipt `nonce` is kept operator-side (NOT in the
130
+ * receipt), so the on-chain value reveals nothing; keep it to later prove the
131
+ * payload with `verifyPayload`. If `nonce` is omitted a fresh 16-byte one is drawn.
132
+ */
133
+ export function saltedPayloadHash(
134
+ payload: Uint8Array | string,
135
+ nonce?: string,
136
+ ): { payload_hash: string; nonce: string } {
137
+ const nonceHex = nonce ?? bytesToHex(randomBytes(16));
138
+ const nonceBytes = hexToBytes(nonceHex);
139
+ const payloadBytes = typeof payload === 'string' ? new TextEncoder().encode(payload) : payload;
140
+ const combined = new Uint8Array(nonceBytes.length + payloadBytes.length);
141
+ combined.set(nonceBytes, 0);
142
+ combined.set(payloadBytes, nonceBytes.length);
143
+ return { payload_hash: `sha256:${bytesToHex(sha256(combined))}`, nonce: nonceHex };
144
+ }
145
+
146
+ /** Prove a payload matches a salted `payload_hash`, given the nonce kept at record time. */
147
+ export function verifyPayload(payload: Uint8Array | string, nonce: string, payloadHash: string): boolean {
148
+ try {
149
+ return saltedPayloadHash(payload, nonce).payload_hash === payloadHash;
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ /** Which pre-0.5.0 SDK's serialiser reproduced a legacy `payload_hash`. */
156
+ export type PayloadDialect = 'jcs' | 'legacy-ts' | 'legacy-py';
157
+
158
+ /** Old `@zanii/sdk`: `JSON.stringify`, insertion order, no spaces, raw UTF-8. */
159
+ function legacyTsEncode(payload: unknown): string | undefined {
160
+ return JSON.stringify(payload) ?? 'null';
161
+ }
162
+
163
+ /** Old `zanii` (Python): `json.dumps(sort_keys=True)` — `, ` / `: ` separators, \uXXXX escapes. */
164
+ function legacyPyEncode(value: unknown): string {
165
+ const esc = (str: string): string => {
166
+ let out = '';
167
+ for (const ch of str) {
168
+ const c = ch.codePointAt(0)!;
169
+ if (ch === '"') out += '\\"';
170
+ else if (ch === '\\') out += '\\\\';
171
+ else if (ch === '\n') out += '\\n';
172
+ else if (ch === '\r') out += '\\r';
173
+ else if (ch === '\t') out += '\\t';
174
+ else if (c < 0x20) out += `\\u${c.toString(16).padStart(4, '0')}`;
175
+ else if (c < 0x7f) out += ch;
176
+ else if (c > 0xffff) {
177
+ // Python escapes astral characters as a surrogate pair
178
+ const u = c - 0x10000;
179
+ out += `\\u${(0xd800 + (u >> 10)).toString(16)}\\u${(0xdc00 + (u & 0x3ff)).toString(16)}`;
180
+ } else out += `\\u${c.toString(16).padStart(4, '0')}`;
181
+ }
182
+ return `"${out}"`;
183
+ };
184
+ const enc = (v: unknown): string => {
185
+ if (v === null || v === undefined) return 'null';
186
+ if (typeof v === 'boolean') return v ? 'true' : 'false';
187
+ if (typeof v === 'number') return Number.isInteger(v) ? String(v) : String(v);
188
+ if (typeof v === 'string') return esc(v);
189
+ if (Array.isArray(v)) return `[${v.map(enc).join(', ')}]`;
190
+ const o = v as Record<string, unknown>;
191
+ const keys = Object.keys(o).sort();
192
+ return `{${keys.map((k) => `${esc(k)}: ${enc(o[k])}`).join(', ')}}`;
193
+ };
194
+ return enc(value);
195
+ }
196
+
197
+ /**
198
+ * Verify a payload against a receipt recorded BEFORE the JCS rule (no `payload_encoding`
199
+ * field). Such a receipt does not record which SDK wrote it, so this tries each old
200
+ * dialect and reports which one matched.
201
+ *
202
+ * Honest limits: the `legacy-ts` dialect depends on the object's original KEY INSERTION
203
+ * ORDER, which is not recoverable from a re-parsed payload, so it only matches when the
204
+ * caller reconstructs the object in the order it was recorded. Float formatting is
205
+ * likewise best-effort. A `null` result means no dialect reproduced the hash; it does not
206
+ * prove the payload wrong.
207
+ */
208
+ export function verifyPayloadLegacy(
209
+ payload: unknown,
210
+ nonce: string,
211
+ payloadHash: string,
212
+ ): { ok: boolean; dialect: PayloadDialect | null } {
213
+ const attempts: Array<[PayloadDialect, () => Uint8Array | string]> = [
214
+ ['jcs', () => payloadBytes(payload)],
215
+ ['legacy-ts', () => legacyTsEncode(payload)!],
216
+ ['legacy-py', () => legacyPyEncode(payload)],
217
+ ];
218
+ for (const [dialect, encode] of attempts) {
219
+ try {
220
+ if (saltedPayloadHash(encode(), nonce).payload_hash === payloadHash) return { ok: true, dialect };
221
+ } catch {
222
+ /* this dialect cannot encode it — try the next */
223
+ }
224
+ }
225
+ return { ok: false, dialect: null };
226
+ }
227
+
228
+ export function createReceipt(
229
+ fields: {
230
+ agentId: string;
231
+ delegation: DelegationCert[];
232
+ action: string;
233
+ target: string;
234
+ payloadHash: string;
235
+ ts: string;
236
+ prev: string | null;
237
+ /** Optional provenance — included in the signed receipt only when provided. */
238
+ runtimeHash?: string;
239
+ modelId?: string;
240
+ manifestHash?: string;
241
+ /** Optional pseudonymous data-subject tag — per-subject auditability (`@zanii/subject`). */
242
+ subjectTag?: string;
243
+ /** How `payloadHash` committed to the payload. Set `'jcs'` when it came from `payloadBytes`. */
244
+ payloadEncoding?: 'jcs';
245
+ /** What is at stake. Signed into the receipt when set. */
246
+ risk?: RiskLevel;
247
+ },
248
+ agentPrivateKey: Uint8Array,
249
+ ): Receipt {
250
+ if (Number.isNaN(Date.parse(fields.ts))) throw new Error(`invalid ts timestamp: ${fields.ts}`);
251
+ if (fields.risk !== undefined && !isRiskLevel(fields.risk)) {
252
+ throw new Error(`invalid risk '${String(fields.risk)}': expected one of low, medium, high, critical`);
253
+ }
254
+ const unsigned = {
255
+ v: 1 as const,
256
+ agent_id: fields.agentId,
257
+ delegation: fields.delegation,
258
+ action: fields.action,
259
+ target: fields.target,
260
+ payload_hash: fields.payloadHash,
261
+ ts: fields.ts,
262
+ prev: fields.prev,
263
+ ...(fields.runtimeHash ? { runtime_hash: fields.runtimeHash } : {}),
264
+ ...(fields.modelId ? { model_id: fields.modelId } : {}),
265
+ ...(fields.manifestHash ? { manifest_hash: fields.manifestHash } : {}),
266
+ ...(fields.subjectTag ? { subject_tag: fields.subjectTag } : {}),
267
+ ...(fields.payloadEncoding ? { payload_encoding: fields.payloadEncoding } : {}),
268
+ ...(fields.risk ? { risk: fields.risk } : {}),
269
+ };
270
+ const sig = ed25519.sign(canonicalBytes(unsigned), agentPrivateKey);
271
+ return { ...unsigned, sig: encodeSig(sig) };
272
+ }
273
+
274
+ /** Hash of the full receipt (incl. sig) — what the next receipt's `prev` points to. */
275
+ export function receiptHash(receipt: Receipt): string {
276
+ return jcsHash(receipt);
277
+ }
278
+
279
+ export function verifyReceiptSignature(receipt: Receipt): boolean {
280
+ const pub = publicKeyFromDid(receipt.agent_id);
281
+ const sig = decodeSig(receipt.sig);
282
+ if (!pub || !sig) return false;
283
+ const { sig: _omitted, ...unsigned } = receipt;
284
+ try {
285
+ return ed25519.verify(sig, canonicalBytes(unsigned), pub);
286
+ } catch {
287
+ return false;
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Full single-receipt verification per SPEC §4:
293
+ * signature → delegation chain (at receipt time) → scope covers target → prev linkage.
294
+ */
295
+ export function verifyReceipt(
296
+ receipt: Receipt,
297
+ opts: { expectedOwner?: string; revoked?: Set<string>; expectedPrev?: string | null } = {},
298
+ ): VerifyResult {
299
+ if (receipt.v !== 1) return { ok: false, error: 'unsupported receipt version' };
300
+ if (!verifyReceiptSignature(receipt)) return { ok: false, error: 'bad receipt signature' };
301
+
302
+ const ts = new Date(receipt.ts);
303
+ if (Number.isNaN(ts.getTime())) return { ok: false, error: 'invalid ts' };
304
+
305
+ const chain = verifyChain(receipt.delegation, {
306
+ agentId: receipt.agent_id,
307
+ expectedRoot: opts.expectedOwner,
308
+ revoked: opts.revoked,
309
+ at: ts,
310
+ });
311
+ if (!chain.ok) return { ok: false, error: `delegation: ${chain.error}` };
312
+
313
+ if (!scopesAllow(chain.effectiveScopes!, receipt.target)) {
314
+ return { ok: false, error: `target ${receipt.target} outside effective scopes` };
315
+ }
316
+
317
+ if (opts.expectedPrev !== undefined && receipt.prev !== opts.expectedPrev) {
318
+ return { ok: false, error: 'prev hash does not match preceding receipt (chain broken)' };
319
+ }
320
+
321
+ return { ok: true };
322
+ }
323
+
324
+ /**
325
+ * Verify an agent's full receipt sequence: every receipt valid and hash-linked
326
+ * to the one before it. Returns the index of the first bad receipt on failure.
327
+ */
328
+ export function verifyReceiptChain(
329
+ receipts: Receipt[],
330
+ opts: { expectedOwner?: string; revoked?: Set<string> } = {},
331
+ ): VerifyResult & { failedIndex?: number } {
332
+ let expectedPrev: string | null = null;
333
+ for (let i = 0; i < receipts.length; i++) {
334
+ const r = receipts[i]!;
335
+ const result = verifyReceipt(r, { ...opts, expectedPrev });
336
+ if (!result.ok) return { ok: false, error: `receipt ${i}: ${result.error}`, failedIndex: i };
337
+ expectedPrev = receiptHash(r);
338
+ }
339
+ return { ok: true };
340
+ }