@appforge-ci/core 0.3.0 → 0.3.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/dist/agent-readiness.d.ts +11 -3
- package/dist/agent-readiness.d.ts.map +1 -1
- package/dist/agent-readiness.js +15 -5
- package/dist/agent-readiness.js.map +1 -1
- package/dist/agent-readiness.test.js +34 -10
- package/dist/agent-readiness.test.js.map +1 -1
- package/dist/api-client.d.ts +39 -1
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +52 -0
- package/dist/api-client.js.map +1 -1
- package/dist/appforge-verify.test.d.ts +2 -0
- package/dist/appforge-verify.test.d.ts.map +1 -0
- package/dist/appforge-verify.test.js +958 -0
- package/dist/appforge-verify.test.js.map +1 -0
- package/dist/attestation-keys.d.ts +20 -0
- package/dist/attestation-keys.d.ts.map +1 -0
- package/dist/attestation-keys.js +27 -0
- package/dist/attestation-keys.js.map +1 -0
- package/dist/attestation-keys.test.d.ts +2 -0
- package/dist/attestation-keys.test.d.ts.map +1 -0
- package/dist/attestation-keys.test.js +45 -0
- package/dist/attestation-keys.test.js.map +1 -0
- package/dist/attestation-signing.d.ts +226 -0
- package/dist/attestation-signing.d.ts.map +1 -0
- package/dist/attestation-signing.js +276 -0
- package/dist/attestation-signing.js.map +1 -0
- package/dist/attestation-signing.test.d.ts +2 -0
- package/dist/attestation-signing.test.d.ts.map +1 -0
- package/dist/attestation-signing.test.js +250 -0
- package/dist/attestation-signing.test.js.map +1 -0
- package/dist/attestation-workerd.test.d.ts +2 -0
- package/dist/attestation-workerd.test.d.ts.map +1 -0
- package/dist/attestation-workerd.test.js +183 -0
- package/dist/attestation-workerd.test.js.map +1 -0
- package/dist/attestation.d.ts +169 -22
- package/dist/attestation.d.ts.map +1 -1
- package/dist/attestation.js +42 -16
- package/dist/attestation.js.map +1 -1
- package/dist/build-vpn.d.ts +11 -1
- package/dist/build-vpn.d.ts.map +1 -1
- package/dist/build-vpn.js +27 -0
- package/dist/build-vpn.js.map +1 -1
- package/dist/fleet-vpn.d.ts +64 -0
- package/dist/fleet-vpn.d.ts.map +1 -0
- package/dist/fleet-vpn.js +88 -0
- package/dist/fleet-vpn.js.map +1 -0
- package/dist/fleet-vpn.test.d.ts +2 -0
- package/dist/fleet-vpn.test.d.ts.map +1 -0
- package/dist/fleet-vpn.test.js +69 -0
- package/dist/fleet-vpn.test.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/schemas-repo-url.test.d.ts +2 -0
- package/dist/schemas-repo-url.test.d.ts.map +1 -0
- package/dist/schemas-repo-url.test.js +67 -0
- package/dist/schemas-repo-url.test.js.map +1 -0
- package/dist/schemas.d.ts +18 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +56 -10
- package/dist/schemas.js.map +1 -1
- package/dist/ssh-deploy-key.d.ts +59 -0
- package/dist/ssh-deploy-key.d.ts.map +1 -0
- package/dist/ssh-deploy-key.js +124 -0
- package/dist/ssh-deploy-key.js.map +1 -0
- package/dist/ssh-deploy-key.test.d.ts +2 -0
- package/dist/ssh-deploy-key.test.d.ts.map +1 -0
- package/dist/ssh-deploy-key.test.js +117 -0
- package/dist/ssh-deploy-key.test.js.map +1 -0
- package/dist/types.d.ts +57 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -5
- package/dist/types.js.map +1 -1
- package/dist/vpn-attestation-workerd.test.d.ts +2 -0
- package/dist/vpn-attestation-workerd.test.d.ts.map +1 -0
- package/dist/vpn-attestation-workerd.test.js +184 -0
- package/dist/vpn-attestation-workerd.test.js.map +1 -0
- package/dist/vpn-attestation.d.ts +1015 -0
- package/dist/vpn-attestation.d.ts.map +1 -0
- package/dist/vpn-attestation.js +963 -0
- package/dist/vpn-attestation.js.map +1 -0
- package/dist/vpn-attestation.test.d.ts +2 -0
- package/dist/vpn-attestation.test.d.ts.map +1 -0
- package/dist/vpn-attestation.test.js +613 -0
- package/dist/vpn-attestation.test.js.map +1 -0
- package/dist/vpn-egress-presets.d.ts +1 -1
- package/dist/vpn-egress-presets.d.ts.map +1 -1
- package/dist/vpn-egress-presets.js +2 -2
- package/dist/vpn-egress-presets.js.map +1 -1
- package/dist/vpn-sandbox.d.ts +67 -0
- package/dist/vpn-sandbox.d.ts.map +1 -0
- package/dist/vpn-sandbox.js +22 -0
- package/dist/vpn-sandbox.js.map +1 -0
- package/dist/vpn-sandbox.test.d.ts +2 -0
- package/dist/vpn-sandbox.test.d.ts.map +1 -0
- package/dist/vpn-sandbox.test.js +34 -0
- package/dist/vpn-sandbox.test.js.map +1 -0
- package/dist/vpn-ssh.d.ts +93 -0
- package/dist/vpn-ssh.d.ts.map +1 -1
- package/dist/vpn-ssh.js +213 -1
- package/dist/vpn-ssh.js.map +1 -1
- package/dist/vpn-ssh.test.js +200 -1
- package/dist/vpn-ssh.test.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,958 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync } from "node:crypto";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { auditCheckpointStatement, auditEntryHash, auditGenesisHash, bytesToBase64, canonicalJson, createEd25519Signer, ed25519KeyId, ed25519SeedToPkcs8, sha256Hex, signAuditCheckpoint, } from "./attestation.js";
|
|
10
|
+
import { ATTESTATION_DOMAIN, signWithDomain } from "./attestation-signing.js";
|
|
11
|
+
/**
|
|
12
|
+
* Tests of `scripts/appforge-verify.mjs`, the zero-dependency offline verifier.
|
|
13
|
+
*
|
|
14
|
+
* The fixtures here are written with this package's WebCrypto signer and
|
|
15
|
+
* hashing (`packages/core/src/attestation*.ts`); the verifier uses
|
|
16
|
+
* `node:crypto` and its own canonicaliser. So every passing case is also a
|
|
17
|
+
* cross-implementation check of what the server writes versus what an
|
|
18
|
+
* auditor's copy of the script reads.
|
|
19
|
+
*
|
|
20
|
+
* A verifier that cannot fail proves nothing, so most of this file is
|
|
21
|
+
* mutations: take evidence that verifies, break exactly one property, and
|
|
22
|
+
* require the verifier to say so. The last test in the file fails the build
|
|
23
|
+
* if any check the verifier performs is never seen failing here.
|
|
24
|
+
*/
|
|
25
|
+
const SCRIPT = fileURLToPath(new URL("../../../scripts/appforge-verify.mjs", import.meta.url));
|
|
26
|
+
const V = (await import(new URL("../../../scripts/appforge-verify.mjs", import.meta.url).href));
|
|
27
|
+
const clone = (o) => JSON.parse(JSON.stringify(o));
|
|
28
|
+
async function testKey(label, over = {}) {
|
|
29
|
+
const pkcs8 = ed25519SeedToPkcs8(new Uint8Array(createHash("sha256").update(`appforge-verify-test:${label}`).digest()));
|
|
30
|
+
const priv = createPrivateKey({ key: Buffer.from(pkcs8), format: "der", type: "pkcs8" });
|
|
31
|
+
const pub = createPublicKey(priv);
|
|
32
|
+
const raw = new Uint8Array(pub.export({ format: "der", type: "spki" }).subarray(-32));
|
|
33
|
+
const kid = await ed25519KeyId(raw);
|
|
34
|
+
return {
|
|
35
|
+
signer: await createEd25519Signer(kid, pkcs8),
|
|
36
|
+
kid,
|
|
37
|
+
entry: { kid, alg: "Ed25519", publicKey: bytesToBase64(raw), notBefore: "2026-01-01T00:00:00Z", status: "active", ...over },
|
|
38
|
+
pem: pub.export({ format: "pem", type: "spki" }),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const KEY = await testKey("server");
|
|
42
|
+
const OTHER = await testKey("stranger");
|
|
43
|
+
const keyList = [KEY.entry];
|
|
44
|
+
const ORG = "org_acme";
|
|
45
|
+
const at = (n) => `2026-09-${String(10 + (n % 18)).padStart(2, "0")}T10:00:${String(n % 60).padStart(2, "0")}Z`;
|
|
46
|
+
async function appendEntry(chain, orgId, kind, ref, when, data, prevHashOverride) {
|
|
47
|
+
const prev = chain.length ? chain[chain.length - 1] : null;
|
|
48
|
+
const seq = (prev?.seq ?? 0) + 1;
|
|
49
|
+
const prevHash = prevHashOverride ?? prev?.hash ?? (await auditGenesisHash(orgId));
|
|
50
|
+
const payload = { v: 1, seq, orgId, kind, ref, at: when, data };
|
|
51
|
+
const row = { seq, prevHash, hash: await auditEntryHash(prevHash, payload), kind, refId: ref, payload, createdAt: when.replace("T", " ").replace("Z", "") };
|
|
52
|
+
chain.push(row);
|
|
53
|
+
return row;
|
|
54
|
+
}
|
|
55
|
+
const vpnData = (i, over = {}) => ({ type: i === 0 ? "tunnel_created" : ["tunnel_up", "tunnel_down", "lock_acquired"][i % 3], tunnelId: "tun_1", buildId: `bld_${i % 5}`, agentId: "agt_1", detail: null, ...over });
|
|
56
|
+
async function vpnChain(orgId, n, mutate) {
|
|
57
|
+
const chain = [];
|
|
58
|
+
for (let i = 0; i < n; i++) {
|
|
59
|
+
const data = vpnData(i);
|
|
60
|
+
mutate?.(i, data);
|
|
61
|
+
await appendEntry(chain, orgId, "vpn_event", `evt_${orgId}_${i}`, at(i), data);
|
|
62
|
+
}
|
|
63
|
+
return chain;
|
|
64
|
+
}
|
|
65
|
+
async function checkpointFor(key, orgId, row, issuedAt) {
|
|
66
|
+
const stmt = auditCheckpointStatement({ orgId, seq: row.seq, hash: row.hash, issuedAt });
|
|
67
|
+
return { orgId, seq: row.seq, hash: row.hash, issuedAt, keyId: key.kid, signature: await signAuditCheckpoint(key.signer, stmt) };
|
|
68
|
+
}
|
|
69
|
+
// The NDJSON format of GET /orgs/:orgId/vpn/audit/export (the route's own test feeds the real output to this verifier too).
|
|
70
|
+
function exportText(orgId, rows, checkpoints, extra = {}) {
|
|
71
|
+
const lines = [{ type: "header", format: "appforge-audit-export", version: 1, orgId, generatedAt: "2026-10-02T15:00:00Z", ...(extra.chainHeadSeq !== undefined ? { chainHeadSeq: extra.chainHeadSeq } : {}) }];
|
|
72
|
+
for (const r of rows)
|
|
73
|
+
lines.push({ type: "entry", ...r });
|
|
74
|
+
for (const c of checkpoints)
|
|
75
|
+
lines.push({ type: "checkpoint", ...c });
|
|
76
|
+
const last = rows[rows.length - 1];
|
|
77
|
+
lines.push({ type: "trailer", next: extra.next ?? null, entries: rows.length, headSeq: extra.headSeq ?? last.seq, headHash: extra.headHash ?? last.hash });
|
|
78
|
+
return lines.map((l) => JSON.stringify(l)).join("\n") + "\n";
|
|
79
|
+
}
|
|
80
|
+
const parseLines = (text) => text.trim().split("\n").map((l) => JSON.parse(l));
|
|
81
|
+
const joinLines = (lines) => lines.map((l) => JSON.stringify(l)).join("\n") + "\n";
|
|
82
|
+
const keysDoc = () => clone(keyList);
|
|
83
|
+
const run = (text, keys = keysDoc(), opts = {}) => V.verifyExport(text, keys, opts);
|
|
84
|
+
// Evidence used by the export tests: 30 entries, signed checkpoints at 10, 20 and 30.
|
|
85
|
+
const chain30 = await vpnChain(ORG, 30);
|
|
86
|
+
const cp10 = await checkpointFor(KEY, ORG, chain30[9], "2026-10-01T00:00:10Z");
|
|
87
|
+
const cp20 = await checkpointFor(KEY, ORG, chain30[19], "2026-10-01T00:00:20Z");
|
|
88
|
+
const cp30 = await checkpointFor(KEY, ORG, chain30[29], "2026-10-01T00:00:30Z");
|
|
89
|
+
const GOOD = exportText(ORG, chain30, [cp10, cp20, cp30]);
|
|
90
|
+
// ---------------------------------------------------------------- the two implementations agree
|
|
91
|
+
test("the verifier's canonicaliser and hashes agree with packages/core on every value, including the ones both must reject", async () => {
|
|
92
|
+
const values = [
|
|
93
|
+
{ b: 1, a: [true, null, "xé\n\u0001\"\\ 😀"], "é": { z: 0, y: -5 }, A: 9007199254740991 },
|
|
94
|
+
[], {}, "", null, 0, -0, [[[]]], { "": 1, a: { "": [] } }, "
", "😀",
|
|
95
|
+
];
|
|
96
|
+
for (const v of values)
|
|
97
|
+
assert.equal(V.canonicalize(v), canonicalJson(v));
|
|
98
|
+
const sparse = [1, , 3]; // eslint-disable-line no-sparse-arrays
|
|
99
|
+
const rejects = [1.5, NaN, Infinity, undefined, 2 ** 60, { a: undefined }, "\ud800", sparse, new Date(), () => 1, 1n, new Map(), Object.assign(Object.create({ inherited: 1 }), { own: 1 })];
|
|
100
|
+
for (const v of rejects) {
|
|
101
|
+
assert.throws(() => canonicalJson(v), /./, `core must reject ${String(v)}`);
|
|
102
|
+
assert.throws(() => V.canonicalize(v), /./, `verifier must reject ${String(v)}`);
|
|
103
|
+
}
|
|
104
|
+
let deep = 1;
|
|
105
|
+
for (let i = 0; i < 40; i++)
|
|
106
|
+
deep = [deep];
|
|
107
|
+
assert.throws(() => canonicalJson(deep));
|
|
108
|
+
assert.throws(() => V.canonicalize(deep));
|
|
109
|
+
assert.equal(V.genesisHash(ORG), await auditGenesisHash(ORG));
|
|
110
|
+
assert.equal(V.entryHash(chain30[0].prevHash, chain30[0].payload), chain30[0].hash);
|
|
111
|
+
assert.equal(V.sha256hex("abc"), await sha256Hex("abc"));
|
|
112
|
+
assert.equal(V.keyIdOf(Buffer.from(KEY.entry.publicKey, "base64")), KEY.kid);
|
|
113
|
+
assert.deepEqual(V.DOMAIN, { attestation: ATTESTATION_DOMAIN, checkpoint: "appforge-checkpoint-v1\n", entry: "appforge-audit-v1\n" });
|
|
114
|
+
});
|
|
115
|
+
// ---------------------------------------------------------------- audit export: accept
|
|
116
|
+
test("export: a complete, fully signed export verifies (exit 0) and reports what it covered", () => {
|
|
117
|
+
const r = run(GOOD);
|
|
118
|
+
assert.deepEqual(r.problems, []);
|
|
119
|
+
assert.equal(r.exit, 0);
|
|
120
|
+
assert.equal(r.signedThroughSeq, 30);
|
|
121
|
+
assert.equal(r.entries, 30);
|
|
122
|
+
assert.ok(r.checks.every(([, ok]) => ok));
|
|
123
|
+
});
|
|
124
|
+
test("export: pages concatenated, and a slice that starts after pruned history anchored by a checkpoint, verify", async () => {
|
|
125
|
+
// Three pages of ten, as the endpoint would serve them (each page repeats the checkpoint at its anchor).
|
|
126
|
+
const p1 = exportText(ORG, chain30.slice(0, 10), [cp10], { next: { fromSeq: 11 } });
|
|
127
|
+
const p2 = exportText(ORG, chain30.slice(10, 20), [cp10, cp20], { next: { fromSeq: 21 } });
|
|
128
|
+
const p3 = exportText(ORG, chain30.slice(20, 30), [cp20, cp30]);
|
|
129
|
+
const r = run(p1 + p2 + p3);
|
|
130
|
+
assert.deepEqual(r.problems, []);
|
|
131
|
+
assert.equal(r.exit, 0);
|
|
132
|
+
// Only the first page: consistent and signed, but incomplete.
|
|
133
|
+
assert.equal(run(p1).exit, 3);
|
|
134
|
+
assert.match(run(p1).notes.join("\n"), /more pages exist/);
|
|
135
|
+
// A slice from seq 11 whose anchor (seq 10) is a signed checkpoint, as after retention pruned the start.
|
|
136
|
+
const slice = exportText(ORG, chain30.slice(10, 30), [cp10, cp20, cp30]);
|
|
137
|
+
const s = run(slice);
|
|
138
|
+
assert.deepEqual(s.problems, []);
|
|
139
|
+
assert.equal(s.exit, 0);
|
|
140
|
+
assert.equal(s.firstSeq, 11);
|
|
141
|
+
});
|
|
142
|
+
test("export: entries after the newest signed checkpoint are 'incomplete' (3) unless the reader opts in; a file with no signed checkpoint is never 0", async () => {
|
|
143
|
+
const tail = exportText(ORG, chain30, [cp10, cp20]);
|
|
144
|
+
const r = run(tail);
|
|
145
|
+
assert.equal(r.exit, 3);
|
|
146
|
+
assert.equal(r.unsignedTail, 10);
|
|
147
|
+
assert.match(r.notes.join("\n"), /10 entries after the newest signed checkpoint/);
|
|
148
|
+
assert.equal(run(tail, keysDoc(), { allowUnsignedTail: true }).exit, 0);
|
|
149
|
+
// Nothing signed in the slice: opting in to an unsigned tail does not make it authentic.
|
|
150
|
+
const none = exportText(ORG, chain30, []);
|
|
151
|
+
assert.equal(run(none).exit, 3);
|
|
152
|
+
assert.equal(run(none, keysDoc(), { allowUnsignedTail: true }).exit, 3);
|
|
153
|
+
// A slice whose only signed checkpoint is its anchor: the entries themselves are unsigned.
|
|
154
|
+
const anchored = exportText(ORG, chain30.slice(10, 20), [cp10]);
|
|
155
|
+
assert.equal(run(anchored, keysDoc(), { allowUnsignedTail: true }).exit, 3);
|
|
156
|
+
});
|
|
157
|
+
// ---------------------------------------------------------------- audit export: break one property at a time
|
|
158
|
+
function expectFail(name, text, keys = keysDoc(), opts = {}, re) {
|
|
159
|
+
const r = run(text, keys, opts);
|
|
160
|
+
assert.equal(r.exit, 1, `${name}: expected exit 1, got ${r.exit} (${r.problems.join("; ")})`);
|
|
161
|
+
if (re)
|
|
162
|
+
assert.match(r.problems.join("\n"), re, name);
|
|
163
|
+
return r;
|
|
164
|
+
}
|
|
165
|
+
test("export: gaps, reordering, edits, splices and truncation are all caught", async () => {
|
|
166
|
+
const L = () => parseLines(GOOD);
|
|
167
|
+
const entryIdx = (lines, seq) => lines.findIndex((l) => l.type === "entry" && l.seq === seq);
|
|
168
|
+
let lines = L();
|
|
169
|
+
lines.splice(entryIdx(lines, 15), 1);
|
|
170
|
+
expectFail("a removed entry (gap)", joinLines(lines), keysDoc(), {}, /expected seq 15, found 16/);
|
|
171
|
+
lines = L();
|
|
172
|
+
const i14 = entryIdx(lines, 14);
|
|
173
|
+
[lines[i14], lines[i14 + 1]] = [lines[i14 + 1], lines[i14]];
|
|
174
|
+
expectFail("two entries swapped", joinLines(lines), keysDoc(), {}, /expected seq 14/);
|
|
175
|
+
lines = L();
|
|
176
|
+
lines[entryIdx(lines, 5)].payload.data.detail = "edited";
|
|
177
|
+
expectFail("an edited payload", joinLines(lines), keysDoc(), {}, /entry 5 .*hash|entry 5 .*tampered/);
|
|
178
|
+
// Edited AND its own hash recomputed: the next link breaks. At the very end, the checkpoint and trailer do.
|
|
179
|
+
for (const seq of [5, 30]) {
|
|
180
|
+
lines = L();
|
|
181
|
+
const e = lines[entryIdx(lines, seq)];
|
|
182
|
+
e.payload.data.detail = "edited";
|
|
183
|
+
e.hash = V.entryHash(e.prevHash, e.payload);
|
|
184
|
+
expectFail(`an edited payload with a recomputed hash (seq ${seq})`, joinLines(lines));
|
|
185
|
+
}
|
|
186
|
+
lines = L();
|
|
187
|
+
const dup = lines[entryIdx(lines, 7)];
|
|
188
|
+
lines.splice(entryIdx(lines, 8), 0, clone(dup));
|
|
189
|
+
expectFail("a duplicated entry", joinLines(lines), keysDoc(), {}, /expected seq 8, found 7/);
|
|
190
|
+
// Truncation: the last entries removed, with and without the trailer fixed up, with and without the later checkpoint left in.
|
|
191
|
+
lines = L();
|
|
192
|
+
const kept = lines.filter((l) => !(l.type === "entry" && l.seq > 20));
|
|
193
|
+
expectFail("last 10 entries cut, trailer untouched", joinLines(kept), keysDoc(), {}, /trailer head does not match/);
|
|
194
|
+
const fixedTrailer = clone(kept);
|
|
195
|
+
const trailer = fixedTrailer[fixedTrailer.length - 1];
|
|
196
|
+
trailer.headSeq = 20;
|
|
197
|
+
trailer.headHash = chain30[19].hash;
|
|
198
|
+
expectFail("last 10 entries cut and the trailer rewritten (the signed checkpoint at 30 gives it away)", joinLines(fixedTrailer), keysDoc(), {}, /beyond the end of the chain/);
|
|
199
|
+
const stripped = fixedTrailer.filter((l) => !(l.type === "checkpoint" && l.seq > 20));
|
|
200
|
+
const strippedRun = run(joinLines(stripped));
|
|
201
|
+
assert.equal(strippedRun.exit, 0, "a clean prefix with every later trace removed IS a valid shorter export: only a head you pinned earlier can reveal it");
|
|
202
|
+
expectFail("the same cut, caught by the head the customer pinned earlier", joinLines(stripped), keysDoc(), { pinHead: `30:${chain30[29].hash}` }, /pinned head hash is NOT in this chain/);
|
|
203
|
+
expectFail("no trailer", joinLines(L().slice(0, -1)), keysDoc(), {}, /no closing trailer/);
|
|
204
|
+
expectFail("download cut mid-line", GOOD.slice(0, GOOD.length - 40), keysDoc(), {}, /not valid JSON|no closing trailer/);
|
|
205
|
+
expectFail("no header", joinLines(L().slice(1)), keysDoc(), {}, /no valid header/);
|
|
206
|
+
expectFail("no entries", exportText(ORG, [chain30[0]], []).replace(/.*"type":"entry".*\n/, ""), keysDoc(), {}, /no entries/);
|
|
207
|
+
expectFail("a line of an unknown type", joinLines([...L().slice(0, 3), { type: "mystery" }, ...L().slice(3)]), keysDoc(), {}, /unknown line type/);
|
|
208
|
+
expectFail("a garbage line", GOOD.replace("\n", "\nnot json at all\n"), keysDoc(), {}, /not valid JSON/);
|
|
209
|
+
// A missing middle page.
|
|
210
|
+
const p1 = exportText(ORG, chain30.slice(0, 10), [cp10], { next: { fromSeq: 11 } });
|
|
211
|
+
const p3 = exportText(ORG, chain30.slice(20, 30), [cp20, cp30]);
|
|
212
|
+
expectFail("page 2 of 3 missing", p1 + p3, keysDoc(), {}, /expected seq 11, found 21/);
|
|
213
|
+
// Another org's evidence.
|
|
214
|
+
const header = parseLines(GOOD)[0];
|
|
215
|
+
const evil = await vpnChain("org_evil", 30);
|
|
216
|
+
expectFail("another org's whole chain under this org's header", joinLines([header, ...evil.map((r) => ({ type: "entry", ...r })), { type: "trailer", next: null, entries: 30, headSeq: 30, headHash: evil[29].hash }]), keysDoc(), {}, /prevHash does not link/);
|
|
217
|
+
// Even re-linked to this org's genesis, with every hash valid, payloads naming another org are refused.
|
|
218
|
+
const relinked = [];
|
|
219
|
+
await appendEntry(relinked, "org_evil", "vpn_event", "evt_1", at(0), vpnData(0), await auditGenesisHash(ORG));
|
|
220
|
+
for (let i = 1; i < 5; i++)
|
|
221
|
+
await appendEntry(relinked, "org_evil", "vpn_event", `evt_${i}`, at(i), vpnData(i));
|
|
222
|
+
expectFail("another org's entries re-linked to this org's genesis", exportText(ORG, relinked, []), keysDoc(), {}, /payload belongs to another org/);
|
|
223
|
+
// Splice: the first 15 entries of this org then org_evil's 16.. re-linked so every hash is "valid".
|
|
224
|
+
const spliced = chain30.slice(0, 15);
|
|
225
|
+
for (let i = 15; i < 30; i++)
|
|
226
|
+
await appendEntry(spliced, ORG, "vpn_event", `evt_evil_${i}`, at(i), vpnData(i, { detail: "spliced" }));
|
|
227
|
+
expectFail("a re-linked splice, rejected by the signed checkpoints that disagree", exportText(ORG, spliced, [cp10, cp20, cp30]), keysDoc(), {}, /checkpoint@20 does not match the chain/);
|
|
228
|
+
});
|
|
229
|
+
test("export: payload and kind rules fail closed", async () => {
|
|
230
|
+
const rebuild = async (mutate) => {
|
|
231
|
+
const c = chain30.slice(0, 12).map((r) => clone(r));
|
|
232
|
+
await mutate(c);
|
|
233
|
+
return exportText(ORG, c, []);
|
|
234
|
+
};
|
|
235
|
+
// An entry of a kind this verifier does not know is rejected, not skipped (hash and links are all valid).
|
|
236
|
+
const unknownKind = chain30.slice(0, 5);
|
|
237
|
+
await appendEntry(unknownKind, ORG, "mystery", "x", at(5), { a: 1 });
|
|
238
|
+
expectFail("unknown kind", exportText(ORG, unknownKind, []), keysDoc(), {}, /kind "mystery" is not known/);
|
|
239
|
+
// Known kinds that later changes add are accepted.
|
|
240
|
+
const futureKinds = chain30.slice(0, 5);
|
|
241
|
+
await appendEntry(futureKinds, ORG, "attestation", "att_1", at(5), { buildId: "bld_1", docSha256: "ab".repeat(32) });
|
|
242
|
+
await appendEntry(futureKinds, ORG, "gap", "gap_1", at(6), { reason: "agent crashed" });
|
|
243
|
+
assert.equal(run(exportText(ORG, futureKinds, [])).exit, 3, "valid structure, nothing signed -> incomplete, not failed");
|
|
244
|
+
assert.deepEqual(run(exportText(ORG, futureKinds, [])).problems, []);
|
|
245
|
+
// Row columns that disagree with the hashed payload.
|
|
246
|
+
let text = await rebuild(async (c) => { c[3].kind = "attestation"; });
|
|
247
|
+
expectFail("row kind differs from its payload", text, keysDoc(), {}, /disagree with its payload/);
|
|
248
|
+
text = await rebuild(async (c) => { c[3].refId = "other"; });
|
|
249
|
+
expectFail("row refId differs from its payload", text, keysDoc(), {}, /disagree with its payload/);
|
|
250
|
+
// An unexpected field smuggled into a hashed vpn_event.
|
|
251
|
+
const extra = chain30.slice(0, 3);
|
|
252
|
+
await appendEntry(extra, ORG, "vpn_event", "evt_x", at(3), { ...vpnData(3), note: "hidden" });
|
|
253
|
+
expectFail("extra field inside vpn_event data", exportText(ORG, extra, []), keysDoc(), {}, /unknown fields/);
|
|
254
|
+
// A float in a payload cannot be hashed identically everywhere.
|
|
255
|
+
const floats = chain30.slice(0, 3).map((r) => clone(r));
|
|
256
|
+
floats[2].payload.data.detail = 1.5;
|
|
257
|
+
expectFail("float in payload", exportText(ORG, floats, []), keysDoc(), {}, /canonical JSON/);
|
|
258
|
+
// A string payload must be canonical text.
|
|
259
|
+
const stringy = chain30.slice(0, 3).map((r) => clone(r));
|
|
260
|
+
stringy[1].payload = JSON.stringify(stringy[1].payload, null, 1);
|
|
261
|
+
expectFail("non-canonical string payload", exportText(ORG, stringy, []), keysDoc(), {}, /not canonical JSON/);
|
|
262
|
+
const canonicalString = chain30.slice(0, 3).map((r) => clone(r));
|
|
263
|
+
for (const r of canonicalString)
|
|
264
|
+
r.payload = canonicalJson(r.payload);
|
|
265
|
+
assert.deepEqual(run(exportText(ORG, canonicalString, [])).problems, [], "canonical string payloads (as the database stores them) are fine");
|
|
266
|
+
// Malformed hashes.
|
|
267
|
+
const badHash = chain30.slice(0, 3).map((r) => clone(r));
|
|
268
|
+
badHash[2].prevHash = "xyz";
|
|
269
|
+
expectFail("non-hex prevHash", exportText(ORG, badHash, []), keysDoc(), {}, /malformed hash/);
|
|
270
|
+
});
|
|
271
|
+
test("export: checkpoint and key rules", async () => {
|
|
272
|
+
// Untrusted / wrong / empty key sets.
|
|
273
|
+
expectFail("a key set that does not contain the signing key", GOOD, [OTHER.entry], {}, /not in the trusted key set/);
|
|
274
|
+
expectFail("an empty key set", GOOD, [], {}, /key set is empty/);
|
|
275
|
+
expectFail("a key whose kid does not match its public key", GOOD, [{ ...KEY.entry, kid: "0".repeat(16) }], {}, /does not match its public key/);
|
|
276
|
+
expectFail("a malformed key list", GOOD, { nope: true }, {}, /expected a JSON array/);
|
|
277
|
+
expectFail("an unknown field in a key entry", GOOD, [{ ...KEY.entry, extra: 1 }], {}, /unknown field/);
|
|
278
|
+
// Validity windows against each checkpoint's own issuedAt.
|
|
279
|
+
expectFail("key revoked before the checkpoints were issued", GOOD, [{ ...KEY.entry, status: "revoked", revokedAt: "2026-10-01T00:00:00Z" }], {}, /revoked as of/);
|
|
280
|
+
assert.equal(run(GOOD, [{ ...KEY.entry, status: "revoked", revokedAt: "2026-10-02T00:00:00Z" }]).exit, 0, "revoked after issue: earlier signatures stay valid");
|
|
281
|
+
expectFail("key retired before issue", GOOD, [{ ...KEY.entry, status: "retired", notAfter: "2026-10-01T00:00:15Z" }], {}, /retired or expired/);
|
|
282
|
+
assert.equal(run(GOOD, [{ ...KEY.entry, status: "retired", notAfter: "2026-10-01T00:01:00Z" }]).exit, 0, "a retired key's earlier signatures verify");
|
|
283
|
+
expectFail("key not yet valid at issue", GOOD, [{ ...KEY.entry, notBefore: "2026-10-02T00:00:00Z" }], {}, /not yet valid/);
|
|
284
|
+
// Dates that match the shape but are not real instants would be NaN to Date.parse and silently disable the window: refuse the list.
|
|
285
|
+
for (const bad of ["2026-13-01T00:00:00Z", "2026-02-30T00:00:00Z", "2026-10-32T00:00:00Z", "2026-10-01T25:00:00Z", "2026-10-01T00:60:00Z"]) {
|
|
286
|
+
expectFail(`revokedAt ${bad}`, GOOD, [{ ...KEY.entry, status: "revoked", revokedAt: bad }], {}, /revokedAt must be an ISO-8601/);
|
|
287
|
+
expectFail(`notAfter ${bad}`, GOOD, [{ ...KEY.entry, status: "retired", notAfter: bad }], {}, /notAfter must be an ISO-8601/);
|
|
288
|
+
expectFail(`notBefore ${bad}`, GOOD, [{ ...KEY.entry, notBefore: bad }], {}, /notBefore must be an ISO-8601/);
|
|
289
|
+
}
|
|
290
|
+
// Rotation: checkpoints 10 and 20 by the old key, 30 by the new one. Both listed.
|
|
291
|
+
const next = await testKey("next-generation", { notBefore: "2026-10-01T00:00:25Z" });
|
|
292
|
+
const cp30new = await checkpointFor(next, ORG, chain30[29], "2026-10-01T00:00:30Z");
|
|
293
|
+
const rotated = exportText(ORG, chain30, [cp10, cp20, cp30new]);
|
|
294
|
+
const both = [{ ...KEY.entry, status: "retired", notAfter: "2026-10-01T00:00:25Z" }, next.entry];
|
|
295
|
+
assert.equal(run(rotated, both).exit, 0, "old and new key both verify after rotation");
|
|
296
|
+
expectFail("rotation list without the new key", rotated, [KEY.entry].map((k) => ({ ...k, status: "retired", notAfter: "2026-10-01T00:00:25Z" })), {}, /not in the trusted key set/);
|
|
297
|
+
expectFail("rotation list that dropped the old key", rotated, [next.entry], {}, /not in the trusted key set/);
|
|
298
|
+
// Checkpoint tampering.
|
|
299
|
+
const tamper = async (mutate, re, name) => {
|
|
300
|
+
const lines = parseLines(GOOD);
|
|
301
|
+
const idx = lines.findIndex((l) => l.type === "checkpoint" && l.seq === 20);
|
|
302
|
+
mutate(lines[idx]);
|
|
303
|
+
expectFail(name, joinLines(lines), keysDoc(), {}, re);
|
|
304
|
+
};
|
|
305
|
+
await tamper((c) => { c.hash = "0".repeat(64); }, /signature does not verify/, "checkpoint hash edited");
|
|
306
|
+
await tamper((c) => { c.issuedAt = "2026-10-03T00:00:00Z"; }, /signature does not verify/, "checkpoint issuedAt edited");
|
|
307
|
+
await tamper((c) => { c.seq = 21; }, /signature does not verify/, "checkpoint seq edited");
|
|
308
|
+
await tamper((c) => { c.orgId = "org_evil"; }, /signature does not verify/, "checkpoint org edited");
|
|
309
|
+
await tamper((c) => { c.signature = Buffer.alloc(64, 7).toString("base64"); }, /signature does not verify/, "garbage signature");
|
|
310
|
+
await tamper((c) => { c.signature = "***"; }, /signature does not verify/, "non-base64 signature");
|
|
311
|
+
await tamper((c) => { c.keyId = OTHER.kid; }, /not in the trusted key set/, "claimed under another key");
|
|
312
|
+
await tamper((c) => { delete c.signature; }, /signature does not verify/, "signature removed");
|
|
313
|
+
// A correctly signed checkpoint for a hash that is NOT the chain's.
|
|
314
|
+
const wrong = await checkpointFor(KEY, ORG, { seq: 20, hash: "ab".repeat(32) }, "2026-10-01T00:00:20Z");
|
|
315
|
+
expectFail("a validly signed checkpoint that disagrees with the chain", exportText(ORG, chain30, [cp10, wrong, cp30]), keysDoc(), {}, /checkpoint@20 does not match the chain/);
|
|
316
|
+
// Another org's validly signed checkpoint pasted in.
|
|
317
|
+
const alien = await checkpointFor(KEY, "org_evil", chain30[19], "2026-10-01T00:00:20Z");
|
|
318
|
+
expectFail("another org's checkpoint", exportText(ORG, chain30, [cp10, alien, cp30]), keysDoc(), {}, /another org/);
|
|
319
|
+
// Domain confusion: the checkpoint statement signed as an attestation.
|
|
320
|
+
const confused = { ...cp20, signature: await signWithDomain(KEY.signer, ATTESTATION_DOMAIN, { v: 1, type: "appforge.audit.checkpoint", orgId: ORG, seq: 20, hash: cp20.hash, issuedAt: cp20.issuedAt }) };
|
|
321
|
+
expectFail("a signature made under the attestation domain", exportText(ORG, chain30, [cp10, confused, cp30]), keysDoc(), {}, /signature does not verify/);
|
|
322
|
+
// Duplicates: identical is harmless (pages overlap), conflicting is not.
|
|
323
|
+
assert.equal(run(exportText(ORG, chain30, [cp10, cp20, cp20, cp30])).exit, 0);
|
|
324
|
+
const rival = await checkpointFor(KEY, ORG, chain30[19], "2026-10-01T00:00:21Z");
|
|
325
|
+
expectFail("two different checkpoints at one seq", exportText(ORG, chain30, [cp10, cp20, rival, cp30]), keysDoc(), {}, /two different checkpoints/);
|
|
326
|
+
// An export of a range of a longer chain says so in its header; the org's newest checkpoint then legitimately lies past its end...
|
|
327
|
+
const range = exportText(ORG, chain30.slice(0, 20), [cp10, cp20, cp30], { chainHeadSeq: 30 });
|
|
328
|
+
const rangeRun = run(range);
|
|
329
|
+
assert.deepEqual(rangeRun.problems, []);
|
|
330
|
+
// Not exit 0: the header's chainHeadSeq is unauthenticated, so a range export is indistinguishable from one whose newest entries were dropped.
|
|
331
|
+
assert.equal(rangeRun.exit, 3, "a signed checkpoint past the end is never reported as complete");
|
|
332
|
+
assert.match(rangeRun.notes.join("\n"), /chain that continues to seq 30/);
|
|
333
|
+
assert.match(rangeRun.notes.join("\n"), /beyond this file's last entry/);
|
|
334
|
+
// ...but a signed checkpoint beyond even the head the export claims is a contradiction: the server cut its own chain short.
|
|
335
|
+
expectFail("checkpoint past the claimed chain head", exportText(ORG, chain30.slice(0, 20), [cp10, cp20, cp30], { chainHeadSeq: 25 }), keysDoc(), {}, /cut off/);
|
|
336
|
+
expectFail("checkpoint past the end with the head claimed at the end", exportText(ORG, chain30.slice(0, 20), [cp10, cp20, cp30], { chainHeadSeq: 20 }), keysDoc(), {}, /cut off/);
|
|
337
|
+
// A checkpoint beyond the chain's end, with no later page expected, means entries were cut.
|
|
338
|
+
const cp40 = await checkpointFor(KEY, ORG, { seq: 40, hash: "cd".repeat(32) }, "2026-10-01T00:00:40Z");
|
|
339
|
+
expectFail("a signed checkpoint past the last entry", exportText(ORG, chain30, [cp10, cp20, cp30, cp40]), keysDoc(), {}, /cut off/);
|
|
340
|
+
});
|
|
341
|
+
test("export: a key list is rejected, not narrowed, when an entry is ambiguous or incomplete", () => {
|
|
342
|
+
// Each of these used to be a way for a revocation or a window to silently not apply.
|
|
343
|
+
expectFail("the same kid listed twice", GOOD, [KEY.entry, { ...KEY.entry }], {}, /listed twice/);
|
|
344
|
+
expectFail("a revoked key with no revokedAt (a revocation that would never apply)", GOOD, [{ ...KEY.entry, status: "revoked" }], {}, /a revoked key needs revokedAt/);
|
|
345
|
+
expectFail("revokedAt on a key that is not revoked", GOOD, [{ ...KEY.entry, revokedAt: "2026-10-01T00:00:00Z" }], {}, /revokedAt is only valid on a revoked key/);
|
|
346
|
+
expectFail("a retired key with no notAfter", GOOD, [{ ...KEY.entry, status: "retired" }], {}, /a retired key needs notAfter/);
|
|
347
|
+
expectFail("notAfter not after notBefore", GOOD, [{ ...KEY.entry, status: "retired", notAfter: KEY.entry.notBefore }], {}, /notAfter must be after notBefore/);
|
|
348
|
+
expectFail("an unknown status", GOOD, [{ ...KEY.entry, status: "paused" }], {}, /status must be active, retired or revoked/);
|
|
349
|
+
expectFail("an unsupported algorithm", GOOD, [{ ...KEY.entry, alg: "RSA" }], {}, /unsupported algorithm/);
|
|
350
|
+
expectFail("a kid that is not 16 lowercase hex characters", GOOD, [{ ...KEY.entry, kid: KEY.kid.toUpperCase() }], {}, /kid must be 16 lowercase hex/);
|
|
351
|
+
expectFail("a public key that is not 32 bytes of base64", GOOD, [{ ...KEY.entry, publicKey: "AAAA" }], {}, /publicKey must be base64 of 32 bytes/);
|
|
352
|
+
expectFail("a key entry that is not an object", GOOD, ["nope"], {}, /entry is not an object/);
|
|
353
|
+
expectFail("a key with no notBefore", GOOD, [{ ...KEY.entry, notBefore: undefined }], {}, /notBefore must be an ISO-8601/);
|
|
354
|
+
// The key list may also be the {keys: [...]} wrapper.
|
|
355
|
+
assert.equal(run(GOOD, { keys: [KEY.entry] }).exit, 0);
|
|
356
|
+
});
|
|
357
|
+
test("export: structure of the file itself - header, trailer position and the slice anchor", async () => {
|
|
358
|
+
const L = () => parseLines(GOOD);
|
|
359
|
+
// The trailer must be the LAST line: a checkpoint (even an identical, harmless one) or anything else after it means the file does not end where it says it does.
|
|
360
|
+
expectFail("a checkpoint line after the trailer", GOOD + JSON.stringify({ type: "checkpoint", ...cp30 }) + "\n", keysDoc(), {}, /no closing trailer/);
|
|
361
|
+
expectFail("a second header after the trailer", GOOD + JSON.stringify(L()[0]) + "\n", keysDoc(), {}, /no closing trailer/);
|
|
362
|
+
assert.equal(run(GOOD + "\n\n \n").exit, 0, "blank lines after the trailer are not content");
|
|
363
|
+
// Header rules: one org throughout, with format and version.
|
|
364
|
+
let lines = L();
|
|
365
|
+
lines.splice(1, 0, { ...lines[0], orgId: "org_evil" });
|
|
366
|
+
expectFail("a second page header naming another org", joinLines(lines), keysDoc(), {}, /different orgs/);
|
|
367
|
+
lines = L();
|
|
368
|
+
delete lines[0].orgId;
|
|
369
|
+
expectFail("a header with no orgId", joinLines(lines), keysDoc(), {}, /no valid header/);
|
|
370
|
+
lines = L();
|
|
371
|
+
lines[0].version = 2;
|
|
372
|
+
expectFail("a header of another version", joinLines(lines), keysDoc(), {}, /no valid header/);
|
|
373
|
+
lines = L();
|
|
374
|
+
lines[0].format = "something-else";
|
|
375
|
+
expectFail("a header of another format", joinLines(lines), keysDoc(), {}, /no valid header/);
|
|
376
|
+
// A signed checkpoint sitting exactly at the slice's anchor must agree with the anchor hash the first entry links to.
|
|
377
|
+
const wrongAnchor = await checkpointFor(KEY, ORG, { seq: 10, hash: "ab".repeat(32) }, "2026-10-01T00:00:10Z");
|
|
378
|
+
expectFail("a signed checkpoint at the anchor that disagrees with the anchor hash", exportText(ORG, chain30.slice(10, 30), [wrongAnchor, cp20, cp30]), keysDoc(), {}, /hash differs from the slice's anchor/);
|
|
379
|
+
assert.equal(run(exportText(ORG, chain30.slice(10, 30), [cp10, cp20, cp30])).exit, 0, "and the same slice with the genuine anchor checkpoint verifies");
|
|
380
|
+
});
|
|
381
|
+
test("export: every structural rule of an entry's payload is enforced, with the hash recomputed so only that rule can fail", () => {
|
|
382
|
+
// Three valid entries; the third one's payload is altered and its hash recomputed, so the chain links and hashes all hold.
|
|
383
|
+
const withPayload = (mutate) => {
|
|
384
|
+
const c = chain30.slice(0, 3).map((r) => clone(r));
|
|
385
|
+
const last = c[2];
|
|
386
|
+
last.payload = mutate(last.payload, last) ?? last.payload;
|
|
387
|
+
last.hash = V.entryHash(last.prevHash, last.payload);
|
|
388
|
+
return exportText(ORG, c, []);
|
|
389
|
+
};
|
|
390
|
+
const cases = [
|
|
391
|
+
["payload.seq differs from the entry's seq", (p) => { p.seq = 99; }, /payload seq 99 does not match/],
|
|
392
|
+
["payload.seq of another type", (p) => { p.seq = "3"; }, /payload seq "3" does not match/],
|
|
393
|
+
["payload version 2", (p) => { p.v = 2; }, /version is not 1/],
|
|
394
|
+
["payload of another org", (p) => { p.orgId = "org_evil"; }, /belongs to another org/],
|
|
395
|
+
["an extra top-level payload field", (p) => { p.extra = 1; }, /missing or unknown fields/],
|
|
396
|
+
["a missing top-level payload field", (p) => { delete p.at; }, /missing or unknown fields/],
|
|
397
|
+
["an empty ref", (p, row) => { p.ref = ""; row.refId = ""; }, /ref is missing/],
|
|
398
|
+
["a malformed timestamp", (p) => { p.at = "yesterday"; }, /timestamp is malformed/],
|
|
399
|
+
["data that is an array", (p) => { p.data = []; }, /data is not an object/],
|
|
400
|
+
["a payload that is not an object", () => "[1]", /payload is not an object/],
|
|
401
|
+
["a missing vpn_event data field", (p) => { delete p.data.agentId; }, /vpn_event data has missing or unknown fields/],
|
|
402
|
+
["an empty vpn_event type", (p) => { p.data.type = ""; }, /vpn_event type is malformed/],
|
|
403
|
+
["a vpn_event type longer than 64", (p) => { p.data.type = "t".repeat(65); }, /vpn_event type is malformed/],
|
|
404
|
+
["a vpn_event type that is not a string", (p) => { p.data.type = 7; }, /vpn_event type is malformed/],
|
|
405
|
+
["a vpn_event tunnelId that is a number", (p) => { p.data.tunnelId = 7; }, /vpn_event field is malformed/],
|
|
406
|
+
["a vpn_event buildId longer than 128", (p) => { p.data.buildId = "b".repeat(129); }, /vpn_event field is malformed/],
|
|
407
|
+
["a vpn_event agentId that is an object", (p) => { p.data.agentId = {}; }, /vpn_event field is malformed/],
|
|
408
|
+
["a vpn_event detail longer than 2000", (p) => { p.data.detail = "d".repeat(2001); }, /vpn_event field is malformed/],
|
|
409
|
+
];
|
|
410
|
+
for (const [name, mutate, re] of cases) {
|
|
411
|
+
// A string payload (the "[1]" case) is hashed as the text it is.
|
|
412
|
+
expectFail(name, withPayload(mutate), keysDoc(), {}, re);
|
|
413
|
+
}
|
|
414
|
+
// Null is a legal value for the nullable fields, and a 2000-character detail is the longest allowed.
|
|
415
|
+
assert.equal(run(withPayload((p) => { p.data.detail = "d".repeat(2000); p.data.tunnelId = null; })).exit, 3);
|
|
416
|
+
});
|
|
417
|
+
test("export: --pin-head catches a history rewritten by someone who holds the signing key", async () => {
|
|
418
|
+
// The operator rewrites entry 5 and re-signs every checkpoint: it verifies as authentic...
|
|
419
|
+
const forgedChain = await vpnChain(ORG, 30, (i, d) => { if (i === 4)
|
|
420
|
+
d.detail = "FORGED"; });
|
|
421
|
+
const forged = exportText(ORG, forgedChain, [
|
|
422
|
+
await checkpointFor(KEY, ORG, forgedChain[9], "2026-10-01T00:00:10Z"),
|
|
423
|
+
await checkpointFor(KEY, ORG, forgedChain[19], "2026-10-01T00:00:20Z"),
|
|
424
|
+
await checkpointFor(KEY, ORG, forgedChain[29], "2026-10-01T00:00:30Z"),
|
|
425
|
+
]);
|
|
426
|
+
assert.equal(run(forged).exit, 0, "without a saved head, a consistent re-signed rewrite is indistinguishable");
|
|
427
|
+
// ...but not against a head the customer kept from the genuine export.
|
|
428
|
+
for (const pin of [chain30[9].hash, `10:${chain30[9].hash}`, `30:${chain30[29].hash}`]) {
|
|
429
|
+
assert.equal(run(GOOD, keysDoc(), { pinHead: pin }).exit, 0, `genuine export vs pin ${pin.slice(0, 12)}`);
|
|
430
|
+
assert.equal(run(forged, keysDoc(), { pinHead: pin }).exit, 1, `forged export vs pin ${pin.slice(0, 12)}`);
|
|
431
|
+
}
|
|
432
|
+
expectFail("a pin with the right hash at the wrong seq", GOOD, keysDoc(), { pinHead: `11:${chain30[9].hash}` }, /NOT in this chain/);
|
|
433
|
+
expectFail("a malformed pin", GOOD, keysDoc(), { pinHead: "not-a-hash" }, /--pin-head must be/);
|
|
434
|
+
assert.match(run(GOOD).notes.join("\n"), /no --pin-head given/);
|
|
435
|
+
});
|
|
436
|
+
// ---------------------------------------------------------------- evidence bundle (port of the spike's 26 mutation tests)
|
|
437
|
+
const CONTROL_RESULTS = (failing) => [
|
|
438
|
+
["NET-01", "network", "direct TCP connect to a public IP (no proxy)", "blocked"],
|
|
439
|
+
["NET-02", "network", "direct HTTPS with the proxy environment bypassed", "blocked"],
|
|
440
|
+
["NET-06", "network", "non-allow-listed host via the HTTP proxy", "403 from proxy"],
|
|
441
|
+
["SEC-01", "secrets", "read the agent config", "blocked"],
|
|
442
|
+
["SEC-13", "secrets", "sudo -n true", "blocked"],
|
|
443
|
+
["FUNC-01", "function", "reach the LAN canary through the proxy", "allowed"],
|
|
444
|
+
].map(([id, category, title, expect], i) => ({
|
|
445
|
+
id, category, title, expect,
|
|
446
|
+
verdict: failing && i === 2 ? "fail" : i === 5 && !failing ? "pass" : "pass",
|
|
447
|
+
reason: failing && i === 2 ? "reached the host through the proxy" : "as expected",
|
|
448
|
+
control: { ran: true, ok: true, exit: 0 },
|
|
449
|
+
observed: { exit: 1, timedOut: false, durationMs: 12 + i },
|
|
450
|
+
}));
|
|
451
|
+
const CONN_LOG = [
|
|
452
|
+
{ dest: "10.20.0.5:443", route: "tunnel", decision: "allow", bytesUp: 1200, bytesDown: 88000 },
|
|
453
|
+
{ dest: "10.20.0.5:443", route: "tunnel", decision: "allow", bytesUp: 300, bytesDown: 4000 },
|
|
454
|
+
{ dest: "127.0.0.1:52730", route: "none", decision: "deny", bytesUp: 0, bytesDown: 0 },
|
|
455
|
+
{ dest: "169.254.169.254:80", route: "none", decision: "deny", bytesUp: 0, bytesDown: 0 },
|
|
456
|
+
{ dest: "one.one.one.one:443", route: "none", decision: "deny", bytesUp: 0, bytesDown: 0 },
|
|
457
|
+
].map((r, i) => ({ n: i + 1, ...r }));
|
|
458
|
+
async function makeBundle(buildId, issuedAt, opts = {}) {
|
|
459
|
+
const chain = [];
|
|
460
|
+
for (let i = 0; i < 40; i++) {
|
|
461
|
+
const data = vpnData(i, { buildId: `bld_old${Math.floor(i / 4)}` });
|
|
462
|
+
if (opts.forkHistory && i === 5)
|
|
463
|
+
data.detail = "FORGED";
|
|
464
|
+
await appendEntry(chain, ORG, "vpn_event", `evt_h${i}`, at(i), data);
|
|
465
|
+
}
|
|
466
|
+
const anchor = { seq: chain[39].seq, hash: chain[39].hash };
|
|
467
|
+
for (const [i, type] of ["lock_acquired", "tunnel_up", "lock_released"].entries()) {
|
|
468
|
+
await appendEntry(chain, ORG, "vpn_event", `evt_${buildId}_${i}`, "2026-10-02T14:00:01Z", { type, tunnelId: "tun_1", buildId, agentId: "agt_1", detail: null });
|
|
469
|
+
}
|
|
470
|
+
const head = chain[chain.length - 1];
|
|
471
|
+
const results = opts.noControls ? [] : CONTROL_RESULTS(!!opts.violation);
|
|
472
|
+
const summary = opts.badControlSummary ? { ...V.summariseControls(results), pass: 99 } : V.summariseControls(results);
|
|
473
|
+
const egressPolicy = { v: 1, mode: "tunnel-only", lanCidrs: ["10.20.0.0/16"], allowedDomains: ["*.corp.acme.example"], directEgress: "deny", sshViaProxyOnly: true };
|
|
474
|
+
const destinations = V.summariseConnections(CONN_LOG);
|
|
475
|
+
if (opts.badSummary)
|
|
476
|
+
destinations[0].bytesUp += 1;
|
|
477
|
+
const doc = {
|
|
478
|
+
type: "appforge.vpn.build-attestation", version: 1, id: `att_${buildId}`, issuedAt, completeness: opts.completeness ?? "full",
|
|
479
|
+
subject: { orgId: opts.subjectOrgId ?? ORG, appId: "app_1", buildId, workflow: "vpn-compliance-check", commitSha: "0123456789abcdef0123456789abcdef01234567", tunnelId: "tun_1" },
|
|
480
|
+
policy: { egressPolicy, egressPolicySha256: opts.badPolicyHash ? "0".repeat(64) : V.sha256hex(V.canonicalize(egressPolicy)) },
|
|
481
|
+
connections: {
|
|
482
|
+
totals: { allowed: 2, denied: 3, errors: 0, bytesUp: 1500, bytesDown: 92000 }, destinations, logRecords: CONN_LOG.length,
|
|
483
|
+
logSha256: V.sha256hex(CONN_LOG.map((r) => V.canonicalize(r)).join("\n") + "\n"),
|
|
484
|
+
},
|
|
485
|
+
controlTests: opts.noControls ? undefined : { packVersion: "1", phase: "preflight", results, summary, resultsSha256: opts.badResultsSha ? "0".repeat(64) : V.sha256hex(V.canonicalize(results)) },
|
|
486
|
+
auditChain: { headSeq: head.seq, headHash: opts.badHeadHash ? "0".repeat(64) : head.hash },
|
|
487
|
+
result: { buildStatus: opts.violation ? "aborted_control_failure" : "succeeded", controlsVerdict: opts.noControls ? "not_evaluated" : opts.violation ? "violations_detected" : "all_enforced" },
|
|
488
|
+
};
|
|
489
|
+
if (opts.noControls)
|
|
490
|
+
delete doc.controlTests;
|
|
491
|
+
const docSha = V.sha256hex(V.canonicalize(doc));
|
|
492
|
+
await appendEntry(chain, ORG, opts.regKind ?? "attestation", doc.id, issuedAt, { buildId: opts.regBuildId ?? buildId, docSha256: opts.regDocSha ?? docSha, keyId: KEY.kid });
|
|
493
|
+
const cp = await checkpointFor(KEY, ORG, chain[chain.length - 1], issuedAt);
|
|
494
|
+
const slice = chain.filter((r) => r.seq > anchor.seq).map(({ seq, prevHash, hash, payload }) => ({ seq, prevHash, hash, payload }));
|
|
495
|
+
return {
|
|
496
|
+
format: "appforge-evidence-bundle", version: 1,
|
|
497
|
+
attestation: { doc, signatures: [{ alg: "Ed25519", keyId: KEY.kid, role: "server", signature: await signWithDomain(KEY.signer, ATTESTATION_DOMAIN, doc) }] },
|
|
498
|
+
auditChain: { orgId: ORG, anchor, entries: slice, checkpoints: [cp] },
|
|
499
|
+
connectionLog: { encoding: "records", records: CONN_LOG.map((r) => ({ ...r })) },
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
const CLEAN = await makeBundle("bld_ok_0001", "2026-10-02T14:03:50Z");
|
|
503
|
+
const VIOLATION = await makeBundle("bld_bad_0002", "2026-10-02T15:10:50Z", { violation: true });
|
|
504
|
+
const seenFailing = new Set();
|
|
505
|
+
const seenChecks = new Set();
|
|
506
|
+
function expectBundle(name, bundle, want, o = {}) {
|
|
507
|
+
const r = V.verifyBundle(bundle, o.keys ?? keysDoc(), o.opts ?? {});
|
|
508
|
+
for (const [n, ok] of r.checks) {
|
|
509
|
+
seenChecks.add(n);
|
|
510
|
+
if (!ok)
|
|
511
|
+
seenFailing.add(n);
|
|
512
|
+
}
|
|
513
|
+
assert.equal(r.exit, want, `${name}: want exit ${want}, got ${r.exit} (${r.problems.join("; ")})`);
|
|
514
|
+
return r;
|
|
515
|
+
}
|
|
516
|
+
test("bundle: untouched evidence verifies; a recorded violation is authentic but exit 2; no controls / degraded is exit 3", async () => {
|
|
517
|
+
expectBundle("untouched clean bundle", CLEAN, 0);
|
|
518
|
+
expectBundle("untouched violation bundle: authentic, violation recorded", VIOLATION, 2);
|
|
519
|
+
expectBundle("legitimately signed but degraded attestation", await makeBundle("bld_deg", "2026-10-02T16:00:00Z", { completeness: "server_observed_only" }), 3);
|
|
520
|
+
expectBundle("attestation without control tests", await makeBundle("bld_nc", "2026-10-02T16:10:00Z", { noControls: true }), 3);
|
|
521
|
+
});
|
|
522
|
+
test("bundle: every way of altering the evidence is caught (port of the evidence spike's 26 mutation tests)", async () => {
|
|
523
|
+
let b;
|
|
524
|
+
b = clone(VIOLATION);
|
|
525
|
+
b.attestation.doc.controlTests.results.find((x) => x.verdict === "fail").verdict = "pass";
|
|
526
|
+
expectBundle("1 flip a FAIL control-test verdict to PASS", b, 1);
|
|
527
|
+
b = clone(VIOLATION);
|
|
528
|
+
const ct = b.attestation.doc.controlTests;
|
|
529
|
+
ct.results.find((x) => x.verdict === "fail").verdict = "pass";
|
|
530
|
+
ct.summary = { pass: ct.summary.pass + 1, fail: ct.summary.fail - 1, inconclusive: ct.summary.inconclusive };
|
|
531
|
+
ct.resultsSha256 = V.sha256hex(V.canonicalize(ct.results));
|
|
532
|
+
expectBundle("2 flip the verdict AND fix summary and hash (the signature must still catch it)", b, 1);
|
|
533
|
+
b = clone(CLEAN);
|
|
534
|
+
b.attestation.doc.connections.destinations[0].bytesUp += 1;
|
|
535
|
+
expectBundle("3 change a per-destination byte count in the signed doc", b, 1);
|
|
536
|
+
b = clone(CLEAN);
|
|
537
|
+
b.connectionLog.records[0].bytesUp += 5;
|
|
538
|
+
expectBundle("4 change a raw connection-log record", b, 1);
|
|
539
|
+
b = clone(CLEAN);
|
|
540
|
+
b.connectionLog.records = b.connectionLog.records.filter((r) => r.decision !== "deny");
|
|
541
|
+
expectBundle("5 delete the denied connections from the raw log", b, 1);
|
|
542
|
+
b = clone(CLEAN);
|
|
543
|
+
b.auditChain.entries.splice(3, 1);
|
|
544
|
+
expectBundle("6 remove one chain entry", b, 1);
|
|
545
|
+
b = clone(CLEAN);
|
|
546
|
+
[b.auditChain.entries[2], b.auditChain.entries[3]] = [b.auditChain.entries[3], b.auditChain.entries[2]];
|
|
547
|
+
expectBundle("7 reorder two chain entries", b, 1);
|
|
548
|
+
b = clone(CLEAN);
|
|
549
|
+
b.auditChain.entries[1].payload.data.detail = "edited";
|
|
550
|
+
expectBundle("8 edit an event payload inside the chain", b, 1);
|
|
551
|
+
b = clone(CLEAN);
|
|
552
|
+
b.auditChain.entries[1].payload.data.detail = "edited";
|
|
553
|
+
b.auditChain.entries[1].hash = V.entryHash(b.auditChain.entries[1].prevHash, b.auditChain.entries[1].payload);
|
|
554
|
+
expectBundle("9 edit an event AND recompute its hash (the next link breaks)", b, 1);
|
|
555
|
+
b = clone(CLEAN);
|
|
556
|
+
b.attestation.doc.subject.commitSha = "f".repeat(40);
|
|
557
|
+
expectBundle("10 change the commit sha in the attestation", b, 1);
|
|
558
|
+
b = clone(CLEAN);
|
|
559
|
+
b.attestation.doc.subject.buildId = "bld_other";
|
|
560
|
+
expectBundle("11 replay the attestation under another buildId", b, 1);
|
|
561
|
+
b = clone(CLEAN);
|
|
562
|
+
b.attestation.signatures = [];
|
|
563
|
+
expectBundle("12 strip the signatures", b, 1);
|
|
564
|
+
b = clone(CLEAN);
|
|
565
|
+
b.attestation.signatures[0].signature = Buffer.alloc(64, 7).toString("base64");
|
|
566
|
+
expectBundle("13 garbage signature", b, 1);
|
|
567
|
+
expectBundle("14 verify with a different (untrusted) key set", CLEAN, 1, { keys: [OTHER.entry] });
|
|
568
|
+
b = clone(CLEAN);
|
|
569
|
+
b.auditChain.checkpoints = [];
|
|
570
|
+
expectBundle("15 remove the signed checkpoint", b, 1);
|
|
571
|
+
b = clone(CLEAN);
|
|
572
|
+
b.auditChain.checkpoints[0].hash = "0".repeat(64);
|
|
573
|
+
expectBundle("16 tamper the checkpoint hash", b, 1);
|
|
574
|
+
b = clone(CLEAN);
|
|
575
|
+
b.auditChain.checkpoints[0].issuedAt = "2026-10-03T00:00:00Z";
|
|
576
|
+
expectBundle("17 tamper the checkpoint issuedAt (inside the signed statement)", b, 1);
|
|
577
|
+
b = clone(CLEAN);
|
|
578
|
+
b.attestation.doc.policy.egressPolicy.directEgress = "allow";
|
|
579
|
+
expectBundle("18 loosen the inlined egress policy", b, 1);
|
|
580
|
+
expectBundle("19 key revoked BEFORE the attestation was issued", CLEAN, 1, { keys: [{ ...KEY.entry, status: "revoked", revokedAt: "2026-10-01T00:00:00Z" }] });
|
|
581
|
+
expectBundle("20 key revoked AFTER issue time (still valid for this document)", CLEAN, 0, { keys: [{ ...KEY.entry, status: "revoked", revokedAt: "2026-10-03T00:00:00Z" }] });
|
|
582
|
+
expectBundle("21 key expired before issue time", CLEAN, 1, { keys: [{ ...KEY.entry, status: "retired", notAfter: "2026-10-02T00:00:00Z" }] });
|
|
583
|
+
const forged = await makeBundle("bld_ok_0001", "2026-10-02T14:03:50Z", { forkHistory: true });
|
|
584
|
+
expectBundle("22 forged history, operator re-signed: without a pin it looks authentic", forged, 0);
|
|
585
|
+
expectBundle("23 forged history vs the anchor hash the customer saved earlier -> caught", forged, 1, { opts: { pinHead: CLEAN.auditChain.anchor.hash } });
|
|
586
|
+
expectBundle("24 the genuine bundle vs the same pinned hash -> still fine", CLEAN, 0, { opts: { pinHead: CLEAN.auditChain.anchor.hash } });
|
|
587
|
+
// The attestation signature moved onto a checkpoint-style statement, and a checkpoint signature reused as the attestation's.
|
|
588
|
+
b = clone(CLEAN);
|
|
589
|
+
b.attestation.signatures[0].signature = await signWithDomain(KEY.signer, "appforge-checkpoint-v1\n", b.attestation.doc);
|
|
590
|
+
expectBundle("25 attestation signature made under the checkpoint domain", b, 1);
|
|
591
|
+
b = clone(CLEAN);
|
|
592
|
+
b.auditChain.orgId = "org_evil";
|
|
593
|
+
expectBundle("26 chain presented for another org", b, 1);
|
|
594
|
+
// Extras that are not in the spike's list.
|
|
595
|
+
b = clone(CLEAN);
|
|
596
|
+
b.auditChain.anchor = null;
|
|
597
|
+
expectBundle("27 anchor removed from a slice that does not start at genesis", b, 1);
|
|
598
|
+
b = clone(CLEAN);
|
|
599
|
+
b.auditChain.entries[b.auditChain.entries.length - 1].payload.data.docSha256 = "0".repeat(64);
|
|
600
|
+
expectBundle("28 the chain's registration of the attestation edited", b, 1);
|
|
601
|
+
b = clone(CLEAN);
|
|
602
|
+
b.auditChain.entries.pop();
|
|
603
|
+
expectBundle("29 the registering entry (and so the checkpoint's seq) cut off", b, 1);
|
|
604
|
+
b = clone(CLEAN);
|
|
605
|
+
b.format = "something-else";
|
|
606
|
+
expectBundle("30 wrong bundle format", b, 1);
|
|
607
|
+
expectBundle("31 empty trusted key set", CLEAN, 1, { keys: [] });
|
|
608
|
+
b = clone(CLEAN);
|
|
609
|
+
b.attestation.doc.connections.logSha256 = "0".repeat(64);
|
|
610
|
+
expectBundle("32 log hash edited in the document", b, 1);
|
|
611
|
+
});
|
|
612
|
+
/** A bundle that must fail, for the stated reason: the message AND the named check must both say so, so a second check cannot be what is hiding a removed one. */
|
|
613
|
+
function expectBundleFails(name, bundle, problem, failedCheck, o = {}) {
|
|
614
|
+
const r = V.verifyBundle(bundle, o.keys ?? keysDoc(), o.opts ?? {});
|
|
615
|
+
for (const [n, ok] of r.checks) {
|
|
616
|
+
seenChecks.add(n);
|
|
617
|
+
if (!ok)
|
|
618
|
+
seenFailing.add(n);
|
|
619
|
+
}
|
|
620
|
+
assert.equal(r.exit, 1, `${name}: want exit 1, got ${r.exit} (${r.problems.join("; ")})`);
|
|
621
|
+
assert.match(r.problems.join("\n"), problem, name);
|
|
622
|
+
if (failedCheck !== null)
|
|
623
|
+
assert.ok(r.checks.some(([n, ok]) => n === failedCheck && !ok), `${name}: check "${failedCheck}" should have failed; failed: ${r.checks.filter(([, ok]) => !ok).map(([n]) => n).join(" | ")}`);
|
|
624
|
+
}
|
|
625
|
+
const C_SERVER = "attestation signed by a trusted server key valid at issue time";
|
|
626
|
+
const C_CHAIN = "hash chain recomputes from anchor to head";
|
|
627
|
+
const C_REGISTERED = "attestation is registered in the chain (docSha256 matches)";
|
|
628
|
+
const C_CPS = "signed checkpoint(s) verify and match the chain";
|
|
629
|
+
const C_LOGHASH = "connection log sha256 matches the attestation";
|
|
630
|
+
const C_LOGSUM = "per-destination summary recomputes from the raw log";
|
|
631
|
+
const C_CONTROLS = "control-test summary and resultsSha256 recompute";
|
|
632
|
+
const C_POLICY = "egress policy hash matches the inlined policy";
|
|
633
|
+
test("bundle: each check is pinned on its own, by a bundle whose ONLY defect is that one (the signatures and hashes are all valid)", async () => {
|
|
634
|
+
let b;
|
|
635
|
+
// A signature by a key the verifier trusts, over the right document, but not in the server role.
|
|
636
|
+
b = clone(CLEAN);
|
|
637
|
+
b.attestation.signatures[0].role = "agent";
|
|
638
|
+
expectBundleFails("an agent-role signature alone", b, /no valid server signature/, C_SERVER);
|
|
639
|
+
b = clone(CLEAN);
|
|
640
|
+
b.attestation.signatures[0].role = undefined;
|
|
641
|
+
expectBundleFails("a signature with no role", b, /no valid server signature/, C_SERVER);
|
|
642
|
+
b = clone(CLEAN);
|
|
643
|
+
b.attestation.signatures = [{ ...b.attestation.signatures[0], role: "agent" }, { ...b.attestation.signatures[0], signature: Buffer.alloc(64, 7).toString("base64") }];
|
|
644
|
+
expectBundleFails("a valid agent signature next to a garbage server one", b, /signature does not verify/, C_SERVER);
|
|
645
|
+
b = clone(CLEAN);
|
|
646
|
+
b.attestation.signatures = [{ ...b.attestation.signatures[0], role: "agent" }, { ...b.attestation.signatures[0] }];
|
|
647
|
+
expectBundle("an agent signature next to the valid server one is fine", b, 0);
|
|
648
|
+
// The chain belongs to the org the document is about.
|
|
649
|
+
expectBundleFails("a validly signed document about another org, shipped with this org's chain", await makeBundle("bld_org", "2026-10-02T16:20:00Z", { subjectOrgId: "org_other" }), /audit chain missing or belongs to a different org/, C_CHAIN);
|
|
650
|
+
b = clone(CLEAN);
|
|
651
|
+
delete b.auditChain;
|
|
652
|
+
expectBundleFails("no audit chain at all", b, /audit chain missing/, C_CHAIN);
|
|
653
|
+
// The chain registers THIS attestation: kind, build and document hash.
|
|
654
|
+
expectBundleFails("the registration entry carries another document hash", await makeBundle("bld_reg1", "2026-10-02T16:30:00Z", { regDocSha: "ab".repeat(32) }), /does not contain this exact attestation/, C_REGISTERED);
|
|
655
|
+
expectBundleFails("the registration entry carries another build", await makeBundle("bld_reg2", "2026-10-02T16:31:00Z", { regBuildId: "bld_other" }), /does not contain this exact attestation/, C_REGISTERED);
|
|
656
|
+
expectBundleFails("the registration entry is of another kind", await makeBundle("bld_reg3", "2026-10-02T16:32:00Z", { regKind: "gap" }), /does not contain this exact attestation/, C_REGISTERED);
|
|
657
|
+
expectBundleFails("a signed head hash that is not the chain's head entry", await makeBundle("bld_head", "2026-10-02T16:33:00Z", { badHeadHash: true }), /chain head is not in the supplied chain/, "attestation's audit-chain head exists in the chain and matches");
|
|
658
|
+
// Checkpoints: at least one must verify and cover the slice.
|
|
659
|
+
b = clone(CLEAN);
|
|
660
|
+
b.auditChain.checkpoints = [];
|
|
661
|
+
expectBundleFails("no checkpoint at all", b, /no signed checkpoint covers this chain slice/, C_CPS);
|
|
662
|
+
b = clone(CLEAN);
|
|
663
|
+
delete b.auditChain.checkpoints;
|
|
664
|
+
expectBundleFails("no checkpoints field", b, /no signed checkpoint covers this chain slice/, C_CPS);
|
|
665
|
+
// A checkpoint at the anchor is genuine but covers none of the slice's entries.
|
|
666
|
+
b = clone(CLEAN);
|
|
667
|
+
b.auditChain.checkpoints = [await checkpointFor(KEY, ORG, b.auditChain.anchor, "2026-10-02T14:03:50Z")];
|
|
668
|
+
expectBundleFails("only a checkpoint at the anchor (before the attestation)", b, /no checkpoint at or after the attestation's chain head/, "a checkpoint covers the attestation's head or later");
|
|
669
|
+
// The slice: anchor and entries.
|
|
670
|
+
b = clone(CLEAN);
|
|
671
|
+
b.auditChain.anchor = null;
|
|
672
|
+
expectBundleFails("no anchor, and not starting at seq 1", b, /no anchor and does not start at seq 1/, C_CHAIN);
|
|
673
|
+
b = clone(CLEAN);
|
|
674
|
+
b.auditChain.anchor = { seq: "40", hash: b.auditChain.anchor.hash };
|
|
675
|
+
expectBundleFails("an anchor whose seq is not an integer", b, /chain anchor is malformed/, C_CHAIN);
|
|
676
|
+
b = clone(CLEAN);
|
|
677
|
+
b.auditChain.anchor = { seq: 40, hash: "xyz" };
|
|
678
|
+
expectBundleFails("an anchor whose hash is not hex", b, /chain anchor is malformed/, C_CHAIN);
|
|
679
|
+
b = clone(CLEAN);
|
|
680
|
+
b.auditChain.entries = [];
|
|
681
|
+
expectBundleFails("an empty slice", b, /the slice contains no entries/, C_CHAIN);
|
|
682
|
+
// Derived data inside the SIGNED document must recompute from the raw data.
|
|
683
|
+
expectBundleFails("a signed per-destination summary that disagrees with the raw log", await makeBundle("bld_sum", "2026-10-02T16:40:00Z", { badSummary: true }), /per-destination summary does not match the raw connection log/, C_LOGSUM);
|
|
684
|
+
expectBundleFails("a signed control summary that disagrees with the results", await makeBundle("bld_ctl1", "2026-10-02T16:41:00Z", { badControlSummary: true }), /control-test summary does not match the individual results/, C_CONTROLS);
|
|
685
|
+
expectBundleFails("a signed resultsSha256 that disagrees with the results", await makeBundle("bld_ctl2", "2026-10-02T16:42:00Z", { badResultsSha: true }), /control-test summary does not match the individual results/, C_CONTROLS);
|
|
686
|
+
expectBundleFails("a signed egress policy hash that disagrees with the inlined policy", await makeBundle("bld_pol", "2026-10-02T16:43:00Z", { badPolicyHash: true }), /egress policy hash does not match the inlined policy/, C_POLICY);
|
|
687
|
+
b = clone(CLEAN);
|
|
688
|
+
b.connectionLog.records[0].bytesUp += 1;
|
|
689
|
+
expectBundleFails("a raw log record changed", b, /connection log was altered \(sha256 mismatch\)/, C_LOGHASH);
|
|
690
|
+
// A bundle with no raw log checks only the signed summary, and says so.
|
|
691
|
+
b = clone(CLEAN);
|
|
692
|
+
delete b.connectionLog;
|
|
693
|
+
expectBundle("no raw connection log is not a failure", b, 0);
|
|
694
|
+
});
|
|
695
|
+
test("bundle: the checks are exhaustive - no check the verifier performs is untested for failure", () => {
|
|
696
|
+
// Re-run the clean bundle with a pin so the pin check is listed, then compare with everything seen failing above.
|
|
697
|
+
expectBundle("clean with pin", CLEAN, 0, { opts: { pinHead: CLEAN.auditChain.anchor.hash } });
|
|
698
|
+
const never = [...seenChecks].filter((c) => !seenFailing.has(c));
|
|
699
|
+
assert.deepEqual(never, [], `these checks never failed in any mutation, so nothing proves they can: ${never.join(" | ")}`);
|
|
700
|
+
assert.ok(seenChecks.size >= 10, "the bundle verifier reports its checks");
|
|
701
|
+
// Check names are fixed text, never built from the file, which is why they need no escaping of their own when printed.
|
|
702
|
+
for (const n of seenChecks)
|
|
703
|
+
assert.ok(!/[^\x20-\x7e]/.test(n), `check name must be plain printable ASCII: ${JSON.stringify(n)}`);
|
|
704
|
+
});
|
|
705
|
+
test("export: the same exhaustiveness rule - every check of the export verifier is seen failing", async () => {
|
|
706
|
+
const names = new Set(run(GOOD, keysDoc(), { pinHead: chain30[9].hash }).checks.map(([n]) => n));
|
|
707
|
+
const failing = new Set();
|
|
708
|
+
const probe = (text, keys = keysDoc(), opts = {}) => { for (const [n, ok] of run(text, keys, opts).checks)
|
|
709
|
+
if (!ok)
|
|
710
|
+
failing.add(n); };
|
|
711
|
+
const lines = parseLines(GOOD);
|
|
712
|
+
probe(GOOD.replace("\n", "\nnot json\n"));
|
|
713
|
+
probe(joinLines(lines.slice(1)));
|
|
714
|
+
probe(joinLines(lines.slice(0, -1)));
|
|
715
|
+
const gap = clone(lines);
|
|
716
|
+
gap.splice(gap.findIndex((l) => l.type === "entry" && l.seq === 9), 1);
|
|
717
|
+
probe(joinLines(gap));
|
|
718
|
+
const cut = lines.filter((l) => !(l.type === "entry" && l.seq > 25));
|
|
719
|
+
probe(joinLines(cut));
|
|
720
|
+
probe(GOOD, [OTHER.entry]);
|
|
721
|
+
probe(GOOD, keysDoc(), { pinHead: "ab".repeat(32) });
|
|
722
|
+
const never = [...names].filter((n) => !failing.has(n));
|
|
723
|
+
assert.deepEqual(never, [], `export checks never seen failing: ${never.join(" | ")}`);
|
|
724
|
+
for (const n of names)
|
|
725
|
+
assert.ok(!/[^\x20-\x7e]/.test(n), `check name must be plain printable ASCII: ${JSON.stringify(n)}`);
|
|
726
|
+
});
|
|
727
|
+
// ---------------------------------------------------------------- printable HTML and --render-check
|
|
728
|
+
async function renderedHtml(bundle) {
|
|
729
|
+
return V.renderEvidenceHtml(bundle, { docSha256: V.sha256hex(V.canonicalize(bundle.attestation.doc)) });
|
|
730
|
+
}
|
|
731
|
+
test("html: the embedded JSON verifies, and --render-check proves the visible text is what the JSON says", async () => {
|
|
732
|
+
const html = await renderedHtml(CLEAN);
|
|
733
|
+
assert.deepEqual(V.renderCheck(html), []);
|
|
734
|
+
assert.deepEqual(V.detectInput(html).bundle, CLEAN);
|
|
735
|
+
expectBundle("bundle read back out of the page", V.detectInput(html).bundle, 0);
|
|
736
|
+
// The page shows the verdict derived from the JSON.
|
|
737
|
+
assert.match(html, /All network and isolation controls were enforced/);
|
|
738
|
+
assert.match(await renderedHtml(VIOLATION), /Control violations were detected/);
|
|
739
|
+
// Known limitation, stated by the test: editing the visible text is invisible to the JSON verifier...
|
|
740
|
+
const lie = html.replace("All network and isolation controls were enforced", "ALL GOOD, TRUST US");
|
|
741
|
+
assert.notEqual(lie, html);
|
|
742
|
+
expectBundle("JSON of the edited page still verifies (the limitation)", V.detectInput(lie).bundle, 0);
|
|
743
|
+
// ...which is exactly what --render-check closes.
|
|
744
|
+
assert.match(V.renderCheck(lie).join("\n"), /does not match a re-render/);
|
|
745
|
+
const edits = [
|
|
746
|
+
["a destination row", html.replace(/(<td class="mono">127\.0\.0\.1:52730<\/td><td>none<\/td><td>)deny/, "$1allow")],
|
|
747
|
+
["a control-test verdict cell", html.replace(/>PASS<\/td>/, ">FAIL</td>")],
|
|
748
|
+
["the document hash shown", html.replace(/Document sha256<\/dt><dd><code>[0-9a-f]{64}/, "Document sha256</dt><dd><code>" + "0".repeat(64))],
|
|
749
|
+
["an appended paragraph", html.replace("</main>", "<p>Independently audited.</p></main>")],
|
|
750
|
+
];
|
|
751
|
+
for (const [name, edited] of edits) {
|
|
752
|
+
assert.notEqual(edited, html, name);
|
|
753
|
+
assert.notEqual(V.renderCheck(edited).length, 0, `render-check must catch: ${name}`);
|
|
754
|
+
}
|
|
755
|
+
// Two embedded blocks, or none, are refused rather than "first one wins".
|
|
756
|
+
const block = html.match(/<script type="application\/json" id="appforge-evidence">[\s\S]*?<\/script>/)[0];
|
|
757
|
+
assert.match(V.renderCheck(html.replace("</main>", `${block}</main>`)).join("\n"), /more than one embedded/);
|
|
758
|
+
assert.match(V.renderCheck(html.replace(block, "")).join("\n"), /no embedded appforge-evidence block/);
|
|
759
|
+
assert.throws(() => V.detectInput(html.replace("</main>", `${block}</main>`)), /more than one/);
|
|
760
|
+
// The embedded JSON is escaped so a hostile string cannot close the script tag.
|
|
761
|
+
const hostile = clone(CLEAN);
|
|
762
|
+
hostile.attestation.doc.subject.workflow = "</script><script>alert(1)</script>";
|
|
763
|
+
const hostileHtml = await renderedHtml(hostile);
|
|
764
|
+
assert.ok(!/<script>alert/.test(hostileHtml));
|
|
765
|
+
});
|
|
766
|
+
// ---------------------------------------------------------------- the command line
|
|
767
|
+
function cli(args, files = {}) {
|
|
768
|
+
const dir = mkdtempSync(join(tmpdir(), "appforge-verify-"));
|
|
769
|
+
try {
|
|
770
|
+
for (const [name, body] of Object.entries(files))
|
|
771
|
+
writeFileSync(join(dir, name), body);
|
|
772
|
+
const argv = args.map((a) => (a.startsWith("@") ? join(dir, a.slice(1)) : a));
|
|
773
|
+
const res = spawnSync(process.execPath, [SCRIPT, ...argv], { encoding: "utf8", cwd: dir });
|
|
774
|
+
return { status: res.status, out: res.stdout, err: res.stderr };
|
|
775
|
+
}
|
|
776
|
+
finally {
|
|
777
|
+
rmSync(dir, { recursive: true, force: true });
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
test("cli: exit codes are 0 / 1 / 2 / 3 / 64 as documented, for exports, bundles and pages", async () => {
|
|
781
|
+
const keys = JSON.stringify(keyList);
|
|
782
|
+
assert.equal(cli(["@export.ndjson", "--keys", "@keys.json"], { "export.ndjson": GOOD, "keys.json": keys }).status, 0);
|
|
783
|
+
assert.equal(cli(["@export.ndjson", "--keys", "@keys.json"], { "export.ndjson": exportText(ORG, chain30, [cp10, cp20]), "keys.json": keys }).status, 3);
|
|
784
|
+
assert.equal(cli(["@export.ndjson", "--keys", "@keys.json", "--allow-unsigned-tail"], { "export.ndjson": exportText(ORG, chain30, [cp10, cp20]), "keys.json": keys }).status, 0);
|
|
785
|
+
const broken = parseLines(GOOD);
|
|
786
|
+
broken.splice(5, 1);
|
|
787
|
+
assert.equal(cli(["@export.ndjson", "--keys", "@keys.json"], { "export.ndjson": joinLines(broken), "keys.json": keys }).status, 1);
|
|
788
|
+
assert.equal(cli(["@bundle.json", "--keys", "@keys.json"], { "bundle.json": JSON.stringify(CLEAN), "keys.json": keys }).status, 0);
|
|
789
|
+
assert.equal(cli(["@bundle.json", "--keys", "@keys.json"], { "bundle.json": JSON.stringify(VIOLATION, null, 2), "keys.json": keys }).status, 2);
|
|
790
|
+
assert.equal(cli(["@page.html", "--keys", "@keys.json", "--render-check"], { "page.html": await renderedHtml(CLEAN), "keys.json": keys }).status, 0);
|
|
791
|
+
// Unreadable / missing input and bad key files fail closed (1), usage errors are 64.
|
|
792
|
+
assert.equal(cli(["@missing.ndjson", "--keys", "@keys.json"], { "keys.json": keys }).status, 1);
|
|
793
|
+
assert.equal(cli(["@export.ndjson", "--keys", "@keys.json"], { "export.ndjson": GOOD, "keys.json": "{not json" }).status, 1);
|
|
794
|
+
assert.equal(cli([]).status, 64);
|
|
795
|
+
assert.equal(cli(["a", "b"]).status, 64);
|
|
796
|
+
assert.equal(cli(["@export.ndjson", "--bogus"], { "export.ndjson": GOOD }).status, 64);
|
|
797
|
+
assert.equal(cli(["@export.ndjson", "--keys"], { "export.ndjson": GOOD }).status, 64);
|
|
798
|
+
assert.equal(cli(["@export.ndjson", "--keys", "@keys.json", "--render-check"], { "export.ndjson": GOOD, "keys.json": keys }).status, 64);
|
|
799
|
+
});
|
|
800
|
+
test("cli: --render-check fails a page whose visible text was edited, and plain verification does not (the documented limitation)", async () => {
|
|
801
|
+
const keys = JSON.stringify(keyList);
|
|
802
|
+
const lie = (await renderedHtml(CLEAN)).replace("All network and isolation controls were enforced", "ALL GOOD, TRUST US");
|
|
803
|
+
const plain = cli(["@page.html", "--keys", "@keys.json"], { "page.html": lie, "keys.json": keys });
|
|
804
|
+
assert.equal(plain.status, 0);
|
|
805
|
+
assert.match(plain.out, /NOT authenticated, only its embedded JSON/);
|
|
806
|
+
const checked = cli(["@page.html", "--keys", "@keys.json", "--render-check"], { "page.html": lie, "keys.json": keys });
|
|
807
|
+
assert.equal(checked.status, 1);
|
|
808
|
+
assert.match(checked.out, /does not match a re-render/);
|
|
809
|
+
});
|
|
810
|
+
test("cli: with no --keys the script uses ONLY its embedded keys: an export signed by a key that is not embedded does not verify", () => {
|
|
811
|
+
const r = cli(["@export.ndjson"], { "export.ndjson": GOOD });
|
|
812
|
+
assert.equal(r.status, 1);
|
|
813
|
+
assert.match(r.out, /trusted key set is empty|is not in the trusted key set/);
|
|
814
|
+
assert.ok(!V.EMBEDDED_KEYS.some((k) => k.kid === KEY.entry.kid), "the test key is never one of the embedded production keys");
|
|
815
|
+
});
|
|
816
|
+
test("cli: --json prints the machine-readable result", () => {
|
|
817
|
+
const r = cli(["@export.ndjson", "--keys", "@keys.json", "--json"], { "export.ndjson": GOOD, "keys.json": JSON.stringify(keyList) });
|
|
818
|
+
const parsed = JSON.parse(r.out);
|
|
819
|
+
assert.equal(parsed.exit, 0);
|
|
820
|
+
assert.equal(parsed.signedThroughSeq, 30);
|
|
821
|
+
assert.equal(parsed.keysSource, "--keys");
|
|
822
|
+
});
|
|
823
|
+
test("cli: --make-key-entry builds a key-list entry from a PUBLIC key and refuses a private one", async () => {
|
|
824
|
+
const r = cli(["--make-key-entry", "@pub.pem", "--not-before", "2026-10-02T00:00:00Z"], { "pub.pem": KEY.pem });
|
|
825
|
+
assert.equal(r.status, 0);
|
|
826
|
+
assert.deepEqual(JSON.parse(r.out), { ...KEY.entry, notBefore: "2026-10-02T00:00:00Z" });
|
|
827
|
+
const priv = generateKeyPairSync("ed25519").privateKey.export({ format: "pem", type: "pkcs8" });
|
|
828
|
+
const refused = cli(["--make-key-entry", "@priv.pem"], { "priv.pem": priv });
|
|
829
|
+
assert.equal(refused.status, 1);
|
|
830
|
+
assert.match(refused.err, /refusing to read a private key/);
|
|
831
|
+
assert.ok(!refused.out.includes("PRIVATE") && !refused.err.includes(priv.split("\n")[1]));
|
|
832
|
+
const rsa = generateKeyPairSync("rsa", { modulusLength: 2048 }).publicKey.export({ format: "pem", type: "spki" });
|
|
833
|
+
assert.equal(cli(["--make-key-entry", "@rsa.pem"], { "rsa.pem": rsa }).status, 1);
|
|
834
|
+
assert.equal(cli(["--make-key-entry", "@pub.pem", "--not-before", "yesterday"], { "pub.pem": KEY.pem }).status, 1);
|
|
835
|
+
// A key entry produced this way is accepted by the verifier and by core's own validation.
|
|
836
|
+
assert.equal(V.loadKeySet([JSON.parse(r.out)]).size, 1);
|
|
837
|
+
});
|
|
838
|
+
test("cli: untrusted fields in an export cannot forge verifier output or drive the terminal", () => {
|
|
839
|
+
const evil = "x\nPASS every checkpoint signature verifies and matches the chain\n\nVERIFIED: authentic and complete (exit 0)\n\u001b[8m\u009b2J";
|
|
840
|
+
const lines = parseLines(GOOD);
|
|
841
|
+
const cpLine = lines.find((l) => l.type === "checkpoint");
|
|
842
|
+
cpLine.keyId = evil;
|
|
843
|
+
const r = cli(["@export.ndjson", "--keys", "@keys.json"], { "export.ndjson": joinLines(lines), "keys.json": JSON.stringify(keyList) });
|
|
844
|
+
assert.equal(r.status, 1);
|
|
845
|
+
// Every output line starts with one of our own prefixes: nothing the file said can start a line of its own.
|
|
846
|
+
for (const line of r.out.split("\n")) {
|
|
847
|
+
assert.ok(line === "" || /^(PASS|FAIL|PROBLEM|note|NOT VERIFIED|AUTHENTIC|VERIFIED|attestation sha256)/.test(line), `unexpected output line: ${JSON.stringify(line)}`);
|
|
848
|
+
}
|
|
849
|
+
assert.equal(r.out.split("\n").filter((l) => l.startsWith("VERIFIED")).length, 0, "no forged VERIFIED line");
|
|
850
|
+
assert.equal(r.out.split("\n").filter((l) => l.startsWith("PASS every checkpoint")).length, 0, "no forged PASS line");
|
|
851
|
+
// eslint-disable-next-line no-control-regex
|
|
852
|
+
assert.ok(!/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/.test(r.out), "no raw control characters reach the terminal");
|
|
853
|
+
// The same holds for the other interpolated fields: a hostile kid in a key list, and a hostile seq.
|
|
854
|
+
const badKeys = cli(["@export.ndjson", "--keys", "@keys.json"], { "export.ndjson": GOOD, "keys.json": JSON.stringify([{ ...KEY.entry, alg: evil }]) });
|
|
855
|
+
assert.equal(badKeys.status, 1);
|
|
856
|
+
assert.ok(!badKeys.out.includes("\u001b") && !badKeys.out.includes("\u009b") && !/^VERIFIED/m.test(badKeys.out));
|
|
857
|
+
const r2 = run(GOOD.replace(/"seq":10,"hash"/, '"seq":"1\\n2","hash"'));
|
|
858
|
+
assert.ok(r2.problems.every((p) => !p.includes("\n")));
|
|
859
|
+
});
|
|
860
|
+
// ---------------------------------------------------------------- hostile text, both layers
|
|
861
|
+
//
|
|
862
|
+
// Two independent layers keep an untrusted string off the terminal: show() (used when a field is interpolated into a
|
|
863
|
+
// message) and escapeUnsafe() at print time (which also covers the fields that are only JSON.stringify'd: line.type,
|
|
864
|
+
// payload.kind and trailer.next, and file names and error messages). Each is pinned here without relying on the other.
|
|
865
|
+
// Escape, CSI (C1), OSC, bell, bidi override, line separator, zero width space.
|
|
866
|
+
const HOSTILE = "a\u009b2J\u001b]0;pwn\u0007\u202egnp.exe\u2028\u200b";
|
|
867
|
+
// Everything but \n, which is ours.
|
|
868
|
+
// eslint-disable-next-line no-control-regex
|
|
869
|
+
const UNSAFE = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u00ad\u061c\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff\ufff9-\ufffb]/;
|
|
870
|
+
const OURS = /^(PASS|FAIL|PROBLEM|note|NOT VERIFIED|AUTHENTIC|VERIFIED|attestation sha256|usage:|\s+appforge-verify)/;
|
|
871
|
+
function assertTerminalSafe(name, r, expectEscaped) {
|
|
872
|
+
assert.ok(!UNSAFE.test(r.out), `${name}: raw unsafe character on stdout: ${JSON.stringify(r.out)}`);
|
|
873
|
+
assert.ok(!UNSAFE.test(r.err), `${name}: raw unsafe character on stderr: ${JSON.stringify(r.err)}`);
|
|
874
|
+
assert.match(r.out + r.err, expectEscaped, `${name}: the hostile text should be shown escaped, not dropped`);
|
|
875
|
+
// On stdout every line starts with one of our own prefixes: nothing the file said can start a line of its own.
|
|
876
|
+
for (const line of r.out.split("\n"))
|
|
877
|
+
assert.ok(line === "" || OURS.test(line), `${name}: unexpected output line ${JSON.stringify(line)}`);
|
|
878
|
+
}
|
|
879
|
+
test("cli: fields that are not run through show() (line.type, payload.kind, trailer.next, file names, option names) are still escaped when printed", async () => {
|
|
880
|
+
const keys = JSON.stringify(keyList);
|
|
881
|
+
const files = (text) => ({ "export.ndjson": text, "keys.json": keys });
|
|
882
|
+
// line.type: "unknown line type" prints JSON.stringify(type), which leaves C1, bidi and U+2028 as they are.
|
|
883
|
+
const lines = parseLines(GOOD);
|
|
884
|
+
lines.splice(1, 0, { type: HOSTILE });
|
|
885
|
+
let r = cli(["@export.ndjson", "--keys", "@keys.json"], files(joinLines(lines)));
|
|
886
|
+
assert.equal(r.status, 1);
|
|
887
|
+
assert.match(r.out, /unknown line type/);
|
|
888
|
+
assertTerminalSafe("line.type", r, /\\u009b2J.*\\u202e/);
|
|
889
|
+
// payload.kind: a validly hashed entry of a hostile kind.
|
|
890
|
+
const evilKind = chain30.slice(0, 3);
|
|
891
|
+
await appendEntry(evilKind, ORG, HOSTILE, "x", at(3), { a: 1 });
|
|
892
|
+
r = cli(["@export.ndjson", "--keys", "@keys.json"], files(exportText(ORG, evilKind, [])));
|
|
893
|
+
assert.equal(r.status, 1);
|
|
894
|
+
assert.match(r.out, /is not known to this verifier/);
|
|
895
|
+
assertTerminalSafe("payload.kind", r, /\\u009b2J.*\\u202e/);
|
|
896
|
+
// trailer.next: the "more pages exist" note prints it.
|
|
897
|
+
r = cli(["@export.ndjson", "--keys", "@keys.json"], files(exportText(ORG, chain30.slice(0, 10), [cp10], { next: HOSTILE })));
|
|
898
|
+
assert.equal(r.status, 3);
|
|
899
|
+
assert.match(r.out, /more pages exist/);
|
|
900
|
+
assertTerminalSafe("trailer.next", r, /\\u009b2J.*\\u202e/);
|
|
901
|
+
// A hostile option name, a hostile input path (the read error names it) and a hostile --make-key-entry path.
|
|
902
|
+
r = cli([`--${HOSTILE}`]);
|
|
903
|
+
assert.equal(r.status, 64);
|
|
904
|
+
assertTerminalSafe("option name", r, /unknown option/);
|
|
905
|
+
r = cli([HOSTILE, "--keys", "@keys.json"], { "keys.json": keys });
|
|
906
|
+
assert.equal(r.status, 1);
|
|
907
|
+
assertTerminalSafe("input path", r, /\\u009b2J.*\\u202e/);
|
|
908
|
+
r = cli(["--make-key-entry", HOSTILE]);
|
|
909
|
+
assert.equal(r.status, 1);
|
|
910
|
+
assertTerminalSafe("key entry path", r, /\\u009b2J.*\\u202e/);
|
|
911
|
+
});
|
|
912
|
+
test("export: values interpolated into a message are escaped by show() itself, before anything is printed", async () => {
|
|
913
|
+
const safe = (name, r) => {
|
|
914
|
+
for (const m of [...r.problems, ...r.notes]) {
|
|
915
|
+
assert.ok(!UNSAFE.test(m), `${name}: message has a raw unsafe character: ${JSON.stringify(m)}`);
|
|
916
|
+
assert.ok(!m.includes("\n"), `${name}: message spans lines: ${JSON.stringify(m)}`);
|
|
917
|
+
}
|
|
918
|
+
assert.ok(r.problems.length > 0, `${name}: expected a problem to inspect`);
|
|
919
|
+
};
|
|
920
|
+
// The signing key id named by a checkpoint.
|
|
921
|
+
let lines = parseLines(GOOD);
|
|
922
|
+
lines.find((l) => l.type === "checkpoint").keyId = HOSTILE;
|
|
923
|
+
let r = run(joinLines(lines));
|
|
924
|
+
assert.match(r.problems.join("\n"), /not in the trusted key set/);
|
|
925
|
+
safe("keyId", r);
|
|
926
|
+
// A checkpoint seq, an entry seq.
|
|
927
|
+
lines = parseLines(GOOD);
|
|
928
|
+
lines.find((l) => l.type === "checkpoint").seq = HOSTILE;
|
|
929
|
+
r = run(joinLines(lines));
|
|
930
|
+
assert.match(r.problems.join("\n"), /malformed/);
|
|
931
|
+
safe("checkpoint seq", r);
|
|
932
|
+
lines = parseLines(GOOD);
|
|
933
|
+
lines.find((l) => l.type === "entry" && l.seq === 3).seq = HOSTILE;
|
|
934
|
+
r = run(joinLines(lines));
|
|
935
|
+
assert.match(r.problems.join("\n"), /is malformed/);
|
|
936
|
+
safe("entry seq", r);
|
|
937
|
+
// Fields of a key list: algorithm, kid, an unknown field name, a bad time.
|
|
938
|
+
for (const [name, entry, re] of [
|
|
939
|
+
["alg", { ...KEY.entry, alg: HOSTILE }, /unsupported algorithm/],
|
|
940
|
+
["unknown field", { ...KEY.entry, [HOSTILE]: 1 }, /unknown field/],
|
|
941
|
+
["notBefore", { ...KEY.entry, notBefore: HOSTILE }, /notBefore must be/],
|
|
942
|
+
["status", { ...KEY.entry, status: HOSTILE }, /status must be/],
|
|
943
|
+
]) {
|
|
944
|
+
r = run(GOOD, [entry]);
|
|
945
|
+
assert.match(r.problems.join("\n"), re, name);
|
|
946
|
+
safe(`key ${name}`, r);
|
|
947
|
+
}
|
|
948
|
+
r = run(GOOD, [{ ...KEY.entry, kid: HOSTILE, publicKey: "AAAA" }]);
|
|
949
|
+
safe("key kid", r);
|
|
950
|
+
// A hostile key id never reaches a message unescaped through a bundle either.
|
|
951
|
+
const b = clone(CLEAN);
|
|
952
|
+
b.attestation.signatures[0].keyId = HOSTILE;
|
|
953
|
+
b.attestation.signatures[0].role = HOSTILE;
|
|
954
|
+
const br = V.verifyBundle(b, keysDoc(), {});
|
|
955
|
+
assert.match(br.problems.join("\n"), /not in the trusted key set/);
|
|
956
|
+
safe("bundle signature role/keyId", br);
|
|
957
|
+
});
|
|
958
|
+
//# sourceMappingURL=appforge-verify.test.js.map
|