OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / handlers / ssh-keys.js

256 lines · javascript ref:
/**
 * SSH key handlers for the classic-git-auth SSH push path (Phase 2, Option B).
 *
 *   git-acl-check { fingerprint, repo, op }   (UDS-only)
 *     The authority oracle the pgv3-git-shell forced command calls. Reads
 *     (upload-pack/archive) are open; pushes (receive-pack) require the
 *     fingerprint to resolve to a User with write. CRITICAL: it evaluates the
 *     NAMED fingerprint with a fresh ssh-transport caller — it must NOT inherit
 *     the connecting wrapper's uds bypass (that would authorize everyone).
 *
 *   add-ssh-key { publicKey, userId? }
 *     Channel A (UDS operator, may target { userId }, default owner) and
 *     Channel B (HTTP self-service, links to the caller's own user). Registers
 *     an `ssh-key` identity and regenerates the authorized_keys managed block.
 *
 *   list-ssh-keys { userId? } / remove-ssh-key { fingerprint }
 *
 * Enrollment writes nothing to disk unless authorizedKeys is configured
 * (PGV3_AUTHORIZED_KEYS + PGV3_GIT_SHELL) — until then a key is registered
 * (so punch:// works) but the SSH-account path is dormant.
 */

import process from '#node:process'

import { SSHKeyParser } from 'hyper-identity/auth/ssh-parser'

import { AuthError } from '../auth/check.js'
import { canPushGit, resolveGitReader } from '../auth/git-acl.js'
import { syncAuthorizedKeys } from '../auth/authorized-keys.js'
import { findOwnerUserId, listLinkedSSHKeys } from '../auth/ssh-identity.js'
import { recordPush } from '../services/push-index.js'

