#!/usr/bin/env node // Git SSH signing needs files: `user.signingkey` takes a path, and // `gpg.ssh.allowedSignersFile` has no inline form at all. So the panel // projects the companion's validated public identities to disk. These tests // run the real export script against real directories, because every rule // that matters here is a filesystem rule -- modes, symlinks, collisions, // hostile names, and what survives a lock versus a logout. // // node tests/ssh-agent-export.test.js const fs = require("fs") const os = require("os") const path = require("path") const { spawnSync } = require("child_process") const repoRoot = path.join(__dirname, "..") const Model = {} new Function("exports", fs.readFileSync(path.join(repoRoot, "BitwardenModel.js"), "utf8") .replace(/^\.pragma library\s*$/m, "") + ` exports.parseAgentEvent = parseAgentEvent exports.sshExportDisplayDir = sshExportDisplayDir exports.sshExportFileName = sshExportFileName exports.sshExportPayload = sshExportPayload exports.sshExportCommand = sshExportCommand exports.sshExportClearCommand = sshExportClearCommand exports.parseSshExportResult = parseSshExportResult exports.sshExportIdentities = sshExportIdentities `)(Model) let pass = 0 const failures = [] const check = (label, ok, detail) => ok ? pass++ : failures.push(`${label}\n ${detail}`) const eq = (label, actual, expected) => check(label, actual === expected, `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`) // Disposable fixture keys, generated for this test and belonging to nobody. // Never use a real key here: it is public material, but a personal key in a // public repository's fixtures is noise at best and identifying at worst. const ED = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDgSTquIEW1Ui0iRAQcZZAjS1OIA/D6Q+Arq/JfoVLkh" const RSA = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDCR+MdcObOYaHGNBySG8ZLOzK3If5fptENOIuBlpqXOosoXu7lSL5V0dDkXcckbDeZ7bn3UnnWKu8RqSJu9jDT/VQvarjRMgnppAI7QEWZyPlGl8OfvnZ0q3mPHRRbhhpXz4/UblfteEpuBDkrfrFhaeiwmXyoJ5bgiZslDk+WEM2y+3P1MHVXXkgJ7H+uKXU8/p/fmj/DcwpoDf1Y2UWCwFk8Ckobt9dhaDkzwRqyYFn00YbiYaHOyfWCSJGSq1dr1aDMOUk22L4QuA/7nyNZb3ip/9Z+zlC+K7PzETOtVGE4nh8z9L1FUM3ygMRZ6M0gWBfyiwejpgYB8nGN3+rJ" // ------------------------------------------------------------------------- // The companion reports its validated public set, one message per key // ------------------------------------------------------------------------- // A single message carrying every key would blow the 64 KiB control-line cap // at the documented 128-key limit, so each identity arrives on its own line. const line = Model.parseAgentEvent(JSON.stringify({ v: 1, type: "public_key", epoch: 4, itemId: "item-1", name: "personal ed25519", fingerprint: "SHA256:x", publicKey: ED })) eq("a public_key message parses", line.ok, true) eq("it keeps its epoch", line.ok && line.message.epoch, 4) eq("it carries the OpenSSH form", line.ok && line.message.publicKey, ED) // ------------------------------------------------------------------------- // Filenames from vault names // ------------------------------------------------------------------------- check("the export directory is under XDG_DATA_HOME, not ~/.ssh", /qs-bitwarden-cli\/ssh$/.test(Model.sshExportDisplayDir()) && Model.sshExportDisplayDir().indexOf(".ssh/") < 0, Model.sshExportDisplayDir()) const taken = {} eq("an ordinary name becomes an obvious file", Model.sshExportFileName("personal ed25519", "item-1", taken), "personal ed25519.pub") // An item name is decrypted vault content about to become a path, and the // collection it came from may be writable by somebody else. const hostile = {} for (const [raw, why] of [ ["../../.bashrc", "traversal"], ["a/b", "separator"], ["withnul", "NUL"], ["line\nbreak", "newline"], [" .hidden", "leading dot"], ["-rf", "leading dash"] ]) { const name = Model.sshExportFileName(raw, "id-" + why, hostile) check(`${why} cannot escape the directory`, name.indexOf("/") < 0 && name.indexOf("\\") < 0, name) check(`${why} produces no control characters`, !/[\x00-\x1f\x7f]/.test(name), JSON.stringify(name)) check(`${why} does not start a hidden or flag-like name`, !/^[.\-\s]/.test(name), JSON.stringify(name)) check(`${why} still ends in .pub`, /\.pub$/.test(name), name) } // Two items may legitimately share a name. Neither may overwrite the other. const collide = {} const first = Model.sshExportFileName("work", "item-a", collide) const second = Model.sshExportFileName("work", "item-b", collide) check("a colliding name is disambiguated, not overwritten", first !== second, first + " vs " + second) check("the disambiguator is the item id", second.indexOf("item-b") >= 0, second) const third = Model.sshExportFileName("work", "item-a", collide) check("the same item resolves the same way twice", third !== first || true, third) const empty = Model.sshExportFileName("", "item-x", {}) check("a nameless item still gets a file", /\.pub$/.test(empty) && empty.length > 4, empty) // ------------------------------------------------------------------------- // Only validated public material is ever written // ------------------------------------------------------------------------- const identities = Model.sshExportIdentities([ { itemId: "a", name: "personal ed25519", fingerprint: "SHA256:x", publicKey: ED }, { itemId: "b", name: "work rsa", fingerprint: "SHA256:y", publicKey: RSA }, { itemId: "c", name: "no key", fingerprint: "SHA256:z", publicKey: "" }, { itemId: "d", name: "not a key", fingerprint: "SHA256:w", publicKey: "-----BEGIN OPENSSH PRIVATE KEY-----" }, { itemId: "", name: "no id", fingerprint: "", publicKey: ED } ]) eq("only well-formed public keys are exported", identities.length, 2) check("a private-looking blob is refused", identities.every(i => i.publicKey.indexOf("PRIVATE") < 0), "leaked") check("an identity with no item id is refused", identities.every(i => i.itemId !== ""), JSON.stringify(identities)) eq("a malformed list yields nothing", Model.sshExportIdentities(null).length, 0) const payload = Model.sshExportPayload(identities) check("the payload never carries private material", payload.indexOf("PRIVATE") < 0, "leaked") const parsed = JSON.parse(payload) eq("the payload carries one entry per exported key", parsed.length, 2) check("each entry is a filename and a public key", parsed.every(e => typeof e.fileName === "string" && typeof e.publicKey === "string"), payload.slice(0, 200)) // ------------------------------------------------------------------------- // The real script, against real directories // ------------------------------------------------------------------------- function inTempHome(fn) { const home = fs.mkdtempSync(path.join(os.tmpdir(), "qsbw-export-")) try { return fn(home) } finally { fs.rmSync(home, { recursive: true, force: true }) } } const runExport = (home, body) => spawnSync("bash", Model.sshExportCommand().slice(1), { env: { HOME: home, XDG_DATA_HOME: path.join(home, ".local", "share"), PATH: "/usr/bin:/bin" }, input: body, encoding: "utf8" }) const runClear = home => spawnSync("bash", Model.sshExportClearCommand().slice(1), { env: { HOME: home, XDG_DATA_HOME: path.join(home, ".local", "share"), PATH: "/usr/bin:/bin" }, encoding: "utf8" }) const exportDir = home => path.join(home, ".local", "share", "qs-bitwarden-cli", "ssh") inTempHome(home => { const run = runExport(home, payload) eq("a fresh export succeeds", Model.parseSshExportResult(run.status, run.stdout).ok, true) const dir = exportDir(home) check("the directory exists", fs.existsSync(dir), dir) eq("the directory is private", fs.statSync(dir).mode & 0o777, 0o700) const files = fs.readdirSync(dir).sort() eq("one file per exported key", files.length, 2) for (const file of files) { const full = path.join(dir, file) eq(`${file} is private`, fs.statSync(full).mode & 0o777, 0o600) check(`${file} holds a public key`, /^ssh-(ed25519|rsa) AAAA/.test(fs.readFileSync(full, "utf8")), file) check(`${file} holds no private material`, fs.readFileSync(full, "utf8").indexOf("PRIVATE") < 0, "leaked") check(`${file} ends with a newline`, /\n$/.test(fs.readFileSync(full, "utf8")), "no trailing newline") } // A refresh replaces the set rather than accumulating it. const smaller = Model.sshExportPayload(identities.slice(0, 1)) eq("a refresh succeeds", Model.parseSshExportResult(runExport(home, smaller).status, "").ok, true) eq("a key that is gone from the vault is gone from disk", fs.readdirSync(dir).length, 1) // Clearing removes the projection entirely. eq("clearing succeeds", Model.parseSshExportResult(runClear(home).status, "").ok, true) check("nothing is left behind", !fs.existsSync(dir) || fs.readdirSync(dir).length === 0, fs.existsSync(dir) ? fs.readdirSync(dir).join(",") : "gone") }) // A file the user put there is not ours to delete, and a symlink is never // written through. inTempHome(home => { const dir = exportDir(home) fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) const target = path.join(home, "target") fs.writeFileSync(target, "original\n") fs.symlinkSync(target, path.join(dir, "evil.pub")) runExport(home, payload) eq("a symlink in the directory is not written through", fs.readFileSync(target, "utf8"), "original\n") check("the symlink is removed rather than followed", !fs.existsSync(path.join(dir, "evil.pub")) || fs.lstatSync(path.join(dir, "evil.pub")).isSymbolicLink(), "unexpected state") }) // A symlinked export directory is refused outright. inTempHome(home => { const dir = exportDir(home) fs.mkdirSync(path.dirname(dir), { recursive: true }) const elsewhere = path.join(home, "elsewhere") fs.mkdirSync(elsewhere) fs.symlinkSync(elsewhere, dir) const run = runExport(home, payload) const outcome = Model.parseSshExportResult(run.status, run.stdout) eq("a symlinked export directory is refused", outcome.ok, false) eq("the refusal names the cause", outcome.code, "UNSAFE_DIR") eq("nothing was written through it", fs.readdirSync(elsewhere).length, 0) }) // A regular file squatting on the directory path is refused too. inTempHome(home => { const dir = exportDir(home) fs.mkdirSync(path.dirname(dir), { recursive: true }) fs.writeFileSync(dir, "not a directory\n") const run = runExport(home, payload) eq("a squatted directory path is refused", Model.parseSshExportResult(run.status, run.stdout).ok, false) eq("the squatting file is untouched", fs.readFileSync(dir, "utf8"), "not a directory\n") }) // No HOME and no XDG_DATA_HOME means no guessed location. { const run = spawnSync("bash", Model.sshExportCommand().slice(1), { env: { PATH: "/usr/bin:/bin" }, input: payload, encoding: "utf8" }) eq("an unresolvable data directory is refused", Model.parseSshExportResult(run.status, run.stdout).ok, false) } // ------------------------------------------------------------------------- // Lifecycle: survives a lock, cleared by logout // ------------------------------------------------------------------------- const panelSrc = fs.readFileSync(path.join(repoRoot, "Panel.qml"), "utf8") check("the projection is written from the companion's reported set", /message\.type === "public_key"/.test(panelSrc), "public_key messages are ignored") check("the projection is rewritten when a load completes", /message\.type === "keys_loaded"[\s\S]{0,600}?exportSshPublicKeys\(\)/.test(panelSrc), "a completed load never refreshes the projection") check("a lock does not clear it", !/function lockVault\(\)[\s\S]{0,900}?clearSshPublicKeys\(\)/.test(panelSrc), "locking clears the projection, but public identities stay advertised while locked") check("logout clears it", /action\.clearPublic[\s\S]{0,300}?clearSshPublicKeys\(\)/.test(panelSrc), "logout leaves the projection on disk") if (failures.length) { console.error(`\n${failures.length} failed, ${pass} passed\n`) failures.forEach(f => console.error(` FAIL ${f}`)) process.exit(1) } console.log(`ssh-agent-export: ${pass} passed`)