OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / test / unit / attestation.test.js

667 lines · javascript ref:
/**
 * Unit tests for lib/auth/attestation.js
 *
 * Tests attestation creation, verification, tamper detection,
 * and auth-method-agnostic design.
 */

import test from 'brittle'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import Corestore from 'corestore'
import { PermissionAttestation } from '../../lib/auth/attestation.js'
import { IdentityRegistry } from '../../lib/auth/identity-registry.js'
import { ChallengeManager } from '../../lib/auth/challenge-manager.js'
import { ANProto } from '../../lib/anproto.js'

const tmpDir = path.join(os.tmpdir(), 'pear-git-test-attest-' + Date.now())
let storeIdx = 0

// Helper to create test registry with unique storage
async function createTestRegistry () {
  const storePath = path.join(tmpDir, 'store-' + (storeIdx++))
  const store = new Corestore(storePath)
  const registry = new IdentityRegistry({ corestore: store })
  await registry.ready()
  return registry
}

// ========================================
// Helpers
// ========================================

function createServerIdentity () {
  const keypair = crypto.keyPair()
  return {
    publicKey: keypair.publicKey,
    secretKey: keypair.secretKey,
    getPublicKeyHex () { return keypair.publicKey.toString('hex') },
    sign (data) {
      const buf = typeof data === 'string' ? Buffer.from(data) : data
      return crypto.sign(buf, keypair.secretKey)
    }
  }
}

function createUser (overrides = {}) {
  return {
    userId: crypto.randomBytes(16).toString('hex'),
    globalPermissions: ['admin', 'read', 'write'],
    repos: [{ name: '*', permissions: ['admin', 'read', 'write'] }],
    identities: [],
    createdAt: Date.now(),
    ...overrides
  }
}

test('setup: create temp dir', async (t) => {
  await fs.promises.mkdir(tmpDir, { recursive: true })
  t.pass('temp dir created')
})

// ========================================
// Attestation Creation
// ========================================

test('create - produces valid attestation with all fields', async (t) => {
  const server = createServerIdentity()
  const user = createUser()
  const userPublicKey = crypto.randomBytes(32).toString('hex')

  const attestation = PermissionAttestation.create({
    user,
    userPublicKey,
    identityMethod: 'web-identity',
    identityId: userPublicKey,
    serverIdentity: server
  })

  t.is(attestation.version, 1, 'version is 1')
  t.ok(attestation.epoch > 0, 'has epoch timestamp')
  t.is(attestation.serverPublicKey, server.getPublicKeyHex(), 'has server public key')
  t.is(attestation.userPublicKey, userPublicKey, 'has user public key')
  t.is(attestation.identityMethod, 'web-identity', 'has identity method')
  t.is(attestation.identityId, userPublicKey, 'has identity ID')
  t.is(attestation.userId, user.userId, 'has user ID')
  t.alike(attestation.permissions, ['admin', 'read', 'write'], 'has permissions')
  t.alike(attestation.repos, ['*'], 'has repos')
  t.ok(attestation.signature, 'has signature')
  t.is(attestation.signature.length, 128, 'signature is 64-byte hex (128 chars)')
})

test('create - throws on missing required fields', async (t) => {
  const server = createServerIdentity()

  t.exception(() => {
    PermissionAttestation.create({
      user: null,
      userPublicKey: 'abc',
      identityMethod: 'web-identity',
      serverIdentity: server
    })
  }, /Missing required fields/)
})

// ========================================
// Attestation Verification
// ========================================

test('verify - accepts valid attestation', async (t) => {
  const server = createServerIdentity()
  const user = createUser()
  const userPublicKey = crypto.randomBytes(32).toString('hex')

  const attestation = PermissionAttestation.create({
    user,
    userPublicKey,
    identityMethod: 'web-identity',
    identityId: userPublicKey,
    serverIdentity: server
  })

  const result = PermissionAttestation.verify(attestation, server.getPublicKeyHex())

  t.ok(result.valid, 'attestation is valid')
  t.ok(result.grant, 'has grant object')
  t.is(result.grant.userPublicKey, userPublicKey, 'grant has correct user key')
  t.is(result.grant.identityMethod, 'web-identity', 'grant has correct method')
  t.alike(result.grant.permissions, ['admin', 'read', 'write'], 'grant has permissions')
})

