import test from 'brittle'
import { mkdtempSync, writeFileSync, mkdirSync, symlinkSync, chmodSync } from '#node:fs'
import { tmpdir } from '#node:os'
import { join } from '#node:path'
import process from '#node:process'
import { scanSSHKeys, scanHomeSSHKeys } from '../lib/auth/ssh-scan.js'
import { SSHKeyParser } from '../lib/auth/ssh-parser.js'
// Known good SSH public keys for fixture testing. These are real Ed25519
// pubkeys generated for the test suite only; no secret-key material is
// checked in. Pubkeys are public, so this is safe.
const ED25519_KEY = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPMbEhcffafHpuMVWZ0Q0iqQKg5vS7ALfRG0/XBT8fmP test@example'
const RSA_KEY = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDNAYMZkFcAWkfWO7uKCg5V1hf9yV8cIIQ0MBPeRELvUxD9tyfxBDQhLNDlcmVGdO2ar8e2BsTBMQ7Smvp4ekXQj9Z+35DkQyAUlLYpGjJFYcB7cEiM7IwjGt9z7mGZkS0r1/UNu2MSSawYFHQ0uLyHYmcl0FlBYq8XAxA6mnPrZpcrPzQmGL8w2kFnL9Z1XclRBG7QMCw8ZCLr6a6yxa6drNbEFXmhZuXHH0RlQSoqbLs0Jc6Lk9ahQwCkYmNH3Cz2hWGLpGU2vvc8fF7bgWe+T4ZHG2J2CSSoO1fABQUM6qdh4jWi3j7XTvqEK1+QRY2fXq+NxCdoW2qiayw6ARXT test-rsa@example'
test('scanSSHKeys on empty/missing dir returns []', async (t) => {
const missing = join(tmpdir(), 'nonexistent-' + Date.now())
const result = await scanSSHKeys(missing)
t.alike(result, [])
})
test('scanSSHKeys picks up *.pub files and parses them', async (t) => {
const dir = mkdtempSync(join(tmpdir(), 'ssh-scan-'))
writeFileSync(join(dir, 'id_ed25519.pub'), ED25519_KEY + '\n')
writeFileSync(join(dir, 'id_rsa.pub'), RSA_KEY + '\n')
// Distractor files that must be ignored
writeFileSync(join(dir, 'id_ed25519'), 'SECRET — must NOT be read')
writeFileSync(join(dir, 'config'), 'Host foo\n')
writeFileSync(join(dir, 'known_hosts'), 'github.com ssh-ed25519 AAA...\n')
const result = await scanSSHKeys(dir)
t.is(result.length, 2, 'found two .pub files')
const types = result.map(r => r.type).sort()
t.alike(types, ['ed25519', 'rsa'])
const ed = result.find(r => r.type === 'ed25519')
t.is(ed.comment, 'test@example')
t.ok(ed.fingerprint.startsWith('SHA256:'))
t.is(ed.publicKey, ED25519_KEY)
})
test('scanSSHKeys silently skips malformed .pub files', async (t) => {
const dir = mkdtempSync(join(tmpdir(), 'ssh-scan-bad-'))
writeFileSync(join(dir, 'good.pub'), ED25519_KEY + '\n')
writeFileSync(join(dir, 'broken.pub'), 'not an ssh key\n')
writeFileSync(join(dir, 'empty.pub'), '')
const result = await scanSSHKeys(dir)
t.is(result.length, 1, 'only good.pub returned')
t.is(result[0].type, 'ed25519')
})
test('SSHKeyParser.fingerprint is deterministic and matches ssh-keygen format', async (t) => {
const a = SSHKeyParser.fingerprint(ED25519_KEY)
const b = SSHKeyParser.fingerprint(ED25519_KEY)
t.is(a, b, 'deterministic')
t.ok(a.startsWith('SHA256:'))
t.absent(a.endsWith('='), 'no base64 padding (ssh-keygen convention)')
// Known-vector: MUST equal the real OpenSSH fingerprint (`ssh-keygen -lf`).
// Regression guard for the BLAKE2b-mislabeled-as-SHA256 bug — the fp used to
// be hypercore-crypto.hash (BLAKE2b-256), which passed the prefix/determinism
// checks above but never matched ssh-keygen / ssh-add -l / GitHub.
t.is(a, 'SHA256:a32IzTLPV5ODqnTOYvICRbujbXaS/ZXa1ozJbzz/jOs', 'real SHA-256, matches ssh-keygen -lf')
// Two different keys produce different fingerprints
const c = SSHKeyParser.fingerprint(RSA_KEY)
t.not(a, c)
})
test('scanHomeSSHKeys returns [] without $HOME', async (t) => {
// Cross-runtime: bare-process's env is a Proxy that rejects `delete`,
// so blank it instead (the source treats falsy HOME the same as unset).
const origHome = process.env.HOME
process.env.HOME = ''
t.teardown(() => { if (origHome) process.env.HOME = origHome })
const result = await scanHomeSSHKeys()
t.alike(result, [])
})
test('scanHomeSSHKeys returns [] when running as root (default)', async (t) => {
// We can't become root in a test, so we fake it by monkey-patching getuid.
const origGetuid = process.getuid
process.getuid = () => 0
t.teardown(() => { process.getuid = origGetuid })
const result = await scanHomeSSHKeys()
t.alike(result, [], 'root scan blocked')
})
test('scanHomeSSHKeys respects allowRoot override', async (t) => {
const origGetuid = process.getuid
const origHome = process.env.HOME
// Point HOME at a test dir so we can actually scan something
const dir = mkdtempSync(join(tmpdir(), 'ssh-home-'))
mkdirSync(join(dir, '.ssh'))
writeFileSync(join(dir, '.ssh', 'id_ed25519.pub'), ED25519_KEY + '\n')
process.getuid = () => 0
process.env.HOME = dir
t.teardown(() => {
process.getuid = origGetuid
process.env.HOME = origHome
})
const result = await scanHomeSSHKeys({ allowRoot: true })
t.is(result.length, 1, 'allowRoot lets root scan')
})