@intentius/chant 0.100.0 → 0.101.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/dist/build.d.ts +6 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/build-options.d.ts +2 -0
- package/dist/cli/build-options.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/import.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.d.ts +8 -0
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +22 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/config.d.ts +11 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/lexicon.d.ts +24 -1
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/plan-digest.d.ts +26 -5
- package/dist/lifecycle/plan-digest.d.ts.map +1 -1
- package/dist/lint/config.d.ts +4 -4
- package/dist/op/activities/activity-contracts.d.ts +1 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/propose-upgrade.d.ts +2 -0
- package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
- package/dist/op/index.d.ts +1 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/serializer.d.ts +8 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/telemetry-attribution.d.ts +77 -0
- package/dist/telemetry-attribution.d.ts.map +1 -0
- package/dist/workspace/agent-cli.d.ts +83 -0
- package/dist/workspace/agent-cli.d.ts.map +1 -0
- package/dist/workspace/changes-cli.d.ts.map +1 -1
- package/dist/workspace/changes.d.ts +8 -1
- package/dist/workspace/changes.d.ts.map +1 -1
- package/dist/workspace/checks/links.d.ts +1 -0
- package/dist/workspace/checks/links.d.ts.map +1 -1
- package/dist/workspace/checks/live.d.ts +40 -0
- package/dist/workspace/checks/live.d.ts.map +1 -0
- package/dist/workspace/checks.d.ts +21 -2
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/compose-graph.d.ts +63 -0
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/decide.d.ts +1 -1
- package/dist/workspace/decide.d.ts.map +1 -1
- package/dist/workspace/declaration.d.ts +32 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +138 -3
- package/dist/workspace/export-cli.d.ts +12 -0
- package/dist/workspace/export-cli.d.ts.map +1 -0
- package/dist/workspace/export.d.ts +145 -0
- package/dist/workspace/export.d.ts.map +1 -0
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/import.d.ts +73 -0
- package/dist/workspace/import.d.ts.map +1 -0
- package/dist/workspace/kinds.d.ts +6 -2
- package/dist/workspace/kinds.d.ts.map +1 -1
- package/dist/workspace/lineage-adopt-cli.d.ts +15 -0
- package/dist/workspace/lineage-adopt-cli.d.ts.map +1 -0
- package/dist/workspace/lineage-adopt.d.ts +106 -0
- package/dist/workspace/lineage-adopt.d.ts.map +1 -0
- package/dist/workspace/lineage-check.d.ts +9 -2
- package/dist/workspace/lineage-check.d.ts.map +1 -1
- package/dist/workspace/lineage-cli.d.ts +6 -1
- package/dist/workspace/lineage-cli.d.ts.map +1 -1
- package/dist/workspace/lineage-hash-index.d.ts +110 -0
- package/dist/workspace/lineage-hash-index.d.ts.map +1 -0
- package/dist/workspace/lineage-init.d.ts +10 -0
- package/dist/workspace/lineage-init.d.ts.map +1 -1
- package/dist/workspace/lineage-lock.d.ts +147 -0
- package/dist/workspace/lineage-lock.d.ts.map +1 -1
- package/dist/workspace/lineage-migrations.d.ts +15 -3
- package/dist/workspace/lineage-migrations.d.ts.map +1 -1
- package/dist/workspace/lineage-provenance.d.ts +18 -0
- package/dist/workspace/lineage-provenance.d.ts.map +1 -0
- package/dist/workspace/lineage-upgrade-cli.d.ts +2 -0
- package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.d.ts +30 -1
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/lineage-versions.d.ts +116 -0
- package/dist/workspace/lineage-versions.d.ts.map +1 -0
- package/dist/workspace/links.d.ts +31 -5
- package/dist/workspace/links.d.ts.map +1 -1
- package/dist/workspace/ls-generated.d.ts +37 -0
- package/dist/workspace/ls-generated.d.ts.map +1 -0
- package/dist/workspace/ls.d.ts +4 -0
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/nested-graph.d.ts +56 -0
- package/dist/workspace/nested-graph.d.ts.map +1 -0
- package/dist/workspace/nesting.d.ts +21 -0
- package/dist/workspace/nesting.d.ts.map +1 -0
- package/dist/workspace/pin-cli.d.ts +10 -0
- package/dist/workspace/pin-cli.d.ts.map +1 -0
- package/dist/workspace/pin-integrity.d.ts +51 -0
- package/dist/workspace/pin-integrity.d.ts.map +1 -0
- package/dist/workspace/reason-codes.d.ts +26 -2
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-sessions.d.ts +7 -11
- package/dist/workspace/record-sessions.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +30 -1
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-close.d.ts +5 -2
- package/dist/workspace/records-close.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +22 -4
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +43 -5
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/returns.d.ts +129 -0
- package/dist/workspace/returns.d.ts.map +1 -0
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/dist/workspace/template-manifest.d.ts +11 -3
- package/dist/workspace/template-manifest.d.ts.map +1 -1
- package/dist/workspace/trust/attestor.d.ts +8 -0
- package/dist/workspace/trust/attestor.d.ts.map +1 -1
- package/dist/workspace/trust/dsse.d.ts +58 -0
- package/dist/workspace/trust/dsse.d.ts.map +1 -0
- package/dist/workspace/trust/evidence-cli.d.ts +66 -0
- package/dist/workspace/trust/evidence-cli.d.ts.map +1 -0
- package/dist/workspace/trust/evidence.d.ts +93 -0
- package/dist/workspace/trust/evidence.d.ts.map +1 -0
- package/dist/workspace/trust/policy.d.ts +54 -1
- package/dist/workspace/trust/policy.d.ts.map +1 -1
- package/dist/workspace/trust/provenance.d.ts +21 -1
- package/dist/workspace/trust/provenance.d.ts.map +1 -1
- package/dist/workspace/trust/rotation.d.ts +132 -0
- package/dist/workspace/trust/rotation.d.ts.map +1 -0
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/signers-cli.d.ts +49 -0
- package/dist/workspace/trust/signers-cli.d.ts.map +1 -0
- package/dist/workspace/trust/ssh-commit.d.ts +12 -0
- package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
- package/dist/workspace/trust/test-repo.d.ts +13 -0
- package/dist/workspace/trust/test-repo.d.ts.map +1 -1
- package/dist/workspace/trust/verify.d.ts +15 -0
- package/dist/workspace/trust/verify.d.ts.map +1 -1
- package/dist/workspace/work-evidence.d.ts +1 -1
- package/dist/workspace/work-evidence.d.ts.map +1 -1
- package/dist/workspace/write-scope.d.ts +199 -0
- package/dist/workspace/write-scope.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/build.ts +8 -0
- package/src/cli/build-options.ts +4 -1
- package/src/cli/commands/build.ts +2 -0
- package/src/cli/commands/import-live.test.ts +69 -1
- package/src/cli/commands/import.ts +48 -22
- package/src/cli/handlers/fan-out.test.ts +6 -6
- package/src/cli/handlers/fan-out.ts +2 -1
- package/src/cli/handlers/graph.test.ts +42 -0
- package/src/cli/handlers/graph.ts +22 -0
- package/src/cli/handlers/operator.ts +1 -1
- package/src/cli/main.test.ts +32 -0
- package/src/cli/main.ts +103 -6
- package/src/cli/mcp/workspace-tools.test.ts +1 -1
- package/src/cli/mcp/workspace-tools.ts +33 -2
- package/src/cli/registry.ts +22 -0
- package/src/cli/serve-mcp-workspace.test.ts +1 -1
- package/src/codegen/release-wiring.test.ts +5 -1
- package/src/components/fan-out-output.test.ts +1 -1
- package/src/components/fan-out.test.ts +1 -1
- package/src/components/promote.test.ts +1 -1
- package/src/config.ts +12 -0
- package/src/content-digest.test.ts +2 -2
- package/src/lexicon.ts +25 -1
- package/src/lifecycle/gate-ledger.test.ts +14 -0
- package/src/lifecycle/gate-ledger.ts +2 -1
- package/src/lifecycle/plan-digest.test.ts +54 -3
- package/src/lifecycle/plan-digest.ts +38 -8
- package/src/op/activities/activity-contracts.ts +1 -0
- package/src/op/activities/propose-upgrade.ts +8 -5
- package/src/op/gate-approval.test.ts +17 -0
- package/src/op/gate.ts +3 -3
- package/src/op/index.ts +1 -1
- package/src/serializer.ts +9 -0
- package/src/telemetry-attribution.test.ts +91 -0
- package/src/telemetry-attribution.ts +145 -0
- package/src/workspace/agent-cli.ts +134 -0
- package/src/workspace/agent.schema.json +356 -0
- package/src/workspace/behold-kinds.test.ts +1 -1
- package/src/workspace/changes-cli.ts +5 -0
- package/src/workspace/changes.schema.json +179 -1
- package/src/workspace/changes.ts +58 -4
- package/src/workspace/check-live.test.ts +192 -0
- package/src/workspace/check.schema.json +64 -0
- package/src/workspace/checks/links.ts +23 -2
- package/src/workspace/checks/live.ts +113 -0
- package/src/workspace/checks.test.ts +2 -2
- package/src/workspace/checks.ts +20 -3
- package/src/workspace/compose-graph.test.ts +47 -0
- package/src/workspace/compose-graph.ts +120 -3
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +7 -1
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +5 -0
- package/src/workspace/declaration.schema.json +138 -3
- package/src/workspace/declaration.ts +106 -1
- package/src/workspace/declared-kinds.test.ts +33 -0
- package/src/workspace/evidence.schema.json +279 -0
- package/src/workspace/export-cli.ts +180 -0
- package/src/workspace/export-import.test.ts +282 -0
- package/src/workspace/export.ts +486 -0
- package/src/workspace/graph-cli.ts +47 -1
- package/src/workspace/graph-contract.test.ts +89 -4
- package/src/workspace/graph.schema.json +206 -1
- package/src/workspace/import.ts +325 -0
- package/src/workspace/kinds.test.ts +5 -5
- package/src/workspace/kinds.ts +25 -3
- package/src/workspace/lineage-adopt-cli.ts +103 -0
- package/src/workspace/lineage-adopt.test.ts +552 -0
- package/src/workspace/lineage-adopt.ts +452 -0
- package/src/workspace/lineage-check.ts +26 -5
- package/src/workspace/lineage-cli.ts +12 -2
- package/src/workspace/lineage-hash-index.ts +305 -0
- package/src/workspace/lineage-init.test.ts +9 -0
- package/src/workspace/lineage-init.ts +29 -9
- package/src/workspace/lineage-lock.ts +54 -0
- package/src/workspace/lineage-migrations.test.ts +27 -0
- package/src/workspace/lineage-migrations.ts +44 -14
- package/src/workspace/lineage-provenance.ts +40 -0
- package/src/workspace/lineage-upgrade-cli.ts +9 -5
- package/src/workspace/lineage-upgrade.test.ts +92 -1
- package/src/workspace/lineage-upgrade.ts +111 -16
- package/src/workspace/lineage-versions.ts +348 -0
- package/src/workspace/links.test.ts +121 -2
- package/src/workspace/links.ts +97 -6
- package/src/workspace/ls-contract.test.ts +84 -1
- package/src/workspace/ls-generated.ts +111 -0
- package/src/workspace/ls.schema.json +19 -0
- package/src/workspace/ls.ts +8 -1
- package/src/workspace/member-commands.ts +3 -1
- package/src/workspace/nested-graph.test.ts +176 -0
- package/src/workspace/nested-graph.ts +169 -0
- package/src/workspace/nesting.ts +37 -0
- package/src/workspace/pin-cli.test.ts +71 -0
- package/src/workspace/pin-cli.ts +57 -0
- package/src/workspace/pin-integrity.test.ts +121 -0
- package/src/workspace/pin-integrity.ts +104 -0
- package/src/workspace/points-write.schema.json +1 -0
- package/src/workspace/read-contract.test.ts +24 -0
- package/src/workspace/reason-codes.test.ts +10 -0
- package/src/workspace/reason-codes.ts +29 -2
- package/src/workspace/record-sessions.ts +12 -14
- package/src/workspace/records-amend.schema.json +4 -0
- package/src/workspace/records-cli.ts +93 -12
- package/src/workspace/records-close.schema.json +6 -2
- package/src/workspace/records-close.ts +10 -2
- package/src/workspace/records-formats.test.ts +13 -5
- package/src/workspace/records-new.schema.json +4 -0
- package/src/workspace/records-review.schema.json +4 -0
- package/src/workspace/records-sessions.test.ts +23 -6
- package/src/workspace/records-write.test.ts +65 -8
- package/src/workspace/records-write.ts +70 -6
- package/src/workspace/records.schema.json +55 -3
- package/src/workspace/records.ts +96 -5
- package/src/workspace/returns.ts +328 -0
- package/src/workspace/signers.schema.json +206 -0
- package/src/workspace/status-gates.ts +2 -1
- package/src/workspace/template-manifest.ts +22 -4
- package/src/workspace/trust/attestor.ts +15 -0
- package/src/workspace/trust/dsse.ts +134 -0
- package/src/workspace/trust/evidence-cli.ts +195 -0
- package/src/workspace/trust/evidence.test.ts +241 -0
- package/src/workspace/trust/evidence.ts +207 -0
- package/src/workspace/trust/policy.ts +110 -3
- package/src/workspace/trust/provenance.ts +41 -4
- package/src/workspace/trust/record-seal.test.ts +1 -1
- package/src/workspace/trust/rotation.test.ts +258 -0
- package/src/workspace/trust/rotation.ts +336 -0
- package/src/workspace/trust/seal.ts +11 -0
- package/src/workspace/trust/signers-cli.ts +178 -0
- package/src/workspace/trust/ssh-commit.ts +53 -4
- package/src/workspace/trust/test-repo.ts +18 -0
- package/src/workspace/trust/trust.test.ts +8 -2
- package/src/workspace/trust/verify-cli.ts +1 -0
- package/src/workspace/trust/verify.ts +22 -0
- package/src/workspace/work-evidence.schema.json +4 -0
- package/src/workspace/write-scope.test.ts +340 -0
- package/src/workspace/write-scope.ts +448 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2553: signer rotation, verified from history, with revocation by position.
|
|
3
|
+
*
|
|
4
|
+
* The attacker controls the change and its commit dates: a rotation signed by
|
|
5
|
+
* the key it adds, too few signatures, a replayed or rolled-back set, a commit
|
|
6
|
+
* signature passed off as a rotation signature, a main branch whose set was
|
|
7
|
+
* changed without a rotation, and a revoked key's commit backdated to before
|
|
8
|
+
* the revocation.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterEach, describe, expect, test } from "vitest";
|
|
12
|
+
import { queryRecords, type RecordView } from "../records-cli";
|
|
13
|
+
import { activeAttestors } from "./attestor";
|
|
14
|
+
import { policyAtBase, resolveBase } from "./provenance";
|
|
15
|
+
import { distinctHolders, signerHistory } from "./rotation";
|
|
16
|
+
import { parseAllowedSigners } from "./policy";
|
|
17
|
+
import { SIGNERS_OUTPUT_SCHEMA_ID, signersDocument } from "./signers-cli";
|
|
18
|
+
import { contract } from "../__fixtures__/contract-repo";
|
|
19
|
+
import signersSchema from "../signers.schema.json";
|
|
20
|
+
import { verifyChange } from "./verify";
|
|
21
|
+
import { hasSshKeygen, note, TestRepo, writeRecordKind, writeRotation, type Key } from "./test-repo";
|
|
22
|
+
|
|
23
|
+
const repos: TestRepo[] = [];
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
while (repos.length) repos.pop()!.cleanup();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function signers(...entries: Array<[string, Key]>): string {
|
|
29
|
+
return entries.map(([p, k]) => `${p} ${k.pub}`).join("\n") + "\n";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function verify(r: TestRepo, require?: "attested") {
|
|
33
|
+
return verifyChange({ repo: r.dir, base: "main", require, attestors: await activeAttestors() });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function records(r: TestRepo, kind: string): Promise<RecordView[]> {
|
|
37
|
+
const doc = await queryRecords({ kind, cwd: r.dir, base: "main" });
|
|
38
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
39
|
+
return doc.records;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function level(rs: RecordView[], id: string): string {
|
|
43
|
+
return rs.find((x) => x.id === id)!.provenance.level;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** main holds version 1: alice and bob, with `threshold`. */
|
|
47
|
+
function setup(label: string, threshold = 1) {
|
|
48
|
+
const r = new TestRepo(label);
|
|
49
|
+
repos.push(r);
|
|
50
|
+
const alice = r.key("alice");
|
|
51
|
+
const bob = r.key("bob");
|
|
52
|
+
const carol = r.key("carol");
|
|
53
|
+
const kind = writeRecordKind(r);
|
|
54
|
+
const v1 = signers(["alice@example.test", alice], ["bob@example.test", bob]);
|
|
55
|
+
r.write(".chant/allowed_signers", v1);
|
|
56
|
+
if (threshold !== 1) {
|
|
57
|
+
r.write(".chant/allowed_signers.rotation.json", JSON.stringify({ schema: 1, version: 1, previous: null, threshold, signatures: [] }));
|
|
58
|
+
}
|
|
59
|
+
r.commit("signers v1", alice);
|
|
60
|
+
return { r, alice, bob, carol, kind, v1 };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe.skipIf(!hasSshKeygen)("signer rotation", () => {
|
|
64
|
+
test("a new set signed by the old one is accepted, and history shows both versions", async () => {
|
|
65
|
+
const { r, alice, bob, carol, v1 } = setup("rotate");
|
|
66
|
+
r.git(["checkout", "-q", "-b", "change"]);
|
|
67
|
+
r.write(".chant/allowed_signers", signers(["alice@example.test", alice], ["bob@example.test", bob], ["carol@example.test", carol]));
|
|
68
|
+
writeRotation(r, { previousText: v1, version: 2, by: [["alice@example.test", alice]] });
|
|
69
|
+
r.commit("add carol", alice);
|
|
70
|
+
const report = await verify(r);
|
|
71
|
+
expect(report.failures).toEqual([]);
|
|
72
|
+
expect(report.rotation).toEqual({ from: 1, to: 2, signedBy: ["alice@example.test"] });
|
|
73
|
+
|
|
74
|
+
r.git(["checkout", "-q", "main"]);
|
|
75
|
+
r.git(["merge", "-q", "--ff-only", "change"]);
|
|
76
|
+
const h = signerHistory(r.dir, r.head(), ".chant/allowed_signers");
|
|
77
|
+
expect(h.broken).toBeUndefined();
|
|
78
|
+
expect(h.versions.map((v) => [v.version, v.signedBy])).toEqual([
|
|
79
|
+
[1, []],
|
|
80
|
+
[2, ["alice@example.test"]],
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("attack: the change's new signer signs her own admission", async () => {
|
|
85
|
+
const { r, alice, bob, carol, v1 } = setup("self-admit");
|
|
86
|
+
r.git(["checkout", "-q", "-b", "change"]);
|
|
87
|
+
r.write(".chant/allowed_signers", signers(["alice@example.test", alice], ["bob@example.test", bob], ["carol@example.test", carol]));
|
|
88
|
+
writeRotation(r, { previousText: v1, version: 2, by: [["carol@example.test", carol]] });
|
|
89
|
+
r.commit("add carol", alice);
|
|
90
|
+
const report = await verify(r);
|
|
91
|
+
expect(report.ok).toBe(false);
|
|
92
|
+
expect(report.failures.join("\n")).toMatch(/signed by 0 of the 1 signers of version 1/);
|
|
93
|
+
expect(report.rotation).toMatchObject({ from: 1, code: "rotation-threshold-not-met" });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a threshold of 2 needs two distinct keys; one key under two names counts once", async () => {
|
|
97
|
+
const { r, alice, bob, carol, v1 } = setup("threshold", 2);
|
|
98
|
+
r.git(["checkout", "-q", "-b", "change"]);
|
|
99
|
+
const v2 = signers(["alice@example.test", alice], ["bob@example.test", bob], ["carol@example.test", carol]);
|
|
100
|
+
r.write(".chant/allowed_signers", v2);
|
|
101
|
+
writeRotation(r, { previousText: v1, version: 2, threshold: 2, by: [["alice@example.test", alice]] });
|
|
102
|
+
r.commit("one signature", alice);
|
|
103
|
+
expect((await verify(r)).failures.join("\n")).toMatch(/signed by 1 of the 2/);
|
|
104
|
+
|
|
105
|
+
// alice's key listed twice at base would still be one key.
|
|
106
|
+
writeRotation(r, { previousText: v1, version: 2, threshold: 2, by: [["alice@example.test", alice], ["alice@example.test", alice]] });
|
|
107
|
+
r.commit("same key twice", alice);
|
|
108
|
+
expect((await verify(r)).failures.join("\n")).toMatch(/signed by 1 of the 2/);
|
|
109
|
+
|
|
110
|
+
writeRotation(r, { previousText: v1, version: 2, threshold: 2, by: [["alice@example.test", alice], ["bob@example.test", bob]] });
|
|
111
|
+
r.commit("two signatures", alice);
|
|
112
|
+
const report = await verify(r);
|
|
113
|
+
expect(report.failures).toEqual([]);
|
|
114
|
+
expect(report.rotation && "to" in report.rotation && report.rotation.signedBy.sort()).toEqual(["alice@example.test", "bob@example.test"]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("attack: a commit signature, in the git namespace, is not a rotation signature", async () => {
|
|
118
|
+
const { r, alice, bob, carol, v1 } = setup("namespace");
|
|
119
|
+
r.git(["checkout", "-q", "-b", "change"]);
|
|
120
|
+
r.write(".chant/allowed_signers", signers(["alice@example.test", alice], ["bob@example.test", bob], ["carol@example.test", carol]));
|
|
121
|
+
writeRotation(r, { previousText: v1, version: 2, by: [["alice@example.test", alice]], namespace: "git" });
|
|
122
|
+
r.commit("add carol", alice);
|
|
123
|
+
const report = await verify(r);
|
|
124
|
+
expect(report.ok).toBe(false);
|
|
125
|
+
expect(report.rotation).toMatchObject({ code: "rotation-threshold-not-met" });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("attack: rolling back to an older set, or replaying an old rotation, is refused", async () => {
|
|
129
|
+
const { r, alice, bob, v1 } = setup("rollback");
|
|
130
|
+
// version 2 removes bob, and is merged.
|
|
131
|
+
const v2 = signers(["alice@example.test", alice]);
|
|
132
|
+
r.write(".chant/allowed_signers", v2);
|
|
133
|
+
writeRotation(r, { previousText: v1, version: 2, by: [["alice@example.test", alice]] });
|
|
134
|
+
r.commit("remove bob", alice);
|
|
135
|
+
const replay = r.git(["show", "HEAD:.chant/allowed_signers.rotation.json"]);
|
|
136
|
+
|
|
137
|
+
// Restore v1 and bob, keeping version 2's rotation file.
|
|
138
|
+
r.git(["checkout", "-q", "-b", "rollback"]);
|
|
139
|
+
r.write(".chant/allowed_signers", v1);
|
|
140
|
+
r.write(".chant/allowed_signers.rotation.json", replay);
|
|
141
|
+
r.commit("bring bob back", alice);
|
|
142
|
+
expect((await verify(r)).failures.join("\n")).toMatch(/\(rotation-version-skew\).*the next version is 3/);
|
|
143
|
+
|
|
144
|
+
// Or claim to be version 3 with version 1's digest as previous.
|
|
145
|
+
writeRotation(r, { previousText: v1, version: 3, by: [["alice@example.test", alice], ["bob@example.test", bob]] });
|
|
146
|
+
r.commit("claim v3", alice);
|
|
147
|
+
expect((await verify(r)).failures.join("\n")).toMatch(/\(rotation-previous-mismatch\).*names previous/);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("attack: a signer set changed on main without a rotation breaks the history, and nothing verifies", async () => {
|
|
151
|
+
const { r, alice, bob, carol, kind } = setup("broken");
|
|
152
|
+
r.write("records/n1.md", note("n1"));
|
|
153
|
+
r.commit("n1", alice);
|
|
154
|
+
r.write(".chant/allowed_signers", signers(["alice@example.test", alice], ["bob@example.test", bob], ["carol@example.test", carol]));
|
|
155
|
+
r.commit("pushed straight to main", alice);
|
|
156
|
+
const policy = policyAtBase(r.dir, resolveBase(r.dir, "main"));
|
|
157
|
+
expect(policy.problems[0]).toMatch(/signer history .* is broken/);
|
|
158
|
+
expect(signerHistory(r.dir, "main", ".chant/allowed_signers").broken).toMatchObject({ code: "rotation-missing" });
|
|
159
|
+
expect(level(await records(r, kind), "n1")).toBe("unattested");
|
|
160
|
+
r.git(["checkout", "-q", "-b", "change"]);
|
|
161
|
+
expect((await verify(r)).ok).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("attack: a revoked key keeps what was merged before its revocation, and nothing after, whatever the dates say", async () => {
|
|
165
|
+
const { r, alice, bob, v1, kind } = setup("position");
|
|
166
|
+
r.write("records/early.md", note("early"));
|
|
167
|
+
r.commit("bob, before revocation", bob);
|
|
168
|
+
r.git(["branch", "fork-before-revocation"]);
|
|
169
|
+
|
|
170
|
+
const v2 = signers(["alice@example.test", alice]);
|
|
171
|
+
r.write(".chant/allowed_signers", v2);
|
|
172
|
+
writeRotation(r, { previousText: v1, version: 2, by: [["alice@example.test", alice]] });
|
|
173
|
+
r.commit("revoke bob", alice);
|
|
174
|
+
|
|
175
|
+
// bob, after revocation, backdates a commit onto a branch forked before it...
|
|
176
|
+
r.git(["checkout", "-q", "fork-before-revocation"]);
|
|
177
|
+
r.write("records/late.md", note("late"));
|
|
178
|
+
const old = "2001-01-01T00:00:00Z";
|
|
179
|
+
r.commit("bob, backdated", bob, { GIT_AUTHOR_DATE: old, GIT_COMMITTER_DATE: old });
|
|
180
|
+
// ...and it is merged after the revocation, by a signer in good standing.
|
|
181
|
+
r.git(["checkout", "-q", "main"]);
|
|
182
|
+
r.git(["-c", "gpg.format=ssh", "-c", `user.signingkey=${alice.file}`, "merge", "-q", "--no-ff", "-S", "-m", "merge", "fork-before-revocation"]);
|
|
183
|
+
|
|
184
|
+
// And straight onto main, backdated too.
|
|
185
|
+
r.write("records/direct.md", note("direct"));
|
|
186
|
+
r.commit("bob, backdated, on main", bob, { GIT_AUTHOR_DATE: old, GIT_COMMITTER_DATE: old });
|
|
187
|
+
|
|
188
|
+
const rs = await records(r, kind);
|
|
189
|
+
expect(level(rs, "early")).toBe("attested");
|
|
190
|
+
expect(rs.find((x) => x.id === "early")!.provenance.principal).toBe("bob@example.test");
|
|
191
|
+
expect(level(rs, "late")).toBe("unattested");
|
|
192
|
+
expect(level(rs, "direct")).toBe("unattested");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("the commit that adds the first signer set is judged by the set before it, which is none", async () => {
|
|
196
|
+
const r = new TestRepo("genesis");
|
|
197
|
+
repos.push(r);
|
|
198
|
+
const alice = r.key("alice");
|
|
199
|
+
const kind = writeRecordKind(r);
|
|
200
|
+
r.write(".chant/allowed_signers", signers(["alice@example.test", alice]));
|
|
201
|
+
r.write("records/n1.md", note("n1"));
|
|
202
|
+
r.commit("signers and a record together", alice);
|
|
203
|
+
r.write("records/n2.md", note("n2"));
|
|
204
|
+
r.commit("n2", alice);
|
|
205
|
+
const rs = await records(r, kind);
|
|
206
|
+
expect(level(rs, "n1")).toBe("unattested");
|
|
207
|
+
expect(level(rs, "n2")).toBe("attested");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("attack: one person with two keys signs twice, and counts once", async () => {
|
|
211
|
+
const r = new TestRepo("two-keys");
|
|
212
|
+
repos.push(r);
|
|
213
|
+
const a1 = r.key("alice1");
|
|
214
|
+
const a2 = r.key("alice2");
|
|
215
|
+
const bob = r.key("bob");
|
|
216
|
+
const v1 = signers(["alice@example.test", a1], ["alice@example.test", a2], ["bob@example.test", bob]);
|
|
217
|
+
r.write(".chant/allowed_signers", v1);
|
|
218
|
+
r.write(".chant/allowed_signers.rotation.json", JSON.stringify({ schema: 1, version: 1, previous: null, threshold: 2, signatures: [] }));
|
|
219
|
+
r.commit("v1, threshold 2", a1);
|
|
220
|
+
r.git(["checkout", "-q", "-b", "change"]);
|
|
221
|
+
r.write(".chant/allowed_signers", signers(["alice@example.test", a1], ["bob@example.test", bob]));
|
|
222
|
+
writeRotation(r, { previousText: v1, version: 2, threshold: 2, by: [["alice@example.test", a1], ["alice@example.test", a2]] });
|
|
223
|
+
r.commit("alice twice", a1);
|
|
224
|
+
const report = await verify(r);
|
|
225
|
+
expect(report.rotation).toMatchObject({ code: "rotation-threshold-not-met" });
|
|
226
|
+
expect(report.failures.join("\n")).toMatch(/signed by 1 of the 2/);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("a threshold the new set cannot meet is refused, so the set can never be frozen", async () => {
|
|
230
|
+
const { r, alice, v1 } = setup("unsatisfiable");
|
|
231
|
+
r.git(["checkout", "-q", "-b", "change"]);
|
|
232
|
+
// Two lines, one principal: one distinct signer for a threshold of 2.
|
|
233
|
+
r.write(".chant/allowed_signers", signers(["alice@example.test", alice], ["alice@example.test", r.key("alice-laptop")]));
|
|
234
|
+
writeRotation(r, { previousText: v1, version: 2, threshold: 2, by: [["alice@example.test", alice]] });
|
|
235
|
+
r.commit("threshold 2", alice);
|
|
236
|
+
const report = await verify(r);
|
|
237
|
+
expect(report.rotation).toMatchObject({ code: "rotation-threshold-unsatisfiable" });
|
|
238
|
+
expect(distinctHolders(parseAllowedSigners(signers(["a", alice], ["b", alice])).signers)).toBe(1);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("signers --json prints a document signers.schema.json accepts, intact or broken", async () => {
|
|
242
|
+
const { r, alice, carol, v1 } = setup("contract");
|
|
243
|
+
const { expectValid } = contract(signersSchema);
|
|
244
|
+
r.write(".chant/allowed_signers", signers(["alice@example.test", alice], ["carol@example.test", carol]));
|
|
245
|
+
writeRotation(r, { previousText: v1, version: 2, by: [["alice@example.test", alice]] });
|
|
246
|
+
r.commit("v2", alice);
|
|
247
|
+
const base = r.head();
|
|
248
|
+
let doc = signersDocument(r.dir, base, ".chant/allowed_signers", signerHistory(r.dir, base, ".chant/allowed_signers"));
|
|
249
|
+
expect(doc).toMatchObject({ $schema: SIGNERS_OUTPUT_SCHEMA_ID, broken: null, versions: [{ version: 1 }, { version: 2, principals: ["alice@example.test", "carol@example.test"], signedBy: ["alice@example.test"] }] });
|
|
250
|
+
expectValid(doc);
|
|
251
|
+
r.write(".chant/allowed_signers", v1);
|
|
252
|
+
r.commit("rolled back by hand", alice);
|
|
253
|
+
doc = signersDocument(r.dir, r.head(), ".chant/allowed_signers", signerHistory(r.dir, r.head(), ".chant/allowed_signers"));
|
|
254
|
+
expect("broken" in doc && doc.broken).toMatchObject({ code: "rotation-version-skew" });
|
|
255
|
+
expectValid(doc);
|
|
256
|
+
expectValid({ $schema: SIGNERS_OUTPUT_SCHEMA_ID, contract: 1, chant: "0.0.0", error: { code: "signers-file-missing", message: "m" } });
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signer rotation, verified from history (#2553, #2524 D5, ws-001).
|
|
3
|
+
*
|
|
4
|
+
* A signer set is the signers file plus a threshold. A new set is valid only
|
|
5
|
+
* when a threshold of the previous set signed it, as in TUF's root rotation.
|
|
6
|
+
* The signatures live beside the signers file, in `<signers>.rotation.json`:
|
|
7
|
+
*
|
|
8
|
+
* ```json
|
|
9
|
+
* { "schema": 1, "version": 2, "previous": "sha256:…", "threshold": 1,
|
|
10
|
+
* "signatures": [{ "principal": "alice@example.com", "signature": "-----BEGIN SSH SIGNATURE-----…" }] }
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* Each signature is `ssh-keygen -Y sign -n chant-signers` over the statement
|
|
14
|
+
* {@link rotationStatement} returns: the new version, the digest of the
|
|
15
|
+
* previous signers file, the digest of the new one and the new threshold. The
|
|
16
|
+
* version number and the previous digest together stop an old, once-valid set
|
|
17
|
+
* from being replayed, and the namespace stops a commit signature from
|
|
18
|
+
* standing in for a rotation signature.
|
|
19
|
+
*
|
|
20
|
+
* ## Revocation by position
|
|
21
|
+
*
|
|
22
|
+
* Dates prove nothing: whoever makes a commit sets its date. So a commit is
|
|
23
|
+
* judged by where it sits in the base's history. Walking the base's
|
|
24
|
+
* first-parent line from the oldest commit, each commit that changes the
|
|
25
|
+
* signer set starts a version, and each version must be signed by a threshold
|
|
26
|
+
* of the one before. A commit already in the base's history is judged by the
|
|
27
|
+
* version in effect just before the first-parent commit that brought it in. A
|
|
28
|
+
* key removed at version N therefore still vouches for what was merged before
|
|
29
|
+
* N, and for nothing merged after, however the later commit is dated. A commit
|
|
30
|
+
* not yet in the base's history is judged by the latest version.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { createHash } from "node:crypto";
|
|
34
|
+
import { execFileSync } from "node:child_process";
|
|
35
|
+
import { posix } from "node:path";
|
|
36
|
+
import { z } from "zod";
|
|
37
|
+
import { canonicalJson } from "../../effect-receipt";
|
|
38
|
+
import type { ReasonCode } from "../reason-codes";
|
|
39
|
+
import { sshFingerprint } from "./dsse";
|
|
40
|
+
import { parseAllowedSigners, type ExcludedSigner, type Signer } from "./policy";
|
|
41
|
+
import { verifySshSignature } from "./ssh-commit";
|
|
42
|
+
|
|
43
|
+
/** Why a signer set is not a valid next version (#2553). Each is in `reason-codes.ts` and in `signers.schema.json`. */
|
|
44
|
+
export const ROTATION_REFUSAL_CODES = [
|
|
45
|
+
"signers-removed",
|
|
46
|
+
"rotation-unparseable",
|
|
47
|
+
"rotation-invalid",
|
|
48
|
+
"rotation-first-version",
|
|
49
|
+
"rotation-missing",
|
|
50
|
+
"rotation-version-skew",
|
|
51
|
+
"rotation-previous-mismatch",
|
|
52
|
+
"rotation-threshold-unsatisfiable",
|
|
53
|
+
"rotation-threshold-not-met",
|
|
54
|
+
] as const satisfies readonly ReasonCode[];
|
|
55
|
+
export type RotationRefusalCode = (typeof ROTATION_REFUSAL_CODES)[number];
|
|
56
|
+
|
|
57
|
+
/** A refused version: the code and a sentence for people. */
|
|
58
|
+
export interface RotationRefusal {
|
|
59
|
+
code: RotationRefusalCode;
|
|
60
|
+
reason: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The ssh-keygen namespace rotation signatures are made in. Never `git`. */
|
|
64
|
+
export const ROTATION_NAMESPACE = "chant-signers";
|
|
65
|
+
|
|
66
|
+
/** The rotation file for a signers file. */
|
|
67
|
+
export function rotationPath(signersPath: string): string {
|
|
68
|
+
return posix.join(posix.dirname(signersPath), `${posix.basename(signersPath)}.rotation.json`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `sha256:<hex>` of a signers file's bytes, line endings normalised. */
|
|
72
|
+
export function signersDigest(text: string): string {
|
|
73
|
+
return `sha256:${createHash("sha256").update(text.replace(/\r\n?/g, "\n"), "utf8").digest("hex")}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const rotationFileSchema = z
|
|
77
|
+
.object({
|
|
78
|
+
schema: z.literal(1),
|
|
79
|
+
version: z.number().int().min(1),
|
|
80
|
+
previous: z.string().regex(/^sha256:[0-9a-f]{64}$/).nullable(),
|
|
81
|
+
threshold: z.number().int().min(1),
|
|
82
|
+
signatures: z.array(z.object({ principal: z.string().min(1), signature: z.string().min(1) }).strict()),
|
|
83
|
+
})
|
|
84
|
+
.strict();
|
|
85
|
+
export type RotationFile = z.infer<typeof rotationFileSchema>;
|
|
86
|
+
|
|
87
|
+
/** The bytes each rotation signature covers. */
|
|
88
|
+
export function rotationStatement(r: { version: number; previous: string | null; threshold: number }, signers: string): Buffer {
|
|
89
|
+
return Buffer.from(
|
|
90
|
+
canonicalJson({ type: "chant-signer-set", version: r.version, previous: r.previous, signers, threshold: r.threshold }),
|
|
91
|
+
"utf8",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One version of the signer set, as history established it. */
|
|
96
|
+
export interface SignerVersion {
|
|
97
|
+
/** 1 and up; 0 marks where the signers file was removed and nothing was in effect. */
|
|
98
|
+
version: number;
|
|
99
|
+
/** The first-parent commit that made this version, or null for a set not yet merged. */
|
|
100
|
+
commit: string | null;
|
|
101
|
+
digest: string;
|
|
102
|
+
threshold: number;
|
|
103
|
+
signers: Signer[];
|
|
104
|
+
excluded: ExcludedSigner[];
|
|
105
|
+
/** The principals whose signatures made this version valid; empty for the first version. */
|
|
106
|
+
signedBy: string[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface SignerHistory {
|
|
110
|
+
versions: SignerVersion[];
|
|
111
|
+
/** Where the chain broke, when it did. Nothing verifies then. */
|
|
112
|
+
broken?: { commit: string | null } & RotationRefusal;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The state of the signer files at one point, as text. */
|
|
116
|
+
export interface SignerFiles {
|
|
117
|
+
signers?: string;
|
|
118
|
+
rotation?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Check `next` against the version before it. Returns the new version, or
|
|
123
|
+
* the reason it is not valid. `prev` undefined means there was no signer set,
|
|
124
|
+
* so `next` is a first version and needs no signatures.
|
|
125
|
+
*/
|
|
126
|
+
export function nextVersion(prev: SignerVersion | undefined, next: SignerFiles, commit: string | null): SignerVersion | RotationRefusal {
|
|
127
|
+
if (next.signers === undefined) return { code: "signers-removed", reason: "the signers file was removed" };
|
|
128
|
+
const digest = signersDigest(next.signers);
|
|
129
|
+
let rotation: RotationFile | undefined;
|
|
130
|
+
if (next.rotation !== undefined) {
|
|
131
|
+
let raw: unknown;
|
|
132
|
+
try {
|
|
133
|
+
raw = JSON.parse(next.rotation);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
return { code: "rotation-unparseable", reason: `the rotation file is not JSON: ${err instanceof Error ? err.message : String(err)}` };
|
|
136
|
+
}
|
|
137
|
+
const parsed = rotationFileSchema.safeParse(raw);
|
|
138
|
+
if (!parsed.success) return { code: "rotation-invalid", reason: `the rotation file is invalid: ${parsed.error.issues.map((i) => `${i.path.join(".") || "/"}: ${i.message}`).join("; ")}` };
|
|
139
|
+
rotation = parsed.data;
|
|
140
|
+
}
|
|
141
|
+
const set = parseAllowedSigners(next.signers);
|
|
142
|
+
const threshold = rotation?.threshold ?? 1;
|
|
143
|
+
// A threshold no future rotation could meet would freeze the set for good.
|
|
144
|
+
const holders = distinctHolders(set.signers);
|
|
145
|
+
if (threshold > holders) {
|
|
146
|
+
return {
|
|
147
|
+
code: "rotation-threshold-unsatisfiable",
|
|
148
|
+
reason: `the threshold is ${threshold}, and the set has ${holders} distinct signer${holders === 1 ? "" : "s"} to meet it`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!prev) {
|
|
153
|
+
if (rotation && (rotation.version !== 1 || rotation.previous !== null)) {
|
|
154
|
+
return { code: "rotation-first-version", reason: `a first signer set must be version 1 with no previous digest, not version ${rotation.version}` };
|
|
155
|
+
}
|
|
156
|
+
return { version: 1, commit, digest, threshold, signers: set.signers, excluded: set.excluded, signedBy: [] };
|
|
157
|
+
}
|
|
158
|
+
if (digest === prev.digest && threshold === prev.threshold) return prev;
|
|
159
|
+
if (!rotation) return { code: "rotation-missing", reason: `the signer set changed from version ${prev.version} with no rotation file signed by the set before it` };
|
|
160
|
+
if (rotation.version !== prev.version + 1) return { code: "rotation-version-skew", reason: `the rotation says version ${rotation.version}; the next version is ${prev.version + 1}` };
|
|
161
|
+
if (rotation.previous !== prev.digest) return { code: "rotation-previous-mismatch", reason: `the rotation names previous ${rotation.previous}, but version ${prev.version} is ${prev.digest}` };
|
|
162
|
+
|
|
163
|
+
const statement = rotationStatement(rotation, digest);
|
|
164
|
+
// Each signature counts once per key and once per principal: one key listed
|
|
165
|
+
// under two names is one signer, and so is one person with two keys.
|
|
166
|
+
const keys = new Set<string>();
|
|
167
|
+
const signedBy: string[] = [];
|
|
168
|
+
for (const s of rotation.signatures) {
|
|
169
|
+
if (signedBy.includes(s.principal)) continue;
|
|
170
|
+
const mine = prev.signers.filter((x) => x.principal === s.principal);
|
|
171
|
+
const r = verifySshSignature(mine, statement, s.signature, ROTATION_NAMESPACE);
|
|
172
|
+
if (!r.ok) continue;
|
|
173
|
+
const key = r.key ?? (mine.length === 1 ? sshFingerprint(mine[0].key) : `principal:${r.principal}`);
|
|
174
|
+
if (keys.has(key)) continue;
|
|
175
|
+
keys.add(key);
|
|
176
|
+
signedBy.push(r.principal);
|
|
177
|
+
}
|
|
178
|
+
if (keys.size < prev.threshold) {
|
|
179
|
+
return {
|
|
180
|
+
code: "rotation-threshold-not-met",
|
|
181
|
+
reason: `version ${rotation.version} is signed by ${keys.size} of the ${prev.threshold} signers of version ${prev.version} it needs${
|
|
182
|
+
signedBy.length ? ` (${signedBy.join(", ")})` : ""
|
|
183
|
+
}`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
return { version: rotation.version, commit, digest, threshold, signers: set.signers, excluded: set.excluded, signedBy };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* How many signers could each give a counted signature: principals and keys
|
|
191
|
+
* are both distinct, so this is the size of a largest matching between them.
|
|
192
|
+
*/
|
|
193
|
+
export function distinctHolders(signers: readonly Signer[]): number {
|
|
194
|
+
const keysOf = new Map<string, Set<string>>();
|
|
195
|
+
for (const s of signers) {
|
|
196
|
+
const set = keysOf.get(s.principal) ?? new Set<string>();
|
|
197
|
+
set.add(s.key);
|
|
198
|
+
keysOf.set(s.principal, set);
|
|
199
|
+
}
|
|
200
|
+
const owner = new Map<string, string>();
|
|
201
|
+
const tryAssign = (p: string, seen: Set<string>): boolean => {
|
|
202
|
+
for (const k of keysOf.get(p) ?? []) {
|
|
203
|
+
if (seen.has(k)) continue;
|
|
204
|
+
seen.add(k);
|
|
205
|
+
const o = owner.get(k);
|
|
206
|
+
if (o === undefined || tryAssign(o, seen)) {
|
|
207
|
+
owner.set(k, p);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return false;
|
|
212
|
+
};
|
|
213
|
+
let n = 0;
|
|
214
|
+
for (const p of keysOf.keys()) if (tryAssign(p, new Set())) n++;
|
|
215
|
+
return n;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function git(repo: string, args: string[]): string {
|
|
219
|
+
return execFileSync("git", args, { cwd: repo, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024 });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** A file's text at `commit`, or undefined when it is not there. */
|
|
223
|
+
export function fileAt(repo: string, commit: string, path: string): string | undefined {
|
|
224
|
+
try {
|
|
225
|
+
return git(repo, ["cat-file", "blob", `${commit}:${path}`]);
|
|
226
|
+
} catch {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Walk the signer set's history along the first-parent line of `base`,
|
|
233
|
+
* oldest first. A removal ends the chain; a later signers file starts a new
|
|
234
|
+
* one, which only the protected-write check in CI could have admitted.
|
|
235
|
+
*/
|
|
236
|
+
export function signerHistory(repo: string, base: string, signersPath: string): SignerHistory {
|
|
237
|
+
const paths = [signersPath, rotationPath(signersPath)];
|
|
238
|
+
const commits = git(repo, ["--literal-pathspecs", "log", "--first-parent", "--reverse", "--format=%H", base, "--", ...paths])
|
|
239
|
+
.split("\n")
|
|
240
|
+
.filter(Boolean);
|
|
241
|
+
const versions: SignerVersion[] = [];
|
|
242
|
+
let prev: SignerVersion | undefined;
|
|
243
|
+
for (const commit of commits) {
|
|
244
|
+
const files = { signers: fileAt(repo, commit, paths[0]), rotation: fileAt(repo, commit, paths[1]) };
|
|
245
|
+
if (files.signers === undefined) {
|
|
246
|
+
// Earlier versions still judge what was merged under them; from here on, nothing verifies.
|
|
247
|
+
if (prev) versions.push({ version: 0, commit, digest: "", threshold: 0, signers: [], excluded: [], signedBy: [] });
|
|
248
|
+
prev = undefined;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const next = nextVersion(prev, files, commit);
|
|
252
|
+
if ("code" in next) return { versions, broken: { commit, code: next.code, reason: next.reason } };
|
|
253
|
+
if (next !== prev) versions.push(next);
|
|
254
|
+
prev = next;
|
|
255
|
+
}
|
|
256
|
+
return { versions };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Finds the signer set in effect for a commit, by its position in the base's
|
|
261
|
+
* history. Built once per base; the first-parent line is read once and
|
|
262
|
+
* each lookup is a binary search.
|
|
263
|
+
*/
|
|
264
|
+
export class SignerPositions {
|
|
265
|
+
private line: string[] | undefined;
|
|
266
|
+
private index = new Map<string, number>();
|
|
267
|
+
private memo = new Map<string, SignerVersion | null>();
|
|
268
|
+
|
|
269
|
+
constructor(
|
|
270
|
+
private readonly repo: string,
|
|
271
|
+
private readonly base: string,
|
|
272
|
+
private readonly history: SignerHistory,
|
|
273
|
+
) {}
|
|
274
|
+
|
|
275
|
+
private isAncestor(a: string, b: string): boolean {
|
|
276
|
+
try {
|
|
277
|
+
execFileSync("git", ["merge-base", "--is-ancestor", a, b], { cwd: this.repo, stdio: "ignore" });
|
|
278
|
+
return true;
|
|
279
|
+
} catch {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The base's first-parent line, newest first. */
|
|
285
|
+
private firstParents(): string[] {
|
|
286
|
+
if (!this.line) {
|
|
287
|
+
this.line = git(this.repo, ["rev-list", "--first-parent", this.base]).split("\n").filter(Boolean);
|
|
288
|
+
this.line.forEach((c, i) => this.index.set(c, i));
|
|
289
|
+
}
|
|
290
|
+
return this.line;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The version that judges `commit`, or null when no signer set was in
|
|
295
|
+
* effect there (before the first one, or the chain is broken).
|
|
296
|
+
*/
|
|
297
|
+
versionFor(commit: string): SignerVersion | null {
|
|
298
|
+
const hit = this.memo.get(commit);
|
|
299
|
+
if (hit !== undefined) return hit;
|
|
300
|
+
const answer = this.lookup(commit);
|
|
301
|
+
this.memo.set(commit, answer);
|
|
302
|
+
return answer;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private lookup(commit: string): SignerVersion | null {
|
|
306
|
+
if (this.history.broken) return null;
|
|
307
|
+
const latest = this.history.versions.at(-1) ?? null;
|
|
308
|
+
if (!this.isAncestor(commit, this.base)) return latest;
|
|
309
|
+
const line = this.firstParents();
|
|
310
|
+
// The oldest first-parent commit that contains `commit` is where it came in.
|
|
311
|
+
let lo = 0;
|
|
312
|
+
let hi = line.length - 1;
|
|
313
|
+
let at = 0;
|
|
314
|
+
while (lo <= hi) {
|
|
315
|
+
const mid = (lo + hi) >> 1;
|
|
316
|
+
if (this.isAncestor(commit, line[mid])) {
|
|
317
|
+
at = mid;
|
|
318
|
+
lo = mid + 1;
|
|
319
|
+
} else {
|
|
320
|
+
hi = mid - 1;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// The newest version made strictly before that commit.
|
|
324
|
+
let best: SignerVersion | null = null;
|
|
325
|
+
let bestIndex = Infinity;
|
|
326
|
+
for (const v of this.history.versions) {
|
|
327
|
+
const i = v.commit ? this.index.get(v.commit) : undefined;
|
|
328
|
+
if (i === undefined || i <= at) continue;
|
|
329
|
+
if (i < bestIndex) {
|
|
330
|
+
best = v;
|
|
331
|
+
bestIndex = i;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return best;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -176,6 +176,17 @@ function checkSeal(policy: TrustPolicy, s: SealSubject): SealCheck {
|
|
|
176
176
|
if (typeof s.payload === "string") return { attested: false, code: "seal-signature-invalid", message: s.payload };
|
|
177
177
|
if (!policy.active) return checkIntegrity(s.principal, s.payload, seal.signature, s.namespace);
|
|
178
178
|
const listed = policy.signers.filter((x) => normalise(x.principal) === normalise(s.principal));
|
|
179
|
+
if (listed.length === 0 && policy.returnedFrom !== undefined) {
|
|
180
|
+
// Returned work (#2552, ws-004): a signer the service never saw is unverifiable until an admin admits them.
|
|
181
|
+
const intact = checkIntegrity(s.principal, s.payload, seal.signature, s.namespace);
|
|
182
|
+
if (intact.attested === false) return intact;
|
|
183
|
+
return {
|
|
184
|
+
attested: null,
|
|
185
|
+
code: "seal-unverifiable",
|
|
186
|
+
message: `${s.what} came back in return ${policy.returnedFrom}, sealed by ${s.principal}${intact.key ? ` with ${intact.key}` : ""}, whom neither ${where} nor an admission for that return lists; it counts once an admin admits the signer (chant workspace admit ${policy.returnedFrom})`,
|
|
187
|
+
...(intact.key ? { key: intact.key } : {}),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
179
190
|
if (listed.length === 0) {
|
|
180
191
|
return { attested: false, code: "seal-signer-unlisted", message: `${s.principal} has no key in ${where}, so the seal can't count` };
|
|
181
192
|
}
|