OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / cli / commands / identity.js

1119 lines · javascript ref:
/**
 * pgh identity commands
 *
 * Reads from ~/.hyperident/ (shared identity with hyperident, hyper-wallet, etc.)
 * Links device identity to pear-git user account.
 *
 * Subcommands: show, link, link-noise, list, export,
 *              setup, status, change-password, link-device, join-device
 */

import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import { IdentityManager } from '../../identity/identity-manager.js'
import { DeviceLinker } from '../../identity/device-linker.js'
import { loadConfig, saveConfig } from '../config.js'
import { jsonOut, log, error } from '../output.js'

const HYPERIDENT_DIR = path.join(os.homedir(), '.hyperident')
const PEAR_GIT_CONFIG_DIR = path.join(os.homedir(), '.config', 'pear-git')
const DEFAULTS_SERVER = 'http://localhost:5005/git'

function readIdentity () {
  const idPubPath = path.join(HYPERIDENT_DIR, 'identity.pub')
  const deviceJsonPath = path.join(HYPERIDENT_DIR, 'device.json')
  const devicePubPath = path.join(HYPERIDENT_DIR, 'device.pub')

  const result = { exists: false }

  try {
    fs.statSync(HYPERIDENT_DIR)
    result.exists = true
  } catch {
    return result
  }

  try {
    result.identityPub = fs.readFileSync(idPubPath, 'utf8').trim()
  } catch {}

  try {
    result.device = JSON.parse(fs.readFileSync(deviceJsonPath, 'utf8'))
  } catch {}

  try {
    result.devicePub = fs.readFileSync(devicePubPath, 'utf8').trim()
  } catch {}

  return result
}

/**
 * Read local peer-identity.json (Noise key from P2P connections)
 */
function readPeerIdentity () {
  const keyFile = path.join(PEAR_GIT_CONFIG_DIR, 'peer-identity.json')
  try {
    const data = fs.readFileSync(keyFile, 'utf8')
    return JSON.parse(data)
  } catch {
    return null
  }
}

/**
 * Resolve storage path from flags, storage locator, or default.
 * Matches the server's storage resolution so CLI and server use the same path.
 */
function resolveStorage (flags) {
  if (flags.storage) return path.resolve(flags.storage)

  // Check storage locator (same as server uses)
  try {
    const locatorPath = path.join(os.homedir(), '.config', 'pear-git', 'storage-locator.json')
    const locator = JSON.parse(fs.readFileSync(locatorPath, 'utf-8'))
    if (locator.storagePath) return locator.storagePath
  } catch {}

  return PEAR_GIT_CONFIG_DIR
}

export async function identityCommand (client, sub, args, flags) {
  switch (sub) {
    case 'show':
      return identityShow(client, flags)
    case 'link':
      return identityLink(client, args, flags)
    case 'link-noise':
      return identityLinkNoise(client, flags)
    case 'list':
      return identityList(client, flags)
    case 'export':
      return identityExport(flags)
    case 'setup':
      return identitySetup(client, flags)
    case 'export-mnemonic':
      return identityExportMnemonic(flags)
    case 'status':
      return identityStatus(flags)
    case 'change-password':
      return identityChangePassword(flags)
    case 'link-device':
      return identityLinkDevice(flags)
    case 'join-device':
      return identityJoinDevice(args, flags)
    case 'attestation':
      return identityAttestation(client, flags)
    case 'restore':
      return identityRestore(client, flags)
    case 'export-backup':
      return identityExportBackup(flags)
    case 'import-backup':
      return identityImportBackup(args, flags)
    default:
      identityHelp()
  }
}

