OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / test / unit / identity-export.test.js

238 lines · javascript ref:
/**
 * Unit tests for identity export/import workflow
 *
 * Tests encrypted backup via SecureStorage + attestation bundling.
 */

import test from 'brittle'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import crypto from 'hypercore-crypto'
import { SecureStorage } from '../../lib/identity/secure-storage.js'
import { PermissionAttestation } from '../../lib/auth/attestation.js'

function tmpdir () {
  const dir = path.join(os.tmpdir(), 'pear-git-test-export-' + Date.now() + '-' + Math.random().toString(36).slice(2))
  fs.mkdirSync(dir, { recursive: true })
  return dir
}

function cleanup (dir) {
  try { fs.rmSync(dir, { recursive: true }) } catch {}
}

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

// ========================================
// Export/Import Roundtrip
// ========================================

test('export-import - roundtrip with correct password preserves data', async (t) => {
  const dir = tmpdir()
  const vaultFile = path.join(dir, 'identity-backup.enc')
  const password = Buffer.from('test-password-123')

  // Build backup data
  const keypair = crypto.keyPair()
  const server = createServerIdentity()

  const backup = {
    version: 1,
    exportedAt: new Date().toISOString(),
    publicKey: keypair.publicKey.toString('hex'),
    secretKey: keypair.secretKey.toString('hex'),
    attestation: PermissionAttestation.create({
      user: {
        userId: 'test-user',
        globalPermissions: ['admin', 'read', 'write'],
        repos: [{ name: '*', permissions: ['admin', 'read', 'write'] }]
      },
      userPublicKey: keypair.publicKey.toString('hex'),
      identityMethod: 'web-identity',
      identityId: keypair.publicKey.toString('hex'),
      serverIdentity: server
    })
  }

  // Encrypt and write
  const vault = new SecureStorage(vaultFile)
  await vault.encrypt(Buffer.from(JSON.stringify(backup)), password)

  t.ok(fs.existsSync(vaultFile), 'vault file created')

  // Read and decrypt
  const decrypted = await vault.decrypt(Buffer.from('test-password-123'))
  const restored = JSON.parse(decrypted.toString())

  t.is(restored.version, 1, 'version preserved')
  t.is(restored.publicKey, backup.publicKey, 'public key preserved')
  t.is(restored.secretKey, backup.secretKey, 'secret key preserved')
  t.ok(restored.attestation, 'attestation included')
  t.is(restored.attestation.userPublicKey, backup.publicKey, 'attestation user key matches')

  // Verify attestation still valid
  const check = PermissionAttestation.verify(restored.attestation, server.getPublicKeyHex())
  t.ok(check.valid, 'attestation still valid after decrypt')
  t.alike(check.grant.permissions, ['admin', 'read', 'write'], 'permissions preserved')

  cleanup(dir)
})

// ========================================
// Wrong Password
// ========================================

test('export-import - wrong password fails decrypt', async (t) => {
  const dir = tmpdir()
  const vaultFile = path.join(dir, 'identity-backup.enc')

  const backup = { version: 1, publicKey: 'abc123' }
  const vault = new SecureStorage(vaultFile)
  await vault.encrypt(Buffer.from(JSON.stringify(backup)), Buffer.from('correct-password'))

  try {
    await vault.decrypt(Buffer.from('wrong-password'))
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err.message.includes('Decryption failed') || err.message.includes('wrong password'), 'error indicates wrong password')
  }

  cleanup(dir)
})

// ========================================
// Vault File Permissions
// ========================================

test('export-import - vault file has restricted permissions', async (t) => {
  const dir = tmpdir()
  const vaultFile = path.join(dir, 'identity-backup.enc')

  const vault = new SecureStorage(vaultFile)
  await vault.encrypt(Buffer.from('secret data'), Buffer.from('password'))

  const stat = fs.statSync(vaultFile)
  // mode includes file type bits; mask with 0o777 for permission bits only
  const perms = stat.mode & 0o777
  t.is(perms, 0o600, 'vault file is owner-only (0600)')

  cleanup(dir)
})

// ========================================
// Export Without Attestation
// ========================================

test('export-import - roundtrip without attestation', async (t) => {
  const dir = tmpdir()
  const vaultFile = path.join(dir, 'identity-backup.enc')

  const backup = {
    version: 1,
    exportedAt: new Date().toISOString(),
    publicKey: crypto.randomBytes(32).toString('hex'),
    secretKey: crypto.randomBytes(64).toString('hex'),
    attestation: null
  }

  const vault = new SecureStorage(vaultFile)
  await vault.encrypt(Buffer.from(JSON.stringify(backup)), Buffer.from('pw'))

  const decrypted = await vault.decrypt(Buffer.from('pw'))
  const restored = JSON.parse(decrypted.toString())

  t.is(restored.publicKey, backup.publicKey, 'key preserved')
  t.is(restored.attestation, null, 'null attestation preserved')

  cleanup(dir)
})

// ========================================
// Vault File Missing
// ========================================

test('import - missing vault file throws', async (t) => {
  const dir = tmpdir()
  const vaultFile = path.join(dir, 'nonexistent.enc')

  const vault = new SecureStorage(vaultFile)

  t.is(vault.exists(), false, 'exists() returns false for missing file')

  try {
    await vault.decrypt(Buffer.from('password'))
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err.message.includes('ENOENT') || err.message.includes('no such file'), 'error indicates missing file')
  }

  cleanup(dir)
})

// ========================================
// Corrupted Vault
// ========================================

test('import - corrupted vault file throws', async (t) => {
  const dir = tmpdir()
  const vaultFile = path.join(dir, 'corrupted.enc')

  // Write invalid vault data
  fs.writeFileSync(vaultFile, JSON.stringify({
    version: 1,
    salt: 'aa',
    nonce: 'bb',
    ciphertext: 'cc'
  }))

  const vault = new SecureStorage(vaultFile)

  try {
    await vault.decrypt(Buffer.from('password'))
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err, 'corrupted vault throws error')
  }

  cleanup(dir)
})

// ========================================
// Unsupported Vault Version
// ========================================

test('import - unsupported vault version throws', async (t) => {
  const dir = tmpdir()
  const vaultFile = path.join(dir, 'future.enc')

  fs.writeFileSync(vaultFile, JSON.stringify({
    version: 99,
    salt: 'aa',
    nonce: 'bb',
    ciphertext: 'cc'
  }))

  const vault = new SecureStorage(vaultFile)

  try {
    await vault.decrypt(Buffer.from('password'))
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err.message.includes('Unsupported vault version'), 'error indicates bad version')
  }

  cleanup(dir)
})