/**
* Identity Manager — Mnemonic generation, proof chains, vault coordination
*
* Orchestrates keet-identity-key and SecureStorage to provide:
* - Mnemonic generation + encrypted vault storage
* - Proof chain creation (identity → device attestation)
* - Proof verification
* - Mnemonic export (password-protected)
*/
import fs from 'bare-fs'
import path from 'bare-path'
import IdentityKey from 'keet-identity-key'
import sodium from 'sodium-native'
import { SecureStorage } from './secure-storage.js'
export class IdentityManager {
constructor ({ storagePath, identityFile }) {
this.storagePath = storagePath
// Allow override — client uses device-identity.json, server uses server-identity.json
this.identityFile = identityFile || path.join(storagePath, 'server-identity.json')
// Vault: prefer existing server-mnemonic.enc (backward compat), else device-mnemonic.enc
const oldVault = path.join(storagePath, 'server-mnemonic.enc')
const newVault = path.join(storagePath, 'device-mnemonic.enc')
let vaultFile = newVault
try {
fs.statSync(oldVault)
vaultFile = oldVault
} catch {}
this.vaultFile = vaultFile
this.vault = new SecureStorage(this.vaultFile)
}
/**
* Ensure a device keypair exists at this.identityFile.
* If server-identity.json already exists (server machine), this is a no-op.
* Otherwise, generates a new Ed25519 keypair.
* @returns {boolean} true if newly generated, false if already existed
*/
async ensureKeypair () {
try {
await fs.promises.readFile(this.identityFile, 'utf-8')
return false // already exists
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
// Generate new Ed25519 keypair
const crypto = (await import('hypercore-crypto')).default
const keypair = crypto.keyPair()
const data = {
publicKey: keypair.publicKey.toString('hex'),
secretKey: keypair.secretKey.toString('hex'),
createdAt: Date.now()
}
await fs.promises.mkdir(path.dirname(this.identityFile), { recursive: true })
await fs.promises.writeFile(this.identityFile, JSON.stringify(data, null, 2), {
encoding: 'utf-8', mode: 0o600
})
return true // newly generated
}
/**
* Get current identity status
* @returns {{ hasKeypair: boolean, hasVault: boolean, hasProof: boolean, identityPublicKey: string|null }}
*/
status () {
let hasKeypair = false
let hasProof = false
let identityPublicKey = null
let devicePublicKey = null
try {
const data = JSON.parse(fs.readFileSync(this.identityFile, 'utf-8'))
hasKeypair = !!(data.publicKey && data.secretKey)
hasProof = !!data.proof
identityPublicKey = data.identityPublicKey || null
devicePublicKey = data.publicKey || null
} catch {}
return {
hasKeypair,
hasVault: this.vault.exists(),
hasProof,
identityPublicKey,
devicePublicKey
}
}
/**
* Generate mnemonic, create proof chain, encrypt vault
* @param {Buffer} password - Encryption password
* @returns {{ mnemonic: string, identityPublicKey: string }}
*/
async setup (password) {
// Read existing server identity (must exist)
const identityData = JSON.parse(await fs.promises.readFile(this.identityFile, 'utf-8'))
const devicePublicKey = Buffer.from(identityData.publicKey, 'hex')
// Generate mnemonic
const mnemonic = IdentityKey.generateMnemonic()
// Create identity from mnemonic
const identity = await IdentityKey.from({ mnemonic })
// Bootstrap proof: identity → device
const proof = await identity.bootstrap(devicePublicKey)
// Sanity check: verify the proof
const verified = IdentityKey.verify(proof, null, {
expectedDevice: devicePublicKey
})
if (!verified) {
identity.clear()
throw new Error('Proof verification failed after bootstrap')
}
// Encrypt mnemonic to vault
const mnemonicBuf = Buffer.from(mnemonic, 'utf-8')
await this.vault.encrypt(mnemonicBuf, password)
sodium.sodium_memzero(mnemonicBuf)
// Store proof + identity public key in server-identity.json
identityData.identityPublicKey = identity.identityPublicKey.toString('hex')
identityData.proof = proof.toString('hex')
identityData.proofCreatedAt = Date.now()
await fs.promises.writeFile(this.identityFile, JSON.stringify(identityData, null, 2), {
encoding: 'utf-8',
mode: 0o600
})
const identityPublicKeyHex = identity.identityPublicKey.toString('hex')
// Zero secrets
identity.clear()
return { mnemonic, identityPublicKey: identityPublicKeyHex }
}
/**
* Setup from an existing mnemonic (used by device linking)
* Creates a new proof chain for THIS device's key
* @param {string} mnemonic - BIP39 mnemonic
* @param {Buffer} password - Encryption password
* @returns {{ identityPublicKey: string }}
*/
async setupFromMnemonic (mnemonic, password) {
// Read existing server identity
const identityData = JSON.parse(await fs.promises.readFile(this.identityFile, 'utf-8'))
const devicePublicKey = Buffer.from(identityData.publicKey, 'hex')
// Create identity from mnemonic
const identity = await IdentityKey.from({ mnemonic })
// Bootstrap proof for this device
const proof = await identity.bootstrap(devicePublicKey)
// Verify
const verified = IdentityKey.verify(proof, null, {
expectedDevice: devicePublicKey
})
if (!verified) {
identity.clear()
throw new Error('Proof verification failed')
}
// Encrypt mnemonic to local vault
const mnemonicBuf = Buffer.from(mnemonic, 'utf-8')
await this.vault.encrypt(mnemonicBuf, password)
sodium.sodium_memzero(mnemonicBuf)
// Store proof + identity public key
identityData.identityPublicKey = identity.identityPublicKey.toString('hex')
identityData.proof = proof.toString('hex')
identityData.proofCreatedAt = Date.now()
await fs.promises.writeFile(this.identityFile, JSON.stringify(identityData, null, 2), {
encoding: 'utf-8',
mode: 0o600
})
const identityPublicKeyHex = identity.identityPublicKey.toString('hex')
identity.clear()
return { identityPublicKey: identityPublicKeyHex }
}
/**
* Verify proof chain stored in server-identity.json
* @returns {{ valid: boolean, identityPublicKey: string|null, devicePublicKey: string|null }}
*/
verifyProof () {
try {
const data = JSON.parse(fs.readFileSync(this.identityFile, 'utf-8'))
if (!data.proof) {
return { valid: false, identityPublicKey: null, devicePublicKey: null }
}
const proof = Buffer.from(data.proof, 'hex')
const devicePublicKey = Buffer.from(data.publicKey, 'hex')
const result = IdentityKey.verify(proof, null, {
expectedDevice: devicePublicKey
})
if (!result) {
return { valid: false, identityPublicKey: null, devicePublicKey: null }
}
return {
valid: true,
identityPublicKey: result.identityPublicKey.toString('hex'),
devicePublicKey: result.devicePublicKey.toString('hex')
}
} catch {
return { valid: false, identityPublicKey: null, devicePublicKey: null }
}
}
/**
* Export mnemonic from vault (password required)
* @param {Buffer} password
* @returns {string} mnemonic
*/
async exportMnemonic (password) {
const plaintext = await this.vault.decrypt(password)
const mnemonic = plaintext.toString('utf-8')
sodium.sodium_memzero(plaintext)
return mnemonic
}
/**
* Change vault password
* @param {Buffer} oldPassword
* @param {Buffer} newPassword
*/
async changePassword (oldPassword, newPassword) {
await this.vault.changePassword(oldPassword, newPassword)
}
/**
* Attest another device's public key using this identity
* @param {Buffer} devicePublicKey - Public key of device to attest
* @param {Buffer} password - Vault password (to recover mnemonic)
* @returns {Buffer} Proof buffer for the new device
*/
async attestDevice (devicePublicKey, password) {
const plaintext = await this.vault.decrypt(password)
const mnemonic = plaintext.toString('utf-8')
sodium.sodium_memzero(plaintext)
const identity = await IdentityKey.from({ mnemonic })
const proof = await identity.bootstrap(devicePublicKey)
identity.clear()
return proof
}
}
export default IdentityManager