@zanii/core 0.1.0
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/LICENSE +159 -0
- package/README.md +64 -0
- package/dist/a2a.d.ts +58 -0
- package/dist/a2a.d.ts.map +1 -0
- package/dist/a2a.js +102 -0
- package/dist/a2a.js.map +1 -0
- package/dist/bundle.d.ts +55 -0
- package/dist/bundle.d.ts.map +1 -0
- package/dist/bundle.js +92 -0
- package/dist/bundle.js.map +1 -0
- package/dist/delegation.d.ts +48 -0
- package/dist/delegation.d.ts.map +1 -0
- package/dist/delegation.js +110 -0
- package/dist/delegation.js.map +1 -0
- package/dist/encoding.d.ts +8 -0
- package/dist/encoding.d.ts.map +1 -0
- package/dist/encoding.js +29 -0
- package/dist/encoding.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +10 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +33 -0
- package/dist/keys.js.map +1 -0
- package/dist/merkle.d.ts +12 -0
- package/dist/merkle.d.ts.map +1 -0
- package/dist/merkle.js +146 -0
- package/dist/merkle.js.map +1 -0
- package/dist/receipt.d.ts +49 -0
- package/dist/receipt.d.ts.map +1 -0
- package/dist/receipt.js +87 -0
- package/dist/receipt.js.map +1 -0
- package/dist/sth.d.ts +17 -0
- package/dist/sth.d.ts.map +1 -0
- package/dist/sth.js +24 -0
- package/dist/sth.js.map +1 -0
- package/package.json +43 -0
- package/src/a2a.ts +139 -0
- package/src/bundle.ts +176 -0
- package/src/delegation.ts +134 -0
- package/src/encoding.ts +30 -0
- package/src/index.ts +56 -0
- package/src/keys.ts +38 -0
- package/src/merkle.ts +154 -0
- package/src/receipt.ts +123 -0
- package/src/sth.ts +35 -0
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zanii/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zanii protocol: verifiable identity, signed action receipts, Merkle transparency-log proofs. Pure, no I/O.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"homepage": "https://ledger.zanii.agency",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"src",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@noble/curves": "^1.8.1",
|
|
27
|
+
"@noble/hashes": "^1.7.1",
|
|
28
|
+
"@scure/base": "^1.2.4",
|
|
29
|
+
"canonicalize": "^2.1.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^26.1.0",
|
|
33
|
+
"tsx": "^4.19.3",
|
|
34
|
+
"typescript": "^5.8.2",
|
|
35
|
+
"vitest": "^3.0.9"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc -p tsconfig.build.json",
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"demo": "tsx demo.ts",
|
|
41
|
+
"typecheck": "tsc --noEmit"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/a2a.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-org co-signed receipts — SPEC §9. One interaction, two signers, one
|
|
3
|
+
* neutral proof that enters both agents' hash chains.
|
|
4
|
+
*/
|
|
5
|
+
import { ed25519 } from '@noble/curves/ed25519';
|
|
6
|
+
import { canonicalBytes, decodeSig, encodeSig, jcsHash } from './encoding.js';
|
|
7
|
+
import { publicKeyFromDid } from './keys.js';
|
|
8
|
+
import { scopesAllow, verifyChain, type DelegationCert } from './delegation.js';
|
|
9
|
+
import type { VerifyResult } from './receipt.js';
|
|
10
|
+
|
|
11
|
+
export interface A2AParty {
|
|
12
|
+
agent_id: string;
|
|
13
|
+
delegation: DelegationCert[];
|
|
14
|
+
prev: string | null;
|
|
15
|
+
sig: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface A2AReceipt {
|
|
19
|
+
v: 1;
|
|
20
|
+
type: 'a2a';
|
|
21
|
+
action: string;
|
|
22
|
+
target: string;
|
|
23
|
+
payload_hash: string;
|
|
24
|
+
ts: string;
|
|
25
|
+
initiator: A2AParty;
|
|
26
|
+
responder: A2AParty;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The body both parties sign: the receipt with both sig fields removed. */
|
|
30
|
+
export type A2ABody = Omit<A2AReceipt, 'initiator' | 'responder'> & {
|
|
31
|
+
initiator: Omit<A2AParty, 'sig'>;
|
|
32
|
+
responder: Omit<A2AParty, 'sig'>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function buildA2ABody(fields: {
|
|
36
|
+
action: string;
|
|
37
|
+
target: string;
|
|
38
|
+
payloadHash: string;
|
|
39
|
+
ts: string;
|
|
40
|
+
initiator: Omit<A2AParty, 'sig'>;
|
|
41
|
+
responder: Omit<A2AParty, 'sig'>;
|
|
42
|
+
}): A2ABody {
|
|
43
|
+
if (Number.isNaN(Date.parse(fields.ts))) throw new Error(`invalid ts timestamp: ${fields.ts}`);
|
|
44
|
+
if (fields.initiator.agent_id === fields.responder.agent_id) {
|
|
45
|
+
throw new Error('initiator and responder must be distinct agents');
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
v: 1,
|
|
49
|
+
type: 'a2a',
|
|
50
|
+
action: fields.action,
|
|
51
|
+
target: fields.target,
|
|
52
|
+
payload_hash: fields.payloadHash,
|
|
53
|
+
ts: fields.ts,
|
|
54
|
+
initiator: fields.initiator,
|
|
55
|
+
responder: fields.responder,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Both parties sign the identical body — order-independent. */
|
|
60
|
+
export function signA2ABody(body: A2ABody, privateKey: Uint8Array): string {
|
|
61
|
+
return encodeSig(ed25519.sign(canonicalBytes(body), privateKey));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function assembleA2AReceipt(body: A2ABody, initiatorSig: string, responderSig: string): A2AReceipt {
|
|
65
|
+
return {
|
|
66
|
+
...body,
|
|
67
|
+
initiator: { ...body.initiator, sig: initiatorSig },
|
|
68
|
+
responder: { ...body.responder, sig: responderSig },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Hash of the full receipt (both sigs) — what both agents' next `prev` points to. */
|
|
73
|
+
export function a2aHash(receipt: A2AReceipt): string {
|
|
74
|
+
return jcsHash(receipt);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function bodyOf(receipt: A2AReceipt): A2ABody {
|
|
78
|
+
const { sig: _i, ...initiator } = receipt.initiator;
|
|
79
|
+
const { sig: _r, ...responder } = receipt.responder;
|
|
80
|
+
return { ...receipt, initiator, responder };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function partySigOk(body: A2ABody, party: A2AParty): boolean {
|
|
84
|
+
const pub = publicKeyFromDid(party.agent_id);
|
|
85
|
+
const sig = decodeSig(party.sig);
|
|
86
|
+
if (!pub || !sig) return false;
|
|
87
|
+
try {
|
|
88
|
+
return ed25519.verify(sig, canonicalBytes(body), pub);
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Full A2A verification per SPEC §9. `expectedPrevFor` maps agent DID → required prev. */
|
|
95
|
+
export function verifyA2AReceipt(
|
|
96
|
+
receipt: A2AReceipt,
|
|
97
|
+
opts: { revoked?: Set<string>; expectedPrevFor?: Record<string, string | null> } = {},
|
|
98
|
+
): VerifyResult {
|
|
99
|
+
if (receipt.v !== 1 || receipt.type !== 'a2a') return { ok: false, error: 'not an a2a receipt' };
|
|
100
|
+
if (receipt.initiator.agent_id === receipt.responder.agent_id) {
|
|
101
|
+
return { ok: false, error: 'initiator and responder must be distinct' };
|
|
102
|
+
}
|
|
103
|
+
const ts = new Date(receipt.ts);
|
|
104
|
+
if (Number.isNaN(ts.getTime())) return { ok: false, error: 'invalid ts' };
|
|
105
|
+
|
|
106
|
+
const body = bodyOf(receipt);
|
|
107
|
+
for (const [role, party] of [['initiator', receipt.initiator], ['responder', receipt.responder]] as const) {
|
|
108
|
+
if (!partySigOk(body, party)) return { ok: false, error: `${role}: bad signature` };
|
|
109
|
+
const chain = verifyChain(party.delegation, { agentId: party.agent_id, revoked: opts.revoked, at: ts });
|
|
110
|
+
if (!chain.ok) return { ok: false, error: `${role} delegation: ${chain.error}` };
|
|
111
|
+
if (!scopesAllow(chain.effectiveScopes!, receipt.target)) {
|
|
112
|
+
return { ok: false, error: `${role}: target ${receipt.target} outside effective scopes` };
|
|
113
|
+
}
|
|
114
|
+
const expected = opts.expectedPrevFor?.[party.agent_id];
|
|
115
|
+
if (expected !== undefined && party.prev !== expected) {
|
|
116
|
+
return { ok: false, error: `${role}: prev hash does not match chain head (chain broken)` };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { ok: true };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The DIDs participating in any log receipt (solo or a2a). */
|
|
123
|
+
export function participantsOf(entry: { agent_id?: string; type?: string; initiator?: A2AParty; responder?: A2AParty }): string[] {
|
|
124
|
+
if (entry.type === 'a2a') return [entry.initiator!.agent_id, entry.responder!.agent_id];
|
|
125
|
+
return entry.agent_id ? [entry.agent_id] : [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The `prev` a given participant declared in a log receipt, or undefined if not a party. */
|
|
129
|
+
export function prevFor(
|
|
130
|
+
entry: { agent_id?: string; prev?: string | null; type?: string; initiator?: A2AParty; responder?: A2AParty },
|
|
131
|
+
agentId: string,
|
|
132
|
+
): string | null | undefined {
|
|
133
|
+
if (entry.type === 'a2a') {
|
|
134
|
+
if (entry.initiator?.agent_id === agentId) return entry.initiator.prev;
|
|
135
|
+
if (entry.responder?.agent_id === agentId) return entry.responder.prev;
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
return entry.agent_id === agentId ? (entry.prev as string | null) : undefined;
|
|
139
|
+
}
|
package/src/bundle.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit bundles — SPEC §8. A self-contained, offline-verifiable export of one
|
|
3
|
+
* agent's complete action history: receipts, proofs, revocations, anchors.
|
|
4
|
+
* verifyAuditBundle() is pure: no network, no trusted party.
|
|
5
|
+
*/
|
|
6
|
+
import { hexToBytes } from '@noble/hashes/utils';
|
|
7
|
+
import { canonicalBytes, jcsHash } from './encoding.js';
|
|
8
|
+
import { certHash, verifyRevocation, type RevocationRecord } from './delegation.js';
|
|
9
|
+
import { leafHash, verifyConsistency, verifyInclusion } from './merkle.js';
|
|
10
|
+
import { verifySTH, type SignedTreeHead } from './sth.js';
|
|
11
|
+
import { verifyReceipt, type Receipt } from './receipt.js';
|
|
12
|
+
import { participantsOf, prevFor, verifyA2AReceipt, type A2AReceipt } from './a2a.js';
|
|
13
|
+
|
|
14
|
+
export interface BundledReceipt {
|
|
15
|
+
receipt: Receipt | A2AReceipt;
|
|
16
|
+
hash: string;
|
|
17
|
+
index: number;
|
|
18
|
+
proof: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface BundledRevocation {
|
|
22
|
+
revocation: RevocationRecord;
|
|
23
|
+
cert_hash: string;
|
|
24
|
+
index: number;
|
|
25
|
+
proof: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface BundledAnchor {
|
|
29
|
+
sth: SignedTreeHead;
|
|
30
|
+
backend: string;
|
|
31
|
+
ref: string;
|
|
32
|
+
block?: number;
|
|
33
|
+
anchored_at: string;
|
|
34
|
+
/** Consistency proof from the anchored tree to this bundle's STH. */
|
|
35
|
+
consistency_proof: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AuditBundle {
|
|
39
|
+
v: 1;
|
|
40
|
+
bundle_type: 'zanii-audit-bundle';
|
|
41
|
+
generated_at: string;
|
|
42
|
+
log_id: string;
|
|
43
|
+
agent_id: string;
|
|
44
|
+
sth: SignedTreeHead;
|
|
45
|
+
receipts: BundledReceipt[];
|
|
46
|
+
revocations: BundledRevocation[];
|
|
47
|
+
anchors: BundledAnchor[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface BundleCheck {
|
|
51
|
+
label: string;
|
|
52
|
+
ok: boolean;
|
|
53
|
+
detail?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface BundleReport {
|
|
57
|
+
ok: boolean;
|
|
58
|
+
checks: BundleCheck[];
|
|
59
|
+
stats: {
|
|
60
|
+
receipts: number;
|
|
61
|
+
first_ts: string | null;
|
|
62
|
+
last_ts: string | null;
|
|
63
|
+
revoked_certs: number;
|
|
64
|
+
anchored_through: number | null;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const root = (sth: SignedTreeHead) => hexToBytes(sth.root.replace('sha256:', ''));
|
|
69
|
+
|
|
70
|
+
/** Fully verify an audit bundle offline. Every failure is reported, none throws. */
|
|
71
|
+
export function verifyAuditBundle(bundle: AuditBundle): BundleReport {
|
|
72
|
+
const checks: BundleCheck[] = [];
|
|
73
|
+
const push = (label: string, ok: boolean, detail?: string) => checks.push({ label, ok, detail });
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
push('bundle format', bundle.v === 1 && bundle.bundle_type === 'zanii-audit-bundle');
|
|
77
|
+
push(
|
|
78
|
+
'signed tree head signature and log identity',
|
|
79
|
+
verifySTH(bundle.sth) && bundle.sth.log_id === bundle.log_id,
|
|
80
|
+
bundle.log_id,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Receipts: signature/delegation/scope, identity, hash, inclusion, prev-chain continuity.
|
|
84
|
+
let receiptsOk = true;
|
|
85
|
+
let chainOk = true;
|
|
86
|
+
let expectedPrev: string | null = null;
|
|
87
|
+
const sorted = [...bundle.receipts].sort((a, b) => a.index - b.index);
|
|
88
|
+
for (const [i, br] of sorted.entries()) {
|
|
89
|
+
// revocation reported separately below
|
|
90
|
+
const check =
|
|
91
|
+
(br.receipt as A2AReceipt).type === 'a2a'
|
|
92
|
+
? verifyA2AReceipt(br.receipt as A2AReceipt)
|
|
93
|
+
: verifyReceipt(br.receipt as Receipt);
|
|
94
|
+
const identityOk = participantsOf(br.receipt).includes(bundle.agent_id);
|
|
95
|
+
const hashOk = jcsHash(br.receipt) === br.hash;
|
|
96
|
+
const inclusionOk = verifyInclusion(
|
|
97
|
+
leafHash(canonicalBytes(br.receipt)),
|
|
98
|
+
br.index,
|
|
99
|
+
bundle.sth.size,
|
|
100
|
+
br.proof.map(hexToBytes),
|
|
101
|
+
root(bundle.sth),
|
|
102
|
+
);
|
|
103
|
+
if (!check.ok || !identityOk || !hashOk || !inclusionOk) {
|
|
104
|
+
receiptsOk = false;
|
|
105
|
+
push(`receipt #${i} (leaf ${br.index})`, false, check.error ?? 'hash/identity/inclusion mismatch');
|
|
106
|
+
}
|
|
107
|
+
if (prevFor(br.receipt, bundle.agent_id) !== expectedPrev) chainOk = false;
|
|
108
|
+
expectedPrev = br.hash;
|
|
109
|
+
}
|
|
110
|
+
push(`all ${sorted.length} receipts: signature, delegation, scope, hash, Merkle inclusion`, receiptsOk);
|
|
111
|
+
push('per-agent hash chain is complete and unbroken (full history, no gaps)', chainOk);
|
|
112
|
+
|
|
113
|
+
// Revocations: issuer-signed and present in the tree.
|
|
114
|
+
let revocationsOk = true;
|
|
115
|
+
for (const bv of bundle.revocations) {
|
|
116
|
+
const sigOk = verifyRevocation(bv.revocation) && certHash(bv.revocation.cert) === bv.cert_hash;
|
|
117
|
+
const inclusionOk = verifyInclusion(
|
|
118
|
+
leafHash(canonicalBytes(bv.revocation)),
|
|
119
|
+
bv.index,
|
|
120
|
+
bundle.sth.size,
|
|
121
|
+
bv.proof.map(hexToBytes),
|
|
122
|
+
root(bundle.sth),
|
|
123
|
+
);
|
|
124
|
+
if (!sigOk || !inclusionOk) {
|
|
125
|
+
revocationsOk = false;
|
|
126
|
+
push(`revocation of ${bv.cert_hash.slice(0, 24)}…`, false);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
push(`all ${bundle.revocations.length} revocation records: issuer signature and Merkle inclusion`, revocationsOk);
|
|
130
|
+
|
|
131
|
+
// Anchors: each anchored STH verifies and the bundle tree is an append-only extension of it.
|
|
132
|
+
let anchorsOk = true;
|
|
133
|
+
let anchoredThrough: number | null = null;
|
|
134
|
+
for (const a of bundle.anchors) {
|
|
135
|
+
const sthOk = verifySTH(a.sth) && a.sth.log_id === bundle.log_id;
|
|
136
|
+
const consistent = verifyConsistency(
|
|
137
|
+
a.sth.size,
|
|
138
|
+
bundle.sth.size,
|
|
139
|
+
root(a.sth),
|
|
140
|
+
root(bundle.sth),
|
|
141
|
+
a.consistency_proof.map(hexToBytes),
|
|
142
|
+
);
|
|
143
|
+
if (!sthOk || !consistent) {
|
|
144
|
+
anchorsOk = false;
|
|
145
|
+
push(`anchor ${a.ref}`, false, 'STH signature or consistency proof failed');
|
|
146
|
+
} else {
|
|
147
|
+
anchoredThrough = Math.max(anchoredThrough ?? 0, a.sth.size);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
push(
|
|
151
|
+
`all ${bundle.anchors.length} anchors: anchored STH valid, bundle tree is append-only extension`,
|
|
152
|
+
anchorsOk,
|
|
153
|
+
anchoredThrough !== null ? `history immutable through leaf ${anchoredThrough - 1}` : undefined,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const revokedCertHashes = new Set(bundle.revocations.map((r) => r.cert_hash));
|
|
157
|
+
return {
|
|
158
|
+
ok: checks.every((c) => c.ok),
|
|
159
|
+
checks,
|
|
160
|
+
stats: {
|
|
161
|
+
receipts: sorted.length,
|
|
162
|
+
first_ts: sorted[0]?.receipt.ts ?? null,
|
|
163
|
+
last_ts: sorted[sorted.length - 1]?.receipt.ts ?? null,
|
|
164
|
+
revoked_certs: revokedCertHashes.size,
|
|
165
|
+
anchored_through: anchoredThrough,
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
} catch (err) {
|
|
169
|
+
push('bundle parses and verifies without errors', false, String(err));
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
checks,
|
|
173
|
+
stats: { receipts: 0, first_ts: null, last_ts: null, revoked_certs: 0, anchored_through: null },
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { ed25519 } from '@noble/curves/ed25519';
|
|
2
|
+
import { canonicalBytes, decodeSig, encodeSig, jcsHash } from './encoding.js';
|
|
3
|
+
import { publicKeyFromDid } from './keys.js';
|
|
4
|
+
|
|
5
|
+
export interface DelegationCert {
|
|
6
|
+
v: 1;
|
|
7
|
+
issuer: string;
|
|
8
|
+
subject: string;
|
|
9
|
+
scopes: string[];
|
|
10
|
+
exp: string;
|
|
11
|
+
sig: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ChainResult {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
error?: string;
|
|
17
|
+
/** Only set when ok. Scopes the final subject may act under. */
|
|
18
|
+
effectiveScopes?: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createCert(
|
|
22
|
+
fields: { issuer: string; subject: string; scopes: string[]; exp: string },
|
|
23
|
+
issuerPrivateKey: Uint8Array,
|
|
24
|
+
): DelegationCert {
|
|
25
|
+
if (fields.scopes.length === 0) throw new Error('cert must grant at least one scope');
|
|
26
|
+
if (Number.isNaN(Date.parse(fields.exp))) throw new Error(`invalid exp timestamp: ${fields.exp}`);
|
|
27
|
+
const unsigned = { v: 1 as const, ...fields };
|
|
28
|
+
const sig = ed25519.sign(canonicalBytes(unsigned), issuerPrivateKey);
|
|
29
|
+
return { ...unsigned, sig: encodeSig(sig) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Hash of the full cert (incl. sig) — the revocation identifier. */
|
|
33
|
+
export function certHash(cert: DelegationCert): string {
|
|
34
|
+
return jcsHash(cert);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Does `scope` cover `target`? `a.*` covers `a`, `a.b`, `a.b.c`, `a.*`; `*` covers all. */
|
|
38
|
+
export function scopeCovers(scope: string, target: string): boolean {
|
|
39
|
+
if (scope === '*') return true;
|
|
40
|
+
if (scope === target) return true;
|
|
41
|
+
if (scope.endsWith('.*')) {
|
|
42
|
+
const prefix = scope.slice(0, -2);
|
|
43
|
+
return target === prefix || target.startsWith(prefix + '.');
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function scopesAllow(scopes: string[], target: string): boolean {
|
|
49
|
+
return scopes.some((s) => scopeCovers(s, target));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** In-log revocation record — SPEC §6. Only the cert's issuer can revoke it. */
|
|
53
|
+
export interface RevocationRecord {
|
|
54
|
+
v: 1;
|
|
55
|
+
type: 'revocation';
|
|
56
|
+
cert: DelegationCert;
|
|
57
|
+
ts: string;
|
|
58
|
+
sig: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createRevocation(cert: DelegationCert, ts: string, issuerPrivateKey: Uint8Array): RevocationRecord {
|
|
62
|
+
if (Number.isNaN(Date.parse(ts))) throw new Error(`invalid ts timestamp: ${ts}`);
|
|
63
|
+
const unsigned = { v: 1 as const, type: 'revocation' as const, cert, ts };
|
|
64
|
+
const sig = ed25519.sign(canonicalBytes(unsigned), issuerPrivateKey);
|
|
65
|
+
return { ...unsigned, sig: encodeSig(sig) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Valid iff the embedded cert verifies AND the record is signed by the cert's issuer. */
|
|
69
|
+
export function verifyRevocation(record: RevocationRecord): boolean {
|
|
70
|
+
if (record.v !== 1 || record.type !== 'revocation') return false;
|
|
71
|
+
if (!record.cert || !verifyCertSignature(record.cert)) return false;
|
|
72
|
+
const pub = publicKeyFromDid(record.cert.issuer);
|
|
73
|
+
const sig = decodeSig(record.sig);
|
|
74
|
+
if (!pub || !sig) return false;
|
|
75
|
+
const { sig: _omitted, ...unsigned } = record;
|
|
76
|
+
try {
|
|
77
|
+
return ed25519.verify(sig, canonicalBytes(unsigned), pub);
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function verifyCertSignature(cert: DelegationCert): boolean {
|
|
84
|
+
const pub = publicKeyFromDid(cert.issuer);
|
|
85
|
+
const sig = decodeSig(cert.sig);
|
|
86
|
+
if (!pub || !sig) return false;
|
|
87
|
+
const { sig: _omitted, ...unsigned } = cert;
|
|
88
|
+
try {
|
|
89
|
+
return ed25519.verify(sig, canonicalBytes(unsigned), pub);
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Validate a delegation chain (root first) per SPEC §2.
|
|
97
|
+
* `at` defaults to now; `revoked` is a set of cert hashes.
|
|
98
|
+
*/
|
|
99
|
+
export function verifyChain(
|
|
100
|
+
chain: DelegationCert[],
|
|
101
|
+
opts: { agentId: string; expectedRoot?: string; revoked?: Set<string>; at?: Date },
|
|
102
|
+
): ChainResult {
|
|
103
|
+
if (!Array.isArray(chain) || chain.length === 0) return { ok: false, error: 'empty delegation chain' };
|
|
104
|
+
const at = opts.at ?? new Date();
|
|
105
|
+
const root = chain[0]!;
|
|
106
|
+
|
|
107
|
+
if (opts.expectedRoot && root.issuer !== opts.expectedRoot) {
|
|
108
|
+
return { ok: false, error: `root issuer ${root.issuer} is not expected owner ${opts.expectedRoot}` };
|
|
109
|
+
}
|
|
110
|
+
if (chain[chain.length - 1]!.subject !== opts.agentId) {
|
|
111
|
+
return { ok: false, error: 'chain does not terminate at the agent' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Fold scopes down the chain: a delegate can only narrow, never widen.
|
|
115
|
+
// ponytail: widening scopes are silently dropped, not rejected — reject-on-widen when policy UX exists (Phase 2)
|
|
116
|
+
let effectiveScopes: string[] = [];
|
|
117
|
+
for (let i = 0; i < chain.length; i++) {
|
|
118
|
+
const cert = chain[i]!;
|
|
119
|
+
if (cert.v !== 1) return { ok: false, error: `cert ${i}: unsupported version` };
|
|
120
|
+
if (i > 0 && cert.issuer !== chain[i - 1]!.subject) {
|
|
121
|
+
return { ok: false, error: `cert ${i}: issuer does not match previous subject` };
|
|
122
|
+
}
|
|
123
|
+
const exp = Date.parse(cert.exp);
|
|
124
|
+
if (Number.isNaN(exp)) return { ok: false, error: `cert ${i}: invalid exp` };
|
|
125
|
+
if (at.getTime() >= exp) return { ok: false, error: `cert ${i}: expired` };
|
|
126
|
+
if (opts.revoked?.has(certHash(cert))) return { ok: false, error: `cert ${i}: revoked` };
|
|
127
|
+
if (!verifyCertSignature(cert)) return { ok: false, error: `cert ${i}: bad signature` };
|
|
128
|
+
|
|
129
|
+
effectiveScopes = i === 0 ? [...cert.scopes] : cert.scopes.filter((s) => scopesAllow(effectiveScopes, s));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (effectiveScopes.length === 0) return { ok: false, error: 'chain grants no effective scopes' };
|
|
133
|
+
return { ok: true, effectiveScopes };
|
|
134
|
+
}
|
package/src/encoding.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
2
|
+
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
|
|
3
|
+
import canonicalizeImport from 'canonicalize';
|
|
4
|
+
|
|
5
|
+
// canonicalize is CJS (`module.exports = fn`) with a .d.ts that hides the call signature
|
|
6
|
+
const canonicalizeJson = canonicalizeImport as unknown as (o: unknown) => string | undefined;
|
|
7
|
+
|
|
8
|
+
/** RFC 8785 canonical JSON, UTF-8 bytes. Throws on non-serializable input. */
|
|
9
|
+
export function canonicalBytes(obj: unknown): Uint8Array {
|
|
10
|
+
const s = canonicalizeJson(obj);
|
|
11
|
+
if (typeof s !== 'string') throw new Error('value is not canonicalizable JSON');
|
|
12
|
+
return new TextEncoder().encode(s);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** `sha256:<hex>` of the JCS form of obj. */
|
|
16
|
+
export function jcsHash(obj: unknown): string {
|
|
17
|
+
return `sha256:${bytesToHex(sha256(canonicalBytes(obj)))}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function encodeSig(sig: Uint8Array): string {
|
|
21
|
+
return `ed25519:${bytesToHex(sig)}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Returns null instead of throwing: verification paths must not crash on malformed input. */
|
|
25
|
+
export function decodeSig(s: string): Uint8Array | null {
|
|
26
|
+
if (typeof s !== 'string' || !s.startsWith('ed25519:')) return null;
|
|
27
|
+
const hex = s.slice('ed25519:'.length);
|
|
28
|
+
if (!/^[0-9a-f]{128}$/.test(hex)) return null;
|
|
29
|
+
return hexToBytes(hex);
|
|
30
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export { generateKeypair, didFromPublicKey, publicKeyFromDid, type Keypair } from './keys.js';
|
|
2
|
+
export { canonicalBytes, jcsHash } from './encoding.js';
|
|
3
|
+
export {
|
|
4
|
+
createCert,
|
|
5
|
+
certHash,
|
|
6
|
+
createRevocation,
|
|
7
|
+
verifyRevocation,
|
|
8
|
+
verifyCertSignature,
|
|
9
|
+
verifyChain,
|
|
10
|
+
scopeCovers,
|
|
11
|
+
scopesAllow,
|
|
12
|
+
type DelegationCert,
|
|
13
|
+
type RevocationRecord,
|
|
14
|
+
type ChainResult,
|
|
15
|
+
} from './delegation.js';
|
|
16
|
+
export {
|
|
17
|
+
leafHash,
|
|
18
|
+
merkleRoot,
|
|
19
|
+
inclusionProof,
|
|
20
|
+
verifyInclusion,
|
|
21
|
+
consistencyProof,
|
|
22
|
+
verifyConsistency,
|
|
23
|
+
} from './merkle.js';
|
|
24
|
+
export { createSTH, verifySTH, type SignedTreeHead } from './sth.js';
|
|
25
|
+
export {
|
|
26
|
+
buildA2ABody,
|
|
27
|
+
signA2ABody,
|
|
28
|
+
assembleA2AReceipt,
|
|
29
|
+
a2aHash,
|
|
30
|
+
bodyOf,
|
|
31
|
+
verifyA2AReceipt,
|
|
32
|
+
participantsOf,
|
|
33
|
+
prevFor,
|
|
34
|
+
type A2AReceipt,
|
|
35
|
+
type A2ABody,
|
|
36
|
+
type A2AParty,
|
|
37
|
+
} from './a2a.js';
|
|
38
|
+
export {
|
|
39
|
+
verifyAuditBundle,
|
|
40
|
+
type AuditBundle,
|
|
41
|
+
type BundledReceipt,
|
|
42
|
+
type BundledRevocation,
|
|
43
|
+
type BundledAnchor,
|
|
44
|
+
type BundleReport,
|
|
45
|
+
type BundleCheck,
|
|
46
|
+
} from './bundle.js';
|
|
47
|
+
export {
|
|
48
|
+
createReceipt,
|
|
49
|
+
receiptHash,
|
|
50
|
+
hashPayload,
|
|
51
|
+
verifyReceipt,
|
|
52
|
+
verifyReceiptSignature,
|
|
53
|
+
verifyReceiptChain,
|
|
54
|
+
type Receipt,
|
|
55
|
+
type VerifyResult,
|
|
56
|
+
} from './receipt.js';
|
package/src/keys.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ed25519 } from '@noble/curves/ed25519';
|
|
2
|
+
import { base58 } from '@scure/base';
|
|
3
|
+
|
|
4
|
+
const ED25519_MULTICODEC = Uint8Array.from([0xed, 0x01]);
|
|
5
|
+
const DID_PREFIX = 'did:key:z';
|
|
6
|
+
|
|
7
|
+
export interface Keypair {
|
|
8
|
+
privateKey: Uint8Array;
|
|
9
|
+
publicKey: Uint8Array;
|
|
10
|
+
did: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function generateKeypair(): Keypair {
|
|
14
|
+
const privateKey = ed25519.utils.randomPrivateKey();
|
|
15
|
+
const publicKey = ed25519.getPublicKey(privateKey);
|
|
16
|
+
return { privateKey, publicKey, did: didFromPublicKey(publicKey) };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function didFromPublicKey(publicKey: Uint8Array): string {
|
|
20
|
+
if (publicKey.length !== 32) throw new Error('ed25519 public key must be 32 bytes');
|
|
21
|
+
const prefixed = new Uint8Array(2 + 32);
|
|
22
|
+
prefixed.set(ED25519_MULTICODEC);
|
|
23
|
+
prefixed.set(publicKey, 2);
|
|
24
|
+
return DID_PREFIX + base58.encode(prefixed);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Returns null instead of throwing: verification paths must not crash on malformed input. */
|
|
28
|
+
export function publicKeyFromDid(did: string): Uint8Array | null {
|
|
29
|
+
if (typeof did !== 'string' || !did.startsWith(DID_PREFIX)) return null;
|
|
30
|
+
let decoded: Uint8Array;
|
|
31
|
+
try {
|
|
32
|
+
decoded = base58.decode(did.slice(DID_PREFIX.length));
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (decoded.length !== 34 || decoded[0] !== 0xed || decoded[1] !== 0x01) return null;
|
|
37
|
+
return decoded.slice(2);
|
|
38
|
+
}
|