async function identityShow (client, flags) {
  const id = readIdentity()

  if (!id.exists) {
    error('No identity found at ~/.hyperident/')
    log('Create one with: hyperident init')
    return
  }

  if (flags.json) {
    jsonOut(id)
    return
  }

  log('Local Identity (~/.hyperident/)')
  log('\u2500'.repeat(40))

  if (id.identityPub) {
    log(`Identity key:  ${id.identityPub}`)
    log(`Short key:     ${id.identityPub.slice(0, 16)}...`)
  }

  if (id.device) {
    log(`Device:        ${id.device.name}`)
    log(`Created:       ${id.device.created}`)
  }

  if (id.devicePub) {
    log(`Device key:    ${id.devicePub}`)
  }

  // Show peer identity (Noise key)
  const peer = readPeerIdentity()
  if (peer) {
    log(`Noise key:     ${peer.publicKey.slice(0, 16)}...`)
  }

  // Check if linked to pear-git server
  try {
    const data = await client.get('/api/admin/identities')
    const linked = data.identities?.find(i =>
      i.publicKey === id.devicePub || i.publicKey === id.identityPub
    )
    if (linked) {
      log('')
      log(`Server link:   \u2713 Linked to ${client.server}`)
      log(`User ID:       ${linked.userId}`)
      log(`Method:        ${linked.method}`)
    } else {
      log('')
      log(`Server link:   \u2717 Not linked to ${client.server}`)
      log(`Run: pgh identity link`)
    }
  } catch {
    log('')
    log(`Server link:   ? Cannot reach ${client.server}`)
  }
}

async function identityLink (client, args, flags) {
  const id = readIdentity()

  if (!id.exists || !id.devicePub) {
    error('No identity found at ~/.hyperident/')
    log('Create one with: hyperident init')
    return
  }

  const publicKey = id.devicePub

  try {
    // Check if already linked
    const data = await client.get('/api/admin/identities')
    const existing = data.identities?.find(i => i.publicKey === publicKey)

    if (existing) {
      log(`Already linked to user ${existing.userId}`)
      return
    }

    // Find admin user to link to
    const usersData = await client.get('/api/admin/users')
    const usersList = Array.isArray(usersData) ? usersData : (usersData.users || [])
    const adminUser = usersList.find(u =>
      u.globalPermissions?.includes('admin')
    )

    if (!adminUser) {
      error('No admin user found on server')
      return
    }

    // Link identity via admin API (add identity to admin user)
    const result = await client.post(`/api/admin/users/${adminUser.userId}/identities`, {
      identityId: publicKey,
      publicKey,
      method: 'hyperident'
    })

    if (result.success) {
      log(`\u2713 Identity linked to ${client.server}`)
      log(`  Public key: ${publicKey.slice(0, 16)}...`)
      log(`  User ID:    ${adminUser.userId}`)
    } else {
      error('Link failed: ' + (result.error || 'unknown error'))
    }
  } catch (err) {
    error('Link failed: ' + err.message)
    log('Make sure you have admin access (bearer token)')
  }
}

/**
 * Link local Noise key (P2P identity) to the admin user on the server.
 * This is a one-time bootstrap step — after this, P2P admin commands work.
 *
 * Usage: pgh identity link-noise [--token <admin-token>]
 */
async function identityLinkNoise (client, flags) {
  // Read local peer-identity.json
  const peer = readPeerIdentity()
  if (!peer) {
    error('No peer identity found at ~/.config/pear-git/peer-identity.json')
    log('Run any P2P operation first (e.g., git clone punch://...) to create one')
    return
  }

  const noiseKeyHex = peer.publicKey

  try {
    // Check if already linked
    const data = await client.get('/api/admin/identities')
    const existing = data.identities?.find(i =>
      i.identityId === `noise:${noiseKeyHex}` ||
      (i.method === 'noise-key' && i.credentials?.publicKey === noiseKeyHex)
    )

    if (existing) {
      log(`Noise key already linked to user ${existing.userId}`)
      return
    }

    // Find admin user to link to
    const usersData = await client.get('/api/admin/users')
    const usersList = Array.isArray(usersData) ? usersData : (usersData.users || [])
    const adminUser = usersList.find(u =>
      u.globalPermissions?.includes('admin')
    )

    if (!adminUser) {
      error('No admin user found on server')
      return
    }

    // Link Noise key as identity via admin API
    const result = await client.post(`/api/admin/users/${adminUser.userId}/identities`, {
      identityId: `noise:${noiseKeyHex}`,
      publicKey: noiseKeyHex,
      method: 'noise-key'
    })

    if (result.success) {
      log(`\u2713 Noise key linked to ${client.server}`)
      log(`  Noise key: ${noiseKeyHex.slice(0, 16)}...`)
      log(`  User ID:   ${adminUser.userId}`)
      log('')
      log('P2P admin commands are now available.')
    } else {
      error('Link failed: ' + (result.error || 'unknown error'))
    }
  } catch (err) {
    error('Link failed: ' + err.message)
    log('Make sure you have admin access (bearer token)')
  }
}

