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