/**
* 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
}