OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / revocation-enforced.test.js

118 lines · javascript ref:
// SECURITY (pear-git org #14) — revocation must hold on EVERY front door.
//
// The registry keeps revocation in a separate `revoked:<identityId>` view
// index; the identity record does not carry the flag, so `identity.revoked`
// is always false. Only the sshFingerprint branch of git-acl.js and
// resolveToken ever consulted isRevoked(). The identityId branch (HTTP
// sessions) and the noiseKey branch (punch://) did not, and neither did
// requireWriter / requireAdmin / isAdmin — so revoking a compromised or
// departed identity did not stop it pushing, or invoking writer/admin RPCs,
// for as long as its User still carried write/admin.
//
// These fail against the old code: every "revoked → denied" assertion below
// passes for the revoked identity instead.

import test from 'brittle'
import { canPushGit } from '../lib/auth/git-acl.js'
import { requireWriter, requireAdmin, isAdmin, isIdentityRevoked } from '../lib/auth/check.js'

// Faithful stand-in for hyper-identity's IdentityRegistry. Mirrors the real
// shape that matters here: getUserByIdentity walks the user-identity link and
// is INDIFFERENT to revocation, which is exactly the trap — revocation is only
// visible through isRevoked().
function fakeRegistry ({ usersByIdentity = {}, identitiesByNoise = {}, identitiesBySSH = {}, revoked = [] } = {}) {
  const revokedSet = new Set(revoked)
  return {
    async getUserByIdentity (id) { return usersByIdentity[id] || null },
    async getIdentityByNoiseKey (nk) { return identitiesByNoise[nk] || null },
    async getIdentityBySSHKey (fp) { return identitiesBySSH[fp] || null },
    async isRevoked (id) { return revokedSet.has(id) }
  }
}

const WRITER = { globalPermissions: ['write'] }
const ADMIN = { globalPermissions: ['admin'] }

test('canPushGit: revoked identity denied on the HTTP-session path', async (t) => {
  const reg = fakeRegistry({
    usersByIdentity: { live: WRITER, gone: WRITER },
    revoked: ['gone']
  })
  t.ok(await canPushGit(reg, { transport: 'http', identityId: 'live' }), 'control: live writer can push')
  t.absent(await canPushGit(reg, { transport: 'http', identityId: 'gone' }), 'revoked writer cannot push over HTTP')
})

test('canPushGit: revoked identity denied on the punch:// noise-key path', async (t) => {
  const reg = fakeRegistry({
    usersByIdentity: { 'ident-live': WRITER, 'ident-gone': WRITER },
    identitiesByNoise: { noiseLive: { identityId: 'ident-live' }, noiseGone: { identityId: 'ident-gone' } },
    revoked: ['ident-gone']
  })
  t.ok(await canPushGit(reg, { transport: 'swarm', noiseKey: 'noiseLive' }), 'control: live noise key can push')
  t.absent(await canPushGit(reg, { transport: 'swarm', noiseKey: 'noiseGone' }), 'revoked noise key cannot push')
})

test('canPushGit: the SSH path keeps honouring revocation (no regression)', async (t) => {
  const reg = fakeRegistry({
    usersByIdentity: { 'ident-live': WRITER, 'ident-gone': WRITER },
    identitiesBySSH: { 'SHA256:live': { identityId: 'ident-live' }, 'SHA256:gone': { identityId: 'ident-gone' } },
    revoked: ['ident-gone']
  })
  t.ok(await canPushGit(reg, { transport: 'ssh', sshFingerprint: 'SHA256:live' }), 'control: live ssh key can push')
  t.absent(await canPushGit(reg, { transport: 'ssh', sshFingerprint: 'SHA256:gone' }), 'revoked ssh key still denied')
})

test('a revoked credential does not fall through to another front door', async (t) => {
  // Presenting a revoked session id alongside a live noise key must not
  // launder the revoked one into an allow. Falling through on a revoked
  // credential is how this bypass class comes back.
  const reg = fakeRegistry({
    usersByIdentity: { gone: WRITER, 'ident-live': WRITER },
    identitiesByNoise: { noiseLive: { identityId: 'ident-live' } },
    revoked: ['gone']
  })
  t.absent(
    await canPushGit(reg, { transport: 'http', identityId: 'gone', noiseKey: 'noiseLive' }),
    'revoked identityId is a definite denial, not a reason to try the next branch'
  )
})

test('requireWriter / requireAdmin refuse a revoked identity', async (t) => {
  const reg = fakeRegistry({
    usersByIdentity: { live: ADMIN, gone: ADMIN },
    revoked: ['gone']
  })
  const liveCtx = { transport: 'http', identity: { publicKey: 'live' } }
  const goneCtx = { transport: 'http', identity: { publicKey: 'gone' } }

  await requireWriter(liveCtx, reg)
  await requireAdmin(liveCtx, reg)
  t.pass('control: live admin passes both gates')

  await t.exception(requireWriter(goneCtx, reg), /revoked/, 'revoked identity refused by requireWriter')
  await t.exception(requireAdmin(goneCtx, reg), /revoked/, 'revoked identity refused by requireAdmin')
})

test('isAdmin probe agrees with requireAdmin for a revoked admin', async (t) => {
  const reg = fakeRegistry({ usersByIdentity: { live: ADMIN, gone: ADMIN }, revoked: ['gone'] })
  t.ok(await isAdmin({ transport: 'http', identity: { publicKey: 'live' } }, reg), 'control: live admin')
  t.absent(await isAdmin({ transport: 'http', identity: { publicKey: 'gone' } }, reg),
    'revoked admin is not shown admin-only UI')
})

test('UDS and ownerMatched still bypass — revocation must not self-lock the operator', async (t) => {
  const reg = fakeRegistry({ usersByIdentity: { gone: ADMIN }, revoked: ['gone'] })
  t.ok(await canPushGit(reg, { transport: 'uds', identityId: 'gone' }), 'UDS stays trusted')
  t.ok(await canPushGit(reg, { ownerMatched: true, identityId: 'gone' }), 'intrinsic owner stays trusted')
  await requireWriter({ transport: 'uds' }, reg)
  await requireAdmin({ transport: 'uds' }, reg)
  t.pass('UDS bypasses both gates without consulting revocation')
})

test('isIdentityRevoked fails open on a registry without isRevoked, and on a read error', async (t) => {
  t.absent(await isIdentityRevoked({}, 'x'), 'registry without isRevoked() → not revoked')
  t.absent(await isIdentityRevoked(null, 'x'), 'no registry → not revoked')
  t.absent(await isIdentityRevoked({ isRevoked: async () => { throw new Error('view unavailable') } }, 'x'),
    'transient read error → not revoked (availability over a false lockout)')
})