test('verify - rejects tampered permissions', async (t) => {
  const server = createServerIdentity()
  const user = createUser()
  const userPublicKey = crypto.randomBytes(32).toString('hex')

  const attestation = PermissionAttestation.create({
    user,
    userPublicKey,
    identityMethod: 'web-identity',
    identityId: userPublicKey,
    serverIdentity: server
  })

  // Tamper with permissions
  attestation.permissions = ['admin', 'read', 'write', 'superadmin']

  const result = PermissionAttestation.verify(attestation, server.getPublicKeyHex())
  t.not(result.valid, 'tampered attestation is rejected')
  t.is(result.grant, null, 'no grant for tampered attestation')
})

test('verify - rejects tampered userPublicKey', async (t) => {
  const server = createServerIdentity()
  const user = createUser()
  const userPublicKey = crypto.randomBytes(32).toString('hex')

  const attestation = PermissionAttestation.create({
    user,
    userPublicKey,
    identityMethod: 'web-identity',
    identityId: userPublicKey,
    serverIdentity: server
  })

  // Tamper with user key
  attestation.userPublicKey = crypto.randomBytes(32).toString('hex')

  const result = PermissionAttestation.verify(attestation, server.getPublicKeyHex())
  t.not(result.valid, 'tampered user key is rejected')
})

test('verify - rejects wrong server public key', async (t) => {
  const server = createServerIdentity()
  const otherServer = createServerIdentity()
  const user = createUser()
  const userPublicKey = crypto.randomBytes(32).toString('hex')

  const attestation = PermissionAttestation.create({
    user,
    userPublicKey,
    identityMethod: 'web-identity',
    identityId: userPublicKey,
    serverIdentity: server
  })

  // Verify against a different server
  const result = PermissionAttestation.verify(attestation, otherServer.getPublicKeyHex())
  t.not(result.valid, 'wrong server key is rejected')
})

test('verify - rejects null/missing attestation', async (t) => {
  const server = createServerIdentity()

  t.alike(PermissionAttestation.verify(null, server.getPublicKeyHex()), { valid: false, grant: null })
  t.alike(PermissionAttestation.verify({}, server.getPublicKeyHex()), { valid: false, grant: null })
  t.alike(PermissionAttestation.verify({ signature: 'abc' }, server.getPublicKeyHex()), { valid: false, grant: null })
})

test('verify - rejects wrong version', async (t) => {
  const server = createServerIdentity()
  const user = createUser()
  const userPublicKey = crypto.randomBytes(32).toString('hex')

  const attestation = PermissionAttestation.create({
    user,
    userPublicKey,
    identityMethod: 'web-identity',
    identityId: userPublicKey,
    serverIdentity: server
  })

  attestation.version = 99

  const result = PermissionAttestation.verify(attestation, server.getPublicKeyHex())
  t.not(result.valid, 'wrong version is rejected')
})

// ========================================
// Auth Method Variants
// ========================================

test('create - works with ssh-key identity method', async (t) => {
  const server = createServerIdentity()
  const user = createUser({ globalPermissions: ['read', 'write'] })
  const sshPubKey = 'ssh-ed25519-' + crypto.randomBytes(32).toString('hex')

  const attestation = PermissionAttestation.create({
    user,
    userPublicKey: sshPubKey,
    identityMethod: 'ssh-key',
    identityId: 'ssh:' + sshPubKey.slice(0, 16),
    serverIdentity: server
  })

  t.is(attestation.identityMethod, 'ssh-key', 'method is ssh-key')
  t.is(attestation.userPublicKey, sshPubKey, 'has SSH public key')

  const result = PermissionAttestation.verify(attestation, server.getPublicKeyHex())
  t.ok(result.valid, 'SSH attestation verifies')
  t.alike(result.grant.permissions, ['read', 'write'], 'correct permissions')
})

test('create - works with keet-identity method', async (t) => {
  const server = createServerIdentity()
  const user = createUser()
  const keetKey = crypto.randomBytes(32).toString('hex')

  const attestation = PermissionAttestation.create({
    user,
    userPublicKey: keetKey,
    identityMethod: 'keet-identity',
    identityId: 'keet:' + keetKey.slice(0, 16),
    serverIdentity: server
  })

  t.is(attestation.identityMethod, 'keet-identity', 'method is keet-identity')

  const result = PermissionAttestation.verify(attestation, server.getPublicKeyHex())
  t.ok(result.valid, 'Keet attestation verifies')
})

