OverviewFilesCommitsIssuesDocsReleasesPeers

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

303 lines · javascript ref:
// 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 }) })