OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / auth / authorized-keys.js

161 lines · javascript ref:
/**
 * Managed authorized_keys writer for the SSH push path (classic-git-auth
 * Phase 2, Option B). See spec/flows/classic-git-auth.md.
 *
 * The daemon owns a MARKED BLOCK in the (dedicated `git`) account's
 * ~/.ssh/authorized_keys. Every managed line forces `pgv3-git-shell` and uses
 * `restrict`, so an enrolled contributor key can only ever run git — never a
 * shell, port-forward, or agent. The fingerprint is baked into the forced
 * command (`--fp=SHA256:…`); the wrapper passes it to canPushGit, which
 * resolves it to a User and enforces perms + revocation LIVE (so revoking the
 * ssh-key identity denies the next push without rewriting this file).
 *
 * The managed block is regenerated wholesale from the current enrolled-key set
 * (gitolite-style) — always consistent, never partially edited. Lines OUTSIDE
 * the markers (e.g. the operator's own login keys) are preserved verbatim.
 *
 * Pure string transforms (renderKeyLine / buildManagedBlock / spliceManagedBlock)
 * are separated from the fs sync so they unit-test without touching disk.
 */

import fs from '#node:fs/promises'
import { dirname } from '#node:path'

export const BEGIN_MARKER = '# >>> managed-by=pear-git-v3 — do not edit by hand'
export const END_MARKER = '# <<< managed-by=pear-git-v3'

const DEFAULT_WRAPPER = 'pgv3-git-shell'
// OpenSSH SHA256 fingerprint: "SHA256:" + (un)padded base64.
const FINGERPRINT_RE = /^SHA256:[A-Za-z0-9+/]+={0,2}$/
// A public key line must be a single line and start with a known key type.
const PUBKEY_TYPE_RE = /^(ssh-ed25519|ssh-rsa|ssh-dss|ecdsa-sha2-[a-z0-9-]+|sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-[a-z0-9-]+@openssh\.com) /

function assertSafeFingerprint (fp) {
  if (typeof fp !== 'string' || !FINGERPRINT_RE.test(fp)) {
    throw new Error('invalid SSH fingerprint (expected SHA256:…): ' + fp)
  }
}

function assertSafePublicKey (pub) {
  if (typeof pub !== 'string') throw new Error('public key must be a string')
  const line = pub.trim()
  if (/[\r\n]/.test(line)) throw new Error('public key must be a single line (newline injection)')
  if (!PUBKEY_TYPE_RE.test(line)) throw new Error('unrecognized public key type: ' + line.slice(0, 24))
  return line
}

// Arg baked into the forced command (single token, double-quote-safe).
function safeArg (s, label) {
  if (typeof s !== 'string' || !s.length || /[\s"\\\r\n]/.test(s)) {
    throw new Error('invalid ' + label + ' for forced command: ' + s)
  }
  return s
}

/**
 * Render one forced-command authorized_keys line for an enrolled key. The
 * daemon bakes its OWN socket + repos paths into the command so the wrapper —
 * which runs as the `git` account with a different HOME — is self-configuring.
 *
 * @param {object} entry - { fingerprint, publicKey }  (publicKey = full
 *   "ssh-ed25519 AAAA… comment" line)
 * @param {object} [opts] - { wrapperPath, sock, reposDir }
 * @returns {string}
 */
export function renderKeyLine (entry, opts = {}) {
  assertSafeFingerprint(entry && entry.fingerprint)
  const pub = assertSafePublicKey(entry && entry.publicKey)
  const wrapper = safeArg(opts.wrapperPath || DEFAULT_WRAPPER, 'wrapper path')
  let cmd = wrapper + ' --fp=' + entry.fingerprint
  if (opts.sock) cmd += ' --sock=' + safeArg(opts.sock, 'socket path')
  if (opts.reposDir) cmd += ' --repos=' + safeArg(opts.reposDir, 'repos dir')
  // `restrict` default-denies pty/forwarding/agent/X11 (and future features).
  return 'command="' + cmd + '",restrict ' + pub
}

/**
 * Build the full managed block (markers + one line per entry) for the given
 * enrolled-key set. Returns '' for an empty set (caller removes the block).
 */
export function buildManagedBlock (entries, opts = {}) {
  if (!entries || entries.length === 0) return ''
  const lines = entries.map((e) => renderKeyLine(e, opts))
  return [BEGIN_MARKER, ...lines, END_MARKER].join('\n')
}

function joinParts (parts) {
  const nonEmpty = parts.filter((p) => p && p.length)
  const joined = nonEmpty.join('\n\n')
  return joined ? joined + '\n' : ''
}

/**
 * Replace the managed region (between markers, inclusive) of `content` with
 * `block`. Foreign content before/after the markers is preserved. If no markers
 * exist, the block is appended. If `block` is '', the managed region is removed.
 */
export function spliceManagedBlock (content, block) {
  content = content || ''
  const begin = content.indexOf(BEGIN_MARKER)

  if (begin !== -1) {
    const endAt = content.indexOf(END_MARKER, begin)
    const head = content.slice(0, begin).replace(/\s*$/, '')
    if (endAt !== -1) {
      const tail = content.slice(endAt + END_MARKER.length).replace(/^\s*/, '')
      return joinParts([head, block, tail])
    }
    // BEGIN without END — corrupt; treat everything from BEGIN onward as ours.
    return joinParts([head, block])
  }

  // No markers — append (or no-op when removing).
  if (!block) return content
  return joinParts([content.replace(/\s*$/, ''), block])
}

/**
 * Regenerate the managed block in the authorized_keys file at `path` from the
 * current enrolled-key `entries`. Atomic (tmp + rename), mode 0600. Preserves
 * foreign lines. Pass an empty `entries` to remove the managed block.
 *
 * @param {string} path
 * @param {Array<{fingerprint, publicKey}>} entries
 * @param {object} [opts] - { wrapperPath }
 */
export async function syncAuthorizedKeys (path, entries, opts = {}) {
  let content = ''
  try {
    content = await fs.readFile(path, 'utf8')
  } catch (err) {
    if (err.code !== 'ENOENT') throw err
  }

  const block = buildManagedBlock(entries, opts)
  const next = spliceManagedBlock(content, block)

  await fs.mkdir(dirname(path), { recursive: true }).catch(() => {})
  const tmp = path + '.pgv3-tmp'
  await fs.writeFile(tmp, next, { mode: 0o600 })
  await fs.rename(tmp, path)
  try { await fs.chmod(path, 0o600) } catch {}
  return { count: entries ? entries.length : 0 }
}

/**
 * Parse the fingerprints currently present in the managed block of `content`.
 * Used to report/diff what's enrolled. Returns [] when no block.
 */
export function readManagedFingerprints (content) {
  content = content || ''
  const begin = content.indexOf(BEGIN_MARKER)
  if (begin === -1) return []
  const endAt = content.indexOf(END_MARKER, begin)
  const region = content.slice(begin, endAt === -1 ? undefined : endAt)
  const out = []
  const re = /--fp=(SHA256:[A-Za-z0-9+/]+={0,2})/g
  let m
  while ((m = re.exec(region)) !== null) out.push(m[1])
  return out
}