test('create - works with noise-key method', async (t) => {
  const server = createServerIdentity()
  const user = createUser({ globalPermissions: ['read'] })
  const noiseKey = crypto.randomBytes(32).toString('hex')

  const attestation = PermissionAttestation.create({
    user,
    userPublicKey: noiseKey,
    identityMethod: 'noise-key',
    identityId: 'noise:' + noiseKey.slice(0, 16),
    serverIdentity: server
  })

  t.is(attestation.identityMethod, 'noise-key', 'method is noise-key')

  const result = PermissionAttestation.verify(attestation, server.getPublicKeyHex())
  t.ok(result.valid, 'Noise attestation verifies')
  t.alike(result.grant.permissions, ['read'], 'read-only permissions')
})

// ========================================
// Deterministic Serialization
// ========================================

test('serialize - produces deterministic output regardless of key insertion order', async (t) => {
  const a = { z: 1, a: 2, m: 3 }
  const b = { a: 2, z: 1, m: 3 }

  const serializedA = PermissionAttestation.serialize(a)
  const serializedB = PermissionAttestation.serialize(b)

  t.is(serializedA, serializedB, 'same keys produce same output')
})

test('serialize - different values produce different output', async (t) => {
  const a = { version: 1, permissions: ['admin'] }
  const b = { version: 1, permissions: ['read'] }

  const serializedA = PermissionAttestation.serialize(a)
  const serializedB = PermissionAttestation.serialize(b)

  t.not(serializedA, serializedB, 'different values produce different output')
})

// ========================================
// Attestation with Buffer server key
// ========================================

test('verify - accepts Buffer server public key', async (t) => {
  const server = createServerIdentity()
  const user = createUser()
  const userPublicKey = crypto.randomBytes(32).toString('hex')

  const attestation = PermissionAttestation.create({
    user,
    userPublicKey,
    identityMethod: 'web-identity',
    identityId: userPublicKey,
    serverIdentity: server
  })

  // Pass server key as Buffer instead of hex string
  const result = PermissionAttestation.verify(attestation, server.publicKey)
  t.ok(result.valid, 'Buffer key accepted')
})

// ========================================
// Repos mapping
// ========================================

test('create - maps user repos to name list', async (t) => {
  const server = createServerIdentity()
  const user = createUser({
    repos: [
      { name: 'repo-a', permissions: ['read', 'write'] },
      { name: 'repo-b', permissions: ['read'] }
    ]
  })
  const userPublicKey = crypto.randomBytes(32).toString('hex')

  const attestation = PermissionAttestation.create({
    user,
    userPublicKey,
    identityMethod: 'web-identity',
    identityId: userPublicKey,
    serverIdentity: server
  })

  t.alike(attestation.repos, ['repo-a', 'repo-b'], 'repos extracted from user record')
})

test('create - defaults to wildcard repos when user.repos is empty', async (t) => {
  const server = createServerIdentity()
  const user = createUser({ repos: [] })
  const userPublicKey = crypto.randomBytes(32).toString('hex')

  const attestation = PermissionAttestation.create({
    user,
    userPublicKey,
    identityMethod: 'web-identity',
    identityId: userPublicKey,
    serverIdentity: server
  })

  // Empty repos array maps to empty (not wildcard)
  t.alike(attestation.repos, [], 'empty repos stays empty')
})

// ========================================
// Restore Flow — Web Identity
// ========================================