async function identityList (client, flags) {
  try {
    const data = await client.get('/api/admin/identities')

    if (flags.json) {
      jsonOut(data)
      return
    }

    const identities = data.identities || []

    if (identities.length === 0) {
      log('No identities registered on server')
      return
    }

    log(`Identities on ${client.server}:`)
    log('\u2500'.repeat(60))

    for (const id of identities) {
      const key = id.publicKey?.slice(0, 16) || id.credentials?.publicKey?.slice(0, 16) || '?'
      log(`  ${key}...  ${id.method || '?'}  \u2192 ${id.userId || '?'}`)
    }

    log(`\nTotal: ${identities.length}`)
  } catch (err) {
    error('Failed to list identities: ' + err.message)
  }
}

function identityExport (flags) {
  const id = readIdentity()

  if (!id.exists) {
    error('No identity found at ~/.hyperident/')
    return
  }

  if (flags.json) {
    const peer = readPeerIdentity()
    jsonOut({
      identityKey: id.identityPub,
      deviceKey: id.devicePub,
      device: id.device,
      noiseKey: peer?.publicKey || null
    })
    return
  }

  // Output just the public key for piping
  log(id.devicePub || id.identityPub)
}

// === Phase C: Keet Identity Commands ===

/**
 * Unified first-run wizard — works on both server and client machines.
 *
 * Identity-first: device keypair + vault are created unconditionally.
 * Server connection is optional — configure later with `pgh config set`.
 *
 * Steps:
 *   1. Device keypair (generate if needed)
 *   2. Keet identity vault
 *   3. Server connection (optional)
 */
async function identitySetup (client, flags) {
  log('Welcome to pgh \u2014 Punch Git Hub CLI')
  log('')

  // Step 1: Device keypair
  log('Step 1: Device Identity')
  const identityDir = PEAR_GIT_CONFIG_DIR
  const deviceIdentityFile = path.join(identityDir, 'device-identity.json')
  // Check server storage for backward compat (server machine has server-identity.json)
  const serverStorage = resolveStorage(flags)
  const serverIdentityFile = path.join(serverStorage, 'server-identity.json')
  const effectiveFile = fileExists(serverIdentityFile) ? serverIdentityFile : deviceIdentityFile

  const mgr = new IdentityManager({
    storagePath: identityDir,
    identityFile: effectiveFile
  })

  const generated = await mgr.ensureKeypair()
  const status = mgr.status()
  if (generated) {
    log(`  \u2713 Generated device key: ${status.devicePublicKey.slice(0, 16)}...`)
  } else {
    log(`  \u2713 Device key exists: ${status.devicePublicKey.slice(0, 16)}...`)
  }

  // Step 2: Keet identity vault
  log('')
  log('Step 2: Keet Identity')
  if (status.hasVault && !flags.force) {
    log('  \u2713 Vault already exists')
    log('  Use --force to regenerate')
  } else {
    const password = await promptPassword('  Enter encryption password: ')
    const confirm = await promptPassword('  Confirm password: ')
    log('')

    if (!password.equals(confirm)) {
      error('Passwords do not match')
      zeroBuffer(password)
      zeroBuffer(confirm)
      return
    }
    zeroBuffer(confirm)

    try {
      const result = await mgr.setup(password)

      log('  \u2713 Mnemonic generated and encrypted to vault')
      log('  \u2713 Proof chain: identity \u2192 device (verified)')
      log('')
      log('  Your recovery mnemonic:')
      log(`    ${result.mnemonic}`)
      log('')
      log('  Tip: Retrieve later with: pgh identity export-mnemonic')
      log('')
      log('  \u2713 Identity secured')
    } catch (err) {
      error('Setup failed: ' + err.message)
      zeroBuffer(password)
      return
    } finally {
      zeroBuffer(password)
    }
  }

  // Step 3: Server connection (optional)
  log('')
  log('Step 3: Server (optional)')
  let cfg = loadConfig()

  if (cfg.token && cfg.server !== DEFAULTS_SERVER) {
    log(`  \u2713 Already configured (${cfg.server})`)
    // Try to link identity to server
    try {
      const updatedStatus = mgr.status()
      await linkDeviceToServer(client, updatedStatus.devicePublicKey)
      log('  \u2713 Linked to server')
    } catch {
      log('  \u26a0 Could not link to server (link manually with: pgh identity link-noise)')
    }
  } else {
    log('  No server configured.')
    log('  To connect to a server later:')
    log('    pgh config set server http://<host>:<port>/git')
    log('    pgh config set token <your-token>')
  }

  log('')
  log('Config saved to ~/.config/pear-git/')
  log('')
  log('Next steps:')
  log('  pgh config set server http://<host>:<port>/git   Connect to a server')
  log('  pgh repo list                                     List repos')
  log('  git clone punch://<serverkey>/<repo>               Clone via P2P')
}

