OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity / test / ssh-scan.test.js

113 lines · javascript ref:
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')
})