test('restore flow - web-identity challenge-response + attestation restores permissions', async (t) => {
  const server = createServerIdentity()
  const challengeMgr = new ChallengeManager({ ttl: 30000 })

  // 1. Original user with admin permissions
  const anproto = ANProto.gen()
  const userPubKeyB64 = b4a.toString(anproto.publicKey, 'base64')
  const user = createUser({ globalPermissions: ['admin', 'read', 'write'] })

  // 2. Server issues attestation
  const attestation = PermissionAttestation.create({
    user,
    userPublicKey: userPubKeyB64,
    identityMethod: 'web-identity',
    identityId: userPubKeyB64,
    serverIdentity: server
  })

  // 3. Verify attestation is valid
  const check = PermissionAttestation.verify(attestation, server.getPublicKeyHex())
  t.ok(check.valid, 'attestation is valid')

  // 4. Simulate DB loss — fresh registry, no users
  const registry = await createTestRegistry()
  const lookupBefore = await registry.getIdentityByPublicKey(userPubKeyB64)
  t.is(lookupBefore, null, 'user does not exist in fresh DB')

  // 5. Generate challenge for ownership proof
  const challenge = await challengeMgr.generate({ context: 'restore' })

  // 6. User signs challenge with their ANProto keypair
  const signature = ANProto.sign(challenge.challenge, anproto)

  // 7. Verify key ownership
  const isOwner = ANProto.verify(challenge.challenge, signature, userPubKeyB64)
  t.ok(isOwner, 'user proves key ownership via challenge-response')

  // 8. Verify attestation matches presented key
  t.is(attestation.userPublicKey, userPubKeyB64, 'attestation matches presented key')

  // 9. Re-create user from attestation grant
  const grant = check.grant
  const result = await registry.autoRegister({
    identityId: grant.identityId,
    method: grant.identityMethod,
    credentials: { publicKey: userPubKeyB64 }
  })

  // 10. Verify user was re-created and grant contains correct permissions
  t.ok(result.user, 'user re-created from attestation')
  t.ok(result.identity, 'identity re-created from attestation')
  t.alike(grant.permissions, ['admin', 'read', 'write'], 'attestation grant carries admin permissions')

  challengeMgr.destroy()
})

// ========================================
// Restore Flow — Mismatched Key Rejection
// ========================================

test('restore flow - rejects attestation with mismatched userPublicKey', async (t) => {
  const server = createServerIdentity()

  // Attestation for user A
  const userA = ANProto.gen()
  const userAPubKey = b4a.toString(userA.publicKey, 'base64')
  const user = createUser()

  const attestation = PermissionAttestation.create({
    user,
    userPublicKey: userAPubKey,
    identityMethod: 'web-identity',
    identityId: userAPubKey,
    serverIdentity: server
  })

  // User B tries to use A's attestation
  const userB = ANProto.gen()
  const userBPubKey = b4a.toString(userB.publicKey, 'base64')

  // Key mismatch check (this is what the restore endpoint does)
  t.not(attestation.userPublicKey, userBPubKey, 'keys do not match')
  t.is(attestation.userPublicKey === userBPubKey, false, 'mismatch detected — restore should be rejected')
})

// ========================================
// Restore Flow — User Already Exists (Idempotency)
// ========================================

test('restore flow - handles user already exists by updating permissions', async (t) => {
  const server = createServerIdentity()
  const registry = await createTestRegistry()

  const anproto = ANProto.gen()
  const userPubKey = b4a.toString(anproto.publicKey, 'base64')

  // Create user with read-only (simulating partial re-creation)
  const existing = await registry.autoRegister({
    identityId: userPubKey,
    method: 'web-identity',
    credentials: { publicKey: userPubKey }
  })
  t.alike(existing.user.globalPermissions, ['read'], 'starts with read-only')

  // Original attestation had admin
  const adminUser = createUser({ globalPermissions: ['admin', 'read', 'write'] })
  const attestation = PermissionAttestation.create({
    user: adminUser,
    userPublicKey: userPubKey,
    identityMethod: 'web-identity',
    identityId: userPubKey,
    serverIdentity: server
  })

  const check = PermissionAttestation.verify(attestation, server.getPublicKeyHex())
  t.ok(check.valid, 'attestation valid')

  // Simulate restore: find existing user, verify attestation grant has correct permissions
  const existingIdentity = await registry.getIdentityByPublicKey(userPubKey)
  t.ok(existingIdentity, 'user already exists')

  const existingUser = await registry.getUser(existingIdentity.userId)
  t.ok(existingUser, 'existing user found')
  t.alike(existingUser.globalPermissions, ['read'], 'starts with read-only')

  // Attestation grant carries the admin permissions for upgrade
  t.alike(check.grant.permissions, ['admin', 'read', 'write'], 'attestation grant carries admin permissions for upgrade')
})

// ========================================
// Restore Flow — Expired/Consumed Challenge
// ========================================

test('restore flow - rejects consumed challenge (replay prevention)', async (t) => {
  const challengeMgr = new ChallengeManager({ ttl: 30000 })

  const challenge = await challengeMgr.generate()

  // First consume succeeds
  const record = await challengeMgr.consume(challenge.challengeId)
  t.ok(record, 'first consume succeeds')

  // Second consume fails (replay prevention)
  const replay = await challengeMgr.consume(challenge.challengeId)
  t.not(replay, 'replay rejected')

  challengeMgr.destroy()
})

