// Unit: SSH key handlers (classic-git-auth Phase 2) — the git-acl-check
// authority oracle and add/remove enrollment. git-acl-check is security-
// critical: UDS-only, and it must evaluate the NAMED fingerprint rather than
// inherit the connecting wrapper's uds trust.
import test from 'brittle'
import { mkdtempSync, readFileSync, rmSync, existsSync } from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import { sshKeyHandlers } from '../lib/handlers/ssh-keys.js'
import { readManagedFingerprints } from '../lib/auth/authorized-keys.js'
// A real ed25519 key so SSHKeyParser.fingerprint/parse accept it. Static
// throwaway fixture (private half discarded) — deterministic and runtime-
// agnostic, so the suite runs under bare without ssh-keygen/child_process.
const KDIR = mkdtempSync(join(os.tmpdir(), 'pgv3-sshkey-'))
const PUBKEY = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHxYcab1fod7CwpYISeVVOs7wG7EoYTC7AGJSUEOD/W1 contributor@test'
function memRegistry () {
const identities = new Map()
const link = new Map()
const users = new Map()
const sshIndex = new Map()
const revoked = new Set()
return {
_addUser (userId, user) { users.set(userId, { userId, identities: [], ...user }) },
_linkWeb (pk, userId) { identities.set(pk, { identityId: pk, method: 'web-identity' }); link.set(pk, userId); const u = users.get(userId); if (u) u.identities.push(pk) },
async registerIdentity (identity) {
identities.set(identity.identityId, { ...identity })
const fp = identity.credentials && identity.credentials.fingerprint
if (identity.method === 'ssh-key' && fp) sshIndex.set(fp, identity.identityId)
},
async linkIdentityToUser (id, userId, opts = {}) {
if (!opts.verified) throw new Error('requires verified')
const prev = link.get(id)
if (prev && prev !== userId) throw new Error('Identity already linked to user ' + prev)
link.set(id, userId)
const u = users.get(userId); if (u && !u.identities.includes(id)) u.identities.push(id)
},
async getIdentity (id) { return identities.get(id) || null },
async getIdentityBySSHKey (fp) { const id = sshIndex.get(fp); return id ? (identities.get(id) || null) : null },
async getUserByIdentity (id) { const uid = link.get(id); return uid ? (users.get(uid) || null) : null },
async getUser (uid) { return users.get(uid) || null },
async listUsers () { return Array.from(users.values()) },
async listAllIdentities () { return Array.from(identities.values()) },
async getUserWithIdentities (uid) { const user = users.get(uid); if (!user) return null; const ids = (user.identities || []).map((i) => identities.get(i)).filter(Boolean); return { user, identities: ids } },
async isRevoked (id) { return revoked.has(id) },
async revokeIdentity (id) { revoked.add(id) }
}
}
function handlers (reg, opts = {}) {
const svc = {
identityRegistry: reg,
identity: opts.identity || null,
_sync: { repos: new Set(opts.repos || ['demo']) },
repoAccess: opts.repoAccess || null
}
const map = {}
for (const { name, fn } of sshKeyHandlers(svc, opts)) map[name] = fn
return { map, svc }
}
const uds = () => ({ transport: 'uds' })
const http = (pk) => ({ transport: 'http', identity: { publicKey: pk } })
// ── git-acl-check ──────────────────────────────────────────────
test('git-acl-check: UDS-only', async (t) => {
const { map } = handlers(memRegistry())
await t.exception(() => map['git-acl-check'](http('x'), { repo: 'demo', op: 'upload-pack' }), /UDS-only/)
})
test('git-acl-check: repo existence + reads open', async (t) => {
const { map } = handlers(memRegistry())
t.absent((await map['git-acl-check'](uds(), { repo: 'nope', op: 'upload-pack' })).allowed, 'missing repo denied')
t.ok((await map['git-acl-check'](uds(), { repo: 'demo', op: 'upload-pack' })).allowed, 'read on existing repo open')
})
// ── read gate (repoAccess choke point) ─────────────────────────
// Records the ctx each call was made with, so we can assert the transport AND
// that the resolved userId is threaded through. Mirrors the real shape from
// createRepoAccess: a callable carrying an `.async` variant — the SSH path uses
// .async because identity resolution is asynchronous.
function recordingAccess ({ wouldDeny, enforced }) {
const calls = []
const decide = (name, ctx) => {
calls.push({ name, ctx })
return { read: enforced ? !wouldDeny : true, write: false, reason: 'test', wouldDeny, enforced }
}
const fn = (name, ctx) => decide(name, ctx)
fn.async = async (name, ctx) => decide(name, ctx)
fn.calls = calls
return fn
}
test('read gate: OBSERVE mode reports but still allows', async (t) => {
const access = recordingAccess({ wouldDeny: true, enforced: false })
const { map } = handlers(memRegistry(), { repoAccess: access })
const r = await map['git-acl-check'](uds(), { repo: 'demo', op: 'upload-pack' })
t.ok(r.allowed, 'observe must not break existing SSH clones')
t.is(access.calls.length, 1, 'gate was consulted')
})
test('read gate: ENFORCE denies, and does not confirm the repo exists', async (t) => {
const access = recordingAccess({ wouldDeny: true, enforced: true })
const { map } = handlers(memRegistry(), { repoAccess: access })
const denied = await map['git-acl-check'](uds(), { repo: 'demo', op: 'upload-pack' })
const missing = await map['git-acl-check'](uds(), { repo: 'nope', op: 'upload-pack' })
t.absent(denied.allowed, 'private repo read denied')
// Both answer with the same template, differing only by the name the caller
// already supplied — so a private repo does not confirm its own existence.
t.is(denied.reason, 'repo not found: demo')
t.is(missing.reason, 'repo not found: nope')
t.is(
denied.reason.replace('demo', '<name>'),
missing.reason.replace('nope', '<name>'),
'private is indistinguishable from non-existent'
)
})
test('read gate: consulted with transport ssh, NOT the uds ctx', async (t) => {
// Regression guard. git-acl-check is UDS-only because the git-shell wrapper
// dials the daemon locally, but the real caller is a remote SSH client. If
// ctx were forwarded, repo-access would treat every SSH read as local and
// allow it unconditionally — the same trap the push path documents.
const access = recordingAccess({ wouldDeny: true, enforced: true })
const { map } = handlers(memRegistry(), { repoAccess: access })
await map['git-acl-check'](uds(), { repo: 'demo', op: 'upload-pack' })
t.is(access.calls[0].ctx.transport, 'ssh', 'must not inherit uds trust')
})
test('read gate: the ssh fingerprint is RESOLVED to a userId and passed on', async (t) => {
// Without this the gate still "works" but every SSH caller looks anonymous,
// so a collaborator with a read grant would be denied their own private repo.
const reg = memRegistry()
reg._addUser('carol', { globalPermissions: ['read'] })
await reg.registerIdentity({
identityId: 'ssh:SHA256:carol',
method: 'ssh-key',
publicKey: 'ssh-ed25519 AAA carol@host',
credentials: { fingerprint: 'SHA256:carol' }
})
await reg.linkIdentityToUser('ssh:SHA256:carol', 'carol', { verified: true })
const access = recordingAccess({ wouldDeny: true, enforced: false })
const { map } = handlers(reg, { repoAccess: access })
await map['git-acl-check'](uds(), { repo: 'demo', op: 'upload-pack', fingerprint: 'SHA256:carol' })
t.is(access.calls[0].ctx.userId, 'carol', 'resolved fingerprint → userId')
t.is(access.calls[0].ctx.transport, 'ssh')
})
test('read gate: an UNKNOWN fingerprint resolves to anonymous, not to a user', async (t) => {
const access = recordingAccess({ wouldDeny: true, enforced: false })
const { map } = handlers(memRegistry(), { repoAccess: access })
await map['git-acl-check'](uds(), { repo: 'demo', op: 'upload-pack', fingerprint: 'SHA256:nobody' })
t.is(access.calls[0].ctx.userId, null, 'unresolvable credential is anonymous, never authorized')
})
test('read gate: archive is gated too, not just upload-pack', async (t) => {
const access = recordingAccess({ wouldDeny: true, enforced: true })
const { map } = handlers(memRegistry(), { repoAccess: access })
const r = await map['git-acl-check'](uds(), { repo: 'demo', op: 'archive' })
t.absent(r.allowed, 'git archive over ssh is a read path and must be gated')
})
test('read gate: a public repo still reads', async (t) => {
const access = recordingAccess({ wouldDeny: false, enforced: true })
const { map } = handlers(memRegistry(), { repoAccess: access })
t.ok((await map['git-acl-check'](uds(), { repo: 'demo', op: 'upload-pack' })).allowed)
})
test('read gate: absent gate fails open (unwired svc / partial boot)', async (t) => {
const { map } = handlers(memRegistry())
t.ok((await map['git-acl-check'](uds(), { repo: 'demo', op: 'upload-pack' })).allowed)
})
test('git-acl-check: push gated by the fingerprint, NOT the uds bypass', async (t) => {
const reg = memRegistry()
reg._addUser('alice', { globalPermissions: ['write'] })
reg._addUser('reader', { globalPermissions: ['read'] })
await reg.registerIdentity({ identityId: 'ssh:SHA256:w', method: 'ssh-key', publicKey: 'ssh-ed25519 AAA w@h', credentials: { fingerprint: 'SHA256:w' } })
await reg.linkIdentityToUser('ssh:SHA256:w', 'alice', { verified: true })
await reg.registerIdentity({ identityId: 'ssh:SHA256:r', method: 'ssh-key', publicKey: 'ssh-ed25519 AAA r@h', credentials: { fingerprint: 'SHA256:r' } })
await reg.linkIdentityToUser('ssh:SHA256:r', 'reader', { verified: true })
const { map } = handlers(reg)
t.ok((await map['git-acl-check'](uds(), { repo: 'demo', op: 'receive-pack', fingerprint: 'SHA256:w' })).allowed, 'write key allowed')
t.absent((await map['git-acl-check'](uds(), { repo: 'demo', op: 'receive-pack', fingerprint: 'SHA256:r' })).allowed, 'read-only user denied DESPITE uds transport')
t.absent((await map['git-acl-check'](uds(), { repo: 'demo', op: 'receive-pack', fingerprint: 'SHA256:unknown' })).allowed, 'unenrolled fingerprint denied')
})
test('git-acl-check: revoked ssh key denied', async (t) => {
const reg = memRegistry()
reg._addUser('alice', { globalPermissions: ['write'] })
await reg.registerIdentity({ identityId: 'ssh:SHA256:w', method: 'ssh-key', publicKey: 'ssh-ed25519 AAA w@h', credentials: { fingerprint: 'SHA256:w' } })
await reg.linkIdentityToUser('ssh:SHA256:w', 'alice', { verified: true })
const { map } = handlers(reg)
t.ok((await map['git-acl-check'](uds(), { repo: 'demo', op: 'receive-pack', fingerprint: 'SHA256:w' })).allowed, 'allowed before revoke')
await reg.revokeIdentity('ssh:SHA256:w')
t.absent((await map['git-acl-check'](uds(), { repo: 'demo', op: 'receive-pack', fingerprint: 'SHA256:w' })).allowed, 'denied after revoke')
})
// ── add / remove enrollment ────────────────────────────────────
test('add-ssh-key: UDS enrolls for owner; key then authorizes push', async (t) => {
const reg = memRegistry()
reg._addUser('owner', { globalPermissions: ['admin'] })
const akPath = join(KDIR, 'authorized_keys_a')
const { map } = handlers(reg, { authorizedKeysPath: akPath, wrapperPath: '/opt/pgv3-git-shell' })
const out = await map['add-ssh-key'](uds(), { publicKey: PUBKEY })
t.is(out.userId, 'owner', 'linked to owner')
t.ok(out.fingerprint.startsWith('SHA256:'), 'computed fingerprint')
// authorized_keys written with the enrolled fingerprint
t.ok(existsSync(akPath), 'authorized_keys written')
t.alike(readManagedFingerprints(readFileSync(akPath, 'utf8')), [out.fingerprint], 'block has the key')
// and the enrolled key authorizes push via git-acl-check
const chk = await map['git-acl-check'](uds(), { repo: 'demo', op: 'receive-pack', fingerprint: out.fingerprint })
t.ok(chk.allowed, 'enrolled owner key can push')
})
test('add-ssh-key: HTTP self-service links to caller; invalid key rejected', async (t) => {
const reg = memRegistry()
reg._addUser('alice', { globalPermissions: ['write'] })
reg._linkWeb('alice-pub', 'alice')
const { map } = handlers(reg, { identity: null })
const out = await map['add-ssh-key'](http('alice-pub'), { publicKey: PUBKEY })
t.is(out.userId, 'alice', 'linked to the authenticated caller')
await t.exception(() => map['add-ssh-key'](http('alice-pub'), { publicKey: 'not a key' }), /invalid SSH public key/)
})
test('remove-ssh-key: revoke denies push; ownership enforced', async (t) => {
const reg = memRegistry()
reg._addUser('alice', { globalPermissions: ['write'] })
reg._addUser('bob', { globalPermissions: ['write'] })
reg._linkWeb('alice-pub', 'alice'); reg._linkWeb('bob-pub', 'bob')
const { map } = handlers(reg)
const added = await map['add-ssh-key'](http('alice-pub'), { publicKey: PUBKEY })
await t.exception(() => map['remove-ssh-key'](http('bob-pub'), { fingerprint: added.fingerprint }), /do not own/)
t.ok((await map['git-acl-check'](uds(), { repo: 'demo', op: 'receive-pack', fingerprint: added.fingerprint })).allowed, 'still works (bob failed)')
await map['remove-ssh-key'](http('alice-pub'), { fingerprint: added.fingerprint })
t.absent((await map['git-acl-check'](uds(), { repo: 'demo', op: 'receive-pack', fingerprint: added.fingerprint })).allowed, 'denied after owner removes')
})
// ── resync reads the replicated view, not the local-db enumerator (#73) ──
// A mirror daemon replicates the Autobase VIEW but its local-db enumerator
// (listAllIdentities) never receives peer writes. The resync must enumerate
// via listUsers() + getUserWithIdentities() (both view-backed) so a key
// enrolled on a peer materializes here. These two tests pin that behaviour:
// a view-only key IS written; a db-only key is NOT.
const REMOTE_SSH = {
identityId: 'ssh:SHA256:remote',
method: 'ssh-key',
publicKey: PUBKEY,
credentials: { fingerprint: 'SHA256:remote', type: 'ssh-ed25519' }
}
test('resync materializes a peer-enrolled key from the view (empty listAllIdentities)', async (t) => {
const reg = {
// Mirror: the local-db enumerator the pre-fix code read is EMPTY here.
async listAllIdentities () { return [] },
// Replicated view: the user + their ssh-key ARE present.
async listUsers () { return [{ userId: 'peer', identities: ['ssh:SHA256:remote'] }] },
async getUserWithIdentities (uid) {
return uid === 'peer' ? { user: { userId: 'peer' }, identities: [REMOTE_SSH] } : null
},
async isRevoked () { return false }
}
const akPath = join(KDIR, 'authorized_keys_mirror')
const { svc } = handlers(reg, { authorizedKeysPath: akPath, wrapperPath: '/opt/pgv3-git-shell' })
const res = await svc._resyncAuthorizedKeys()
t.is(res.count, 1, 'one key materialized from the view')
t.alike(readManagedFingerprints(readFileSync(akPath, 'utf8')), ['SHA256:remote'],
'peer-enrolled fingerprint present despite empty listAllIdentities')
})
test('resync ignores a db-only key with no user in the view (listAllIdentities no longer consulted)', async (t) => {
const reg = {
async listAllIdentities () {
return [{ identityId: 'ssh:SHA256:ghost', method: 'ssh-key', publicKey: PUBKEY, credentials: { fingerprint: 'SHA256:ghost' } }]
},
async listUsers () { return [] },
async getUserWithIdentities () { return null },
async isRevoked () { return false }
}
const akPath = join(KDIR, 'authorized_keys_ghost')
const { svc } = handlers(reg, { authorizedKeysPath: akPath, wrapperPath: '/opt/pgv3-git-shell' })
const res = await svc._resyncAuthorizedKeys()
t.is(res.count, 0, 'db-only key not materialized')
t.alike(readManagedFingerprints(readFileSync(akPath, 'utf8')), [], 'managed block empty')
})
test.hook('cleanup', () => { rmSync(KDIR, { recursive: true, force: true }) })