OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / auth / ssh-identity.js

169 lines · javascript ref:
/**
 * SSH identity integration for pear-git-v3.
 *
 * Bridges hyper-identity's SSH primitives to v3's auth layer:
 *
 *   - Principal format: `ssh:<fingerprint>` (the OpenSSH SHA256 fingerprint)
 *     stored as the identityId in IdentityRegistry. The registry already
 *     maintains a reverse index `sshkey:<fingerprint> → identityId` so
 *     lookups are direct.
 *
 *   - Auto-import: at daemon boot AND after first-user TOFU, scan the
 *     running user's `~/.ssh/*.pub`, register each as an `ssh-key`
 *     Identity linked to the owner User. Delegates to
 *     `hyper-identity/auth/ssh-auto-import`.
 *
 *   - Verify: `verifySSHSignature` (unchanged) — validates an SSH
 *     signature against a challenge, used by the `auth-verify-ssh`
 *     handler.
 *
 *   - SSH auth (future CLI): session.publicKey will be "ssh:<fp>" for
 *     SSH-signed sessions. `requireWriter` resolves this via
 *     `registry.getUserByIdentity` same as browser identities.
 */

import { SSHKeyParser } from 'hyper-identity/auth/ssh-parser'
import { scanHomeSSHKeys } from 'hyper-identity/auth/ssh-scan'
import { autoImportLocalSSHKeys } from 'hyper-identity/auth/ssh-auto-import'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'

/**
 * Produce the trust-map-compatible principal for an SSH public key.
 *
 * Kept for backward compatibility with the F.5 auth-verify-ssh handler
 * and any existing trust-map data on disk. The registry uses the
 * full "ssh:<fingerprint>" identityId instead; both resolve to the
 * same identity via the registry's sshkey reverse index.
 *
 * @param {string} sshPublicKey - "ssh-ed25519 AAAA... user@host"
 * @returns {string} 64 hex chars (sha256 of the decoded pubkey blob)
 */
export function sshPrincipal (sshPublicKey) {
  const parts = sshPublicKey.trim().split(/\s+/)
  if (parts.length < 2) throw new Error('Invalid SSH key format')
  const blob = b4a.from(parts[1], 'base64')
  const digest = crypto.hash(blob)
  return b4a.toString(digest, 'hex')
}

/**
 * Verify an SSH signature against a challenge. Pure signature check;
 * does not consult any registry or trust data.
 *
 * @param {string} sshPublicKey - SSH pubkey text
 * @param {string} signatureBase64 - SSH-wire-format signature blob, base64
 * @param {string|Buffer} challenge - message that was signed
 * @returns {Promise<boolean>}
 */
export async function verifySSHSignature (sshPublicKey, signatureBase64, challenge) {
  try {
    const parsedKey = SSHKeyParser.parse(sshPublicKey)
    const parsedSig = SSHKeyParser.parseSignature(signatureBase64)
    const msg = typeof challenge === 'string' ? b4a.from(challenge, 'utf8') : challenge

    if (parsedKey.type === 'ed25519') {
      return crypto.verify(msg, parsedSig.signature, parsedKey.publicKey)
    }
    if (parsedKey.type === 'rsa') {
      const nodeCrypto = await import('node:crypto')
      const pem = SSHKeyParser.toPEM(parsedKey)
      const verifier = nodeCrypto.createVerify('SHA256')
      verifier.update(msg)
      return verifier.verify(pem, parsedSig.signature)
    }
    return false
  } catch {
    return false
  }
}

/**
 * Find the current daemon owner's userId. Returns the userId of the
 * first user with 'admin' in globalPermissions, or null if no admin
 * exists yet (pre-TOFU).
 *
 * @param {IdentityRegistry} registry
 * @returns {Promise<string|null>}
 */
export async function findOwnerUserId (registry) {
  if (!registry) return null
  const users = await registry.listUsers().catch(() => [])
  for (const u of users) {
    if (Array.isArray(u.globalPermissions) && u.globalPermissions.includes('admin')) {
      return u.userId
    }
  }
  return null
}

/**
 * Auto-import local SSH public keys into the IdentityRegistry as
 * `method: 'ssh-key'` identities linked to the daemon owner.
 *
 * Delegates the scan + register flow to hyper-identity's helper; this
 * wrapper just resolves the owner userId and gates on svc state.
 *
 * @param {object} svc
 * @param {object} [opts]
 * @param {boolean} [opts.allowRoot=false]
 * @returns {Promise<{ownerUserId, scanned, imported, skipped, reason?}>}
 */
export async function autoImportSSHKeys (svc, opts = {}) {
  const registry = svc && svc.identityRegistry
  if (!registry) throw new Error('autoImportSSHKeys requires svc.identityRegistry')

  const ownerUserId = await findOwnerUserId(registry)
  if (!ownerUserId) {
    return { ownerUserId: null, scanned: 0, imported: 0, skipped: 0, reason: 'no-owner-yet' }
  }

  // autoImportLocalSSHKeys expects a verified-ownership registry that
  // requires the owner User to already exist and have at least one
  // non-ssh-key identity — matches our TOFU order (web-identity is
  // always registered before the post-TOFU auto-import fires).
  const result = await autoImportLocalSSHKeys(registry, ownerUserId, opts)
  return { ownerUserId, ...result }
}

/**
 * List SSH keys currently linked to a user. Reads the User's
 * identities via the registry, filters `method === 'ssh-key'`, and
 * enriches each entry with the comment / full pubkey found on disk
 * (registry stores credentials.fingerprint+type; the human-readable
 * `user@host` comment lives in the `.pub` file).
 *
 * @param {object} svc
 * @param {string} userId
 * @returns {Promise<Array<{fingerprint, type, comment, publicKey, path, identityId}>>}
 */
export async function listLinkedSSHKeys (svc, userId) {
  const registry = svc && svc.identityRegistry
  if (!registry || !userId) return []

  const { user, identities } = await registry.getUserWithIdentities(userId).catch(() => ({}))
  if (!user || !identities) return []

  const sshIdentities = identities.filter(i => i.method === 'ssh-key')
  if (sshIdentities.length === 0) return []

  // Enrich with on-disk comments. The scan is cheap (a few files).
  // If $HOME/.ssh/ isn't readable we just return identities without
  // comment/path info.
  const diskKeys = await scanHomeSSHKeys({ allowRoot: true }).catch(() => [])
  const byFingerprint = new Map(diskKeys.map(k => [k.fingerprint, k]))

  return sshIdentities.map(id => {
    const fp = id.credentials && id.credentials.fingerprint
    const diskMatch = fp ? byFingerprint.get(fp) : null
    return {
      identityId: id.identityId,
      fingerprint: fp,
      type: id.credentials && id.credentials.type,
      comment: diskMatch ? diskMatch.comment : (id.credentials && id.credentials.comment) || null,
      publicKey: diskMatch ? diskMatch.publicKey : id.publicKey,
      path: diskMatch ? diskMatch.path : null
    }
  })
}