- hypr/apps.lua - hypr/autostart.lua - hypr/envs.lua - hypr/hyprland.lua - hypr/hyprsunset.conf - hypr/input.lua - hypr/looknfeel.lua - hypr/omasettings.lua - hypr/xdph.conf - omarchy/branding/about.txt - omarchy/branding/screensaver.txt - omarchy/extensions/omarchy-menu.jsonc - omarchy/hooks/battery-low.d/play-warning-sound.sample - omarchy/hooks/font-set.d/show-font-notification.sample - omarchy/hooks/post-boot.d/weather.sample - omarchy/hooks/post-update.d/install-voxtype.hook - omarchy/hooks/post-update.d/setup-agent.hook - omarchy/hooks/post-update.d/setup-fingerprint.hook - omarchy/hooks/post-update.d/show-update-notification.sample - omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample - omarchy/hooks/theme-set.d/show-theme-notification.sample - omarchy/shell.json - omarchy/shell.toml - omarchy/theme.name - omarchy/themes/azure-glow/README.md - omarchy/themes/azure-glow/alacritty.toml - omarchy/themes/azure-glow/btop.theme - omarchy/themes/azure-glow/hyprland.conf - omarchy/themes/azure-glow/hyprlock.conf - omarchy/themes/azure-glow/icons.theme - … 269 more
248 lines
12 KiB
JavaScript
248 lines
12 KiB
JavaScript
#!/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"],
|
|
["with nul", "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`)
|