/**
 * Export mnemonic from vault (password required)
 */
async function identityExportMnemonic (flags) {
  const storagePath = resolveStorage(flags)
  // Check both client and server identity locations
  const deviceFile = path.join(PEAR_GIT_CONFIG_DIR, 'device-identity.json')
  const serverFile = path.join(storagePath, 'server-identity.json')
  const effectiveFile = fileExists(serverFile) ? serverFile : deviceFile
  const effectiveDir = fileExists(serverFile) ? storagePath : PEAR_GIT_CONFIG_DIR

  const mgr = new IdentityManager({
    storagePath: effectiveDir,
    identityFile: effectiveFile
  })

  if (!mgr.status().hasVault) {
    error('No identity vault found')
    log('Run `pgh identity setup` first')
    return
  }

  const password = await promptPassword('Vault password: ')
  log('')

  try {
    const mnemonic = await mgr.exportMnemonic(password)
    log('Your recovery mnemonic:')
    log(`  ${mnemonic}`)
  } catch (err) {
    error('Failed: ' + err.message)
  } finally {
    zeroBuffer(password)
    process.exit(0)
  }
}

// === Setup helpers ===

/**
 * Prompt for a line of text input (non-password)
 */
async function promptLine (message, defaultValue) {
  const suffix = defaultValue ? ` [${defaultValue}]: ` : ': '
  process.stdout.write(message + suffix)

  return new Promise((resolve) => {
    let data = ''
    process.stdin.setEncoding('utf-8')
    process.stdin.once('data', (chunk) => {
      data = chunk.trim()
      resolve(data || defaultValue || '')
    })
    process.stdin.resume()
  })
}

/**
 * Test connection to server (unauthenticated)
 */
async function testServerConnection (serverUrl) {
  process.stdout.write('  Testing... ')
  try {
    const res = await fetch(serverUrl + '/api/repos')
    if (!res.ok) {
      log('\u2717 Failed')
      error(`Server returned ${res.status}`)
      return false
    }
    const data = await res.json()
    const count = Array.isArray(data) ? data.length : (data.repos || []).length
    log(`\u2713 Connected (${count} repos)`)
    return true
  } catch (err) {
    log('\u2717 Failed')
    error('Could not connect: ' + err.message)
    return false
  }
}

/**
 * Test authentication with token
 */
async function testAuth (serverUrl, token) {
  process.stdout.write('  Testing... ')
  try {
    const res = await fetch(serverUrl + '/api/admin/users', {
      headers: { Authorization: `Bearer ${token}` }
    })
    if (!res.ok) {
      log('\u2717 Failed')
      error(`Auth failed (${res.status})`)
      return false
    }
    log('\u2713 Authenticated')
    return true
  } catch (err) {
    log('\u2717 Failed')
    error('Auth test failed: ' + err.message)
    return false
  }
}

/**
 * Link device public key to admin user on server
 */
async function linkDeviceToServer (client, devicePublicKey) {
  const usersData = await client.get('/api/admin/users')
  const usersList = Array.isArray(usersData) ? usersData : (usersData.users || [])
  const adminUser = usersList.find(u => u.globalPermissions?.includes('admin'))
  if (!adminUser) return

  // Check if already linked
  const data = await client.get('/api/admin/identities')
  const existing = (data.identities || []).find(i => i.publicKey === devicePublicKey)
  if (existing) return

  await client.post(`/api/admin/users/${adminUser.userId}/identities`, {
    identityId: `device:${devicePublicKey}`,
    publicKey: devicePublicKey,
    method: 'device-key'
  })
}

/**
 * Synchronous file existence check
 */
function fileExists (filePath) {
  try {
    fs.statSync(filePath)
    return true
  } catch {
    return false
  }
}

/**
 * Show current identity status
 */
