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