A key rotation verified the candidate with `git ls-remote`, then immediately ran preflightCandidate, which threw that result away and ran the same command over a second SSH connection. Nothing happens between the two calls that could change the answer, and the proof was already being passed in. preflightCandidate now uses a proof that established a remote commit and falls back to verifying when it is handed nothing usable, so it still works as a standalone gate. Every ssh.exec opens its own connection, so this removes a full TCP, key exchange and authentication round trip from a rotation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
217 lines
11 KiB
JavaScript
217 lines
11 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { createRequire } from "node:module";
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const { UnraidDeployKeyHost, parseDeployKeyMarker } = require("../src/main/unraid-deploy-key-host.cjs");
|
|
|
|
const SERVER = { id: "unraid", basePath: "/mnt/user/appdata" };
|
|
const REPOSITORY = { fullName: "Jens/Portfolio" };
|
|
|
|
// The host reaches the server through a single exec call, so capturing the script
|
|
// it sends is the only way to assert what actually happens to the key material.
|
|
function keyHost(stdout = "") {
|
|
const scripts = [];
|
|
const ssh = {
|
|
exec: async (serverId, command, options) => {
|
|
const encoded = command.match(/printf '%s' '([^']+)'/)?.[1] || "";
|
|
scripts.push({ serverId, options, script: Buffer.from(encoded, "base64").toString("utf8") });
|
|
return { stdout };
|
|
},
|
|
};
|
|
return { host: new UnraidDeployKeyHost({ ssh }), scripts };
|
|
}
|
|
|
|
test("deploy-key storage is repository-scoped, deterministic and stays under the server base path", () => {
|
|
const { host } = keyHost();
|
|
const first = host.paths(REPOSITORY, SERVER);
|
|
const again = host.paths({ fullName: "jens/portfolio" }, SERVER);
|
|
const other = host.paths({ fullName: "Jens/Other" }, SERVER);
|
|
|
|
assert.deepEqual(first, again, "the same repository always resolves to the same directory");
|
|
assert.notEqual(first.directory, other.directory, "a different repository never shares a key directory");
|
|
for (const value of Object.values(first)) {
|
|
assert.ok(value.startsWith("/mnt/user/appdata/.forgeflow/git-credentials/"), value);
|
|
assert.ok(!value.includes(".."));
|
|
}
|
|
assert.ok(!first.directory.toLowerCase().includes("portfolio"), "the repository name is hashed, not embedded");
|
|
});
|
|
|
|
test("the server pull remote is taken from the first usable SSH URL and refused when there is none", () => {
|
|
const { host } = keyHost();
|
|
assert.equal(
|
|
host.remote({ ...REPOSITORY, localStatus: { remoteUrl: "https://gitea.example/Jens/Portfolio.git" }, sshUrl: "git@gitea.example:Jens/Portfolio.git" }, {}),
|
|
"git@gitea.example:Jens/Portfolio.git",
|
|
"an HTTPS remote is skipped in favour of the SSH URL",
|
|
);
|
|
assert.equal(
|
|
host.remote({ ...REPOSITORY }, { cloneUrl: "ssh://git@gitea.example:2222/Jens/Portfolio.git" }),
|
|
"ssh://git@gitea.example:2222/Jens/Portfolio.git",
|
|
);
|
|
assert.throws(
|
|
() => host.remote({ ...REPOSITORY, sshUrl: "https://gitea.example/Jens/Portfolio.git" }, {}),
|
|
(error) => {
|
|
assert.equal(error.code, "SERVER_GIT_SSH_URL_REQUIRED");
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
test("the Git SSH environment pins the scoped key and refuses an unknown host", () => {
|
|
const { host } = keyHost();
|
|
const paths = host.paths(REPOSITORY, SERVER);
|
|
const environment = host.environment(paths);
|
|
|
|
assert.match(environment, /IdentitiesOnly=yes/);
|
|
assert.match(environment, /BatchMode=yes/);
|
|
assert.match(environment, /StrictHostKeyChecking=yes/);
|
|
assert.ok(environment.includes(paths.knownHosts), "the pinned host key file is repository-scoped");
|
|
assert.ok(environment.includes(paths.privateKey));
|
|
});
|
|
|
|
test("a backup copies the current key material into a fresh recovery slot", async () => {
|
|
const publicKey = "ssh-ed25519 QkFL forgeflow";
|
|
const { host, scripts } = keyHost(
|
|
`__FORGEFLOW_KEY_BACKUP__\nrecovery=/mnt/user/appdata/.forgeflow/git-credentials/abc/recovery/backup-1\npublicKey=${Buffer.from(publicKey).toString("base64")}\n`,
|
|
);
|
|
|
|
const backup = await host.backup({ repository: REPOSITORY, server: SERVER });
|
|
assert.equal(backup.publicKey, publicKey);
|
|
assert.match(backup.recovery, /recovery\/backup-1$/);
|
|
assert.match(scripts[0].script, /umask 077/, "recovered key material is not world readable");
|
|
assert.match(scripts[0].script, /deploy-key deploy-key\.pub known_hosts/);
|
|
});
|
|
|
|
test("candidate verification only reports ready on a real remote commit", async () => {
|
|
const remoteSha = "d".repeat(40);
|
|
const candidate = { paths: { privateKey: "/k/deploy-key", publicKey: "/k/deploy-key.pub", knownHosts: "/k/known_hosts" } };
|
|
const context = {
|
|
repository: { ...REPOSITORY, sshUrl: "git@gitea.example:Jens/Portfolio.git" },
|
|
profile: { branch: "main" },
|
|
server: SERVER,
|
|
candidate,
|
|
};
|
|
|
|
const proven = keyHost(`__FORGEFLOW_KEY_PROOF__\nremoteSha=${remoteSha}\nfingerprint=SHA256:new\nhostFingerprint=SHA256:host\n`);
|
|
const proof = await proven.host.verifyCandidate(context);
|
|
assert.deepEqual(proof, { ready: true, remoteSha, fingerprint: "SHA256:new", hostFingerprint: "SHA256:host" });
|
|
assert.match(proven.scripts[0].script, /git ls-remote --exit-code/);
|
|
assert.match(proven.scripts[0].script, /refs\/heads\/main/);
|
|
assert.equal(proven.scripts[0].options.timeout, 45_000);
|
|
assert.deepEqual(await proven.host.preflightCandidate(context), proof);
|
|
|
|
const unproven = keyHost("__FORGEFLOW_KEY_PROOF__\nremoteSha=\nfingerprint=\nhostFingerprint=\n");
|
|
assert.equal((await unproven.host.verifyCandidate(context)).ready, false);
|
|
await assert.rejects(() => unproven.host.preflightCandidate(context), /did not prove the remote branch/);
|
|
});
|
|
|
|
test("a preflight reuses a proof it was handed instead of asking the server again", async () => {
|
|
const reused = keyHost("__FORGEFLOW_KEY_PROOF__\nremoteSha=\nfingerprint=\nhostFingerprint=\n");
|
|
const proof = { ready: true, remoteSha: "f".repeat(40), fingerprint: "SHA256:new", hostFingerprint: "SHA256:host" };
|
|
const context = {
|
|
repository: { ...REPOSITORY, sshUrl: "git@gitea.example:Jens/Portfolio.git" },
|
|
profile: { branch: "main" },
|
|
server: SERVER,
|
|
candidate: { paths: { privateKey: "/k/deploy-key", publicKey: "/k/deploy-key.pub", knownHosts: "/k/known_hosts" } },
|
|
proof,
|
|
};
|
|
|
|
assert.deepEqual(await reused.host.preflightCandidate(context), proof);
|
|
assert.equal(reused.scripts.length, 0, "no second connection is opened");
|
|
|
|
// A proof that never established a remote commit is not a shortcut.
|
|
await assert.rejects(
|
|
() => reused.host.preflightCandidate({ ...context, proof: { ready: false } }),
|
|
/did not prove the remote branch/,
|
|
);
|
|
assert.equal(reused.scripts.length, 1, "an unusable proof falls back to verifying");
|
|
});
|
|
|
|
test("verifying the active key uses the repository-scoped paths rather than a candidate", async () => {
|
|
const { host, scripts } = keyHost(`__FORGEFLOW_KEY_PROOF__\nremoteSha=${"e".repeat(40)}\nfingerprint=SHA256:active\nhostFingerprint=SHA256:host\n`);
|
|
const paths = host.paths(REPOSITORY, SERVER);
|
|
|
|
const proof = await host.verifyActive({
|
|
repository: { ...REPOSITORY, sshUrl: "git@gitea.example:Jens/Portfolio.git" },
|
|
profile: { branch: "main" },
|
|
server: SERVER,
|
|
});
|
|
assert.equal(proof.ready, true);
|
|
assert.ok(scripts[0].script.includes(paths.privateKey));
|
|
assert.ok(scripts[0].script.includes(paths.knownHosts));
|
|
});
|
|
|
|
test("promotion only replaces key material after proving the candidate is complete", async () => {
|
|
const { host, scripts } = keyHost();
|
|
const paths = host.paths(REPOSITORY, SERVER);
|
|
const candidate = { paths: { directory: "/c", privateKey: "/c/deploy-key", publicKey: "/c/deploy-key.pub", knownHosts: "/c/known_hosts" } };
|
|
|
|
await host.promote({ repository: REPOSITORY, server: SERVER, candidate });
|
|
const script = scripts[0].script;
|
|
assert.ok(script.includes("test -s '/c/deploy-key'"), "an empty candidate key is refused before anything is replaced");
|
|
assert.ok(script.includes("test -s '/c/known_hosts'"));
|
|
assert.ok(script.indexOf("test -s") < script.indexOf("mv "), "the checks run before the swap");
|
|
// The staging suffix is appended outside the quoted path, so the command reads
|
|
// mv '<path>'.new '<path>' rather than mv '<path>.new' '<path>'.
|
|
assert.ok(script.includes(`mv '${paths.privateKey}'.new '${paths.privateKey}'`), "the swap is atomic");
|
|
assert.ok(script.includes(`cp -p '/c/deploy-key' '${paths.privateKey}'.new`), "the copy lands on the staging name first");
|
|
});
|
|
|
|
test("rollback restores the recovery slot and removes the candidate", async () => {
|
|
const { host, scripts } = keyHost();
|
|
const paths = host.paths(REPOSITORY, SERVER);
|
|
|
|
await host.rollback({
|
|
repository: REPOSITORY,
|
|
server: SERVER,
|
|
candidate: { paths: { directory: "/candidate" } },
|
|
previous: { key: { recovery: "/recovery/backup-1" } },
|
|
});
|
|
assert.ok(scripts[0].script.includes("cp -p '/recovery/backup-1'"));
|
|
assert.ok(scripts[0].script.includes(paths.directory));
|
|
assert.ok(scripts[0].script.includes("rm -rf -- '/candidate'"));
|
|
});
|
|
|
|
test("committing a rotation discards only the candidate directory", async () => {
|
|
const { host, scripts } = keyHost();
|
|
await host.commit({ server: SERVER, candidate: { paths: { directory: "/candidate" } } });
|
|
// Every script carries the strict-mode preamble that bash() prepends.
|
|
assert.equal(scripts[0].script.split("\n").at(-1), "rm -rf -- '/candidate'");
|
|
assert.ok(!scripts[0].script.includes(".forgeflow/git-credentials"), "the active key directory is never touched on commit");
|
|
});
|
|
|
|
test("every server script runs under strict mode with Git prompts disabled", async () => {
|
|
const { host, scripts } = keyHost();
|
|
await host.commit({ server: SERVER, candidate: { paths: { directory: "/candidate" } } });
|
|
assert.match(scripts[0].script, /^set -euo pipefail\nexport GIT_TERMINAL_PROMPT=0\n/);
|
|
assert.equal(scripts[0].serverId, SERVER.id);
|
|
});
|
|
|
|
test("revocation moves key material aside so it can still be restored", async () => {
|
|
const { host, scripts } = keyHost();
|
|
const paths = host.paths(REPOSITORY, SERVER);
|
|
|
|
await host.revoke({ repository: REPOSITORY, server: SERVER });
|
|
const script = scripts[0].script;
|
|
assert.ok(script.includes(`${paths.recovery}/revoked-`), "revoked material is kept in the recovery area");
|
|
assert.match(script, /mv /, "the key is moved, never deleted");
|
|
assert.ok(!/rm -rf/.test(script), "revocation must not destroy the recovery path");
|
|
});
|
|
|
|
test("restore reinstates the newest recovery slot and reports the public evidence", async () => {
|
|
const publicKey = "ssh-ed25519 UkVT forgeflow";
|
|
const { host, scripts } = keyHost(
|
|
`__FORGEFLOW_KEY_RESTORE__\npublicKey=${Buffer.from(publicKey).toString("base64")}\nfingerprint=SHA256:restored\nhostFingerprint=SHA256:host\n`,
|
|
);
|
|
|
|
const restored = await host.restore({ repository: REPOSITORY, server: SERVER });
|
|
assert.deepEqual(restored, { publicKey, fingerprint: "SHA256:restored", hostFingerprint: "SHA256:host" });
|
|
assert.match(scripts[0].script, /sort \| tail -1/, "the newest slot is chosen deterministically");
|
|
assert.ok(scripts[0].script.includes('test -n "$slot"'), "restoring without a recovery slot fails loudly");
|
|
});
|
|
|
|
test("marker parsing keeps values that themselves contain separators", () => {
|
|
const parsed = parseDeployKeyMarker("noise\n__M__\nkey=a=b=c\nempty\nother=1\n", "__M__");
|
|
assert.deepEqual(parsed, { key: "a=b=c", empty: "", other: "1" });
|
|
});
|