async function identityStatus (flags) {
  const storagePath = resolveStorage(flags)
  const mgr = new IdentityManager({ storagePath })

  const status = mgr.status()

  if (flags.json) {
    const verification = status.hasProof ? mgr.verifyProof() : { valid: false }
    jsonOut({ ...status, proofValid: verification.valid })
    return
  }

  log('Keet Identity Status')
  log('\u2500'.repeat(40))

  log(`Server keypair:  ${status.hasKeypair ? '\u2713' : '\u2717'}`)
  log(`Encrypted vault: ${status.hasVault ? '\u2713' : '\u2717'}`)
  log(`Proof chain:     ${status.hasProof ? '\u2713' : '\u2717'}`)

  if (status.hasProof) {
    const verification = mgr.verifyProof()
    log('')
    log(`Proof valid:     ${verification.valid ? '\u2713' : '\u2717'}`)
    if (verification.valid) {
      log(`Identity key:    ${verification.identityPublicKey.slice(0, 16)}...`)
      log(`Device key:      ${verification.devicePublicKey.slice(0, 16)}...`)
    }
  }

  if (!status.hasVault) {
    log('')
    log('Run `pgh identity setup` to create an encrypted Keet identity.')
  }
}

/**
 * Change vault password
 */
async function identityChangePassword (flags) {
  const storagePath = resolveStorage(flags)
  const mgr = new IdentityManager({ storagePath })

  if (!mgr.status().hasVault) {
    error('No identity vault found')
    log('Run `pgh identity setup` first')
    return
  }

  const oldPw = await promptPassword('Current password: ')
  const newPw = await promptPassword('New password: ')
  const confirm = await promptPassword('Confirm new password: ')
  log('')

  if (!newPw.equals(confirm)) {
    error('Passwords do not match')
    zeroBuffer(oldPw)
    zeroBuffer(newPw)
    zeroBuffer(confirm)
    return
  }
  zeroBuffer(confirm)

  try {
    await mgr.changePassword(oldPw, newPw)
    log('\u2713 Password changed successfully')
  } catch (err) {
    error('Failed: ' + err.message)
  } finally {
    zeroBuffer(oldPw)
    zeroBuffer(newPw)
    process.exit(0)
  }
}

/**
 * Create invite for device linking (source device)
 */
async function identityLinkDevice (flags) {
  const storagePath = resolveStorage(flags)
  const linker = new DeviceLinker({ storagePath })

  const password = await promptPassword('Enter vault password: ')
  log('')

  try {
    const { inviteHex, waitForPeer } = await linker.createInvite(password)

    log('Waiting for device to connect...')
    log('Invite code (share this with the other device):')
    log('')
    log(`  ${inviteHex}`)
    log('')
    log('Tip: On the other device, run:')
    log(`  pgh identity join-device ${inviteHex.slice(0, 16)}...`)
    log('')

    const result = await waitForPeer()
    log('Device linked successfully.')
    if (result.devicePublicKey) {
      log(`  New device key: ${result.devicePublicKey.slice(0, 16)}...`)
    }
  } catch (err) {
    error('Link failed: ' + err.message)
  } finally {
    zeroBuffer(password)
    await linker.close()
  }
}

/**
 * Join with invite from source device (target device)
 */
async function identityJoinDevice (args, flags) {
  const inviteHex = args[0]
  if (!inviteHex) {
    error('Usage: pgh identity join-device <invite-hex>')
    return
  }

  const storagePath = resolveStorage(flags)
  const linker = new DeviceLinker({ storagePath })

  log('Connecting to source device...')

  const password = await promptPassword('Enter encryption password for local vault: ')
  const confirm = await promptPassword('Confirm password: ')
  log('')

  if (!password.equals(confirm)) {
    error('Passwords do not match')
    zeroBuffer(password)
    zeroBuffer(confirm)
    return
  }
  zeroBuffer(confirm)

  try {
    const result = await linker.joinWithInvite(inviteHex, password)

    log('Identity received and secured.')
    log(`  Identity key: ${result.identityPublicKey.slice(0, 16)}...`)

    const mgr = new IdentityManager({ storagePath })
    const verification = mgr.verifyProof()
    if (verification.valid) {
      log(`  Device key:   ${verification.devicePublicKey.slice(0, 16)}...`)
      log('  Proof chain:  identity \u2192 device (verified)')
    }
  } catch (err) {
    error('Join failed: ' + err.message)
  } finally {
    zeroBuffer(password)
    await linker.close()
  }
}

