prae-cli 0.7.1 → 0.9.1

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.
@@ -1,242 +0,0 @@
1
- import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { dirname } from "node:path";
3
- import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from "node:crypto";
4
-
5
- //#region ../ledger/src/entry.ts
6
- /** The hash a chain starts from. */
7
- const GENESIS = "0".repeat(64);
8
- /**
9
- * Deterministic serialization: object keys sorted at every depth.
10
- *
11
- * A hash over `JSON.stringify(entry)` would depend on key insertion order,
12
- * which means a re-serialized entry could hash differently while saying
13
- * exactly the same thing — and a verifier that reports tampering on a
14
- * faithful copy trains its operators to ignore it.
15
- */
16
- function canonical(value) {
17
- if (value === null || typeof value !== "object") return JSON.stringify(value);
18
- if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
19
- return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
20
- }
21
- /** Hash one entry's content together with its position and predecessor. */
22
- function hashEntry(record, seq, prev) {
23
- return createHash("sha256").update(canonical({
24
- ...record,
25
- seq,
26
- prev
27
- })).digest("hex");
28
- }
29
- /** Chain a record onto a predecessor. */
30
- function chain(record, seq, prev) {
31
- return {
32
- ...record,
33
- seq,
34
- prev,
35
- hash: hashEntry(record, seq, prev)
36
- };
37
- }
38
- /** The short form an auditor quotes from a screenshot. */
39
- function seal(entry) {
40
- return `${entry.hash.slice(0, 4)}…${entry.hash.slice(-4)}`;
41
- }
42
-
43
- //#endregion
44
- //#region ../ledger/src/receipt.ts
45
- function generateKeypair() {
46
- const { publicKey, privateKey } = generateKeyPairSync("ed25519");
47
- return {
48
- publicKey: publicKey.export({
49
- type: "spki",
50
- format: "pem"
51
- }).toString(),
52
- privateKey: privateKey.export({
53
- type: "pkcs8",
54
- format: "pem"
55
- }).toString()
56
- };
57
- }
58
- /** Sign a row's seal. The seal already covers the whole canonical record. */
59
- function signEntry(entry, privateKeyPem) {
60
- const key = createPrivateKey(privateKeyPem);
61
- return sign(null, Buffer.from(entry.hash, "hex"), key).toString("base64");
62
- }
63
- function verifySignature(entry, signature, publicKeyPem) {
64
- try {
65
- const key = createPublicKey(publicKeyPem);
66
- return verify(null, Buffer.from(entry.hash, "hex"), key, Buffer.from(signature, "base64"));
67
- } catch {
68
- return false;
69
- }
70
- }
71
- function makeReceipt(entry, privateKeyPem, publicKeyPem) {
72
- return {
73
- receipt: "prae/1",
74
- entry,
75
- signature: signEntry(entry, privateKeyPem),
76
- publicKey: publicKeyPem
77
- };
78
- }
79
- /** Verify a receipt from nothing but the receipt. */
80
- function verifyReceipt(receipt) {
81
- if (receipt.receipt !== "prae/1") return {
82
- ok: false,
83
- reason: `unknown receipt format ${String(receipt.receipt)}`
84
- };
85
- const { seq, prev, hash, sig,...record } = receipt.entry;
86
- if (hashEntry(record, seq, prev) !== hash) return {
87
- ok: false,
88
- reason: "entry contents do not match their seal; the row was altered"
89
- };
90
- if (!verifySignature(receipt.entry, receipt.signature, receipt.publicKey)) return {
91
- ok: false,
92
- reason: "signature does not verify; not sealed by this key"
93
- };
94
- return { ok: true };
95
- }
96
- /**
97
- * A short identifier for a public key, for pinning by eye.
98
- *
99
- * A counterparty checks a receipt against a key they already trust; comparing
100
- * the whole PEM by hand invites skimming, and eight grouped hex bytes do not.
101
- */
102
- function fingerprint(publicKeyPem) {
103
- const der = createPublicKey(publicKeyPem).export({
104
- type: "spki",
105
- format: "der"
106
- });
107
- return createHash("sha256").update(der).digest("hex").slice(0, 16).replace(/(.{4})/g, "$1 ").trim();
108
- }
109
-
110
- //#endregion
111
- //#region ../ledger/src/ledger.ts
112
- /**
113
- * Read a ledger held as text.
114
- *
115
- * Split out from the class because a ledger is not always a file being
116
- * appended to: a build step renders one it imported, and a reader pastes one
117
- * in. Those callers need the same parsing and the same verification the CLI
118
- * runs, and a second implementation of either is how two answers to "does this
119
- * chain hold" come to exist.
120
- */
121
- function readEntries(text) {
122
- return text.split("\n").filter((line) => line.trim() !== "").map((line) => JSON.parse(line));
123
- }
124
- /** Re-derive every hash and confirm each entry names its predecessor. */
125
- function verifyEntries(text) {
126
- const problems = [];
127
- let expectedPrev = GENESIS;
128
- let expectedSeq = 0;
129
- let count = 0;
130
- const lines = text.split("\n").filter((line) => line.trim() !== "");
131
- for (const [index, line] of lines.entries()) {
132
- const at = index + 1;
133
- count += 1;
134
- let entry;
135
- try {
136
- entry = JSON.parse(line);
137
- } catch {
138
- problems.push({
139
- line: at,
140
- message: "not valid JSON"
141
- });
142
- return {
143
- ok: false,
144
- entries: count,
145
- problems
146
- };
147
- }
148
- if (entry.seq !== expectedSeq) problems.push({
149
- line: at,
150
- message: `expected seq ${expectedSeq}, found ${entry.seq}`
151
- });
152
- if (entry.prev !== expectedPrev) problems.push({
153
- line: at,
154
- message: "does not name the previous entry; a row was removed or reordered"
155
- });
156
- const { seq, prev, hash, sig: _sig,...record } = entry;
157
- if (hashEntry(record, seq, prev) !== hash) problems.push({
158
- line: at,
159
- message: "contents do not match the sealed hash"
160
- });
161
- expectedPrev = entry.hash;
162
- expectedSeq = entry.seq + 1;
163
- }
164
- return {
165
- ok: problems.length === 0,
166
- entries: count,
167
- problems
168
- };
169
- }
170
- var Ledger = class Ledger {
171
- path;
172
- seq;
173
- last;
174
- constructor(path, seq, last) {
175
- this.path = path;
176
- this.seq = seq;
177
- this.last = last;
178
- }
179
- /**
180
- * Open a ledger, reading the tail to continue an existing chain.
181
- *
182
- * A corrupt or truncated final line is a hard error rather than something to
183
- * skip past: appending onto a chain whose end we could not read would bury
184
- * the damage under valid-looking entries.
185
- */
186
- /** Sign every appended row with this Ed25519 private key (PEM). */
187
- signWith;
188
- static open(path, options = {}) {
189
- const ledger = Ledger.openUnsigned(path);
190
- ledger.signWith = options.signWith;
191
- return ledger;
192
- }
193
- static openUnsigned(path) {
194
- mkdirSync(dirname(path), { recursive: true });
195
- let text = "";
196
- try {
197
- text = readFileSync(path, "utf8");
198
- } catch (error) {
199
- if (error.code !== "ENOENT") throw error;
200
- writeFileSync(path, "", { flag: "wx" });
201
- return new Ledger(path, 0, GENESIS);
202
- }
203
- const tail = text.split("\n").filter((line) => line.trim() !== "").at(-1);
204
- if (tail === void 0) return new Ledger(path, 0, GENESIS);
205
- let entry;
206
- try {
207
- entry = JSON.parse(tail);
208
- } catch {
209
- throw new Error(`prae ledger: last line of ${path} is not readable; refusing to append onto a chain that cannot be continued`);
210
- }
211
- return new Ledger(path, entry.seq + 1, entry.hash);
212
- }
213
- /** Append one decision and return the entry as written. */
214
- append(record) {
215
- const sealed = chain(record, this.seq, this.last);
216
- const entry = this.signWith === void 0 ? sealed : {
217
- ...sealed,
218
- sig: signEntry(sealed, this.signWith)
219
- };
220
- appendFileSync(this.path, `${JSON.stringify(entry)}\n`);
221
- this.seq += 1;
222
- this.last = entry.hash;
223
- return entry;
224
- }
225
- /** Every entry, in order. */
226
- read() {
227
- return readEntries(readFileSync(this.path, "utf8"));
228
- }
229
- /**
230
- * Re-derive every hash and confirm each entry names its predecessor.
231
- *
232
- * Catches a deleted row, an edited field, and a reordering. Does not catch a
233
- * wholesale rewrite of the file by someone holding the same code — see the
234
- * note in `entry.ts`.
235
- */
236
- verify() {
237
- return verifyEntries(readFileSync(this.path, "utf8"));
238
- }
239
- };
240
-
241
- //#endregion
242
- export { generateKeypair as a, verifyReceipt as c, canonical as d, chain as f, fingerprint as i, verifySignature as l, seal as m, readEntries as n, makeReceipt as o, hashEntry as p, verifyEntries as r, signEntry as s, Ledger as t, GENESIS as u };
File without changes
File without changes
File without changes