@thehonestmachine/oath-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.
Files changed (63) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +9 -0
  3. package/dist/bytes.d.ts +7 -0
  4. package/dist/bytes.d.ts.map +1 -0
  5. package/dist/bytes.js +46 -0
  6. package/dist/bytes.js.map +1 -0
  7. package/dist/ed25519.d.ts +11 -0
  8. package/dist/ed25519.d.ts.map +1 -0
  9. package/dist/ed25519.js +37 -0
  10. package/dist/ed25519.js.map +1 -0
  11. package/dist/entry.d.ts +27 -0
  12. package/dist/entry.d.ts.map +1 -0
  13. package/dist/entry.js +183 -0
  14. package/dist/entry.js.map +1 -0
  15. package/dist/errors.d.ts +5 -0
  16. package/dist/errors.d.ts.map +1 -0
  17. package/dist/errors.js +5 -0
  18. package/dist/errors.js.map +1 -0
  19. package/dist/hash.d.ts +3 -0
  20. package/dist/hash.d.ts.map +1 -0
  21. package/dist/hash.js +9 -0
  22. package/dist/hash.js.map +1 -0
  23. package/dist/index.d.ts +12 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +11 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/jcs.d.ts +19 -0
  28. package/dist/jcs.d.ts.map +1 -0
  29. package/dist/jcs.js +63 -0
  30. package/dist/jcs.js.map +1 -0
  31. package/dist/merkle.d.ts +35 -0
  32. package/dist/merkle.d.ts.map +1 -0
  33. package/dist/merkle.js +177 -0
  34. package/dist/merkle.js.map +1 -0
  35. package/dist/oath.d.ts +34 -0
  36. package/dist/oath.d.ts.map +1 -0
  37. package/dist/oath.js +128 -0
  38. package/dist/oath.js.map +1 -0
  39. package/dist/sth.d.ts +16 -0
  40. package/dist/sth.d.ts.map +1 -0
  41. package/dist/sth.js +95 -0
  42. package/dist/sth.js.map +1 -0
  43. package/dist/types.d.ts +90 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +3 -0
  46. package/dist/types.js.map +1 -0
  47. package/dist/validate.d.ts +17 -0
  48. package/dist/validate.d.ts.map +1 -0
  49. package/dist/validate.js +65 -0
  50. package/dist/validate.js.map +1 -0
  51. package/package.json +55 -0
  52. package/src/bytes.ts +46 -0
  53. package/src/ed25519.ts +40 -0
  54. package/src/entry.ts +208 -0
  55. package/src/errors.ts +4 -0
  56. package/src/hash.ts +10 -0
  57. package/src/index.ts +82 -0
  58. package/src/jcs.ts +60 -0
  59. package/src/merkle.ts +184 -0
  60. package/src/oath.ts +148 -0
  61. package/src/sth.ts +126 -0
  62. package/src/types.ts +108 -0
  63. package/src/validate.ts +81 -0