// === Attestation Commands ===

const ATTESTATION_FILE = path.join(PEAR_GIT_CONFIG_DIR, 'attestation.json')

/**
 * Fetch and save a permission attestation from the server.
 * Requires existing auth (bearer token or linked identity).
 */
async function identityAttestation (client, flags) {
  try {
    const data = await client.get('/api/user/attestation')

    if (!data.attestation) {
      error('Server did not return an attestation (P2P may not be initialized)')
      return
    }

    // Save to config dir
    try { fs.mkdirSync(PEAR_GIT_CONFIG_DIR, { recursive: true }) } catch {}
    fs.writeFileSync(ATTESTATION_FILE, JSON.stringify(data.attestation, null, 2) + '\n', { mode: 0o600 })

    if (flags.json) {
      jsonOut(data.attestation)
      return
    }

    log('\u2713 Attestation saved to ' + ATTESTATION_FILE)
    log('')
    log('Details:')
    log(`  Server:      ${data.attestation.serverPublicKey.slice(0, 16)}...`)
    log(`  User:        ${data.attestation.userPublicKey.slice(0, 16)}...`)
    log(`  Method:      ${data.attestation.identityMethod}`)
    log(`  Permissions: ${data.attestation.permissions.join(', ')}`)
    log(`  Repos:       ${data.attestation.repos.join(', ')}`)
    log(`  Epoch:       ${new Date(data.attestation.epoch).toISOString()}`)
    log('')
    log('This attestation proves your permissions were granted by this server.')
    log('Store it alongside your keypair for disaster recovery.')
  } catch (err) {
    error('Failed to fetch attestation: ' + err.message)
  }
}

/**
 * Restore permissions from a saved attestation.
 * Used when the server has lost its user database but kept its identity.
 */
async function identityRestore (client, flags) {
  // Load attestation
  let attestation
  try {
    attestation = JSON.parse(fs.readFileSync(ATTESTATION_FILE, 'utf-8'))
  } catch {
    error('No attestation file found at ' + ATTESTATION_FILE)
    log('Fetch one first with: pgh identity attestation')
    return
  }

  log('Restoring permissions from attestation...')
  log(`  Server: ${attestation.serverPublicKey.slice(0, 16)}...`)
  log(`  User:   ${attestation.userPublicKey.slice(0, 16)}...`)
  log('')

  try {
    // Step 1: Get a challenge
    const challengeData = await client.post('/api/auth/challenge', { context: 'attestation-restore' })

    // Step 2: Sign the challenge with our device key
    // Try peer identity (Noise key) first, then device identity
    const peer = readPeerIdentity()
    let publicKeyHex = null
    let signatureB64 = null

    if (peer && peer.publicKey === attestation.userPublicKey) {
      // Noise key matches — but we can't sign with it easily via CLI
      // Fall back to web-identity flow
    }

    // For web-identity attestations, we need the user to provide their keypair
    if (attestation.identityMethod === 'web-identity') {
      log('This attestation was issued to a web-identity (ANProto keypair).')
      log('To prove ownership, paste your 132-character keypair:')
      log('')
      const keypairStr = await promptLine('Keypair')

      if (!keypairStr || keypairStr.length !== 132) {
        error('Invalid keypair length')
        return
      }

      const publicKeyB64 = keypairStr.substring(0, 44)

      // Verify it matches the attestation
      if (publicKeyB64 !== attestation.userPublicKey) {
        error('Keypair does not match attestation userPublicKey')
        return
      }

      // Sign the challenge using ANProto
      const { ANProto } = await import('../../anproto.js')
      const keypair = ANProto.loadKeypair(keypairStr)
      signatureB64 = ANProto.sign(challengeData.challenge, keypair)
      publicKeyHex = publicKeyB64
    } else {
      error('Unsupported identity method for CLI restore: ' + attestation.identityMethod)
      log('Use the web UI to restore SSH or Keet identity attestations.')
      return
    }

    // Step 3: Submit restore request
    const result = await client.post('/api/auth/restore', {
      attestation,
      challengeId: challengeData.challengeId,
      publicKey: publicKeyHex,
      signature: signatureB64
    })

    if (result.success) {
      log('')
      log('\u2713 Permissions restored!')
      log(`  User ID:     ${result.userId}`)
      log(`  Permissions: ${result.permissions.join(', ')}`)

      // Save fresh attestation
      if (result.attestation) {
        fs.writeFileSync(ATTESTATION_FILE, JSON.stringify(result.attestation, null, 2) + '\n', { mode: 0o600 })
        log('  Attestation: updated')
      }
    } else {
      error('Restore failed: ' + (result.error || 'unknown error'))
    }
  } catch (err) {
    error('Restore failed: ' + err.message)
  }
}

