OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity / lib / auth / ssh-scan.js

110 lines · javascript ref:
/**
 * Scan a local `~/.ssh/` directory for candidate SSH public keys.
 *
 * Returns an array of { publicKey, fingerprint, type, path } records,
 * one per .pub file we can read and parse. Silently skips files that
 * don't parse (unknown key type, bad format) — those aren't errors,
 * just non-candidates.
 *
 * This module never reads secret key files. It only looks at *.pub.
 *
 * Used by ssh-auto-import.js to seed a hyper-identity registry with
 * local SSH keys on the daemon's first boot.
 */

import process from '#node:process'
import { readdir, readFile, stat } from '#node:fs/promises'
import { join } from '#node:path'

import { SSHKeyParser } from './ssh-parser.js'

/**
 * Scan a directory for readable `*.pub` SSH public keys.
 *
 * @param {string} dir - directory to scan (typically `$HOME/.ssh`)
 * @returns {Promise<Array<{ publicKey, fingerprint, type, path, comment }>>}
 */
export async function scanSSHKeys (dir) {
  let entries
  try {
    entries = await readdir(dir)
  } catch {
    // $HOME/.ssh doesn't exist, or we can't read it — return empty (not an error)
    return []
  }

  const out = []

  for (const name of entries) {
    if (!name.endsWith('.pub')) continue

    const path = join(dir, name)

    // Skip anything that isn't a regular file (symlink to revoked?
    // directory with .pub suffix?)
    let st
    try {
      st = await stat(path)
    } catch {
      continue
    }
    if (!st.isFile()) continue

    let text
    try {
      text = await readFile(path, 'utf8')
    } catch {
      continue // unreadable — likely permissions issue, skip
    }

    const first = text.split('\n').find(line => line.trim().length > 0)
    if (!first) continue

    let parsed
    try {
      parsed = SSHKeyParser.parse(first)
    } catch {
      continue // unsupported key type (dsa, ecdsa, sk-*) — not fatal
    }

    let fingerprint
    try {
      fingerprint = SSHKeyParser.fingerprint(first)
    } catch {
      continue
    }

    // Extract the trailing comment (OpenSSH convention: "ssh-ed25519 AAA... user@host")
    const parts = first.trim().split(/\s+/)
    const comment = parts.slice(2).join(' ') || null

    out.push({
      publicKey: first.trim(),
      fingerprint,
      type: parsed.type,
      path,
      comment
    })
  }

  return out
}

/**
 * Scan the current process owner's SSH directory. Skips if the process
 * has no $HOME (container without a passwd entry), or if we're running
 * as root (safety — don't auto-import root's keys, that's usually a
 * misconfig and the blast radius is large).
 *
 * @param {Object} [opts]
 * @param {boolean} [opts.allowRoot=false] - override the root safety check
 * @returns {Promise<Array<{ publicKey, fingerprint, type, path, comment }>>}
 */
export async function scanHomeSSHKeys (opts = {}) {
  if (!process.env.HOME) return []
  if (!opts.allowRoot && typeof process.getuid === 'function' && process.getuid() === 0) {
    return []
  }
  return scanSSHKeys(join(process.env.HOME, '.ssh'))
}