// 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)')
})