prae-cli 0.8.0 → 0.9.2
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/lib/bin.js +4 -4
- package/lib/{checkpoint-DiznOVpS.js → checkpoint-C_q_Rpa5.js} +1 -1
- package/lib/{cli-UAE968If.js → cli-B_B6-bUZ.js} +1020 -51
- package/lib/{evaluate-wMPkjcIC.js → evaluate-BqLxhYiP.js} +10 -1
- package/lib/index.d.ts +2 -0
- package/lib/index.js +4 -4
- package/lib/{instructions-DulxSqFd.js → instructions-ya0LVnZy.js} +1 -1
- package/lib/{mcp-doNYU-1C.js → mcp-BCXbpqu4.js} +1 -1
- package/lib/playground.js +23 -3
- package/lib/{receipt-check-s3Aryyz-.js → receipt-check-DQK_gVPc.js} +1 -1
- package/lib/src-7X6dO7gC.js +3 -0
- package/lib/src-LA7NdLKb.js +399 -0
- package/lib/{test-run-XNIRHKOu.js → test-run-CCDgJ0Dp.js} +1 -1
- package/lib/{views-CCwVXpo2.js → views-D4UEfbzt.js} +257 -1
- package/package.json +6 -5
- package/lib/src-C9L6NBvu.js +0 -3
- package/lib/src-Ciggx9ja.js +0 -242
|
@@ -348,6 +348,7 @@ function pathArgOf(args, explicit) {
|
|
|
348
348
|
* appending to a pack can never accidentally widen what an agent may do.
|
|
349
349
|
*/
|
|
350
350
|
const STRENGTH = {
|
|
351
|
+
allow: 0,
|
|
351
352
|
warn: 1,
|
|
352
353
|
correct: 2,
|
|
353
354
|
ask: 3,
|
|
@@ -479,6 +480,14 @@ function evaluate(pack, facts) {
|
|
|
479
480
|
evaluated
|
|
480
481
|
};
|
|
481
482
|
}
|
|
483
|
+
const allowed = hits.find((hit) => hit.effect.kind === "allow");
|
|
484
|
+
if (allowed !== void 0) return {
|
|
485
|
+
kind: "allow",
|
|
486
|
+
rule: allowed.rule.id,
|
|
487
|
+
reason: allowed.effect.because,
|
|
488
|
+
trace,
|
|
489
|
+
evaluated
|
|
490
|
+
};
|
|
482
491
|
return {
|
|
483
492
|
kind: "allow",
|
|
484
493
|
trace,
|
|
@@ -487,4 +496,4 @@ function evaluate(pack, facts) {
|
|
|
487
496
|
}
|
|
488
497
|
|
|
489
498
|
//#endregion
|
|
490
|
-
export { evaluate as t };
|
|
499
|
+
export { hostOf as n, withinAny as r, evaluate as t };
|
package/lib/index.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ declare function praeRow(options: {
|
|
|
36
36
|
readonly pack?: string;
|
|
37
37
|
/** The pack as a file the gate watches (hot reload); wins over `pack`. */
|
|
38
38
|
readonly packFile?: string;
|
|
39
|
+
/** The workspace's own packs, loaded and watched beside `pack` / `packFile`. */
|
|
40
|
+
readonly packDir?: string;
|
|
39
41
|
/** `shadow` seals every verdict and lets every call through. */
|
|
40
42
|
readonly mode?: 'enforce' | 'shadow';
|
|
41
43
|
readonly ledger?: string;
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import "./evaluate-
|
|
2
|
-
import "./views-
|
|
3
|
-
import { a as planWrite, i as dshHome, n as applyWrite, o as praeRow, r as defaultProfile, s as profileAt, t as main } from "./cli-
|
|
4
|
-
import "./src-
|
|
1
|
+
import "./evaluate-BqLxhYiP.js";
|
|
2
|
+
import "./views-D4UEfbzt.js";
|
|
3
|
+
import { a as planWrite, i as dshHome, n as applyWrite, o as praeRow, r as defaultProfile, s as profileAt, t as main } from "./cli-B_B6-bUZ.js";
|
|
4
|
+
import "./src-LA7NdLKb.js";
|
|
5
5
|
|
|
6
6
|
export { applyWrite, defaultProfile, dshHome, main, planWrite, praeRow, profileAt };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as evaluate } from "./evaluate-
|
|
1
|
+
import { t as evaluate } from "./evaluate-BqLxhYiP.js";
|
|
2
2
|
import { a as scan, n as MAX_SCAN_BYTES } from "./src-B25SHWcz.js";
|
|
3
3
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
4
4
|
import { resolve, sep } from "node:path";
|
package/lib/playground.js
CHANGED
|
@@ -351,6 +351,7 @@
|
|
|
351
351
|
* appending to a pack can never accidentally widen what an agent may do.
|
|
352
352
|
*/
|
|
353
353
|
const STRENGTH = {
|
|
354
|
+
allow: 0,
|
|
354
355
|
warn: 1,
|
|
355
356
|
correct: 2,
|
|
356
357
|
ask: 3,
|
|
@@ -482,6 +483,14 @@
|
|
|
482
483
|
evaluated
|
|
483
484
|
};
|
|
484
485
|
}
|
|
486
|
+
const allowed = hits.find((hit) => hit.effect.kind === "allow");
|
|
487
|
+
if (allowed !== void 0) return {
|
|
488
|
+
kind: "allow",
|
|
489
|
+
rule: allowed.rule.id,
|
|
490
|
+
reason: allowed.effect.because,
|
|
491
|
+
trace,
|
|
492
|
+
evaluated
|
|
493
|
+
};
|
|
485
494
|
return {
|
|
486
495
|
kind: "allow",
|
|
487
496
|
trace,
|
|
@@ -854,9 +863,9 @@
|
|
|
854
863
|
check.fail(path, "expected an object");
|
|
855
864
|
return;
|
|
856
865
|
}
|
|
857
|
-
const kind = value.kind;
|
|
858
|
-
if (kind !== "deny" && kind !== "ask" && kind !== "correct" && kind !== "warn") {
|
|
859
|
-
check.fail(`${path}.kind`, "expected `deny`, `ask
|
|
866
|
+
const kind = value.kind === "hold" ? "ask" : value.kind;
|
|
867
|
+
if (kind !== "deny" && kind !== "ask" && kind !== "correct" && kind !== "warn" && kind !== "allow") {
|
|
868
|
+
check.fail(`${path}.kind`, "expected `allow`, `deny`, `hold` (or `ask`), `correct`, or `warn`");
|
|
860
869
|
return;
|
|
861
870
|
}
|
|
862
871
|
check.keys(path, value, kind === "correct" ? [
|
|
@@ -890,6 +899,7 @@
|
|
|
890
899
|
check.keys(path, value, [
|
|
891
900
|
"id",
|
|
892
901
|
"title",
|
|
902
|
+
"description",
|
|
893
903
|
"when",
|
|
894
904
|
"effect",
|
|
895
905
|
"frameworks"
|
|
@@ -908,6 +918,10 @@
|
|
|
908
918
|
const title = check.text(`${path}.title`, value.title);
|
|
909
919
|
if (title !== void 0) rule.title = title;
|
|
910
920
|
}
|
|
921
|
+
if (value.description !== void 0) {
|
|
922
|
+
const description = check.text(`${path}.description`, value.description);
|
|
923
|
+
if (description !== void 0) rule.description = description;
|
|
924
|
+
}
|
|
911
925
|
if (value.frameworks !== void 0) {
|
|
912
926
|
const frameworks = readFrameworks(check, `${path}.frameworks`, value.frameworks);
|
|
913
927
|
if (frameworks !== void 0) rule.frameworks = frameworks;
|
|
@@ -968,12 +982,16 @@
|
|
|
968
982
|
"prae",
|
|
969
983
|
"pack",
|
|
970
984
|
"version",
|
|
985
|
+
"description",
|
|
986
|
+
"owner",
|
|
971
987
|
"baseline",
|
|
972
988
|
"rules"
|
|
973
989
|
]);
|
|
974
990
|
if (raw.prae !== "0.1") check.fail(".prae", "expected `\"0.1\"`; this is the only pack version this build reads");
|
|
975
991
|
const name = check.text(".pack", raw.pack);
|
|
976
992
|
const version = check.text(".version", raw.version);
|
|
993
|
+
const description = raw.description === void 0 ? void 0 : check.text(".description", raw.description);
|
|
994
|
+
const owner = raw.owner === void 0 ? void 0 : check.text(".owner", raw.owner);
|
|
977
995
|
let baseline;
|
|
978
996
|
if (raw.baseline !== void 0) if (!isObject(raw.baseline)) check.fail(".baseline", "expected an object");
|
|
979
997
|
else {
|
|
@@ -1010,6 +1028,8 @@
|
|
|
1010
1028
|
prae: "0.1",
|
|
1011
1029
|
pack: name,
|
|
1012
1030
|
version,
|
|
1031
|
+
...description === void 0 ? {} : { description },
|
|
1032
|
+
...owner === void 0 ? {} : { owner },
|
|
1013
1033
|
...baseline === void 0 ? {} : { baseline },
|
|
1014
1034
|
rules
|
|
1015
1035
|
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { _ as hashEntry, a as signBytes, c as fingerprint, d as signEntry, f as verifyReceipt, g as chain, h as canonical, i as sealedKeypair, l as generateKeypair, m as GENESIS, n as readEntries, o as unsealPrivateKey, p as verifySignature, r as verifyEntries, s as verifyBytes, t as Ledger, u as makeReceipt, v as seal } from "./src-LA7NdLKb.js";
|
|
2
|
+
|
|
3
|
+
export { GENESIS, Ledger, canonical, chain, fingerprint, generateKeypair, hashEntry, makeReceipt, readEntries, seal, sealedKeypair, signBytes, signEntry, unsealPrivateKey, verifyBytes, verifyEntries, verifyReceipt, verifySignature };
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { createCipheriv, createDecipheriv, createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, scryptSync, 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/authority.ts
|
|
112
|
+
const priv = (k) => typeof k === "string" ? createPrivateKey(k) : k;
|
|
113
|
+
/** How long a signed answer is good for, by default. */
|
|
114
|
+
const ANSWER_TTL_MS = 5 * 6e4;
|
|
115
|
+
/** A detached signature over exact bytes (a policy pack's file). */
|
|
116
|
+
function signBytes(bytes, privateKey) {
|
|
117
|
+
return sign(null, Buffer.from(bytes), priv(privateKey)).toString("base64");
|
|
118
|
+
}
|
|
119
|
+
function verifyBytes(bytes, signature, publicKeyPem) {
|
|
120
|
+
try {
|
|
121
|
+
return verify(null, Buffer.from(bytes), createPublicKey(publicKeyPem), Buffer.from(signature.trim(), "base64"));
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* How a private key is sealed at rest: scrypt (N = 2^17, r = 8, p = 1) over
|
|
128
|
+
* the passphrase, AES-256-GCM over the key. The sealed file can sit where an
|
|
129
|
+
* agent can read it; each guess at the passphrase costs ~128 MiB and a
|
|
130
|
+
* noticeable fraction of a second, so a strong passphrase holds against an
|
|
131
|
+
* offline attack. (PKCS#8 as Node writes it uses PBKDF2 at 2,048 rounds —
|
|
132
|
+
* too cheap for a file the agent can copy.)
|
|
133
|
+
*/
|
|
134
|
+
const SCRYPT = {
|
|
135
|
+
N: 2 ** 17,
|
|
136
|
+
r: 8,
|
|
137
|
+
p: 1,
|
|
138
|
+
maxmem: 512 * 1024 * 1024
|
|
139
|
+
};
|
|
140
|
+
const MIN_PASSPHRASE = 12;
|
|
141
|
+
const SEALED = "PRAE SEALED KEY";
|
|
142
|
+
function sealPrivateKey(key, passphrase) {
|
|
143
|
+
if (passphrase.length < MIN_PASSPHRASE) throw new Error(`passphrase: ${MIN_PASSPHRASE} characters or more`);
|
|
144
|
+
const salt = randomBytes(16);
|
|
145
|
+
const iv = randomBytes(12);
|
|
146
|
+
const cipher = createCipheriv("aes-256-gcm", scryptSync(passphrase, salt, 32, SCRYPT), iv);
|
|
147
|
+
const der = key.export({
|
|
148
|
+
type: "pkcs8",
|
|
149
|
+
format: "der"
|
|
150
|
+
});
|
|
151
|
+
const data = Buffer.concat([cipher.update(der), cipher.final()]);
|
|
152
|
+
return `-----BEGIN ${SEALED}-----\n${(Buffer.from(JSON.stringify({
|
|
153
|
+
v: 1,
|
|
154
|
+
kdf: "scrypt",
|
|
155
|
+
N: SCRYPT.N,
|
|
156
|
+
r: SCRYPT.r,
|
|
157
|
+
p: SCRYPT.p,
|
|
158
|
+
salt: salt.toString("base64"),
|
|
159
|
+
iv: iv.toString("base64"),
|
|
160
|
+
tag: cipher.getAuthTag().toString("base64"),
|
|
161
|
+
data: data.toString("base64")
|
|
162
|
+
})).toString("base64").match(/.{1,64}/g) ?? []).join("\n")}\n-----END ${SEALED}-----\n`;
|
|
163
|
+
}
|
|
164
|
+
/** A keypair whose private half is sealed under a passphrase. */
|
|
165
|
+
function sealedKeypair(passphrase) {
|
|
166
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
167
|
+
return {
|
|
168
|
+
publicKey: publicKey.export({
|
|
169
|
+
type: "spki",
|
|
170
|
+
format: "pem"
|
|
171
|
+
}).toString(),
|
|
172
|
+
sealedPrivateKey: sealPrivateKey(privateKey, passphrase)
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/** Unseal into memory. Throws on a wrong passphrase or a tampered file. */
|
|
176
|
+
function unsealPrivateKey(sealed, passphrase) {
|
|
177
|
+
const match = sealed.match(/* @__PURE__ */ new RegExp(`-----BEGIN ${SEALED}-----([\\s\\S]+?)-----END ${SEALED}-----`));
|
|
178
|
+
if (match === null) throw new Error("not a PRAE sealed key (an older format?): make a new one with prae keygen --role");
|
|
179
|
+
const box = JSON.parse(Buffer.from((match[1] ?? "").replace(/\s/g, ""), "base64").toString("utf8"));
|
|
180
|
+
if (box.v !== 1 || box.kdf !== "scrypt") throw new Error("unknown sealing");
|
|
181
|
+
const decipher = createDecipheriv("aes-256-gcm", scryptSync(passphrase, Buffer.from(String(box.salt), "base64"), 32, {
|
|
182
|
+
N: Number(box.N),
|
|
183
|
+
r: Number(box.r),
|
|
184
|
+
p: Number(box.p),
|
|
185
|
+
maxmem: SCRYPT.maxmem
|
|
186
|
+
}), Buffer.from(String(box.iv), "base64"));
|
|
187
|
+
decipher.setAuthTag(Buffer.from(String(box.tag), "base64"));
|
|
188
|
+
return createPrivateKey({
|
|
189
|
+
key: Buffer.concat([decipher.update(Buffer.from(String(box.data), "base64")), decipher.final()]),
|
|
190
|
+
format: "der",
|
|
191
|
+
type: "pkcs8"
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
//#endregion
|
|
196
|
+
//#region ../ledger/src/ledger.ts
|
|
197
|
+
/**
|
|
198
|
+
* Read a ledger held as text.
|
|
199
|
+
*
|
|
200
|
+
* Split out from the class because a ledger is not always a file being
|
|
201
|
+
* appended to: a build step renders one it imported, and a reader pastes one
|
|
202
|
+
* in. Those callers need the same parsing and the same verification the CLI
|
|
203
|
+
* runs, and a second implementation of either is how two answers to "does this
|
|
204
|
+
* chain hold" come to exist.
|
|
205
|
+
*/
|
|
206
|
+
function readEntries(text) {
|
|
207
|
+
return text.split("\n").filter((line) => line.trim() !== "").map((line) => JSON.parse(line));
|
|
208
|
+
}
|
|
209
|
+
/** Re-derive every hash and confirm each entry names its predecessor. */
|
|
210
|
+
function verifyEntries(text) {
|
|
211
|
+
const problems = [];
|
|
212
|
+
let expectedPrev = GENESIS;
|
|
213
|
+
let expectedSeq = 0;
|
|
214
|
+
let count = 0;
|
|
215
|
+
const lines = text.split("\n").filter((line) => line.trim() !== "");
|
|
216
|
+
for (const [index, line] of lines.entries()) {
|
|
217
|
+
const at = index + 1;
|
|
218
|
+
count += 1;
|
|
219
|
+
let entry;
|
|
220
|
+
try {
|
|
221
|
+
entry = JSON.parse(line);
|
|
222
|
+
} catch {
|
|
223
|
+
problems.push({
|
|
224
|
+
line: at,
|
|
225
|
+
message: "not valid JSON"
|
|
226
|
+
});
|
|
227
|
+
return {
|
|
228
|
+
ok: false,
|
|
229
|
+
entries: count,
|
|
230
|
+
problems
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
if (entry.seq !== expectedSeq) problems.push({
|
|
234
|
+
line: at,
|
|
235
|
+
message: `expected seq ${expectedSeq}, found ${entry.seq}`
|
|
236
|
+
});
|
|
237
|
+
if (entry.prev !== expectedPrev) problems.push({
|
|
238
|
+
line: at,
|
|
239
|
+
message: "does not name the previous entry; a row was removed or reordered"
|
|
240
|
+
});
|
|
241
|
+
const { seq, prev, hash, sig: _sig,...record } = entry;
|
|
242
|
+
if (hashEntry(record, seq, prev) !== hash) problems.push({
|
|
243
|
+
line: at,
|
|
244
|
+
message: "contents do not match the sealed hash"
|
|
245
|
+
});
|
|
246
|
+
expectedPrev = entry.hash;
|
|
247
|
+
expectedSeq = entry.seq + 1;
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
ok: problems.length === 0,
|
|
251
|
+
entries: count,
|
|
252
|
+
problems
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
/** The last complete line of a file, reading only its end when it can. */
|
|
256
|
+
function lastLine(path) {
|
|
257
|
+
const fd = openSync(path, "r");
|
|
258
|
+
try {
|
|
259
|
+
const size = fstatSync(fd).size;
|
|
260
|
+
for (let span = 64 * 1024;; span *= 4) {
|
|
261
|
+
const start = Math.max(0, size - span);
|
|
262
|
+
const buffer = Buffer.alloc(size - start);
|
|
263
|
+
readSync(fd, buffer, 0, buffer.length, start);
|
|
264
|
+
const lines = buffer.toString("utf8").split("\n").filter((line) => line.trim() !== "");
|
|
265
|
+
if (start === 0 || lines.length > 1) return lines.at(-1);
|
|
266
|
+
}
|
|
267
|
+
} finally {
|
|
268
|
+
closeSync(fd);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/** Where the chain ends now: the next seq and the hash to continue from. */
|
|
272
|
+
function tailOf(path) {
|
|
273
|
+
let tail;
|
|
274
|
+
try {
|
|
275
|
+
tail = lastLine(path);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (error.code !== "ENOENT") throw error;
|
|
278
|
+
return {
|
|
279
|
+
seq: 0,
|
|
280
|
+
last: GENESIS
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (tail === void 0) return {
|
|
284
|
+
seq: 0,
|
|
285
|
+
last: GENESIS
|
|
286
|
+
};
|
|
287
|
+
let entry;
|
|
288
|
+
try {
|
|
289
|
+
entry = JSON.parse(tail);
|
|
290
|
+
} catch {
|
|
291
|
+
throw new Error(`prae ledger: last line of ${path} is not readable; refusing to append onto a chain that cannot be continued`);
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
seq: entry.seq + 1,
|
|
295
|
+
last: entry.hash
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
const SLEEP = new Int32Array(new SharedArrayBuffer(4));
|
|
299
|
+
/** A lock older than this was left by a writer that died holding it. */
|
|
300
|
+
const STALE_LOCK_MS = 1e4;
|
|
301
|
+
/** Run `fn` holding an exclusive lock file; synchronous, like the append. */
|
|
302
|
+
function withLock(lock, fn) {
|
|
303
|
+
const deadline = Date.now() + 3e4;
|
|
304
|
+
for (;;) {
|
|
305
|
+
let fd;
|
|
306
|
+
try {
|
|
307
|
+
fd = openSync(lock, "wx");
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (error.code !== "EEXIST") throw error;
|
|
310
|
+
try {
|
|
311
|
+
if (Date.now() - statSync(lock).mtimeMs > STALE_LOCK_MS) {
|
|
312
|
+
unlinkSync(lock);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
} catch {}
|
|
316
|
+
if (Date.now() > deadline) throw new Error(`prae ledger: ${lock} has been held for 30 s; refusing to append without it`);
|
|
317
|
+
Atomics.wait(SLEEP, 0, 0, 2);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
return fn();
|
|
322
|
+
} finally {
|
|
323
|
+
closeSync(fd);
|
|
324
|
+
try {
|
|
325
|
+
unlinkSync(lock);
|
|
326
|
+
} catch {}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
var Ledger = class Ledger {
|
|
331
|
+
path;
|
|
332
|
+
constructor(path) {
|
|
333
|
+
this.path = path;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Open a ledger, reading the tail to continue an existing chain.
|
|
337
|
+
*
|
|
338
|
+
* A corrupt or truncated final line is a hard error rather than something to
|
|
339
|
+
* skip past: appending onto a chain whose end we could not read would bury
|
|
340
|
+
* the damage under valid-looking entries.
|
|
341
|
+
*/
|
|
342
|
+
/** Sign every appended row with this Ed25519 private key (PEM). */
|
|
343
|
+
signWith;
|
|
344
|
+
/** Or through a signing boundary that holds the key (PRA-108). */
|
|
345
|
+
signer;
|
|
346
|
+
static open(path, options = {}) {
|
|
347
|
+
const ledger = Ledger.openUnsigned(path);
|
|
348
|
+
ledger.signWith = options.signWith;
|
|
349
|
+
ledger.signer = options.signer;
|
|
350
|
+
return ledger;
|
|
351
|
+
}
|
|
352
|
+
static openUnsigned(path) {
|
|
353
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
354
|
+
try {
|
|
355
|
+
writeFileSync(path, "", { flag: "wx" });
|
|
356
|
+
} catch (error) {
|
|
357
|
+
if (error.code !== "EEXIST") throw error;
|
|
358
|
+
}
|
|
359
|
+
tailOf(path);
|
|
360
|
+
return new Ledger(path);
|
|
361
|
+
}
|
|
362
|
+
/** Append one decision and return the entry as written. */
|
|
363
|
+
append(record) {
|
|
364
|
+
return withLock(`${this.path}.lock`, () => {
|
|
365
|
+
const { seq, last } = tailOf(this.path);
|
|
366
|
+
const sealed = chain(record, seq, last);
|
|
367
|
+
const entry = this.signer !== void 0 ? {
|
|
368
|
+
...sealed,
|
|
369
|
+
sig: this.signer(sealed)
|
|
370
|
+
} : this.signWith === void 0 ? sealed : {
|
|
371
|
+
...sealed,
|
|
372
|
+
sig: signEntry(sealed, this.signWith)
|
|
373
|
+
};
|
|
374
|
+
appendFileSync(this.path, `${JSON.stringify(entry)}\n`);
|
|
375
|
+
return entry;
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
/** Every entry, in order. */
|
|
379
|
+
read() {
|
|
380
|
+
return readEntries(readFileSync(this.path, "utf8"));
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Re-derive every hash and confirm each entry names its predecessor.
|
|
384
|
+
*
|
|
385
|
+
* Catches a deleted row, an edited field, and a reordering. Does not catch a
|
|
386
|
+
* wholesale rewrite of the file by someone holding the same code — see the
|
|
387
|
+
* note in `entry.ts`.
|
|
388
|
+
*/
|
|
389
|
+
verify() {
|
|
390
|
+
return verifyEntries(readFileSync(this.path, "utf8"));
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
//#endregion
|
|
395
|
+
//#region ../ledger/src/signer.ts
|
|
396
|
+
const nap = new Int32Array(new SharedArrayBuffer(4));
|
|
397
|
+
|
|
398
|
+
//#endregion
|
|
399
|
+
export { hashEntry as _, signBytes as a, fingerprint as c, signEntry as d, verifyReceipt as f, chain as g, canonical as h, sealedKeypair as i, generateKeypair as l, GENESIS as m, readEntries as n, unsealPrivateKey as o, verifySignature as p, verifyEntries as r, verifyBytes as s, Ledger as t, makeReceipt as u, seal as v };
|