import assert from "node:assert/strict"; import { randomBytes } from "node:crypto"; import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { mkdtempSync } from "node:fs"; import type { PiSshConfig, VaultPaths } from "../src/config.ts"; import { decryptConfig, encryptConfig, loadVault, rotateVaultKey, saveVault } from "../src/vault.ts"; function fixture(): PiSshConfig { return { version: 1, hosts: { packaging: { label: "Packaging", hostName: "192.0.2.10", user: "builder", port: 22, auth: { type: "password", password: "server-secret", method: "auto" }, hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:fixture" }, }, }, }; } function paths(): VaultPaths { const directory = mkdtempSync(join(tmpdir(), "pi-ssh-vault-")); return { directory, encryptedConfig: join(directory, "hosts.enc"), key: join(directory, "vault.key"), }; } test("encrypts and authenticates the complete configuration", () => { const key = randomBytes(32); const encrypted = encryptConfig(fixture(), key); assert.doesNotMatch(encrypted, /server-secret|192\.0\.2\.10|builder/); assert.deepEqual(decryptConfig(encrypted, key), fixture()); const envelope = JSON.parse(encrypted); envelope.ciphertext = `${envelope.ciphertext.slice(0, -2)}AA`; assert.throws(() => decryptConfig(JSON.stringify(envelope), key), /integrity verification/); assert.throws(() => decryptConfig(encrypted, randomBytes(32)), /integrity verification/); }); test("writes an owner-only key and encrypted config without plaintext", () => { const target = paths(); saveVault(fixture(), target); assert.deepEqual(loadVault(target), fixture()); assert.doesNotMatch(readFileSync(target.encryptedConfig, "utf8"), /server-secret/); assert.equal(Buffer.from(readFileSync(target.key, "utf8").trim(), "base64").length, 32); if (process.platform !== "win32") { assert.equal(statSync(target.directory).mode & 0o777, 0o700); assert.equal(statSync(target.encryptedConfig).mode & 0o777, 0o600); assert.equal(statSync(target.key).mode & 0o777, 0o600); } }); test("fails closed when vault files are missing or too broadly readable", () => { const target = paths(); assert.throws(() => loadVault(target), /not configured/); mkdirSync(target.directory, { recursive: true }); writeFileSync(target.key, `${randomBytes(32).toString("base64")}\n`, { mode: 0o600 }); assert.throws(() => loadVault(target), /not configured/); saveVault(fixture(), target); if (process.platform !== "win32") { chmodSync(target.encryptedConfig, 0o644); assert.throws(() => loadVault(target), /permissions/); } }); test("rotates the adjacent key while preserving the encrypted configuration", () => { const target = paths(); saveVault(fixture(), target); const oldKey = readFileSync(target.key, "utf8"); rotateVaultKey(target); assert.notEqual(readFileSync(target.key, "utf8"), oldKey); assert.deepEqual(loadVault(target), fixture()); });