// ========================================
// Restore Flow — Invalid Signature
// ========================================

test('restore flow - rejects invalid signature (wrong key)', async (t) => {
  const userA = ANProto.gen()
  const userB = ANProto.gen()

  const challenge = 'test-challenge-' + Date.now()

  // User A signs the challenge
  const signature = ANProto.sign(challenge, userA)

  // Verify with user B's key — should fail
  const isValid = ANProto.verify(challenge, signature, b4a.toString(userB.publicKey, 'base64'))
  t.not(isValid, 'wrong key rejected')

  // Verify with user A's key — should succeed
  const isValidA = ANProto.verify(challenge, signature, b4a.toString(userA.publicKey, 'base64'))
  t.ok(isValidA, 'correct key accepted')
})

// ========================================
// Edge Cases
// ========================================

test('create - with undefined repos defaults to wildcard', async (t) => {
  const server = createServerIdentity()
  const user = createUser()
  delete user.repos
  const userPublicKey = crypto.randomBytes(32).toString('hex')

  const attestation = PermissionAttestation.create({
    user,
    userPublicKey,
    identityMethod: 'web-identity',
    identityId: userPublicKey,
    serverIdentity: server
  })

  t.alike(attestation.repos, ['*'], 'defaults to wildcard when repos undefined')
})

test('create - with empty permissions array', async (t) => {
  const server = createServerIdentity()
  const user = createUser({ globalPermissions: [] })
  const userPublicKey = crypto.randomBytes(32).toString('hex')

  const attestation = PermissionAttestation.create({
    user,
    userPublicKey,
    identityMethod: 'web-identity',
    identityId: userPublicKey,
    serverIdentity: server
  })

  t.alike(attestation.permissions, [], 'empty permissions preserved')

  const result = PermissionAttestation.verify(attestation, server.getPublicKeyHex())
  t.ok(result.valid, 'still verifies')
  t.alike(result.grant.permissions, [], 'grant has empty permissions')
})

test('verify - rejects modified epoch (tamper detection)', async (t) => {
  const server = createServerIdentity()
  const user = createUser()
  const userPublicKey = crypto.randomBytes(32).toString('hex')

  const attestation = PermissionAttestation.create({
    user,
    userPublicKey,
    identityMethod: 'web-identity',
    identityId: userPublicKey,
    serverIdentity: server
  })

  // Tamper with epoch
  attestation.epoch = attestation.epoch + 1

  const result = PermissionAttestation.verify(attestation, server.getPublicKeyHex())
  t.not(result.valid, 'tampered epoch detected')
})

test('verify - rejects modified userId (tamper detection)', async (t) => {
  const server = createServerIdentity()
  const user = createUser()
  const userPublicKey = crypto.randomBytes(32).toString('hex')

  const attestation = PermissionAttestation.create({
    user,
    userPublicKey,
    identityMethod: 'web-identity',
    identityId: userPublicKey,
    serverIdentity: server
  })

  attestation.userId = 'hijacked-user-id'

  const result = PermissionAttestation.verify(attestation, server.getPublicKeyHex())
  t.not(result.valid, 'tampered userId detected')
})

test('verify - rejects modified identityMethod (tamper detection)', async (t) => {
  const server = createServerIdentity()
  const user = createUser()
  const userPublicKey = crypto.randomBytes(32).toString('hex')

  const attestation = PermissionAttestation.create({
    user,
    userPublicKey,
    identityMethod: 'web-identity',
    identityId: userPublicKey,
    serverIdentity: server
  })

  attestation.identityMethod = 'admin-bypass'

  const result = PermissionAttestation.verify(attestation, server.getPublicKeyHex())
  t.not(result.valid, 'tampered identity method detected')
})

test('roundtrip - JSON parse/stringify preserves attestation validity', async (t) => {
  const server = createServerIdentity()
  const user = createUser()
  const userPublicKey = crypto.randomBytes(32).toString('hex')

  const attestation = PermissionAttestation.create({
    user,
    userPublicKey,
    identityMethod: 'web-identity',
    identityId: userPublicKey,
    serverIdentity: server
  })

  // Simulate localStorage/file storage roundtrip
  const serialized = JSON.stringify(attestation)
  const deserialized = JSON.parse(serialized)

  const result = PermissionAttestation.verify(deserialized, server.getPublicKeyHex())
  t.ok(result.valid, 'attestation survives JSON roundtrip')
})