@fiscalmindset/blindfold 0.4.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.
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Append-only, tamper-evident ledger of "what's sealed in the enclave right now".
3
+ *
4
+ * SAFETY: metadata only — never the value. Each line records the key
5
+ * name, its source, its byte-length (so you can sanity-check after
6
+ * sealing), the tenant DID + map name (so you know exactly which
7
+ * enclave + map holds it), and when. No plaintext, ever.
8
+ *
9
+ * INTEGRITY: each entry carries a `prev` + `hash` forming a hash-chain, so
10
+ * any edit or deletion of a past line is detectable (`verifyLedgerChain`).
11
+ * The enclave remains the source of truth — `blindfold audit` reconciles this
12
+ * ledger against it.
13
+ *
14
+ * Default path: ./.blindfold/sealed.jsonl (override via BLINDFOLD_SEALED_LOG)
15
+ */
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { createHash, createHmac, randomBytes } from "node:crypto";
19
+ import { stateDir, withFileLockSync } from "./env.ts";
20
+
21
+ export interface SealedEntry {
22
+ t: string; // ISO timestamp
23
+ name: string; // KV key inside z:<tid>:secrets
24
+ source: string; // "stdin" | "env:VAR" | "explicit"
25
+ length: number; // byte-count of the value (NOT the value)
26
+ mode: "real" | "mock";
27
+ tenant_did: string;
28
+ map_name: string; // z:<tid>:secrets
29
+ prev?: string; // hash of the previous chained entry ("" for the first)
30
+ hash?: string; // HMAC(key, prev + "\n" + core) — tamper-evidence
31
+ alg?: string; // "hmac-sha256" for keyed entries; absent = legacy sha256
32
+ }
33
+
34
+ export function defaultSealedLogPath(): string {
35
+ return process.env.BLINDFOLD_SEALED_LOG ?? path.join(stateDir(), "sealed.jsonl");
36
+ }
37
+
38
+ /**
39
+ * Local HMAC key for the ledger chain. Unlike the previous plain sha256 chain
40
+ * (which anyone could recompute), this key is not derivable from the ledger, so
41
+ * an attacker who edits a line cannot forge a valid chain. Persisted to
42
+ * .blindfold/ledger.key (0600), generated on first use. Returns null if the key
43
+ * can't be obtained — callers then fall back to the legacy sha256 chain.
44
+ */
45
+ function ledgerKey(): Buffer | null {
46
+ try {
47
+ const keyPath = path.join(stateDir(), "ledger.key");
48
+ if (fs.existsSync(keyPath)) return Buffer.from(fs.readFileSync(keyPath, "utf8").trim(), "hex");
49
+ fs.mkdirSync(path.dirname(keyPath), { recursive: true });
50
+ const key = randomBytes(32);
51
+ fs.writeFileSync(keyPath, key.toString("hex"), { mode: 0o600 });
52
+ try { fs.chmodSync(keyPath, 0o600); } catch { /* best effort */ }
53
+ return key;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /** Canonical serialization of the metadata fields (excludes prev/hash). */
60
+ function coreString(e: SealedEntry): string {
61
+ return JSON.stringify({
62
+ t: e.t, name: e.name, source: e.source, length: e.length,
63
+ mode: e.mode, tenant_did: e.tenant_did, map_name: e.map_name,
64
+ });
65
+ }
66
+
67
+ function sha(s: string): string {
68
+ return createHash("sha256").update(s).digest("hex");
69
+ }
70
+
71
+ function chainHash(key: Buffer | null, prev: string, core: string): { hash: string; alg?: string } {
72
+ if (key) return { hash: createHmac("sha256", key).update(`${prev}\n${core}`).digest("hex"), alg: "hmac-sha256" };
73
+ return { hash: sha(`${prev}\n${core}`) }; // legacy fallback
74
+ }
75
+
76
+ /** Efficiently read the last non-empty line without parsing the whole file. */
77
+ function readLastLine(p: string): string | null {
78
+ if (!fs.existsSync(p)) return null;
79
+ const fd = fs.openSync(p, "r");
80
+ try {
81
+ const size = fs.fstatSync(fd).size;
82
+ if (size === 0) return null;
83
+ const readLen = Math.min(size, 64 * 1024);
84
+ const buf = Buffer.alloc(readLen);
85
+ fs.readSync(fd, buf, 0, readLen, size - readLen);
86
+ const lines = buf.toString("utf8").split("\n").filter((l) => l.trim().length > 0);
87
+ return lines.length ? lines[lines.length - 1]! : null;
88
+ } finally {
89
+ fs.closeSync(fd);
90
+ }
91
+ }
92
+
93
+ export function recordSealed(entry: SealedEntry): void {
94
+ const p = defaultSealedLogPath();
95
+ try {
96
+ // Serialize read-tail + append under an exclusive lock so two concurrent
97
+ // sealers can't read the same prevHash and fork the chain (false "TAMPERED").
98
+ withFileLockSync(p, () => {
99
+ let prevHash = "";
100
+ const last = readLastLine(p);
101
+ if (last) {
102
+ try { prevHash = (JSON.parse(last) as SealedEntry).hash ?? ""; } catch { prevHash = ""; }
103
+ }
104
+ const { hash, alg } = chainHash(ledgerKey(), prevHash, coreString(entry));
105
+ const chained: SealedEntry = { ...entry, prev: prevHash, hash, ...(alg ? { alg } : {}) };
106
+ fs.mkdirSync(path.dirname(p), { recursive: true });
107
+ fs.appendFileSync(p, JSON.stringify(chained) + "\n");
108
+ });
109
+ } catch {
110
+ /* never let logging crash the seal */
111
+ }
112
+ }
113
+
114
+ export function readSealed(): SealedEntry[] {
115
+ const p = defaultSealedLogPath();
116
+ if (!fs.existsSync(p)) return [];
117
+ return fs.readFileSync(p, "utf8")
118
+ .split("\n").filter(Boolean)
119
+ .map(l => { try { return JSON.parse(l) as SealedEntry; } catch { return null; } })
120
+ .filter((e): e is SealedEntry => e !== null);
121
+ }
122
+
123
+ export interface ChainResult {
124
+ ok: boolean;
125
+ total: number;
126
+ legacy: number; // entries with no hash (pre-chain) — unverifiable
127
+ firstBrokenIndex: number; // -1 if intact
128
+ }
129
+
130
+ /**
131
+ * Verify the hash-chain. A broken chain means a line was edited or removed
132
+ * after it was written — the ledger has been tampered with.
133
+ */
134
+ export function verifyLedgerChain(): ChainResult {
135
+ const entries = readSealed();
136
+ const key = ledgerKey();
137
+ let runningPrev = "";
138
+ let legacy = 0;
139
+ let firstBrokenIndex = -1;
140
+ entries.forEach((e, i) => {
141
+ if (!e.hash) { legacy++; return; } // no hash at all — pre-chain, unverifiable
142
+ if (e.alg === "hmac-sha256") {
143
+ // Keyed entry — real tamper detection (requires the local key).
144
+ const expected = key ? createHmac("sha256", key).update(`${runningPrev}\n${coreString(e)}`).digest("hex") : null;
145
+ if (firstBrokenIndex < 0 && (e.prev !== runningPrev || (expected !== null && e.hash !== expected))) {
146
+ firstBrokenIndex = i;
147
+ }
148
+ if (expected === null) legacy++; // key unavailable → can't verify, don't cry tamper
149
+ } else {
150
+ // Legacy plain-sha256 entry: recomputable by anyone, so treat as
151
+ // unverifiable rather than authoritative. The enclave (blindfold audit)
152
+ // remains the source of truth for these.
153
+ legacy++;
154
+ }
155
+ runningPrev = e.hash;
156
+ });
157
+ return { ok: firstBrokenIndex < 0, total: entries.length, legacy, firstBrokenIndex };
158
+ }