export function sshKeyHandlers (svc, opts = {}) {
  const registry = () => svc.identityRegistry
  const authorizedKeysPath = opts.authorizedKeysPath || process.env.PGV3_AUTHORIZED_KEYS || null
  const wrapperPath = opts.wrapperPath || process.env.PGV3_GIT_SHELL || null
  // The daemon bakes its own socket + repos paths into each forced command so
  // the wrapper (running as the `git` account) is self-configuring.
  const sock = opts.sock || process.env.PGV3_DAEMON_SOCK || null
  const reposDir = opts.reposDir || process.env.PEAR_GIT_REPOS || null

  // Rebuild the authorized_keys managed block from ALL non-revoked ssh-key
  // identities (source of truth = the registry). No-op when unconfigured.
  //
  // Enumerate via listUsers() + getUserWithIdentities() — BOTH read the
  // replicated Autobase view, so a key enrolled on any peer (e.g. the
  // mirror's primary) materializes here too. We deliberately do NOT use
  // listAllIdentities(): it scans a local-only Hyperbee that never receives
  // peer writes, which made a mirror's authorized_keys blind to keys
  // enrolled elsewhere even though the registry authorized them — issue #73.
  // Enumerating by user is also the right scope: authorized_keys should hold
  // the keys of actual (non-merged) users, which is exactly what listUsers
  // yields.
  async function resyncAuthorizedKeys () {
    if (!authorizedKeysPath) return { skipped: 'not-configured' }
    const reg = registry()
    const users = await reg.listUsers().catch(() => [])
    const entries = []
    const seen = new Set()
    for (const u of users) {
      if (!u || !u.userId) continue
      const withIds = await reg.getUserWithIdentities(u.userId).catch(() => null)
      const identities = (withIds && withIds.identities) || []
      for (const id of identities) {
        if (!id || id.method !== 'ssh-key') continue
        const fp = id.credentials && id.credentials.fingerprint
        if (!fp || !id.publicKey || seen.has(fp)) continue
        if (typeof reg.isRevoked === 'function' && await reg.isRevoked(id.identityId).catch(() => false)) continue
        seen.add(fp)
        entries.push({ fingerprint: fp, publicKey: id.publicKey })
      }
    }
    await syncAuthorizedKeys(authorizedKeysPath, entries, { wrapperPath, sock, reposDir })
    return { count: entries.length }
  }
  // Exposed so boot can refresh the block once at startup.
  svc._resyncAuthorizedKeys = resyncAuthorizedKeys

  async function resolveSelf (ctx) {
    const pk = ctx && ctx.identity && ctx.identity.publicKey
    const u = pk ? await registry().getUserByIdentity(pk).catch(() => null) : null
    if (!u) throw new AuthError('not authenticated')
    return u.userId
  }

  return [
    {
      name: 'git-acl-check',
      fn: async (ctx, params = {}) => {
        if (!ctx || ctx.transport !== 'uds') {
          throw Object.assign(new Error('git-acl-check is UDS-only'), { code: 'EAUTH', status: 403 })
        }
        const repo = params.repo
        const op = params.op
        const sync = svc._sync
        const exists = !!(sync && sync.repos && typeof sync.repos.has === 'function' && sync.repos.has(repo))
        if (!exists) return { allowed: false, reason: 'repo not found: ' + repo }

        // Reads (upload-pack / archive). Historically always allowed; now gated
        // by the shared choke point so SSH cannot bypass a repo's visibility.
        //
        // Pass transport 'ssh', NOT ctx.transport — same trap the push path
        // documents below: ctx is uds-trusted because the git-shell wrapper
        // dials the daemon locally, but the actual caller is a remote SSH
        // client. Using ctx here would treat every SSH read as local and
        // allow it unconditionally.
        if (op !== 'receive-pack') {
          const access = svc.repoAccess
          // Resolve the NAMED fingerprint to a user — same funnel canPushGit
          // uses below, so a revoked ssh-key resolves to nobody on the read
          // path too. Unresolvable reads as anonymous, never as authorized.
          let userId = null
          if (access && params.fingerprint) {
            try {
              const resolved = await resolveGitReader(registry(), {
                transport: 'ssh',
                sshFingerprint: params.fingerprint
              })
              if (resolved && resolved.user) userId = resolved.user.userId
            } catch { userId = null }
          }
          const decision = access ? await access.async(repo, { transport: 'ssh', userId }) : null
          if (decision && decision.wouldDeny) {
            if (!decision.enforced) {
              // eslint-disable-next-line no-console
              console.log('[read-gate] OBSERVE would-deny ssh:' + op + ' ' + repo + ' (' + decision.reason + ')')
              return { allowed: true, reason: 'read' }
            }
            // eslint-disable-next-line no-console
            console.log('[read-gate] deny ssh:' + op + ' ' + repo + ' (' + decision.reason + ')')
            // Same wording as the not-found branch above, so an unauthorized
            // caller cannot distinguish "private" from "does not exist".
            return { allowed: false, reason: 'repo not found: ' + repo }
          }
          return { allowed: true, reason: 'read' }
        }

        // Evaluate the NAMED fingerprint — fresh ssh caller, NOT ctx (which is
        // uds-trusted and would bypass). canPushGit resolves fp → User → perms,
        // and denies a revoked ssh-key identity (Phase 0).
        const allowed = await canPushGit(registry(), {
          transport: 'ssh',
          sshFingerprint: params.fingerprint || null
        })
        return { allowed, reason: allowed ? 'write' : 'no write access for this key' }
      }
    },

    {
      // Attribute an SSH push for the public profile "repos pushed" list.
      // UDS-only; called by the git-shell wrapper after a successful
      // receive-pack. Resolves the fingerprint → identity → userId (same
      // sshkey index canPushGit uses) and records. Best-effort — never throws
      // back into the push path.
      name: 'record-push',
      fn: async (ctx, params = {}) => {
        if (!ctx || ctx.transport !== 'uds') {
          throw Object.assign(new Error('record-push is UDS-only'), { code: 'EAUTH', status: 403 })
        }
        const repo = params.repo
        const fp = params.fingerprint
        if (!repo || !fp) return { recorded: false }
        try {
          const reg = registry()
          const ident = typeof reg.getIdentityBySSHKey === 'function'
            ? await reg.getIdentityBySSHKey(fp).catch(() => null)
            : null
          const user = ident && ident.identityId
            ? await reg.getUserByIdentity(ident.identityId).catch(() => null)
            : null
          if (user && user.userId) {
            recordPush(user.userId, repo)
            return { recorded: true }
          }
        } catch {}
        return { recorded: false }
      }
    },

    {
      name: 'add-ssh-key',
      fn: async (ctx, params = {}) => {
        const publicKey = (params.publicKey || '').trim()
        if (!publicKey) throw new Error('add-ssh-key requires { publicKey }')

        let fingerprint, type
        try {
          fingerprint = SSHKeyParser.fingerprint(publicKey)
          type = SSHKeyParser.parse(publicKey).type
        } catch (err) {
          throw new Error('invalid SSH public key: ' + (err.message || err))
        }

        const reg = registry()
        let userId
        if (ctx && ctx.transport === 'uds') {
          userId = params.userId || await findOwnerUserId(reg)
          if (!userId) throw new Error('no target user — pass { userId } (no owner exists yet)')
        } else {
          userId = await resolveSelf(ctx) // HTTP self-service links to own account
        }

        const identityId = 'ssh:' + fingerprint
        const comment = publicKey.split(/\s+/).slice(2).join(' ') || null
        await reg.registerIdentity({
          identityId,
          method: 'ssh-key',
          publicKey,
          credentials: { fingerprint, type, comment }
        })
        // Throws if the key is already linked to a DIFFERENT user (anti-theft).
        await reg.linkIdentityToUser(identityId, userId, { verified: true })

        const enrolled = await resyncAuthorizedKeys()
        return { identityId, fingerprint, type, comment, userId, enrolled }
      }
    },

    {
      name: 'list-ssh-keys',
      fn: async (ctx, params = {}) => {
        const userId = (ctx && ctx.transport === 'uds')
          ? (params.userId || await findOwnerUserId(registry()))
          : await resolveSelf(ctx)
        if (!userId) return { keys: [] }
        return { keys: await listLinkedSSHKeys(svc, userId) }
      }
    },

    {
      name: 'remove-ssh-key',
      fn: async (ctx, params = {}) => {
        const fingerprint = params.fingerprint
        if (!fingerprint || fingerprint.indexOf('SHA256:') !== 0) {
          throw new Error('remove-ssh-key requires { fingerprint: "SHA256:…" }')
        }
        const identityId = 'ssh:' + fingerprint
        const reg = registry()

        // Ownership: HTTP callers may only remove their own keys; UDS may remove any.
        if (!(ctx && ctx.transport === 'uds')) {
          const callerId = await resolveSelf(ctx)
          const owner = await reg.getUserByIdentity(identityId).catch(() => null)
          if (!owner || owner.userId !== callerId) {
            throw new AuthError('cannot remove a key you do not own', 403)
          }
        }

        await reg.revokeIdentity(identityId, { reason: 'ssh-key-removed' })
        const enrolled = await resyncAuthorizedKeys()
        return { revoked: true, fingerprint, enrolled }
      }
    }
  ]
}