/**
 * Export identity backup (keypair + attestation) as encrypted JSON file.
 */
async function identityExportBackup (flags) {
  const storagePath = resolveStorage(flags)

  // Gather identity materials
  const backup = { version: 1, exportedAt: new Date().toISOString() }

  // Device identity
  const deviceFile = path.join(PEAR_GIT_CONFIG_DIR, 'device-identity.json')
  const serverFile = path.join(storagePath, 'server-identity.json')
  const effectiveFile = fileExists(serverFile) ? serverFile : deviceFile

  if (fileExists(effectiveFile)) {
    const data = JSON.parse(fs.readFileSync(effectiveFile, 'utf-8'))
    backup.publicKey = data.publicKey
    // Include secret key in backup (will be encrypted)
    backup.secretKey = data.secretKey
  }

  // Peer identity (Noise key)
  const peer = readPeerIdentity()
  if (peer) {
    backup.noisePublicKey = peer.publicKey
    backup.noiseSecretKey = peer.secretKey
  }

  // Attestation
  if (fileExists(ATTESTATION_FILE)) {
    backup.attestation = JSON.parse(fs.readFileSync(ATTESTATION_FILE, 'utf-8'))
  }

  if (!backup.publicKey && !backup.noisePublicKey) {
    error('No identity found to export')
    return
  }

  // Encrypt with password
  const password = await promptPassword('Encryption password: ')
  const confirm = await promptPassword('Confirm password: ')
  log('')

  if (!password.equals(confirm)) {
    error('Passwords do not match')
    zeroBuffer(password)
    zeroBuffer(confirm)
    return
  }
  zeroBuffer(confirm)

  try {
    const { SecureStorage } = await import('../../identity/secure-storage.js')
    const encrypted = await SecureStorage.encrypt(Buffer.from(JSON.stringify(backup)), password)

    const outFile = flags.output || path.join(PEAR_GIT_CONFIG_DIR, 'identity-backup.enc')
    fs.writeFileSync(outFile, JSON.stringify(encrypted, null, 2) + '\n', { mode: 0o600 })

    log('\u2713 Identity backup exported to ' + outFile)
    log('')
    log('Contains:')
    if (backup.publicKey) log(`  Device key:   ${backup.publicKey.slice(0, 16)}...`)
    if (backup.noisePublicKey) log(`  Noise key:    ${backup.noisePublicKey.slice(0, 16)}...`)
    if (backup.attestation) log(`  Attestation:  \u2713 (${backup.attestation.permissions.join(', ')})`)
    log('')
    log('To restore: pgh identity import-backup ' + outFile)
  } catch (err) {
    error('Export failed: ' + err.message)
  } finally {
    zeroBuffer(password)
  }
}

/**
 * Import identity from encrypted backup file.
 */
