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