usertrust 1.5.0 → 2.0.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/README.md +17 -1
- package/dist/anomaly/detector.js.map +1 -1
- package/dist/anomaly/index.d.ts +2 -2
- package/dist/anomaly/index.d.ts.map +1 -1
- package/dist/anomaly/index.js +1 -1
- package/dist/anomaly/index.js.map +1 -1
- package/dist/audit/anchor-doctor.d.ts +42 -0
- package/dist/audit/anchor-doctor.d.ts.map +1 -0
- package/dist/audit/anchor-doctor.js +285 -0
- package/dist/audit/anchor-doctor.js.map +1 -0
- package/dist/audit/anchor-verify.d.ts +229 -0
- package/dist/audit/anchor-verify.d.ts.map +1 -0
- package/dist/audit/anchor-verify.js +809 -0
- package/dist/audit/anchor-verify.js.map +1 -0
- package/dist/audit/anchor.d.ts +177 -0
- package/dist/audit/anchor.d.ts.map +1 -0
- package/dist/audit/anchor.js +922 -0
- package/dist/audit/anchor.js.map +1 -0
- package/dist/audit/canonical.js +1 -1
- package/dist/audit/canonical.js.map +1 -1
- package/dist/audit/chain.d.ts.map +1 -1
- package/dist/audit/chain.js +21 -7
- package/dist/audit/chain.js.map +1 -1
- package/dist/audit/read.d.ts +19 -0
- package/dist/audit/read.d.ts.map +1 -0
- package/dist/audit/read.js +94 -0
- package/dist/audit/read.js.map +1 -0
- package/dist/audit/rekor-verify.d.ts +116 -0
- package/dist/audit/rekor-verify.d.ts.map +1 -0
- package/dist/audit/rekor-verify.js +533 -0
- package/dist/audit/rekor-verify.js.map +1 -0
- package/dist/audit/rekor.d.ts +71 -0
- package/dist/audit/rekor.d.ts.map +1 -0
- package/dist/audit/rekor.js +556 -0
- package/dist/audit/rekor.js.map +1 -0
- package/dist/audit/rotation.js +1 -1
- package/dist/audit/rotation.js.map +1 -1
- package/dist/audit/sigv4.d.ts +44 -0
- package/dist/audit/sigv4.d.ts.map +1 -0
- package/dist/audit/sigv4.js +134 -0
- package/dist/audit/sigv4.js.map +1 -0
- package/dist/audit/verify.d.ts +78 -0
- package/dist/audit/verify.d.ts.map +1 -1
- package/dist/audit/verify.js +195 -1
- package/dist/audit/verify.js.map +1 -1
- package/dist/cli/anchor.d.ts +3 -0
- package/dist/cli/anchor.d.ts.map +1 -0
- package/dist/cli/anchor.js +626 -0
- package/dist/cli/anchor.js.map +1 -0
- package/dist/cli/completions.js +1 -1
- package/dist/cli/export.d.ts +3 -0
- package/dist/cli/export.d.ts.map +1 -0
- package/dist/cli/export.js +58 -0
- package/dist/cli/export.js.map +1 -0
- package/dist/cli/health.js.map +1 -1
- package/dist/cli/init.js +1 -1
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/inspect.d.ts.map +1 -1
- package/dist/cli/inspect.js +4 -34
- package/dist/cli/inspect.js.map +1 -1
- package/dist/cli/main.d.ts +2 -2
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +20 -1
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/pricing.js +1 -1
- package/dist/cli/pricing.js.map +1 -1
- package/dist/cli/ui.d.ts +3 -0
- package/dist/cli/ui.d.ts.map +1 -0
- package/dist/cli/ui.js +35 -0
- package/dist/cli/ui.js.map +1 -0
- package/dist/cli/verify.d.ts.map +1 -1
- package/dist/cli/verify.js +342 -3
- package/dist/cli/verify.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/detect.d.ts +37 -15
- package/dist/detect.d.ts.map +1 -1
- package/dist/detect.js +17 -6
- package/dist/detect.js.map +1 -1
- package/dist/export/markdown.d.ts +12 -0
- package/dist/export/markdown.d.ts.map +1 -0
- package/dist/export/markdown.js +142 -0
- package/dist/export/markdown.js.map +1 -0
- package/dist/govern.d.ts +60 -3
- package/dist/govern.d.ts.map +1 -1
- package/dist/govern.js +842 -229
- package/dist/govern.js.map +1 -1
- package/dist/headless.d.ts.map +1 -1
- package/dist/headless.js +4 -4
- package/dist/headless.js.map +1 -1
- package/dist/index.d.ts +33 -26
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +30 -25
- package/dist/index.js.map +1 -1
- package/dist/ledger/client.d.ts.map +1 -1
- package/dist/ledger/client.js +57 -46
- package/dist/ledger/client.js.map +1 -1
- package/dist/ledger/engine.d.ts.map +1 -1
- package/dist/ledger/engine.js +22 -13
- package/dist/ledger/engine.js.map +1 -1
- package/dist/shared/types.d.ts +114 -475
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js +13 -13
- package/dist/shared/types.js.map +1 -1
- package/dist/snapshot/checkpoint.d.ts.map +1 -1
- package/dist/snapshot/checkpoint.js +20 -2
- package/dist/snapshot/checkpoint.js.map +1 -1
- package/dist/streaming.d.ts +9 -4
- package/dist/streaming.d.ts.map +1 -1
- package/dist/streaming.js +27 -0
- package/dist/streaming.js.map +1 -1
- package/dist/supply-chain/sign.d.ts.map +1 -1
- package/dist/supply-chain/sign.js +3 -0
- package/dist/supply-chain/sign.js.map +1 -1
- package/package.json +6 -4
|
@@ -0,0 +1,922 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Usertools, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* External Anchor Emitter — signed chain-head checkpoints
|
|
5
|
+
*
|
|
6
|
+
* Periodically commits the audit chain's head (Merkle root + size + last
|
|
7
|
+
* hash), Ed25519-signed and hash-chained to the previous checkpoint, to an
|
|
8
|
+
* append-only store the vault operator cannot silently rewrite. Spec:
|
|
9
|
+
* docs/superpowers/specs/2026-07-26-external-anchoring-design.md §5.
|
|
10
|
+
*
|
|
11
|
+
* Additive by construction: the append path (events.jsonl / .meta bytes) is
|
|
12
|
+
* untouched; the emitter only OBSERVES the writer's outputs. Emission is
|
|
13
|
+
* serialized by its own advisory lock (`audit/anchors/.anchor-writer.lock`)
|
|
14
|
+
* so an in-process scheduler and a cron-driven `usertrust anchor now` cannot
|
|
15
|
+
* race the mirror and mint fork evidence. `appendEvent` never blocks on any
|
|
16
|
+
* of this.
|
|
17
|
+
*
|
|
18
|
+
* The vault-local mirror (`audit/anchors/anchors.jsonl`) is a CACHE — trust
|
|
19
|
+
* comes from the external store copies. The private key NEVER lives inside
|
|
20
|
+
* the vault.
|
|
21
|
+
*/
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
|
+
import { createPrivateKey, createPublicKey, sign as cryptoSign, generateKeyPairSync, randomUUID, } from "node:crypto";
|
|
24
|
+
import { closeSync, existsSync, constants as fsConstants, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync, writeSync, } from "node:fs";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { join, resolve, sep } from "node:path";
|
|
27
|
+
import { GENESIS_HASH, VAULT_DIR } from "../shared/constants.js";
|
|
28
|
+
import { anchorPayloadHash, anchorSigningPreimage, gatherOrderedEventHashes, keyIdFromKeyObject, parseAnchorRecord, parseAnchorsContent, publicKeyFromSpkiBase64, verifySignatureRaw, } from "./anchor-verify.js";
|
|
29
|
+
import { canonicalize } from "./canonical.js";
|
|
30
|
+
import { buildMerkleTree } from "./merkle.js";
|
|
31
|
+
import { DEFAULT_REKOR_URL, rekorSink, s3Sink } from "./rekor.js";
|
|
32
|
+
export const DEFAULT_ANCHOR_EVERY_EVENTS = 1000;
|
|
33
|
+
export const DEFAULT_ANCHOR_EVERY_MS = 60_000;
|
|
34
|
+
const DEFAULT_KEY_ENV = "USERTRUST_ANCHOR_KEY";
|
|
35
|
+
const SNAPSHOT_RETRIES = 3;
|
|
36
|
+
/**
|
|
37
|
+
* Atomic identity.json write: temp file + fsync + rename. A bare in-place
|
|
38
|
+
* writeFileSync could be truncated by a crash mid-write, wedging the vault
|
|
39
|
+
* between "no anchor identity" (emitter) and "identity already exists" (init).
|
|
40
|
+
*/
|
|
41
|
+
function writeIdentityFile(rootDir, identity) {
|
|
42
|
+
const path = join(anchorsDir(rootDir), "identity.json");
|
|
43
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
44
|
+
const fd = openSync(tmp, "w", 0o600);
|
|
45
|
+
try {
|
|
46
|
+
writeSync(fd, JSON.stringify(identity, null, "\t"));
|
|
47
|
+
fsyncSync(fd);
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
closeSync(fd);
|
|
51
|
+
}
|
|
52
|
+
renameSync(tmp, path);
|
|
53
|
+
}
|
|
54
|
+
/** Persist the durable anchorSeq high-water (never decreases). */
|
|
55
|
+
function bumpAnchorHighWater(rootDir, anchorSeq) {
|
|
56
|
+
const identity = readAnchorIdentity(rootDir);
|
|
57
|
+
if (identity === null)
|
|
58
|
+
return;
|
|
59
|
+
if ((identity.lastAnchorSeq ?? 0) >= anchorSeq)
|
|
60
|
+
return;
|
|
61
|
+
writeIdentityFile(rootDir, { ...identity, lastAnchorSeq: anchorSeq });
|
|
62
|
+
}
|
|
63
|
+
export function anchorsDir(rootDir) {
|
|
64
|
+
return join(rootDir, VAULT_DIR, "audit", "anchors");
|
|
65
|
+
}
|
|
66
|
+
export function readAnchorIdentity(rootDir) {
|
|
67
|
+
const p = join(anchorsDir(rootDir), "identity.json");
|
|
68
|
+
if (!existsSync(p))
|
|
69
|
+
return null;
|
|
70
|
+
try {
|
|
71
|
+
const parsed = JSON.parse(readFileSync(p, "utf-8"));
|
|
72
|
+
if (parsed.v === 1 && typeof parsed.vaultId === "string" && typeof parsed.keyId === "string") {
|
|
73
|
+
return parsed;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export function defaultKeyPath(vaultId) {
|
|
82
|
+
return join(homedir(), ".usertrust", "keys", `${vaultId}.anchor.pem`);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Resolve a path to its canonical absolute form, realpath-ing the deepest
|
|
86
|
+
* EXISTING ancestor so symlinked parents (macOS /var → /private/var) compare
|
|
87
|
+
* equal even when the leaf doesn't exist yet.
|
|
88
|
+
*/
|
|
89
|
+
function realResolve(p) {
|
|
90
|
+
const abs = resolve(p);
|
|
91
|
+
let base = abs;
|
|
92
|
+
const suffix = [];
|
|
93
|
+
while (!existsSync(base)) {
|
|
94
|
+
const parent = join(base, "..");
|
|
95
|
+
if (parent === base)
|
|
96
|
+
break;
|
|
97
|
+
suffix.unshift(base.slice(parent.length + (parent.endsWith(sep) ? 0 : 1)));
|
|
98
|
+
base = parent;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
base = realpathSync(base);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
/* keep the resolved form */
|
|
105
|
+
}
|
|
106
|
+
return suffix.length === 0 ? base : join(base, ...suffix);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Mint the vault's anchor identity: Ed25519 keypair (private key OUTSIDE the
|
|
110
|
+
* vault, mode 0600), `identity.json`, and an empty mirror file. Returns the
|
|
111
|
+
* public PEM + keyId for out-of-band pinning. Idempotent: refuses to
|
|
112
|
+
* overwrite an existing identity.
|
|
113
|
+
*/
|
|
114
|
+
export function initAnchorIdentity(rootDir, opts) {
|
|
115
|
+
const dir = anchorsDir(rootDir);
|
|
116
|
+
const identityPath = join(dir, "identity.json");
|
|
117
|
+
if (existsSync(identityPath)) {
|
|
118
|
+
throw new Error(`Anchor identity already exists: ${identityPath}`);
|
|
119
|
+
}
|
|
120
|
+
const vaultId = randomUUID();
|
|
121
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
122
|
+
const keyId = keyIdFromKeyObject(publicKey);
|
|
123
|
+
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
|
|
124
|
+
const publicKeySpki = publicKey.export({ type: "spki", format: "der" }).toString("base64");
|
|
125
|
+
// Canonicalize BOTH sides before comparing: a relative --key-file (e.g.
|
|
126
|
+
// ".usertrust/keys/anchor.pem") would never match an absolute prefix, and
|
|
127
|
+
// symlinked temp dirs (macOS /var → /private/var) would defeat a plain
|
|
128
|
+
// resolve() comparison — either way silently landing the signing key
|
|
129
|
+
// inside the vault it vouches for (AC-6.2).
|
|
130
|
+
const keyFile = realResolve(opts?.keyFile ?? defaultKeyPath(vaultId));
|
|
131
|
+
const keyDir = join(keyFile, "..");
|
|
132
|
+
const vaultAbs = realResolve(join(rootDir, VAULT_DIR));
|
|
133
|
+
if (keyFile === vaultAbs || keyFile.startsWith(vaultAbs + sep)) {
|
|
134
|
+
throw new Error("Refusing to write the anchor private key inside the vault (AC-6.2)");
|
|
135
|
+
}
|
|
136
|
+
mkdirSync(keyDir, { recursive: true, mode: 0o700 });
|
|
137
|
+
writeFileSync(keyFile, privateKey.export({ type: "pkcs8", format: "pem" }), {
|
|
138
|
+
mode: 0o600,
|
|
139
|
+
});
|
|
140
|
+
mkdirSync(join(dir, "outbox"), { recursive: true });
|
|
141
|
+
const identity = {
|
|
142
|
+
v: 1,
|
|
143
|
+
vaultId,
|
|
144
|
+
keyId,
|
|
145
|
+
publicKeySpki,
|
|
146
|
+
createdAt: new Date().toISOString(),
|
|
147
|
+
keyHistory: [{ keyId, publicKeySpki }],
|
|
148
|
+
};
|
|
149
|
+
writeIdentityFile(rootDir, identity);
|
|
150
|
+
// Touch the mirror so "mirror file missing" (accidental loss / partial
|
|
151
|
+
// restore) is distinguishable from "fresh vault, no anchors yet".
|
|
152
|
+
const mirrorPath = join(dir, "anchors.jsonl");
|
|
153
|
+
if (!existsSync(mirrorPath)) {
|
|
154
|
+
writeFileSync(mirrorPath, "", { mode: 0o600 });
|
|
155
|
+
}
|
|
156
|
+
return { identity, publicKeyPem, keyFile };
|
|
157
|
+
}
|
|
158
|
+
export function resolveSigner(config, vaultId) {
|
|
159
|
+
if (config.type === "external") {
|
|
160
|
+
const pub = createPublicKey({
|
|
161
|
+
key: Buffer.from(config.publicKeySpki, "base64"),
|
|
162
|
+
format: "der",
|
|
163
|
+
type: "spki",
|
|
164
|
+
});
|
|
165
|
+
const derivedKeyId = keyIdFromKeyObject(pub);
|
|
166
|
+
if (derivedKeyId !== config.keyId) {
|
|
167
|
+
throw new Error("external signer keyId does not match its publicKeySpki");
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
keyId: config.keyId,
|
|
171
|
+
sign: async (preimage) => Buffer.from(await config.sign(Buffer.from(preimage, "utf8"))).toString("base64"),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
let pem;
|
|
175
|
+
const envName = config.env ?? DEFAULT_KEY_ENV;
|
|
176
|
+
const envValue = process.env[envName];
|
|
177
|
+
if (envValue !== undefined && envValue !== "") {
|
|
178
|
+
pem = envValue.includes("-----BEGIN") ? envValue : readFileSync(envValue, "utf-8");
|
|
179
|
+
}
|
|
180
|
+
else if (config.file !== undefined) {
|
|
181
|
+
pem = readFileSync(config.file, "utf-8");
|
|
182
|
+
}
|
|
183
|
+
else if (vaultId !== undefined && existsSync(defaultKeyPath(vaultId))) {
|
|
184
|
+
pem = readFileSync(defaultKeyPath(vaultId), "utf-8");
|
|
185
|
+
}
|
|
186
|
+
if (pem === undefined) {
|
|
187
|
+
throw new Error(`No anchor private key: set ${envName}, pass signer.file, or run \`usertrust anchor init\``);
|
|
188
|
+
}
|
|
189
|
+
const privateKey = createPrivateKey(pem);
|
|
190
|
+
// @types/node 26 dropped KeyObject from createPublicKey's input union; Node
|
|
191
|
+
// derives the public key from a private KeyObject at runtime (documented) —
|
|
192
|
+
// types-only assertion, runtime call unchanged.
|
|
193
|
+
const publicKey = createPublicKey(privateKey);
|
|
194
|
+
return {
|
|
195
|
+
keyId: keyIdFromKeyObject(publicKey),
|
|
196
|
+
sign: async (preimage) => cryptoSign(null, Buffer.from(preimage, "utf8"), privateKey).toString("base64"),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
// ── Sinks ──
|
|
200
|
+
function fileSink(path) {
|
|
201
|
+
return {
|
|
202
|
+
name: `file:${path}`,
|
|
203
|
+
publish: async (record) => {
|
|
204
|
+
const fd = openSync(path, "a", 0o600);
|
|
205
|
+
try {
|
|
206
|
+
writeSync(fd, `${canonicalize(record)}\n`);
|
|
207
|
+
fsyncSync(fd);
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
closeSync(fd);
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
function httpsSink(url, headers) {
|
|
216
|
+
return {
|
|
217
|
+
name: `https:${url}`,
|
|
218
|
+
publish: (record) => new Promise((resolve, reject) => {
|
|
219
|
+
import("node:https")
|
|
220
|
+
.then((https) => {
|
|
221
|
+
const body = Buffer.from(`${canonicalize(record)}\n`, "utf8");
|
|
222
|
+
const req = https.request(url, {
|
|
223
|
+
method: "POST",
|
|
224
|
+
headers: {
|
|
225
|
+
"content-type": "application/json",
|
|
226
|
+
"content-length": String(body.length),
|
|
227
|
+
...headers,
|
|
228
|
+
},
|
|
229
|
+
timeout: 15_000,
|
|
230
|
+
}, (res) => {
|
|
231
|
+
res.resume();
|
|
232
|
+
const code = res.statusCode ?? 0;
|
|
233
|
+
if (code >= 200 && code < 300) {
|
|
234
|
+
resolve();
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
reject(new Error(`anchor sink HTTP ${code}`));
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
req.on("timeout", () => req.destroy(new Error("anchor sink timeout")));
|
|
241
|
+
req.on("error", reject);
|
|
242
|
+
req.end(body);
|
|
243
|
+
})
|
|
244
|
+
.catch(reject);
|
|
245
|
+
}),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
function commandSink(argv) {
|
|
249
|
+
const [cmd, ...args] = argv;
|
|
250
|
+
return {
|
|
251
|
+
name: `command:${cmd ?? ""}`,
|
|
252
|
+
publish: (record) => new Promise((resolve, reject) => {
|
|
253
|
+
if (cmd === undefined) {
|
|
254
|
+
reject(new Error("command sink: empty argv"));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const child = spawn(cmd, args, { stdio: ["pipe", "ignore", "pipe"] });
|
|
258
|
+
let stderr = "";
|
|
259
|
+
child.stderr.on("data", (d) => {
|
|
260
|
+
stderr += d.toString();
|
|
261
|
+
});
|
|
262
|
+
child.on("error", reject);
|
|
263
|
+
child.on("close", (code) => {
|
|
264
|
+
if (code === 0) {
|
|
265
|
+
resolve();
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
reject(new Error(`command sink exited ${code}: ${stderr.slice(0, 300)}`));
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
// A command that exits without reading stdin (crash, refusal)
|
|
272
|
+
// races the write and emits EPIPE on the stdin stream — as an
|
|
273
|
+
// UNHANDLED error unless listened for. The exit code above is
|
|
274
|
+
// the verdict; the broken pipe is just its symptom.
|
|
275
|
+
child.stdin.on("error", () => {
|
|
276
|
+
/* verdict comes from the close handler's exit code */
|
|
277
|
+
});
|
|
278
|
+
child.stdin.end(`${canonicalize(record)}\n`);
|
|
279
|
+
}),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* `rootDir` is optional so existing callers keep working: only the Rekor sink
|
|
284
|
+
* needs it, because inclusion receipts are persisted into the vault alongside
|
|
285
|
+
* the mirror.
|
|
286
|
+
*/
|
|
287
|
+
export function createSink(config, rootDir) {
|
|
288
|
+
// Already an AnchorSink instance (custom/test sink) — pass through.
|
|
289
|
+
if ("publish" in config && typeof config.publish === "function") {
|
|
290
|
+
return config;
|
|
291
|
+
}
|
|
292
|
+
const cfg = config;
|
|
293
|
+
switch (cfg.type) {
|
|
294
|
+
case "file":
|
|
295
|
+
return fileSink(cfg.path);
|
|
296
|
+
case "https":
|
|
297
|
+
return httpsSink(cfg.url, cfg.headers);
|
|
298
|
+
case "command":
|
|
299
|
+
return commandSink(cfg.argv);
|
|
300
|
+
case "s3":
|
|
301
|
+
return s3Sink({
|
|
302
|
+
bucket: cfg.bucket,
|
|
303
|
+
region: cfg.region,
|
|
304
|
+
...(cfg.prefix !== undefined ? { prefix: cfg.prefix } : {}),
|
|
305
|
+
...(cfg.endpoint !== undefined ? { endpoint: cfg.endpoint } : {}),
|
|
306
|
+
});
|
|
307
|
+
case "rekor":
|
|
308
|
+
if (rootDir === undefined) {
|
|
309
|
+
throw new Error("rekor sink requires rootDir");
|
|
310
|
+
}
|
|
311
|
+
return rekorSink(rootDir, cfg.url ?? DEFAULT_REKOR_URL);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// ── Emitter advisory lock (anchor path only — never touches .audit-writer.lock) ──
|
|
315
|
+
/**
|
|
316
|
+
* Lock paths currently held by a LIVE emitter in THIS process. A same-PID
|
|
317
|
+
* lock file is only reclaimable when no live emitter here owns it (i.e. it
|
|
318
|
+
* is a leftover from a crashed prior instance) — otherwise a sibling emitter
|
|
319
|
+
* in the same process would steal a live lock and race the mirror (the
|
|
320
|
+
* exact fork-evidence scenario the lock exists to prevent).
|
|
321
|
+
*/
|
|
322
|
+
const inProcessAnchorLocks = new Set();
|
|
323
|
+
function tryAcquireAnchorLock(dir) {
|
|
324
|
+
const lockPath = join(dir, ".anchor-writer.lock");
|
|
325
|
+
if (inProcessAnchorLocks.has(lockPath))
|
|
326
|
+
return null;
|
|
327
|
+
const content = JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
|
|
328
|
+
const attempt = () => {
|
|
329
|
+
try {
|
|
330
|
+
const fd = openSync(lockPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600);
|
|
331
|
+
try {
|
|
332
|
+
writeSync(fd, content);
|
|
333
|
+
fsyncSync(fd);
|
|
334
|
+
}
|
|
335
|
+
finally {
|
|
336
|
+
closeSync(fd);
|
|
337
|
+
}
|
|
338
|
+
inProcessAnchorLocks.add(lockPath);
|
|
339
|
+
return () => {
|
|
340
|
+
inProcessAnchorLocks.delete(lockPath);
|
|
341
|
+
try {
|
|
342
|
+
unlinkSync(lockPath);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
/* already removed */
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
const first = attempt();
|
|
354
|
+
if (first !== null)
|
|
355
|
+
return first;
|
|
356
|
+
// Stale-lock detection: same liveness check as the audit writer lock.
|
|
357
|
+
try {
|
|
358
|
+
const existing = JSON.parse(readFileSync(lockPath, "utf-8"));
|
|
359
|
+
if (typeof existing.pid === "number" && existing.pid !== process.pid) {
|
|
360
|
+
try {
|
|
361
|
+
process.kill(existing.pid, 0);
|
|
362
|
+
return null; // live holder
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
if (!(err instanceof Error && "code" in err && err.code === "ESRCH")) {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
else if (typeof existing.pid === "number" && existing.pid === process.pid) {
|
|
371
|
+
// Same-PID lock file with no live in-process owner (checked above):
|
|
372
|
+
// leftover from a crashed prior emitter — safe to reclaim.
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
// Corrupt lock file — also reclaimed, but only via the atomic rename
|
|
380
|
+
// below so a concurrent reclaimer cannot double-acquire.
|
|
381
|
+
}
|
|
382
|
+
// ATOMIC reclaim: exactly one contender wins the rename; the loser's
|
|
383
|
+
// rename throws (ENOENT) and backs off. A bare check-then-unlink would let
|
|
384
|
+
// a slow contender delete the winner's freshly created lock (TOCTOU) and
|
|
385
|
+
// admit two emitters — the exact race the lock exists to prevent.
|
|
386
|
+
try {
|
|
387
|
+
const reclaimPath = `${lockPath}.reclaim-${process.pid}-${Date.now()}`;
|
|
388
|
+
renameSync(lockPath, reclaimPath);
|
|
389
|
+
unlinkSync(reclaimPath);
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
return attempt();
|
|
395
|
+
}
|
|
396
|
+
// ── Mirror / outbox helpers ──
|
|
397
|
+
function readMirrorRecords(dir) {
|
|
398
|
+
const mirrorPath = join(dir, "anchors.jsonl");
|
|
399
|
+
if (!existsSync(mirrorPath))
|
|
400
|
+
return { present: false, records: [], errors: [] };
|
|
401
|
+
try {
|
|
402
|
+
const { records, errors } = parseAnchorsContent(readFileSync(mirrorPath, "utf-8"));
|
|
403
|
+
return { present: true, records: records.sort((a, b) => a.anchorSeq - b.anchorSeq), errors };
|
|
404
|
+
}
|
|
405
|
+
catch (err) {
|
|
406
|
+
return {
|
|
407
|
+
present: true,
|
|
408
|
+
records: [],
|
|
409
|
+
errors: [`mirror unreadable: ${err instanceof Error ? err.message : String(err)}`],
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/** Read pending outbox record CONTENTS in anchorSeq order (for redelivery). */
|
|
414
|
+
function pendingOutboxRecords(dir) {
|
|
415
|
+
return outboxSeqs(dir)
|
|
416
|
+
.sort((a, b) => a - b)
|
|
417
|
+
.flatMap((seq) => {
|
|
418
|
+
try {
|
|
419
|
+
const { record } = parseAnchorRecord(readFileSync(outboxPath(dir, seq), "utf-8").trim());
|
|
420
|
+
return record !== null && record.anchorSeq === seq ? [record] : [];
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
return [];
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
function outboxSeqs(dir) {
|
|
428
|
+
const outDir = join(dir, "outbox");
|
|
429
|
+
if (!existsSync(outDir))
|
|
430
|
+
return [];
|
|
431
|
+
try {
|
|
432
|
+
return readdirSync(outDir)
|
|
433
|
+
.filter((f) => f.endsWith(".json"))
|
|
434
|
+
.map((f) => Number.parseInt(f.slice(0, -5), 10))
|
|
435
|
+
.filter((n) => Number.isSafeInteger(n) && n >= 1);
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
return [];
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
function outboxPath(dir, anchorSeq) {
|
|
442
|
+
return join(dir, "outbox", `${String(anchorSeq).padStart(12, "0")}.json`);
|
|
443
|
+
}
|
|
444
|
+
/** Fsync'd outbox write — the record's durable delivery intent. */
|
|
445
|
+
function writeOutboxEntry(dir, record) {
|
|
446
|
+
mkdirSync(join(dir, "outbox"), { recursive: true });
|
|
447
|
+
const fd = openSync(outboxPath(dir, record.anchorSeq), "w", 0o600);
|
|
448
|
+
try {
|
|
449
|
+
writeSync(fd, canonicalize(record));
|
|
450
|
+
fsyncSync(fd);
|
|
451
|
+
}
|
|
452
|
+
finally {
|
|
453
|
+
closeSync(fd);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
function readMeta(rootDir) {
|
|
457
|
+
const metaPath = join(rootDir, VAULT_DIR, "audit", "events.jsonl.meta");
|
|
458
|
+
if (!existsSync(metaPath))
|
|
459
|
+
return "absent";
|
|
460
|
+
try {
|
|
461
|
+
const raw = readFileSync(metaPath, "utf-8");
|
|
462
|
+
if (raw.trim() === "")
|
|
463
|
+
return "unreadable";
|
|
464
|
+
const parsed = JSON.parse(raw);
|
|
465
|
+
if (typeof parsed.lastHash === "string" && typeof parsed.sequence === "number") {
|
|
466
|
+
return { lastHash: parsed.lastHash, sequence: parsed.sequence };
|
|
467
|
+
}
|
|
468
|
+
return "unreadable";
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
return "unreadable";
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// ── Emitter ──
|
|
475
|
+
export function createAnchorEmitter(rootDir, config) {
|
|
476
|
+
const vaultPath = join(rootDir, VAULT_DIR);
|
|
477
|
+
const dir = anchorsDir(rootDir);
|
|
478
|
+
const maybeIdentity = readAnchorIdentity(rootDir);
|
|
479
|
+
if (maybeIdentity === null) {
|
|
480
|
+
throw new Error("No anchor identity — run `usertrust anchor init` first");
|
|
481
|
+
}
|
|
482
|
+
// Non-null binding usable inside closures (control-flow narrowing of the
|
|
483
|
+
// original is lost across the async emit() closure below).
|
|
484
|
+
const identity = maybeIdentity;
|
|
485
|
+
const vaultId = identity.vaultId;
|
|
486
|
+
const signer = resolveSigner(config.signer, vaultId);
|
|
487
|
+
const sinks = (config.sinks ?? []).map((sink) => createSink(sink, rootDir));
|
|
488
|
+
const retries = config.publishRetries ?? 5;
|
|
489
|
+
let anchorSkips = 0;
|
|
490
|
+
let publishFailures = 0;
|
|
491
|
+
let degraded = false;
|
|
492
|
+
let lastEmitError = null;
|
|
493
|
+
let timer = null;
|
|
494
|
+
const inFlight = new Set();
|
|
495
|
+
/** Mirror append: the canonical bytes the record hash covers, fsync'd. */
|
|
496
|
+
function appendToMirror(record) {
|
|
497
|
+
const fd = openSync(join(dir, "anchors.jsonl"), "a", 0o600);
|
|
498
|
+
try {
|
|
499
|
+
writeSync(fd, `${canonicalize(record)}\n`);
|
|
500
|
+
fsyncSync(fd);
|
|
501
|
+
}
|
|
502
|
+
finally {
|
|
503
|
+
closeSync(fd);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
// Serialize publish cycles so an overlapping cycle cannot interleave the
|
|
507
|
+
// outbox drain and double-publish a record.
|
|
508
|
+
let publishChain = Promise.resolve();
|
|
509
|
+
async function deliverToSinks(record) {
|
|
510
|
+
let lastErr = null;
|
|
511
|
+
for (const sink of sinks) {
|
|
512
|
+
let delivered = false;
|
|
513
|
+
for (let attempt = 0; attempt < retries && !delivered; attempt++) {
|
|
514
|
+
try {
|
|
515
|
+
await sink.publish(record);
|
|
516
|
+
delivered = true;
|
|
517
|
+
}
|
|
518
|
+
catch (err) {
|
|
519
|
+
lastErr = err;
|
|
520
|
+
publishFailures++;
|
|
521
|
+
const backoff = Math.min(30_000, 250 * 2 ** attempt) * (0.5 + Math.random());
|
|
522
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (!delivered) {
|
|
526
|
+
degraded = true;
|
|
527
|
+
throw lastErr instanceof Error
|
|
528
|
+
? lastErr
|
|
529
|
+
: new Error(`anchor publish failed to sink ${sink.name}`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
async function publishRecord(record) {
|
|
534
|
+
if (sinks.length === 0) {
|
|
535
|
+
// Pull mode (spec §5.5): mirror-only; a customer job ships records
|
|
536
|
+
// via `usertrust anchor export`. With no sinks there is nothing to
|
|
537
|
+
// deliver, so the outbox entry is not needed.
|
|
538
|
+
try {
|
|
539
|
+
unlinkSync(outboxPath(dir, record.anchorSeq));
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
/* best effort */
|
|
543
|
+
}
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
// Drain the backlog oldest-first BEFORE (and including) the current
|
|
547
|
+
// record so a transient sink outage cannot leave a permanent
|
|
548
|
+
// anchorSeq gap in the append-only store (spec §5.3). Stop on the
|
|
549
|
+
// first failure to preserve store ordering; the emitter stays degraded
|
|
550
|
+
// until the whole backlog clears.
|
|
551
|
+
const backlog = pendingOutboxRecords(dir);
|
|
552
|
+
const queue = backlog.some((r) => r.anchorSeq === record.anchorSeq)
|
|
553
|
+
? backlog
|
|
554
|
+
: [...backlog, record].sort((a, b) => a.anchorSeq - b.anchorSeq);
|
|
555
|
+
for (const rec of queue) {
|
|
556
|
+
await deliverToSinks(rec);
|
|
557
|
+
try {
|
|
558
|
+
unlinkSync(outboxPath(dir, rec.anchorSeq));
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
/* best effort */
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
// Clear degraded only when nothing is left undelivered.
|
|
565
|
+
if (outboxSeqs(dir).length === 0)
|
|
566
|
+
degraded = false;
|
|
567
|
+
}
|
|
568
|
+
function trackPublish(record) {
|
|
569
|
+
const p = publishChain
|
|
570
|
+
.then(() => publishRecord(record))
|
|
571
|
+
.catch(() => {
|
|
572
|
+
/* failure already counted; outbox entry retained for retry */
|
|
573
|
+
})
|
|
574
|
+
.finally(() => {
|
|
575
|
+
inFlight.delete(p);
|
|
576
|
+
});
|
|
577
|
+
publishChain = p;
|
|
578
|
+
inFlight.add(p);
|
|
579
|
+
return p;
|
|
580
|
+
}
|
|
581
|
+
async function snapshotHead() {
|
|
582
|
+
for (let attempt = 0; attempt < SNAPSHOT_RETRIES; attempt++) {
|
|
583
|
+
const meta = readMeta(rootDir);
|
|
584
|
+
if (meta === "unreadable") {
|
|
585
|
+
// .meta is truncate-rewritten in place; a cross-process reader
|
|
586
|
+
// can catch it mid-write. Retry, then skip the cycle.
|
|
587
|
+
await new Promise((r) => setTimeout(r, 25 * (attempt + 1)));
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
const hashes = gatherOrderedEventHashes(vaultPath);
|
|
591
|
+
if (meta === "absent") {
|
|
592
|
+
// Legacy vault: fall back to the ordered segment tail.
|
|
593
|
+
if (hashes.length === 0)
|
|
594
|
+
return { skip: "empty" };
|
|
595
|
+
return {
|
|
596
|
+
sequence: hashes.length,
|
|
597
|
+
lastHash: hashes[hashes.length - 1],
|
|
598
|
+
hashes,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
if (meta.sequence === 0 || hashes.length === 0)
|
|
602
|
+
return { skip: "empty" };
|
|
603
|
+
// Guard against a torn read of the tail line during a concurrent
|
|
604
|
+
// append: the prefix [0..sequence) is stable, the tail may not be.
|
|
605
|
+
if (hashes.length >= meta.sequence && hashes[meta.sequence - 1] === meta.lastHash) {
|
|
606
|
+
return { sequence: meta.sequence, lastHash: meta.lastHash, hashes };
|
|
607
|
+
}
|
|
608
|
+
await new Promise((r) => setTimeout(r, 25 * (attempt + 1)));
|
|
609
|
+
}
|
|
610
|
+
return { skip: "snapshot-unstable" };
|
|
611
|
+
}
|
|
612
|
+
async function emit(rotation) {
|
|
613
|
+
const release = tryAcquireAnchorLock(dir);
|
|
614
|
+
if (release === null) {
|
|
615
|
+
anchorSkips++;
|
|
616
|
+
return { emitted: false, reason: "locked" };
|
|
617
|
+
}
|
|
618
|
+
try {
|
|
619
|
+
// Re-read identity fresh: the durable high-water is bumped on disk
|
|
620
|
+
// each emit, so the creation-time closure copy is stale.
|
|
621
|
+
const liveIdentity = readAnchorIdentity(rootDir) ?? identity;
|
|
622
|
+
const snap = await snapshotHead();
|
|
623
|
+
if ("skip" in snap) {
|
|
624
|
+
if (snap.skip !== "empty")
|
|
625
|
+
anchorSkips++;
|
|
626
|
+
return { emitted: false, reason: snap.skip };
|
|
627
|
+
}
|
|
628
|
+
const mirror = readMirrorRecords(dir);
|
|
629
|
+
if (!mirror.present) {
|
|
630
|
+
// Mirror FILE missing while an identity exists: accidental loss
|
|
631
|
+
// or partial restore. Refusing (loudly) beats silently minting a
|
|
632
|
+
// second GENESIS-linked record — permanent fork evidence in an
|
|
633
|
+
// append-only store. `usertrust anchor resume` re-seeds it.
|
|
634
|
+
degraded = true;
|
|
635
|
+
return { emitted: false, reason: "mirror-missing (run `usertrust anchor resume`)" };
|
|
636
|
+
}
|
|
637
|
+
if (mirror.errors.length > 0) {
|
|
638
|
+
// Corrupt-but-present mirror: same accident class as
|
|
639
|
+
// mirror-missing (disk fault / partial restore / torn tail). An
|
|
640
|
+
// anchorSeq allocated from a partial view can re-occupy a
|
|
641
|
+
// published position with divergent content — permanent fork
|
|
642
|
+
// evidence (spec §5.1). Repair/move the mirror aside first.
|
|
643
|
+
degraded = true;
|
|
644
|
+
return {
|
|
645
|
+
emitted: false,
|
|
646
|
+
reason: "mirror-corrupt (repair the mirror, then `usertrust anchor resume`)",
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
let tail = mirror.records.at(-1) ?? null;
|
|
650
|
+
// Self-heal: a crash between the outbox write and the mirror append
|
|
651
|
+
// leaves a fully minted record in the outbox that the mirror lacks.
|
|
652
|
+
// Re-append it (validated: ours, signed by our key, contiguous)
|
|
653
|
+
// instead of wedging or re-minting the seq — re-minting would
|
|
654
|
+
// publish divergent content at an occupied position (permanent fork
|
|
655
|
+
// evidence in the append-only store).
|
|
656
|
+
for (const orphan of pendingOutboxRecords(dir).filter((o) => o.anchorSeq > (mirror.records.at(-1)?.anchorSeq ?? 0))) {
|
|
657
|
+
const expectedPrev = tail === null ? GENESIS_HASH : anchorPayloadHash(tail);
|
|
658
|
+
const idKey = publicKeyFromSpkiBase64(liveIdentity.publicKeySpki);
|
|
659
|
+
const orphanOk = orphan.vaultId === vaultId &&
|
|
660
|
+
orphan.anchorSeq === (tail?.anchorSeq ?? 0) + 1 &&
|
|
661
|
+
orphan.prevAnchorHash === expectedPrev &&
|
|
662
|
+
idKey !== null &&
|
|
663
|
+
verifySignatureRaw("ed25519", anchorSigningPreimage(orphan), idKey, orphan.sig);
|
|
664
|
+
if (!orphanOk) {
|
|
665
|
+
degraded = true;
|
|
666
|
+
return {
|
|
667
|
+
emitted: false,
|
|
668
|
+
reason: "outbox-orphan-invalid (inspect audit/anchors/outbox, then `usertrust anchor resume`)",
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
appendToMirror(orphan);
|
|
672
|
+
bumpAnchorHighWater(rootDir, orphan.anchorSeq);
|
|
673
|
+
tail = orphan;
|
|
674
|
+
}
|
|
675
|
+
// Durable high-water lives outside the mirror: an emptied mirror
|
|
676
|
+
// (tail null) with a prior anchor recorded must NOT restart at 1.
|
|
677
|
+
const durableHighWater = liveIdentity.lastAnchorSeq ?? 0;
|
|
678
|
+
const highWater = Math.max(tail?.anchorSeq ?? 0, durableHighWater, ...outboxSeqs(dir), 0);
|
|
679
|
+
if ((tail?.anchorSeq ?? 0) < highWater) {
|
|
680
|
+
degraded = true;
|
|
681
|
+
return {
|
|
682
|
+
emitted: false,
|
|
683
|
+
reason: "mirror-behind-highwater (run `usertrust anchor resume`)",
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
// Epoch guard: the resolved signer MUST be the chain's current key.
|
|
687
|
+
// A superseded key (e.g. the pre-rotation PEM still at the default
|
|
688
|
+
// path after `anchor rotate`) would mint permanent
|
|
689
|
+
// rotation-continuity MISMATCH evidence in an append-only store.
|
|
690
|
+
const expectedKeyId = tail?.rotation?.nextKeyId ?? tail?.keyId ?? liveIdentity.keyId;
|
|
691
|
+
if (signer.keyId !== expectedKeyId) {
|
|
692
|
+
degraded = true;
|
|
693
|
+
return {
|
|
694
|
+
emitted: false,
|
|
695
|
+
reason: `stale-signer-key: signer ${signer.keyId} is not the current epoch key ${expectedKeyId} — point USERTRUST_ANCHOR_KEY/--key-file at the post-rotation private key`,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
// Vault-behind guard (symmetric to mirror-behind): an events head
|
|
699
|
+
// BELOW the anchored treeSize means the event log was rolled back
|
|
700
|
+
// (partial restore) — emitting would publish a decreasing-treeSize
|
|
701
|
+
// anchor: permanent rollback evidence from an honest accident.
|
|
702
|
+
if (tail !== null && snap.sequence < tail.treeSize) {
|
|
703
|
+
degraded = true;
|
|
704
|
+
return {
|
|
705
|
+
emitted: false,
|
|
706
|
+
reason: `vault-behind-anchors: events head ${snap.sequence} < anchored treeSize ${tail.treeSize} — restore the newer events before anchoring`,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
if (tail !== null && tail.treeSize === snap.sequence && rotation === undefined) {
|
|
710
|
+
return { emitted: false, reason: "no-new-events" };
|
|
711
|
+
}
|
|
712
|
+
const tree = buildMerkleTree(snap.hashes.slice(0, snap.sequence));
|
|
713
|
+
if (tree.root === undefined) {
|
|
714
|
+
return { emitted: false, reason: "empty" };
|
|
715
|
+
}
|
|
716
|
+
const unsigned = {
|
|
717
|
+
v: 1,
|
|
718
|
+
vaultId,
|
|
719
|
+
anchorSeq: highWater + 1,
|
|
720
|
+
prevAnchorHash: tail === null ? GENESIS_HASH : anchorPayloadHash(tail),
|
|
721
|
+
treeSize: snap.sequence,
|
|
722
|
+
lastHash: snap.lastHash,
|
|
723
|
+
merkleRoot: tree.root,
|
|
724
|
+
timestamp: new Date().toISOString(),
|
|
725
|
+
keyId: signer.keyId,
|
|
726
|
+
...(rotation !== undefined ? { rotation } : {}),
|
|
727
|
+
};
|
|
728
|
+
const sig = await signer.sign(canonicalize(unsigned));
|
|
729
|
+
const record = { ...unsigned, sig };
|
|
730
|
+
const check = parseAnchorRecord(canonicalize(record));
|
|
731
|
+
if (check.record === null) {
|
|
732
|
+
throw new Error(`emitter produced an invalid record: ${check.error}`);
|
|
733
|
+
}
|
|
734
|
+
// Durability order: outbox FIRST (fsync'd delivery intent), then the
|
|
735
|
+
// mirror append, then the high-water bump. A crash after only the
|
|
736
|
+
// outbox write is self-healed above (orphan re-appended to the
|
|
737
|
+
// mirror); the reverse order would strand a mirrored-but-never-
|
|
738
|
+
// published record — a permanent anchor-chain gap in the store that
|
|
739
|
+
// verifies as tampering.
|
|
740
|
+
writeOutboxEntry(dir, record);
|
|
741
|
+
appendToMirror(record);
|
|
742
|
+
// High-water AFTER the mirror append is fsync'd, so a crash leaves
|
|
743
|
+
// high-water ≤ mirror tail (never ahead of what exists).
|
|
744
|
+
bumpAnchorHighWater(rootDir, record.anchorSeq);
|
|
745
|
+
trackPublish(record);
|
|
746
|
+
return { emitted: true, record };
|
|
747
|
+
}
|
|
748
|
+
finally {
|
|
749
|
+
release();
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
function status() {
|
|
753
|
+
const mirror = readMirrorRecords(dir);
|
|
754
|
+
const tail = mirror.records.at(-1) ?? null;
|
|
755
|
+
const meta = readMeta(rootDir);
|
|
756
|
+
const headSeq = typeof meta === "object" ? meta.sequence : null;
|
|
757
|
+
const ts = tail === null ? Number.NaN : Date.parse(tail.timestamp);
|
|
758
|
+
return {
|
|
759
|
+
lastAnchor: tail === null
|
|
760
|
+
? null
|
|
761
|
+
: { anchorSeq: tail.anchorSeq, treeSize: tail.treeSize, timestamp: tail.timestamp },
|
|
762
|
+
eventsSinceLastAnchor: headSeq === null ? 0 : Math.max(0, headSeq - (tail?.treeSize ?? 0)),
|
|
763
|
+
msSinceLastAnchor: Number.isFinite(ts) ? Math.max(0, Date.now() - ts) : null,
|
|
764
|
+
outboxDepth: outboxSeqs(dir).length,
|
|
765
|
+
anchorSkips,
|
|
766
|
+
publishFailures,
|
|
767
|
+
degraded,
|
|
768
|
+
lastEmitError,
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
return {
|
|
772
|
+
anchorNow: () => emit(),
|
|
773
|
+
rotate: (next) => emit({ nextKeyId: next.keyId, nextPublicKeySpki: next.publicKeySpki }),
|
|
774
|
+
status,
|
|
775
|
+
start() {
|
|
776
|
+
if (timer !== null)
|
|
777
|
+
return;
|
|
778
|
+
const everyMs = config.cadence?.everyMs ?? DEFAULT_ANCHOR_EVERY_MS;
|
|
779
|
+
const everyEvents = config.cadence?.everyEvents ?? DEFAULT_ANCHOR_EVERY_EVENTS;
|
|
780
|
+
let lastAnchorAt = Date.now();
|
|
781
|
+
const tick = () => {
|
|
782
|
+
const meta = readMeta(rootDir);
|
|
783
|
+
const headSeq = typeof meta === "object" ? meta.sequence : 0;
|
|
784
|
+
const mirror = readMirrorRecords(dir);
|
|
785
|
+
const anchored = mirror.records.at(-1)?.treeSize ?? 0;
|
|
786
|
+
const due = headSeq - anchored >= everyEvents ||
|
|
787
|
+
(headSeq > anchored && Date.now() - lastAnchorAt >= everyMs);
|
|
788
|
+
if (due) {
|
|
789
|
+
lastAnchorAt = Date.now();
|
|
790
|
+
// A scheduler-driven emit() must never reject into the void:
|
|
791
|
+
// an unhandled rejection (KMS signer outage, ENOSPC on the
|
|
792
|
+
// mirror, self-check throw) would crash the host process the
|
|
793
|
+
// emitter is embedded in. Capture it as a loud, inspectable
|
|
794
|
+
// degraded signal instead (spec §5.2/constraints §4.4).
|
|
795
|
+
emit()
|
|
796
|
+
.then((r) => {
|
|
797
|
+
if (r.emitted)
|
|
798
|
+
lastEmitError = null;
|
|
799
|
+
})
|
|
800
|
+
.catch((err) => {
|
|
801
|
+
anchorSkips++;
|
|
802
|
+
degraded = true;
|
|
803
|
+
lastEmitError = err instanceof Error ? err.message : String(err);
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
};
|
|
807
|
+
// Check at a fraction of the interval so the everyEvents trigger
|
|
808
|
+
// fires promptly; unref so the scheduler never holds the process.
|
|
809
|
+
timer = setInterval(tick, Math.max(250, Math.min(everyMs, 5_000)));
|
|
810
|
+
timer.unref?.();
|
|
811
|
+
},
|
|
812
|
+
async stop() {
|
|
813
|
+
if (timer !== null) {
|
|
814
|
+
clearInterval(timer);
|
|
815
|
+
timer = null;
|
|
816
|
+
}
|
|
817
|
+
await Promise.allSettled([...inFlight]);
|
|
818
|
+
},
|
|
819
|
+
exportSince(since) {
|
|
820
|
+
return readMirrorRecords(dir).records.filter((r) => r.anchorSeq > since);
|
|
821
|
+
},
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Re-seed a lost mirror from the store's newest record (spec §5.1 step 4).
|
|
826
|
+
* The record must belong to this vault's identity; it becomes the mirror
|
|
827
|
+
* tail so the next emission allocates anchorSeq correctly instead of minting
|
|
828
|
+
* a second GENESIS-linked chain.
|
|
829
|
+
*/
|
|
830
|
+
export function resumeAnchorMirror(rootDir, latestRecordRaw) {
|
|
831
|
+
const identity = readAnchorIdentity(rootDir);
|
|
832
|
+
if (identity === null) {
|
|
833
|
+
throw new Error("No anchor identity — run `usertrust anchor init` first");
|
|
834
|
+
}
|
|
835
|
+
const { record, error } = parseAnchorRecord(latestRecordRaw.trim());
|
|
836
|
+
if (record === null) {
|
|
837
|
+
throw new Error(`resume: supplied record is invalid: ${error}`);
|
|
838
|
+
}
|
|
839
|
+
if (record.vaultId !== identity.vaultId) {
|
|
840
|
+
throw new Error(`resume: record vaultId ${record.vaultId} does not match this vault (${identity.vaultId})`);
|
|
841
|
+
}
|
|
842
|
+
// The record becomes the trusted mirror tail (next emission links its
|
|
843
|
+
// prevAnchorHash to it), so it MUST be provably ours: signed under the
|
|
844
|
+
// CURRENT identity key, or a rotation handoff INTO the current key.
|
|
845
|
+
// vaultId alone is public (printed by `anchor status`) — accepting any
|
|
846
|
+
// well-formed record would let a stale/forged file seed a fork.
|
|
847
|
+
const idKey = publicKeyFromSpkiBase64(identity.publicKeySpki);
|
|
848
|
+
const sigOk = idKey !== null &&
|
|
849
|
+
record.keyId === identity.keyId &&
|
|
850
|
+
verifySignatureRaw("ed25519", anchorSigningPreimage(record), idKey, record.sig);
|
|
851
|
+
const handoffOk = record.rotation !== undefined && record.rotation.nextKeyId === identity.keyId;
|
|
852
|
+
if (!sigOk && !handoffOk) {
|
|
853
|
+
throw new Error("resume: record does not verify under this vault's current anchor key — fetch the STORE'S newest record");
|
|
854
|
+
}
|
|
855
|
+
if ((identity.lastAnchorSeq ?? 0) > record.anchorSeq) {
|
|
856
|
+
throw new Error(`resume: durable high-water ${identity.lastAnchorSeq} exceeds the supplied record (${record.anchorSeq}) — fetch the store's newest record`);
|
|
857
|
+
}
|
|
858
|
+
const dir = anchorsDir(rootDir);
|
|
859
|
+
const mirror = readMirrorRecords(dir);
|
|
860
|
+
const tail = mirror.records.at(-1);
|
|
861
|
+
if (tail !== undefined && tail.anchorSeq >= record.anchorSeq) {
|
|
862
|
+
throw new Error(`resume: mirror already at anchorSeq ${tail.anchorSeq} (supplied ${record.anchorSeq})`);
|
|
863
|
+
}
|
|
864
|
+
mkdirSync(dir, { recursive: true });
|
|
865
|
+
const fd = openSync(join(dir, "anchors.jsonl"), "a", 0o600);
|
|
866
|
+
try {
|
|
867
|
+
writeSync(fd, `${canonicalize(record)}\n`);
|
|
868
|
+
fsyncSync(fd);
|
|
869
|
+
}
|
|
870
|
+
finally {
|
|
871
|
+
closeSync(fd);
|
|
872
|
+
}
|
|
873
|
+
// Re-seeding advances the durable high-water so the next emission allocates
|
|
874
|
+
// from the re-seeded tail, not from a stale value.
|
|
875
|
+
bumpAnchorHighWater(rootDir, record.anchorSeq);
|
|
876
|
+
return record;
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Mint a successor keypair for `usertrust anchor rotate` (spec §6): the new
|
|
880
|
+
* private key is written OUTSIDE the vault; the caller emits the cross-signed
|
|
881
|
+
* rotation record via AnchorEmitter.rotate() and then updates identity.json.
|
|
882
|
+
*/
|
|
883
|
+
export function mintSuccessorKey(vaultId) {
|
|
884
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
885
|
+
const keyId = keyIdFromKeyObject(publicKey);
|
|
886
|
+
const keyFile = join(homedir(), ".usertrust", "keys", `${vaultId}.anchor.${Date.now()}.pem`);
|
|
887
|
+
mkdirSync(join(keyFile, ".."), { recursive: true, mode: 0o700 });
|
|
888
|
+
writeFileSync(keyFile, privateKey.export({ type: "pkcs8", format: "pem" }), {
|
|
889
|
+
mode: 0o600,
|
|
890
|
+
});
|
|
891
|
+
return {
|
|
892
|
+
keyId,
|
|
893
|
+
publicKeyPem: publicKey.export({ type: "spki", format: "pem" }),
|
|
894
|
+
publicKeySpki: publicKey.export({ type: "spki", format: "der" }).toString("base64"),
|
|
895
|
+
keyFile,
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Update identity.json after a successful rotation emission. The superseded key
|
|
900
|
+
* is APPENDED to the history rather than replaced: records signed under it may
|
|
901
|
+
* still be sitting in the outbox, and a witness sink must be able to name the
|
|
902
|
+
* key that actually signed each one long after the epoch moved on.
|
|
903
|
+
*/
|
|
904
|
+
export function recordRotatedIdentity(rootDir, next) {
|
|
905
|
+
const identity = readAnchorIdentity(rootDir);
|
|
906
|
+
if (identity === null) {
|
|
907
|
+
throw new Error("No anchor identity to rotate");
|
|
908
|
+
}
|
|
909
|
+
// An identity minted before key history seeds one from its current epoch, so
|
|
910
|
+
// the pre-rotation key is not lost by upgrading mid-life.
|
|
911
|
+
const history = identity.keyHistory ?? [
|
|
912
|
+
{ keyId: identity.keyId, publicKeySpki: identity.publicKeySpki },
|
|
913
|
+
];
|
|
914
|
+
const updated = {
|
|
915
|
+
...identity,
|
|
916
|
+
keyId: next.keyId,
|
|
917
|
+
publicKeySpki: next.publicKeySpki,
|
|
918
|
+
keyHistory: [...history.filter((entry) => entry.keyId !== next.keyId), next],
|
|
919
|
+
};
|
|
920
|
+
writeIdentityFile(rootDir, updated);
|
|
921
|
+
}
|
|
922
|
+
//# sourceMappingURL=anchor.js.map
|