async function identityImportBackup (args, flags) {
  const backupFile = args[0]
  if (!backupFile) {
    error('Usage: pgh identity import-backup <file>')
    return
  }

  if (!fileExists(backupFile)) {
    error('File not found: ' + backupFile)
    return
  }

  const password = await promptPassword('Decryption password: ')
  log('')

  try {
    const { SecureStorage } = await import('../../identity/secure-storage.js')
    const encrypted = JSON.parse(fs.readFileSync(backupFile, 'utf-8'))
    const decrypted = await SecureStorage.decrypt(encrypted, password)
    const backup = JSON.parse(decrypted.toString())

    if (backup.version !== 1) {
      error('Unsupported backup version: ' + backup.version)
      return
    }

    // Restore identity files
    try { fs.mkdirSync(PEAR_GIT_CONFIG_DIR, { recursive: true }) } catch {}

    if (backup.publicKey && backup.secretKey) {
      const identityData = { publicKey: backup.publicKey, secretKey: backup.secretKey }
      const deviceFile = path.join(PEAR_GIT_CONFIG_DIR, 'device-identity.json')
      fs.writeFileSync(deviceFile, JSON.stringify(identityData, null, 2) + '\n', { mode: 0o600 })
      log(`\u2713 Device key restored: ${backup.publicKey.slice(0, 16)}...`)
    }

    if (backup.noisePublicKey && backup.noiseSecretKey) {
      const peerFile = path.join(PEAR_GIT_CONFIG_DIR, 'peer-identity.json')
      fs.writeFileSync(peerFile, JSON.stringify({
        publicKey: backup.noisePublicKey,
        secretKey: backup.noiseSecretKey
      }, null, 2) + '\n', { mode: 0o600 })
      log(`\u2713 Noise key restored: ${backup.noisePublicKey.slice(0, 16)}...`)
    }

    if (backup.attestation) {
      fs.writeFileSync(ATTESTATION_FILE, JSON.stringify(backup.attestation, null, 2) + '\n', { mode: 0o600 })
      log(`\u2713 Attestation restored (${backup.attestation.permissions.join(', ')})`)
    }

    log('')
    log('Identity imported successfully.')
    log('Run `pgh identity restore` to re-establish server permissions.')
  } catch (err) {
    if (err.message.includes('decrypt') || err.message.includes('authentication')) {
      error('Wrong password')
    } else {
      error('Import failed: ' + err.message)
    }
  } finally {
    zeroBuffer(password)
  }
}

// === Password Utilities ===

/**
 * Prompt for password with secure memory
 * Falls back to Buffer.from for non-TTY environments
 */
async function promptPassword (message) {
  process.stdout.write(message)
  try {
    const prompt = (await import('secure-prompt')).default
    const pw = await prompt()
    process.stdout.write('\n')
    return pw
  } catch {
    // Fallback: read from stdin (non-TTY or secure-prompt unavailable)
    return new Promise((resolve) => {
      let data = ''
      process.stdin.setEncoding('utf-8')
      process.stdin.once('data', (chunk) => {
        data = chunk.trim()
        resolve(Buffer.from(data))
      })
      process.stdin.resume()
    })
  }
}

/**
 * Zero a buffer safely (works for both sodium_malloc'd and regular buffers)
 */
function zeroBuffer (buf) {
  if (!buf) return
  try {
    const sodium = require('sodium-native')
    sodium.sodium_memzero(buf)
  } catch {
    buf.fill(0)
  }
}

export function identityHelp () {
  log(`Usage: pgh identity <subcommand> [flags]

Subcommands:
  show                 Show local identity from ~/.hyperident/
  link                 Link hyperident key to pear-git server
  link-noise           Link P2P Noise key to pear-git server
  list                 List all identities on server
  export               Export public key (for sharing)
  setup                Guided first-run wizard (server + auth + identity)
  export-mnemonic      Export recovery mnemonic from vault
  status               Show Keet identity status + proof verification
  change-password      Change vault encryption password
  link-device          Create invite for device linking (source)
  join-device <invite> Join via invite from source device (target)

  attestation          Fetch & save signed permission attestation
  restore              Restore permissions from saved attestation
  export-backup        Export encrypted identity backup (keypair + attestation)
  import-backup <file> Import identity from encrypted backup

Flags:
  --json               Output as JSON
  --server URL         Server URL
  --token TOKEN        Admin bearer token
  --storage PATH       Override storage path (default: ./pear-git-storage)
  --force              Overwrite existing vault (setup only)
  --output PATH        Output file path (export-backup only)

Disaster Recovery:
  1. Fetch attestation while things work:  pgh identity attestation
  2. Export encrypted backup:              pgh identity export-backup
  3. After DB loss, restore permissions:   pgh identity restore
  4. Or import on new machine:             pgh identity import-backup <file>

Keet Identity:
  Run 'pgh identity setup' to create an encrypted mnemonic vault
  with a proof chain linking your Keet identity to this device.

  Link additional devices with:
    Source: pgh identity link-device
    Target: pgh identity join-device <invite-hex>

Hyperident Identity:
  Shared with hyperident, hyper-wallet, and other Holepunch apps.
  Create with: hyperident init

P2P identity (Noise key) is stored at ~/.config/pear-git/peer-identity.json
and created automatically on first P2P operation.`)
}