package/src/jcs.ts ADDED
@@ -0,0 +1,60 @@
1
+ import { ProtocolError } from './errors.js';
2
+ import { utf8Bytes } from './bytes.js';
3
+
4
+ /**
5
+ * JSON Canonicalization Scheme (RFC 8785).
6
+ *
7
+ * Deliberately stricter than JSON.stringify: undefined values, non-plain
8
+ * objects (Date, Map, class instances), functions, symbols, bigints,
9
+ * non-finite numbers, and array holes are protocol errors rather than
10
+ * silent coercions. Trust structures do not get repaired on the way to
11
+ * being hashed.
12
+ *
13
+ * Number and string serialization delegate to JSON.stringify, which
14
+ * implements exactly the ECMAScript serializations RFC 8785 requires
15
+ * (including -0 → "0" and lowercase \u escapes for control characters).
16
+ * Property keys sort by UTF-16 code units, which is the default behavior
17
+ * of Array.prototype.sort for strings.
18
+ */
19
+ export function canonicalize(value: unknown): string {
20
+ if (value === null) return 'null';
21
+ switch (typeof value) {
22
+ case 'boolean':
23
+ return value ? 'true' : 'false';
24
+ case 'number':
25
+ if (!Number.isFinite(value)) throw new ProtocolError('cannot canonicalize non-finite number');
26
+ return JSON.stringify(value);
27
+ case 'string':
28
+ return JSON.stringify(value);
29
+ case 'object':
30
+ break;
31
+ default:
32
+ throw new ProtocolError(`cannot canonicalize value of type ${typeof value}`);
33
+ }
34
+ if (Array.isArray(value)) {
35
+ const parts: string[] = [];
36
+ for (let i = 0; i < value.length; i++) {
37
+ if (!(i in value)) throw new ProtocolError('cannot canonicalize array with holes');
38
+ parts.push(canonicalize(value[i]));
39
+ }
40
+ return '[' + parts.join(',') + ']';
41
+ }
42
+ const proto = Object.getPrototypeOf(value);
43
+ if (proto !== Object.prototype && proto !== null) {
44
+ throw new ProtocolError('cannot canonicalize non-plain object');
45
+ }
46
+ const record = value as Record<string, unknown>;
47
+ const keys = Object.keys(record).sort();
48
+ const parts: string[] = [];
49
+ for (const key of keys) {
50
+ const v = record[key];
51
+ if (v === undefined) throw new ProtocolError(`cannot canonicalize undefined value at key "${key}"`);
52
+ parts.push(JSON.stringify(key) + ':' + canonicalize(v));
53
+ }
54
+ return '{' + parts.join(',') + '}';
55
+ }
56
+
57
+ /** UTF-8 bytes of the canonical serialization — what gets hashed and signed. */
58
+ export function canonicalBytes(value: unknown): Uint8Array {
59
+ return utf8Bytes(canonicalize(value));
60
+ }
package/src/merkle.ts ADDED
@@ -0,0 +1,184 @@
1
+ import { ProtocolError } from './errors.js';
2
+ import { bytesEqual, concatBytes } from './bytes.js';
3
+ import { sha256 } from './hash.js';
4
+
5
+ /**
6
+ * RFC 6962-style Merkle tree (spec §4): SHA-256 with 0x00/0x01 domain
7
+ * separation, split at the largest power of two strictly less than n.
8
+ * Sizes and indices use plain number arithmetic (no 32-bit bitwise ops),
9
+ * so trees are correct up to 2^53 leaves.
10
+ */
11
+
12
+ const LEAF_PREFIX = new Uint8Array([0x00]);
13
+ const NODE_PREFIX = new Uint8Array([0x01]);
14
+
15
+ /** MTH of the empty tree: SHA-256 of the empty string. */
16
+ export const EMPTY_ROOT: Uint8Array = sha256(new Uint8Array(0));
17
+
18
+ export function leafHash(leafBytes: Uint8Array): Uint8Array {
19
+ return sha256(concatBytes(LEAF_PREFIX, leafBytes));
20
+ }
21
+
22
+ export function nodeHash(left: Uint8Array, right: Uint8Array): Uint8Array {
23
+ return sha256(concatBytes(NODE_PREFIX, left, right));
24
+ }
25
+
26
+ function largestPowerOfTwoLessThan(n: number): number {
27
+ let k = 1;
28
+ while (k * 2 < n) k *= 2;
29
+ return k;
30
+ }
31
+
32
+ function assertHashList(hashes: readonly Uint8Array[]): void {
33
+ for (const h of hashes) {
34
+ if (h.length !== 32) throw new ProtocolError('leaf hashes must be 32 bytes');
35
+ }
36
+ }
37
+
38
+ function subRoot(hashes: readonly Uint8Array[], lo: number, hi: number): Uint8Array {
39
+ const n = hi - lo;
40
+ if (n === 1) return hashes[lo]!;
41
+ const k = largestPowerOfTwoLessThan(n);
42
+ return nodeHash(subRoot(hashes, lo, lo + k), subRoot(hashes, lo + k, hi));
43
+ }
44
+
45
+ /** MTH over precomputed leaf hashes (leafHash of each entry's leaf bytes). */
46
+ export function rootFromLeafHashes(hashes: readonly Uint8Array[]): Uint8Array {
47
+ assertHashList(hashes);
48
+ if (hashes.length === 0) return EMPTY_ROOT;
49
+ return subRoot(hashes, 0, hashes.length);
50
+ }
51
+
52
+ /** Audit path for the leaf at `leafIndex` — PATH(m, D[n]) of RFC 6962 §2.1.1. */
53
+ export function inclusionPath(leafIndex: number, hashes: readonly Uint8Array[]): Uint8Array[] {
54
+ assertHashList(hashes);
55
+ if (!Number.isSafeInteger(leafIndex) || leafIndex < 0 || leafIndex >= hashes.length) {
56
+ throw new ProtocolError('leaf index out of range');
57
+ }
58
+ const path = (m: number, lo: number, hi: number): Uint8Array[] => {
59
+ if (hi - lo === 1) return [];
60
+ const k = largestPowerOfTwoLessThan(hi - lo);
61
+ if (m < k) return [...path(m, lo, lo + k), subRoot(hashes, lo + k, hi)];
62
+ return [...path(m - k, lo + k, hi), subRoot(hashes, lo, lo + k)];
63
+ };
64
+ return path(leafIndex, 0, hashes.length);
65
+ }
66
+
67
+ /** Consistency path from `oldSize` to the full list — PROOF(m, D[n]) of RFC 6962 §2.1.2. */
68
+ export function consistencyPath(oldSize: number, hashes: readonly Uint8Array[]): Uint8Array[] {
69
+ assertHashList(hashes);
70
+ if (!Number.isSafeInteger(oldSize) || oldSize < 0 || oldSize > hashes.length) {
71
+ throw new ProtocolError('old size out of range');
72
+ }
73
+ if (oldSize === 0 || oldSize === hashes.length) return [];
74
+ const sub = (m: number, lo: number, hi: number, complete: boolean): Uint8Array[] => {
75
+ const n = hi - lo;
76
+ if (m === n) return complete ? [] : [subRoot(hashes, lo, hi)];
77
+ const k = largestPowerOfTwoLessThan(n);
78
+ if (m <= k) return [...sub(m, lo, lo + k, complete), subRoot(hashes, lo + k, hi)];
79
+ return [...sub(m - k, lo + k, hi, false), subRoot(hashes, lo, lo + k)];
80
+ };
81
+ return sub(oldSize, 0, hashes.length, true);
82
+ }
83
+
84
+ /**
85
+ * Size/index guard for the VERIFIERS, which must fail closed: a malformed
86
+ * size makes verification return false, never throw, so an attacker cannot
87
+ * crash a monitor with a bad proof (spec §6, §6.3). The path GENERATORS,
88
+ * which operate on trusted local data, throw on bad input instead.
89
+ */
90
+ function isValidSize(value: number): boolean {
91
+ return Number.isSafeInteger(value) && value >= 0;
92
+ }
93
+
94
+ function allHashes(path: readonly Uint8Array[]): boolean {
95
+ return path.every((p) => p.length === 32);
96
+ }
97
+
98
+ /**
99
+ * Verify an inclusion proof (spec §6.1). Returns false — never throws — on
100
+ * any mismatch, so attacker-supplied proofs cannot crash a monitor.
101
+ */
102
+ export function verifyInclusion(options: {
103
+ leafHash: Uint8Array;
104
+ leafIndex: number;
105
+ treeSize: number;
106
+ auditPath: readonly Uint8Array[];
107
+ rootHash: Uint8Array;
108
+ }): boolean {
109
+ const { leafHash: leaf, leafIndex, treeSize, auditPath, rootHash } = options;
110
+ if (!isValidSize(leafIndex) || !isValidSize(treeSize)) return false;
111
+ if (leafIndex >= treeSize || leaf.length !== 32 || !allHashes(auditPath)) return false;
112
+ let fn = leafIndex;
113
+ let sn = treeSize - 1;
114
+ let hash = leaf;
115
+ for (const sibling of auditPath) {
116
+ if (sn === 0) return false;
117
+ if (fn % 2 === 1 || fn === sn) {
118
+ hash = nodeHash(sibling, hash);
119
+ while (fn !== 0 && fn % 2 === 0) {
120
+ fn = Math.floor(fn / 2);
121
+ sn = Math.floor(sn / 2);
122
+ }
123
+ } else {
124
+ hash = nodeHash(hash, sibling);
125
+ }
126
+ fn = Math.floor(fn / 2);
127
+ sn = Math.floor(sn / 2);
128
+ }
129
+ return sn === 0 && bytesEqual(hash, rootHash);
130
+ }
131
+
132
+ /**
133
+ * Verify a consistency proof (spec §6.2): the tree at `newSize` is an
134
+ * append-only extension of the tree at `oldSize`. Returns false — never
135
+ * throws — on any mismatch. A false result against two signature-valid
136
+ * STHs is oathbreaker proof (spec §6.3).
137
+ */
138
+ export function verifyConsistency(options: {
139
+ oldSize: number;
140
+ newSize: number;
141
+ oldRoot: Uint8Array;
142
+ newRoot: Uint8Array;
143
+ path: readonly Uint8Array[];
144
+ }): boolean {
145
+ const { oldSize, newSize, oldRoot, newRoot, path } = options;
146
+ if (!isValidSize(oldSize) || !isValidSize(newSize)) return false;
147
+ if (oldSize > newSize || oldRoot.length !== 32 || newRoot.length !== 32 || !allHashes(path)) {
148
+ return false;
149
+ }
150
+ if (oldSize === newSize) return path.length === 0 && bytesEqual(oldRoot, newRoot);
151
+ if (oldSize === 0) return path.length === 0;
152
+ const nodes = isPowerOfTwo(oldSize) ? [oldRoot, ...path] : [...path];
153
+ if (nodes.length === 0) return false;
154
+ let fn = oldSize - 1;
155
+ let sn = newSize - 1;
156
+ while (fn % 2 === 1) {
157
+ fn = Math.floor(fn / 2);
158
+ sn = Math.floor(sn / 2);
159
+ }
160
+ let oldHash = nodes[0]!;
161
+ let newHash = nodes[0]!;
162
+ for (let i = 1; i < nodes.length; i++) {
163
+ const c = nodes[i]!;
164
+ if (sn === 0) return false;
165
+ if (fn % 2 === 1 || fn === sn) {
166
+ oldHash = nodeHash(c, oldHash);
167
+ newHash = nodeHash(c, newHash);
168
+ while (fn !== 0 && fn % 2 === 0) {
169
+ fn = Math.floor(fn / 2);
170
+ sn = Math.floor(sn / 2);
171
+ }
172
+ } else {
173
+ newHash = nodeHash(newHash, c);
174
+ }
175
+ fn = Math.floor(fn / 2);
176
+ sn = Math.floor(sn / 2);
177
+ }
178
+ return sn === 0 && bytesEqual(oldHash, oldRoot) && bytesEqual(newHash, newRoot);
179
+ }
180
+
181
+ function isPowerOfTwo(n: number): boolean {
182
+ while (n % 2 === 0) n /= 2;
183
+ return n === 1;
184
+ }
package/src/oath.ts ADDED
@@ -0,0 +1,148 @@
1
+ import { ProtocolError } from './errors.js';
2
+ import { canonicalBytes } from './jcs.js';
3
+ import { sha256Hex } from './hash.js';
4
+ import { edSign, edVerify } from './ed25519.js';
5
+ import {
6
+ assertKeys,
7
+ assertPlainObject,
8
+ isHandle,
9
+ isHex,
10
+ isNonEmptyString,
11
+ isPositiveInteger,
12
+ isTimestamp,
13
+ } from './validate.js';
14
+ import type { OathDoc, UnsignedOathDoc } from './types.js';
15
+
16
+ /** The spec_version this implementation conforms to (SPEC.md §12). */
17
+ export const SPEC_VERSION = '0.1';
18
+
19
+ /** Default confession latency commitment in seconds (SPEC.md §2.2, §8.2). */
20
+ export const DEFAULT_MAX_LATENCY_SECONDS = 300;
21
+
22
+ const REQUIRED = ['spec_version', 'agent', 'version', 'pubkey', 'scope', 'laws', 'created_at'];
23
+ const OPTIONAL = ['display_name', 'commitments'];
24
+ const COMMITMENT_KEYS = ['entries_per_30d', 'max_latency_seconds'];
25
+
26
+ function validateOathShape(value: unknown, requireSignature: boolean): void {
27
+ const doc = assertPlainObject(value, 'oath document');
28
+ const required = requireSignature ? [...REQUIRED, 'signature'] : REQUIRED;
29
+ const optional = requireSignature ? OPTIONAL : [...OPTIONAL, 'signature'];
30
+ assertKeys(doc, 'oath document', required, optional);
31
+
32
+ if (doc['spec_version'] !== SPEC_VERSION) {
33
+ throw new ProtocolError(`oath document spec_version must be "${SPEC_VERSION}"`);
34
+ }
35
+ if (!isHandle(doc['agent'])) throw new ProtocolError('oath document agent must be a valid handle');
36
+ if ('display_name' in doc && !isNonEmptyString(doc['display_name'])) {
37
+ throw new ProtocolError('oath document display_name must be a non-empty string');
38
+ }
39
+ if (!isPositiveInteger(doc['version'])) {
40
+ throw new ProtocolError('oath document version must be an integer >= 1');
41
+ }
42
+ if (!isHex(doc['pubkey'], 32)) {
43
+ throw new ProtocolError('oath document pubkey must be 32 bytes of lowercase hex');
44
+ }
45
+ if (!isNonEmptyString(doc['scope'])) {
46
+ throw new ProtocolError('oath document scope must be a non-empty string');
47
+ }
48
+ if ('commitments' in doc) {
49
+ const commitments = assertPlainObject(doc['commitments'], 'oath commitments');
50
+ assertKeys(commitments, 'oath commitments', [], COMMITMENT_KEYS);
51
+ for (const key of COMMITMENT_KEYS) {
52
+ if (key in commitments && !isPositiveInteger(commitments[key])) {
53
+ throw new ProtocolError(`oath commitments ${key} must be an integer >= 1`);
54
+ }
55
+ }
56
+ }
57
+ validateLaws(doc['laws']);
58
+ if (!isTimestamp(doc['created_at'])) {
59
+ throw new ProtocolError('oath document created_at must be an RFC 3339 UTC timestamp');
60
+ }
61
+ if (requireSignature && !isHex(doc['signature'], 64)) {
62
+ throw new ProtocolError('oath document signature must be 64 bytes of lowercase hex');
63
+ }
64
+ }
65
+
66
+ function validateLaws(value: unknown): void {
67
+ if (!Array.isArray(value) || value.length === 0) {
68
+ throw new ProtocolError('oath document laws must be a non-empty array');
69
+ }
70
+ value.forEach((law, i) => {
71
+ const obj = assertPlainObject(law, `law at index ${i}`);
72
+ assertKeys(obj, `law at index ${i}`, ['n', 'mode', 'text'], []);
73
+ if (obj['n'] !== i + 1) {
74
+ throw new ProtocolError('laws must be numbered 1..N consecutively');
75
+ }
76
+ if (obj['mode'] !== 'machine' && obj['mode'] !== 'judgment') {
77
+ throw new ProtocolError('law mode must be "machine" or "judgment"');
78
+ }
79
+ if (!isNonEmptyString(obj['text'])) {
80
+ throw new ProtocolError('law text must be a non-empty string');
81
+ }
82
+ });
83
+ }
84
+
85
+ /** Validate a complete signed oath document (SPEC.md §2.2). Structural only. */
86
+ export function assertValidOathDoc(value: unknown): asserts value is OathDoc {
87
+ validateOathShape(value, true);
88
+ }
89
+
90
+ /** Validate an oath document that has not been signed yet. */
91
+ export function assertValidUnsignedOathDoc(value: unknown): asserts value is UnsignedOathDoc {
92
+ validateOathShape(value, false);
93
+ }
94
+
95
+ /** Canonical bytes of the document without its signature — the signing input. */
96
+ export function oathSigningInput(doc: UnsignedOathDoc | OathDoc): Uint8Array {
97
+ const { signature: _signature, ...unsigned } = doc as OathDoc;
98
+ assertValidUnsignedOathDoc(unsigned);
99
+ return canonicalBytes(unsigned);
100
+ }
101
+
102
+ /**
103
+ * The oath hash: SHA-256 (hex) of the canonical bytes of the complete signed
104
+ * document. Identifies this oath version everywhere in the protocol.
105
+ */
106
+ export function oathHash(doc: OathDoc): string {
107
+ assertValidOathDoc(doc);
108
+ return sha256Hex(canonicalBytes(doc));
109
+ }
110
+
111
+ /**
112
+ * Sign an oath document. For version 1 the signing seed is the agent's own
113
+ * key; for an amendment it is the previous version's key (SPEC.md §2.2, §9).
114
+ */
115
+ export function signOathDoc(unsigned: UnsignedOathDoc, signingSeedHex: string): OathDoc {
116
+ assertValidUnsignedOathDoc(unsigned);
117
+ const signature = edSign(canonicalBytes(unsigned), signingSeedHex);
118
+ return { ...unsigned, signature };
119
+ }
120
+
121
+ /**
122
+ * Verify an oath document's signature. `signerPubkeyHex` defaults to the
123
+ * document's own pubkey (correct for version 1); for an amendment pass the
124
+ * previous version's pubkey.
125
+ */
126
+ export function verifyOathSignature(doc: OathDoc, signerPubkeyHex?: string): boolean {
127
+ assertValidOathDoc(doc);
128
+ return edVerify(doc.signature, oathSigningInput(doc), signerPubkeyHex ?? doc.pubkey);
129
+ }
130
+
131
+ /**
132
+ * Validate that `next` is a conforming amendment of `prev` (SPEC.md §9):
133
+ * same agent, version incremented by exactly 1, signed by the previous
134
+ * version's key.
135
+ */
136
+ export function assertValidAmendment(prev: OathDoc, next: OathDoc): void {
137
+ assertValidOathDoc(prev);
138
+ assertValidOathDoc(next);
139
+ if (next.agent !== prev.agent) {
140
+ throw new ProtocolError('amendment must keep the same agent handle');
141
+ }
142
+ if (next.version !== prev.version + 1) {
143
+ throw new ProtocolError('amendment must increment version by exactly 1');
144
+ }
145
+ if (!verifyOathSignature(next, prev.pubkey)) {
146
+ throw new ProtocolError("amendment signature must verify against the previous version's key");
147
+ }
148
+ }
package/src/sth.ts ADDED
@@ -0,0 +1,126 @@
1
+ import { ProtocolError } from './errors.js';
2
+ import { canonicalBytes } from './jcs.js';
3
+ import { bytesToHex } from './bytes.js';
4
+ import { EMPTY_ROOT } from './merkle.js';
5
+ import { edSign, edVerify } from './ed25519.js';
6
+ import {
7
+ assertKeys,
8
+ assertPlainObject,
9
+ isHex,
10
+ isNonEmptyString,
11
+ isNonNegativeInteger,
12
+ isPositiveInteger,
13
+ isTimestamp,
14
+ } from './validate.js';
15
+ import type { Receipt, SignedTreeHead, UnsignedReceipt, UnsignedTreeHead } from './types.js';
16
+
17
+ const EMPTY_ROOT_HEX = bytesToHex(EMPTY_ROOT);
18
+
19
+ function validateSthShape(value: unknown, requireSignature: boolean): void {
20
+ const sth = assertPlainObject(value, 'signed tree head');
21
+ const required = ['ledger', 'tree_size', 'root_hash', 'timestamp'];
22
+ assertKeys(
23
+ sth,
24
+ 'signed tree head',
25
+ requireSignature ? [...required, 'signature'] : required,
26
+ requireSignature ? [] : ['signature'],
27
+ );
28
+ if (!isNonEmptyString(sth['ledger'])) {
29
+ throw new ProtocolError('signed tree head ledger must be a non-empty string');
30
+ }
31
+ if (!isNonNegativeInteger(sth['tree_size'])) {
32
+ throw new ProtocolError('signed tree head tree_size must be a non-negative integer');
33
+ }
34
+ if (!isHex(sth['root_hash'], 32)) {
35
+ throw new ProtocolError('signed tree head root_hash must be 32 bytes of lowercase hex');
36
+ }
37
+ if (sth['tree_size'] === 0 && sth['root_hash'] !== EMPTY_ROOT_HEX) {
38
+ throw new ProtocolError('an empty tree has exactly one possible root hash (SPEC.md §4)');
39
+ }
40
+ if (!isTimestamp(sth['timestamp'])) {
41
+ throw new ProtocolError('signed tree head timestamp must be an RFC 3339 UTC timestamp');
42
+ }
43
+ if (requireSignature && !isHex(sth['signature'], 64)) {
44
+ throw new ProtocolError('signed tree head signature must be 64 bytes of lowercase hex');
45
+ }
46
+ }
47
+
48
+ /** Validate a signed tree head (SPEC.md §5). Structural only. */
49
+ export function assertValidSth(value: unknown): asserts value is SignedTreeHead {
50
+ validateSthShape(value, true);
51
+ }
52
+
53
+ export function assertValidUnsignedSth(value: unknown): asserts value is UnsignedTreeHead {
54
+ validateSthShape(value, false);
55
+ }
56
+
57
+ /** Canonical bytes of the STH without its signature — the signing input. */
58
+ export function sthSigningInput(sth: UnsignedTreeHead | SignedTreeHead): Uint8Array {
59
+ const { signature: _signature, ...unsigned } = sth as SignedTreeHead;
60
+ assertValidUnsignedSth(unsigned);
61
+ return canonicalBytes(unsigned);
62
+ }
63
+
64
+ export function signSth(unsigned: UnsignedTreeHead, ledgerSeedHex: string): SignedTreeHead {
65
+ assertValidUnsignedSth(unsigned);
66
+ const signature = edSign(canonicalBytes(unsigned), ledgerSeedHex);
67
+ return { ...unsigned, signature };
68
+ }
69
+
70
+ export function verifySthSignature(sth: SignedTreeHead, ledgerPubkeyHex: string): boolean {
71
+ assertValidSth(sth);
72
+ return edVerify(sth.signature, sthSigningInput(sth), ledgerPubkeyHex);
73
+ }
74
+
75
+ function validateReceiptShape(value: unknown, requireSignature: boolean): void {
76
+ const receipt = assertPlainObject(value, 'receipt');
77
+ const required = ['ledger', 'leaf_hash', 'received_at', 'mmd_seconds'];
78
+ assertKeys(
79
+ receipt,
80
+ 'receipt',
81
+ requireSignature ? [...required, 'signature'] : required,
82
+ requireSignature ? [] : ['signature'],
83
+ );
84
+ if (!isNonEmptyString(receipt['ledger'])) {
85
+ throw new ProtocolError('receipt ledger must be a non-empty string');
86
+ }
87
+ if (!isHex(receipt['leaf_hash'], 32)) {
88
+ throw new ProtocolError('receipt leaf_hash must be 32 bytes of lowercase hex');
89
+ }
90
+ if (!isTimestamp(receipt['received_at'])) {
91
+ throw new ProtocolError('receipt received_at must be an RFC 3339 UTC timestamp');
92
+ }
93
+ if (!isPositiveInteger(receipt['mmd_seconds'])) {
94
+ throw new ProtocolError('receipt mmd_seconds must be an integer >= 1');
95
+ }
96
+ if (requireSignature && !isHex(receipt['signature'], 64)) {
97
+ throw new ProtocolError('receipt signature must be 64 bytes of lowercase hex');
98
+ }
99
+ }
100
+
101
+ /** Validate a receipt (SPEC.md §11.3). Structural only. */
102
+ export function assertValidReceipt(value: unknown): asserts value is Receipt {
103
+ validateReceiptShape(value, true);
104
+ }
105
+
106
+ export function assertValidUnsignedReceipt(value: unknown): asserts value is UnsignedReceipt {
107
+ validateReceiptShape(value, false);
108
+ }
109
+
110
+ /** Canonical bytes of the receipt without its signature — the signing input. */
111
+ export function receiptSigningInput(receipt: UnsignedReceipt | Receipt): Uint8Array {
112
+ const { signature: _signature, ...unsigned } = receipt as Receipt;
113
+ assertValidUnsignedReceipt(unsigned);
114
+ return canonicalBytes(unsigned);
115
+ }
116
+
117
+ export function signReceipt(unsigned: UnsignedReceipt, ledgerSeedHex: string): Receipt {
118
+ assertValidUnsignedReceipt(unsigned);
119
+ const signature = edSign(canonicalBytes(unsigned), ledgerSeedHex);
120
+ return { ...unsigned, signature };
121
+ }
122
+
123
+ export function verifyReceiptSignature(receipt: Receipt, ledgerPubkeyHex: string): boolean {
124
+ assertValidReceipt(receipt);
125
+ return edVerify(receipt.signature, receiptSigningInput(receipt), ledgerPubkeyHex);
126
+ }
package/src/types.ts ADDED
@@ -0,0 +1,108 @@
1
+ /** Structures defined by SPEC.md v0.1. Field names match the canonical JSON. */
2
+
3
+ export type Verdict = 'compliant' | 'violation' | 'uncertain';
4
+
5
+ export type LawMode = 'machine' | 'judgment';
6
+
7
+ export interface Law {
8
+ n: number;
9
+ mode: LawMode;
10
+ text: string;
11
+ }
12
+
13
+ export interface OathCommitments {
14
+ entries_per_30d?: number;
15
+ max_latency_seconds?: number;
16
+ }
17
+
18
+ export interface UnsignedOathDoc {
19
+ spec_version: string;
20
+ agent: string;
21
+ display_name?: string;
22
+ version: number;
23
+ pubkey: string;
24
+ scope: string;
25
+ commitments?: OathCommitments;
26
+ laws: Law[];
27
+ created_at: string;
28
+ }
29
+
30
+ export interface OathDoc extends UnsignedOathDoc {
31
+ signature: string;
32
+ }
33
+
34
+ export interface Action {
35
+ type: string;
36
+ summary: string;
37
+ content_hash: string | null;
38
+ occurred_at: string;
39
+ }
40
+
41
+ export interface MachineAdjudication {
42
+ mode: 'machine';
43
+ check: string;
44
+ check_version?: string;
45
+ }
46
+
47
+ export interface ModelAdjudication {
48
+ mode: 'model';
49
+ model: string;
50
+ prompt_hash: string;
51
+ inputs_hash: string;
52
+ }
53
+
54
+ export type Adjudication = MachineAdjudication | ModelAdjudication;
55
+
56
+ export interface Confession {
57
+ note: string;
58
+ }
59
+
60
+ export interface UnsignedEntry {
61
+ spec_version: string;
62
+ agent: string;
63
+ oath_version: number;
64
+ action: Action;
65
+ verdict: Verdict;
66
+ law_cited: number | null;
67
+ adjudication: Adjudication;
68
+ confession?: Confession;
69
+ timestamp: string;
70
+ }
71
+
72
+ export interface Entry extends UnsignedEntry {
73
+ signature: string;
74
+ }
75
+
76
+ export interface UnsignedTreeHead {
77
+ ledger: string;
78
+ tree_size: number;
79
+ root_hash: string;
80
+ timestamp: string;
81
+ }
82
+
83
+ export interface SignedTreeHead extends UnsignedTreeHead {
84
+ signature: string;
85
+ }
86
+
87
+ export interface UnsignedReceipt {
88
+ ledger: string;
89
+ leaf_hash: string;
90
+ received_at: string;
91
+ mmd_seconds: number;
92
+ }
93
+
94
+ export interface Receipt extends UnsignedReceipt {
95
+ signature: string;
96
+ }
97
+
98
+ export interface InclusionProof {
99
+ leaf_index: number;
100
+ tree_size: number;
101
+ audit_path: string[];
102
+ }
103
+
104
+ export interface ConsistencyProof {
105
+ old_size: number;
106
+ new_size: number;
107
+ path: string[];
108
+ }