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