OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity / lib / auth / ssh-auto-import.js

114 lines · javascript ref:
/**
 * Auto-import local SSH public keys into a hyper-identity registry,
 * linked to the daemon owner's user account.
 *
 * Rationale: when a user installs pear-git (or any hyper-identity
 * consumer) on their own Linux box, they typically already have
 * `~/.ssh/id_*.pub` files. Making them manually re-register those keys
 * is friction. This helper scans the running user's `~/.ssh/`, parses
 * candidate pubkeys, and registers each as a `method: 'ssh-key'`
 * identity linked to the owner's userId.
 *
 * Safety rules (all must hold before anything gets registered):
 *   1. Process not running as root (unless opts.allowRoot).
 *   2. `$HOME` is set and `.ssh/` is readable.
 *   3. Registry has an explicit ownerUserId (caller resolves "who is
 *      the device owner"). We do not infer owner from the registry.
 *   4. Owner must have at least one non-ssh-key identity already (proves
 *      the account exists and the caller knows what they're doing).
 *
 * The operation is idempotent: keys already registered by fingerprint
 * are skipped, so re-running on every daemon boot is safe.
 *
 * Returns a summary { scanned, imported, skipped } for logging.
 */

import { scanHomeSSHKeys } from './ssh-scan.js'

/**
 * Auto-import local SSH pubkeys to a registry as ssh-key identities
 * linked to the given owner.
 *
 * @param {Object} registry - hyper-identity IdentityRegistry
 * @param {string} ownerUserId - userId to link imported identities to
 * @param {Object} [opts]
 * @param {boolean} [opts.allowRoot=false] - allow running as uid 0
 * @param {boolean} [opts.requireExistingIdentity=true] - owner must
 *        already have at least one non-ssh-key identity (safety check)
 * @returns {Promise<{scanned, imported, skipped, skippedReasons}>}
 */
export async function autoImportLocalSSHKeys (registry, ownerUserId, opts = {}) {
  const result = { scanned: 0, imported: 0, skipped: 0, skippedReasons: {} }
  const requireExistingIdentity = opts.requireExistingIdentity !== false

  if (!registry) throw new Error('autoImportLocalSSHKeys requires a registry')
  if (!ownerUserId) throw new Error('autoImportLocalSSHKeys requires ownerUserId')

  // Verify owner actually exists AND has at least one non-ssh identity
  // (so we don't auto-create perms for a userId that never consented).
  const user = await registry.getUser(ownerUserId)
  if (!user) {
    result.skippedReasons.noSuchUser = true
    return result
  }
  if (requireExistingIdentity) {
    const identities = user.identities || []
    let hasNonSshIdentity = false
    for (const idRef of identities) {
      // identities[] may be either identityId strings or full records
      const idStr = typeof idRef === 'string' ? idRef : (idRef && idRef.identityId)
      if (!idStr) continue
      const rec = await registry.getIdentity(idStr).catch(() => null)
      if (rec && rec.method && rec.method !== 'ssh-key') {
        hasNonSshIdentity = true
        break
      }
    }
    if (!hasNonSshIdentity) {
      result.skippedReasons.noExistingIdentity = true
      return result
    }
  }

  const keys = await scanHomeSSHKeys({ allowRoot: opts.allowRoot })
  result.scanned = keys.length

  for (const k of keys) {
    const existing = await registry.getIdentityBySSHKey(k.fingerprint).catch(() => null)
    if (existing) {
      result.skipped++
      continue
    }

    const identityId = `ssh:${k.fingerprint}`
    try {
      await registry.registerIdentity({
        identityId,
        method: 'ssh-key',
        publicKey: k.publicKey,
        credentials: { fingerprint: k.fingerprint, type: k.type, comment: k.comment },
        keyType: k.type,
        source: 'auto-import',
        autoImportedFrom: k.path,
        createdAt: Date.now()
      })

      // Link to owner — `verified: true` is legitimate here because
      // the caller of auto-import has physical-filesystem access to
      // the user's $HOME, which is the strongest possible assertion
      // of ownership for a locally-present key.
      await registry.linkIdentityToUser(identityId, ownerUserId, { verified: true })
      if (typeof registry.addIdentityToUser === 'function') {
        await registry.addIdentityToUser(ownerUserId, identityId)
      }

      result.imported++
    } catch (err) {
      result.skipped++
      result.skippedReasons[k.path] = err.message || String(err)
    }
  }

  return result
}