@thehonestmachine/oath-sdk 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/src/remote.ts ADDED
@@ -0,0 +1,112 @@
1
+ import {
2
+ assertValidEntry,
3
+ assertValidOathDoc,
4
+ assertValidReceipt,
5
+ entryLeafHash,
6
+ ProtocolError,
7
+ verifyReceiptSignature,
8
+ type Entry,
9
+ type OathDoc,
10
+ type Receipt,
11
+ } from '@thehonestmachine/oath-core';
12
+
13
+ export interface LedgerDiscovery {
14
+ ledger: string;
15
+ pubkey: string;
16
+ api: string;
17
+ mmd_seconds: number;
18
+ spec_version: string;
19
+ }
20
+
21
+ export interface SubmitResult {
22
+ leaf_index: number;
23
+ leaf_hash: string;
24
+ receipt: Receipt;
25
+ }
26
+
27
+ /**
28
+ * A client for a public ledger's API (SPEC.md §11.2) that trusts nothing:
29
+ * every receipt is validated and signature-checked against the ledger's
30
+ * published key before it is returned to the caller.
31
+ */
32
+ export class RemoteLedger {
33
+ private readonly base: string;
34
+ private readonly f: typeof fetch;
35
+ private cachedDiscovery: LedgerDiscovery | null = null;
36
+
37
+ constructor(baseUrl: string, options: { fetch?: typeof fetch } = {}) {
38
+ this.base = baseUrl.replace(/\/+$/, '');
39
+ this.f = options.fetch ?? fetch;
40
+ }
41
+
42
+ private async request(path: string, init?: RequestInit): Promise<unknown> {
43
+ const response = await this.f(`${this.base}${path}`, init);
44
+ const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
45
+ if (!response.ok) {
46
+ throw new ProtocolError(
47
+ `ledger ${path} -> ${response.status}${typeof body['error'] === 'string' ? `: ${body['error']}` : ''}`,
48
+ );
49
+ }
50
+ return body;
51
+ }
52
+
53
+ async discovery(): Promise<LedgerDiscovery> {
54
+ if (this.cachedDiscovery === null) {
55
+ this.cachedDiscovery = (await this.request('/v0/ledger.json')) as LedgerDiscovery;
56
+ }
57
+ return this.cachedDiscovery;
58
+ }
59
+
60
+ private async checkedResult(entry: Entry, raw: Record<string, unknown>): Promise<SubmitResult> {
61
+ const receipt = raw['receipt'];
62
+ assertValidReceipt(receipt);
63
+ const { pubkey } = await this.discovery();
64
+ if (!verifyReceiptSignature(receipt, pubkey)) {
65
+ throw new ProtocolError("the ledger's receipt signature does not verify against its published key");
66
+ }
67
+ if (receipt.leaf_hash !== entryLeafHash(entry)) {
68
+ throw new ProtocolError('the receipt commits to a different leaf than the submitted entry');
69
+ }
70
+ return { leaf_index: Number(raw['leaf_index']), leaf_hash: receipt.leaf_hash, receipt };
71
+ }
72
+
73
+ /** Swear an agent: submit the signed oath v1 plus its oath.swear entry (§2.4). */
74
+ async swear(oath: OathDoc, entry: Entry): Promise<SubmitResult> {
75
+ assertValidOathDoc(oath);
76
+ assertValidEntry(entry);
77
+ const raw = (await this.request('/v0/agents', {
78
+ method: 'POST',
79
+ headers: { 'content-type': 'application/json' },
80
+ body: JSON.stringify({ oath, entry }),
81
+ })) as Record<string, unknown>;
82
+ return this.checkedResult(entry, raw);
83
+ }
84
+
85
+ /** Submit a signed entry; idempotent by leaf hash on the ledger side. */
86
+ async submit(entry: Entry): Promise<SubmitResult> {
87
+ assertValidEntry(entry);
88
+ const raw = (await this.request('/v0/entries', {
89
+ method: 'POST',
90
+ headers: { 'content-type': 'application/json' },
91
+ body: JSON.stringify({ entry }),
92
+ })) as Record<string, unknown>;
93
+ return this.checkedResult(entry, raw);
94
+ }
95
+
96
+ /** Submit an amendment: the new oath version plus its oath.amend entry (§9). */
97
+ async amend(handle: string, oath: OathDoc, entry: Entry): Promise<SubmitResult> {
98
+ assertValidOathDoc(oath);
99
+ assertValidEntry(entry);
100
+ const raw = (await this.request(`/v0/agents/${handle}/oath`, {
101
+ method: 'POST',
102
+ headers: { 'content-type': 'application/json' },
103
+ body: JSON.stringify({ oath, entry }),
104
+ })) as Record<string, unknown>;
105
+ return this.checkedResult(entry, raw);
106
+ }
107
+
108
+ /** The agent's public record: oath versions, entry count, and index. */
109
+ async agent(handle: string): Promise<Record<string, unknown>> {
110
+ return (await this.request(`/v0/agents/${handle}`)) as Record<string, unknown>;
111
+ }
112
+ }
package/src/verify.ts ADDED
@@ -0,0 +1,331 @@
1
+ import {
2
+ assertEntryUnderOath,
3
+ assertValidAmendment,
4
+ assertValidEntry,
5
+ assertValidOathDoc,
6
+ assertValidSth,
7
+ bytesToHex,
8
+ canonicalize,
9
+ consistencyPath,
10
+ hexToBytes,
11
+ leafHash,
12
+ oathHash,
13
+ rootFromLeafHashes,
14
+ utf8Bytes,
15
+ verifyConsistency,
16
+ verifyOathSignature,
17
+ verifySthSignature,
18
+ type Entry,
19
+ type OathDoc,
20
+ type SignedTreeHead,
21
+ } from '@thehonestmachine/oath-core';
22
+ import { readFileSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { LEDGER_FILES, readLeafLines, readOathVersions } from './localLedger.js';
25
+
26
+ export interface CheckResult {
27
+ name: string;
28
+ ok: boolean;
29
+ detail: string;
30
+ }
31
+
32
+ export interface VerificationReport {
33
+ ok: boolean;
34
+ source: string;
35
+ agents: string[];
36
+ treeSize: number;
37
+ checks: CheckResult[];
38
+ }
39
+
40
+ /** Everything a monitor needs, however it was obtained. All of it public. */
41
+ export interface LedgerData {
42
+ source: string;
43
+ /** Exact leaf bytes, position = leaf index. */
44
+ leafLines: string[];
45
+ /** Every signed tree head, in emission order. */
46
+ heads: SignedTreeHead[];
47
+ /** Keys allowed to sign tree heads (agent key locally, ledger key remotely). */
48
+ sthPubkeys: string[];
49
+ /** Oath version chains per agent handle. */
50
+ oathsByAgent: Map<string, OathDoc[]>;
51
+ }
52
+
53
+ /**
54
+ * Verify ledger contents from nothing but public data (SPEC.md §6, §11):
55
+ * recompute the tree, re-check every signature, every tree head, every oath
56
+ * chain, and the consistency of the whole history. Needs no keys and no
57
+ * trust in the operator — this is what a monitor runs.
58
+ */
59
+ export function verifyLedgerData(data: LedgerData): VerificationReport {
60
+ const { leafLines, heads, sthPubkeys, oathsByAgent } = data;
61
+ const checks: CheckResult[] = [];
62
+ const agents = [...oathsByAgent.keys()];
63
+ checks.push({
64
+ name: 'files',
65
+ ok: true,
66
+ detail: `${agents.length} agent(s), ${leafLines.length} entries, ${heads.length} tree heads`,
67
+ });
68
+
69
+ // 1. Oath chains: v1 self-signed, every later version a valid amendment (§2, §9).
70
+ const chainProblems: string[] = [];
71
+ for (const [handle, versions] of oathsByAgent) {
72
+ try {
73
+ if (versions.length === 0) throw new Error('no oath versions');
74
+ versions.forEach((doc) => assertValidOathDoc(doc));
75
+ if (versions[0]!.version !== 1) throw new Error('chain does not start at version 1');
76
+ if (!verifyOathSignature(versions[0]!)) {
77
+ throw new Error('v1 signature does not verify against its own pubkey');
78
+ }
79
+ for (let i = 1; i < versions.length; i++) {
80
+ assertValidAmendment(versions[i - 1]!, versions[i]!);
81
+ }
82
+ } catch (error) {
83
+ chainProblems.push(`${handle}: ${(error as Error).message}`);
84
+ }
85
+ }
86
+ checks.push({
87
+ name: 'oath-chain',
88
+ ok: chainProblems.length === 0,
89
+ detail:
90
+ chainProblems.length === 0
91
+ ? `${agents.length} chain(s), forward-only, signatures verify`
92
+ : chainProblems.join('; '),
93
+ });
94
+
95
+ // 2. Every entry: canonical bytes, valid shape, signed under its oath version (§3).
96
+ const entries: (Entry | null)[] = [];
97
+ const entryProblems: string[] = [];
98
+ leafLines.forEach((line, i) => {
99
+ try {
100
+ const parsed = JSON.parse(line) as unknown;
101
+ if (canonicalize(parsed) !== line) throw new Error('line is not in canonical (JCS) form');
102
+ assertValidEntry(parsed);
103
+ const versions = oathsByAgent.get(parsed.agent);
104
+ const oath = versions?.find((o) => o.version === parsed.oath_version);
105
+ if (oath === undefined) {
106
+ throw new Error(`cites unknown oath ${parsed.agent} v${parsed.oath_version}`);
107
+ }
108
+ assertEntryUnderOath(parsed, oath);
109
+ entries.push(parsed);
110
+ } catch (error) {
111
+ entries.push(null);
112
+ entryProblems.push(`entry ${i}: ${(error as Error).message}`);
113
+ }
114
+ });
115
+ checks.push({
116
+ name: 'entries',
117
+ ok: entryProblems.length === 0,
118
+ detail:
119
+ entryProblems.length === 0
120
+ ? `${leafLines.length} entries valid, canonical, and signed`
121
+ : entryProblems.join('; '),
122
+ });
123
+
124
+ // 3. Sworn records: each agent's first entry is its oath.swear; every
125
+ // amendment is committed to by an oath.amend entry (§2.4, §9).
126
+ const recordProblems: string[] = [];
127
+ for (const [handle, versions] of oathsByAgent) {
128
+ const own = entries.filter((e): e is Entry => e !== null && e.agent === handle);
129
+ const first = own[0];
130
+ const v1Hash = versions.length > 0 ? safeOathHash(versions[0]!) : null;
131
+ if (first === undefined || first.action.type !== 'oath.swear' || first.action.content_hash !== v1Hash) {
132
+ recordProblems.push(`${handle}: first entry is not oath.swear committing to oath v1`);
133
+ }
134
+ // Every declared amendment must be committed to by exactly its own leaf...
135
+ const validAmendHashes = new Set<string>();
136
+ for (let v = 2; v <= versions.length; v++) {
137
+ const hash = safeOathHash(versions[v - 1]!);
138
+ if (hash !== null) validAmendHashes.add(hash);
139
+ if (!own.some((e) => e.action.type === 'oath.amend' && e.action.content_hash === hash)) {
140
+ recordProblems.push(`${handle}: no oath.amend entry commits to oath v${v}`);
141
+ }
142
+ }
143
+ // ...and no reserved oath-lifecycle leaf may be forged. A stranger's
144
+ // monitor is the trust anchor: an extra oath.swear, or an oath.amend
145
+ // committing to an oath that was never served, must fail verification.
146
+ own.forEach((e, i) => {
147
+ if (e.action.type === 'oath.swear' && (i !== 0 || e.action.content_hash !== v1Hash)) {
148
+ recordProblems.push(`${handle}: leaf with a forged/misplaced oath.swear (only leaf 0 may swear v1)`);
149
+ }
150
+ if (e.action.type === 'oath.amend' && !validAmendHashes.has(e.action.content_hash ?? '')) {
151
+ recordProblems.push(`${handle}: oath.amend commits to an oath version the ledger never served`);
152
+ }
153
+ });
154
+ }
155
+ checks.push({
156
+ name: 'sworn-record',
157
+ ok: recordProblems.length === 0,
158
+ detail:
159
+ recordProblems.length === 0
160
+ ? 'every agent swore at its first leaf; every amendment logged'
161
+ : recordProblems.join('; '),
162
+ });
163
+
164
+ // 4. Tree heads: recompute every root, verify every signature (§5).
165
+ const leafHashes = leafLines.map((line) => leafHash(utf8Bytes(line)));
166
+ const headProblems: string[] = [];
167
+ let previousSize = 0;
168
+ heads.forEach((head, i) => {
169
+ try {
170
+ assertValidSth(head);
171
+ if (head.tree_size < previousSize) throw new Error('tree size decreased');
172
+ previousSize = head.tree_size;
173
+ if (head.tree_size > leafHashes.length) {
174
+ throw new Error(`covers ${head.tree_size} leaves but only ${leafHashes.length} are known`);
175
+ }
176
+ const recomputed = bytesToHex(rootFromLeafHashes(leafHashes.slice(0, head.tree_size)));
177
+ if (recomputed !== head.root_hash) {
178
+ throw new Error(`root hash does not match the recomputed tree at size ${head.tree_size}`);
179
+ }
180
+ if (!sthPubkeys.some((pubkey) => verifySthSignature(head, pubkey))) {
181
+ throw new Error('signature does not verify against any accepted key');
182
+ }
183
+ } catch (error) {
184
+ headProblems.push(`tree head ${i}: ${(error as Error).message}`);
185
+ }
186
+ });
187
+ if (leafLines.length > 0 && (heads.length === 0 || heads[heads.length - 1]!.tree_size !== leafLines.length)) {
188
+ headProblems.push('the latest tree head does not cover every entry (SPEC.md §5)');
189
+ }
190
+ checks.push({
191
+ name: 'tree-heads',
192
+ ok: headProblems.length === 0,
193
+ detail:
194
+ headProblems.length === 0
195
+ ? `${heads.length} tree heads recomputed and signature-verified`
196
+ : headProblems.join('; '),
197
+ });
198
+
199
+ // 5. Consistency between every consecutive pair of tree heads (§6.2).
200
+ const consistencyProblems: string[] = [];
201
+ let pairs = 0;
202
+ for (let i = 1; i < heads.length; i++) {
203
+ const prev = heads[i - 1]!;
204
+ const next = heads[i]!;
205
+ if (next.tree_size > leafHashes.length || prev.tree_size > next.tree_size) continue; // reported above
206
+ const ok = verifyConsistency({
207
+ oldSize: prev.tree_size,
208
+ newSize: next.tree_size,
209
+ oldRoot: hexToBytes(prev.root_hash),
210
+ newRoot: hexToBytes(next.root_hash),
211
+ path: consistencyPath(prev.tree_size, leafHashes.slice(0, next.tree_size)),
212
+ });
213
+ pairs++;
214
+ if (!ok) {
215
+ consistencyProblems.push(
216
+ `tree heads ${i - 1} -> ${i} (sizes ${prev.tree_size} -> ${next.tree_size}) are not consistent: history was rewritten`,
217
+ );
218
+ }
219
+ }
220
+ checks.push({
221
+ name: 'consistency',
222
+ ok: consistencyProblems.length === 0,
223
+ detail:
224
+ consistencyProblems.length === 0
225
+ ? `${pairs} consecutive pair(s) prove append-only history`
226
+ : consistencyProblems.join('; '),
227
+ });
228
+
229
+ return {
230
+ ok: checks.every((c) => c.ok),
231
+ source: data.source,
232
+ agents,
233
+ treeSize: leafLines.length,
234
+ checks,
235
+ };
236
+ }
237
+
238
+ function safeOathHash(doc: OathDoc): string | null {
239
+ try {
240
+ return oathHash(doc);
241
+ } catch {
242
+ return null;
243
+ }
244
+ }
245
+
246
+ /** Verify a local ledger directory (SPEC.md Appendix A). */
247
+ export function verifyLocalLedger(dir: string): VerificationReport {
248
+ let data: LedgerData;
249
+ try {
250
+ const oathVersions = readOathVersions(dir);
251
+ const agent = oathVersions[0]!.agent;
252
+ data = {
253
+ source: dir,
254
+ leafLines: readLeafLines(dir),
255
+ heads: JSON.parse(readFileSync(join(dir, LEDGER_FILES.heads), 'utf8')) as SignedTreeHead[],
256
+ // A local ledger's agent key doubles as its ledger key (§5).
257
+ sthPubkeys: oathVersions.map((doc) => doc.pubkey),
258
+ oathsByAgent: new Map([[agent, oathVersions]]),
259
+ };
260
+ } catch (error) {
261
+ return {
262
+ ok: false,
263
+ source: dir,
264
+ agents: [],
265
+ treeSize: 0,
266
+ checks: [{ name: 'files', ok: false, detail: `unreadable ledger: ${(error as Error).message}` }],
267
+ };
268
+ }
269
+ return verifyLedgerData(data);
270
+ }
271
+
272
+ /**
273
+ * Verify a remote public ledger over its read API (SPEC.md §11.2), starting
274
+ * from its discovery document. Entries beyond the latest tree head are
275
+ * within the merge delay and are not judged yet.
276
+ */
277
+ export async function verifyRemoteLedger(
278
+ url: string,
279
+ options: { fetch?: typeof fetch } = {},
280
+ ): Promise<VerificationReport> {
281
+ const f = options.fetch ?? fetch;
282
+ const base = url.replace(/\/+$/, '');
283
+ const get = async (path: string): Promise<unknown> => {
284
+ const response = await f(`${base}${path}`);
285
+ if (!response.ok) throw new Error(`GET ${path} -> ${response.status}`);
286
+ return response.json();
287
+ };
288
+ try {
289
+ const discovery = (await get('/v0/ledger.json')) as { ledger: string; pubkey: string };
290
+ const heads = (await get('/v0/sth/history')) as SignedTreeHead[];
291
+ const covered = heads.length > 0 ? heads[heads.length - 1]!.tree_size : 0;
292
+ const leafLines: string[] = [];
293
+ for (let start = 0; start < covered; start += 1000) {
294
+ const page = (await get(`/v0/entries?start=${start}&end=${Math.min(start + 999, covered - 1)}`)) as {
295
+ entries: { leaf_index: number; leaf_bytes: string }[];
296
+ };
297
+ for (const row of page.entries) {
298
+ leafLines[row.leaf_index] = row.leaf_bytes;
299
+ }
300
+ }
301
+ const handles = new Set<string>();
302
+ for (const line of leafLines) {
303
+ try {
304
+ const agent = (JSON.parse(line) as { agent?: unknown }).agent;
305
+ if (typeof agent === 'string') handles.add(agent);
306
+ } catch {
307
+ /* reported by verifyLedgerData */
308
+ }
309
+ }
310
+ const oathsByAgent = new Map<string, OathDoc[]>();
311
+ for (const handle of handles) {
312
+ const record = (await get(`/v0/agents/${handle}`)) as { oath_versions: OathDoc[] };
313
+ oathsByAgent.set(handle, record.oath_versions);
314
+ }
315
+ return verifyLedgerData({
316
+ source: `${discovery.ledger} (${base})`,
317
+ leafLines,
318
+ heads,
319
+ sthPubkeys: [discovery.pubkey],
320
+ oathsByAgent,
321
+ });
322
+ } catch (error) {
323
+ return {
324
+ ok: false,
325
+ source: base,
326
+ agents: [],
327
+ treeSize: 0,
328
+ checks: [{ name: 'files', ok: false, detail: `unreachable ledger: ${(error as Error).message}` }],
329
+ };
330
+ }
331
+ }