/**
* Identity Manager - Core identity operations for Hyperident
* Handles mnemonic generation, storage, and profile management
*/
import IdentityKey from 'keet-identity-key'
import { CryptoManager } from './crypto.js'
import { FileManager } from './file-ops.js'
import path from 'path'
import fs from 'fs'
/**
* IdentityManager class for managing hyperident identities and profiles
*/
export class IdentityManager {
/**
* Generate a new identity with optional passphrase protection and device chain
* @param {string|null} passphrase - Optional passphrase for encryption
* @param {string} deviceName - Name for this device (default: 'default')
* @returns {Promise<Object>} Generated identity data
*/
static async generateIdentity(passphrase = null, deviceName = 'default') {
try {
// Generate mnemonic using keet-identity-key
const mnemonic = IdentityKey.generateMnemonic()
// Create IdentityKey instance from mnemonic
const identityKey = await IdentityKey.from({ mnemonic })
// Generate device keypair (compatible with keet-identity-key)
const deviceKeyPair = await CryptoManager.generateRawDeviceKeyPair()
// Create device proof using keet-identity-key
const deviceProof = await identityKey.bootstrap(deviceKeyPair.publicKey)
// Ensure proof is a Buffer
const proofBuffer = Buffer.isBuffer(deviceProof) ? deviceProof : Buffer.from(deviceProof)
// Create identity object
const identity = {
mnemonic,
publicKey: identityKey.identityKeyPair.publicKey.toString('hex'),
version: '1.0.0',
created: new Date().toISOString(),
algorithm: 'keet-identity-key',
deviceChain: true
}
// Save the identity
await this.saveIdentity(mnemonic, passphrase)
// Save device chain
await this.saveDeviceChain(deviceKeyPair, proofBuffer, deviceName)
return identity
} catch (error) {
throw new Error(`Failed to generate identity: ${error.message}`)
}
}
/**
* Save identity to ~/.hyperident/ directory
* @param {string} mnemonic - The mnemonic to save
* @param {string|null} passphrase - Optional passphrase for encryption
* @returns {Promise<void>}
*/
static async saveIdentity(mnemonic, passphrase = null) {
try {
// Ensure hyperident directory exists
await FileManager.ensureHyperidentDir()
// Create IdentityKey instance for public key storage
const identityKey = await IdentityKey.from({ mnemonic })
// Create identity data structure
const identityData = {
version: '1.0.0',
created: new Date().toISOString(),
algorithm: 'keet-identity-key',
encrypted: !!passphrase
}
if (passphrase) {
// argon2id + XSalsa20-Poly1305 (hyper-social #47). The KDF cost is RECORDED in the vault, not
// assumed at read time, so it can be raised later without stranding vaults written today.
const salt = CryptoManager.generateSalt()
const params = CryptoManager.kdfParams()
const box = CryptoManager.encryptSecret(Buffer.from(mnemonic, 'utf8'), passphrase, salt, params)
identityData.mnemonic = box.toString('hex')
identityData.salt = salt.toString('hex')
identityData.kdf = params.kdf
identityData.opslimit = params.opslimit
identityData.memlimit = params.memlimit
} else {
// Store mnemonic in plain text
identityData.mnemonic = mnemonic
}
// Save identity file
await FileManager.writeIdentityFile(identityData, identityData.encrypted)
// Save public key file
await FileManager.writePublicKey(identityKey.identityKeyPair.publicKey.toString('hex'))
// Initialize empty profiles.json if it doesn't exist
try {
await FileManager.readProfiles()
} catch (error) {
// File doesn't exist, create empty profiles
const emptyProfiles = {
version: '1.0.0',
profiles: {}
}
await FileManager.writeProfiles(emptyProfiles)
}
} catch (error) {
throw new Error(`Failed to save identity: ${error.message}`)
}
}
/**
* Load identity from ~/.hyperident/ directory
* @param {string|null} passphrase - Optional passphrase for decryption
* @returns {Promise<Object>} Loaded identity data
*/
static async loadIdentity(passphrase = null) {
try {
// Read identity file
const identityData = await FileManager.readIdentityFile()
let mnemonic
if (identityData.encrypted) {
if (!passphrase) {
throw new Error('Identity is encrypted but no passphrase provided')
}
// A vault with no `kdf` field predates the argon2id/secretbox fix (#47) and is XOR-"encrypted".
// Say so plainly instead of reporting "Invalid passphrase": the passphrase is not the problem,
// and that secret should be treated as exposed and re-minted, not merely re-encrypted.
if (!identityData.kdf) {
throw new Error(
'This vault was written with the pre-#47 XOR scheme, which does not protect it. ' +
'Recover the mnemonic with an older hyperident, then re-create the vault. ' +
'Treat the secret as exposed if the file ever left this machine.'
)
}
const salt = Buffer.from(identityData.salt, 'hex')
const params = {
...CryptoManager.kdfParams(),
// Honour the cost the vault was WRITTEN with — using today's default would derive a
// different key and read as a wrong passphrase.
opslimit: identityData.opslimit || CryptoManager.kdfParams().opslimit,
memlimit: identityData.memlimit || CryptoManager.kdfParams().memlimit
}
try {
const box = Buffer.from(identityData.mnemonic, 'hex')
mnemonic = CryptoManager.decryptSecret(box, passphrase, salt, params).toString('utf8')
// Validate that the decrypted mnemonic is valid by trying to create an IdentityKey
await IdentityKey.from({ mnemonic })
} catch (error) {
throw new Error('Invalid passphrase')
}
} else {
if (passphrase) {
throw new Error('Identity is not encrypted but passphrase provided')
}
mnemonic = identityData.mnemonic
}
// Create IdentityKey instance from mnemonic
const identityKey = await IdentityKey.from({ mnemonic })
// Return complete identity object
return {
mnemonic,
publicKey: identityKey.identityKeyPair.publicKey.toString('hex'),
version: identityData.version,
created: identityData.created,
algorithm: identityData.algorithm,
encrypted: identityData.encrypted
}
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error('Identity not found. Run "hyperident init" to create one.')
}
throw new Error(`Failed to load identity: ${error.message}`)
}
}
/**
* Get the public key from the identity
* @returns {Promise<string>} Public key as hex string
*/
static async getPublicKey() {
try {
// Try to read from public key file first (faster)
return await FileManager.readPublicKey()
} catch (error) {
// Fall back to reading from identity file
try {
const identityData = await FileManager.readIdentityFile()
if (identityData.encrypted) {
throw new Error('Identity is encrypted. Cannot extract public key without passphrase.')
}
const identityKey = await IdentityKey.from({ mnemonic: identityData.mnemonic })
return identityKey.identityKeyPair.publicKey.toString('hex')
} catch (identityError) {
throw new Error('Identity not found. Run "hyperident init" to create one.')
}
}
}
/**
* Check if identity files exist
* @returns {boolean} True if identity exists
*/
static identityExists() {
try {
const hyperidentPath = FileManager.getHyperidentPath()
const identityPath = path.join(hyperidentPath, 'identity')
// Check if identity file exists using synchronous method for simplicity
return fs.existsSync(identityPath)
} catch (error) {
return false
}
}
/**
* Create a new profile
* @param {string} name - Profile name
* @param {string} description - Optional profile description
* @returns {Promise<void>}
*/
static async createProfile(name, description = '') {
try {
// Validate profile name
if (!this.validateProfileName(name)) {
throw new Error('Invalid profile name. Must be 1-32 characters, alphanumeric and hyphens only.')
}
// Check if identity exists
if (!this.identityExists()) {
throw new Error('Identity not found. Run "hyperident init" to create one.')
}
// Load existing profiles
let profilesData
try {
profilesData = await FileManager.readProfiles()
} catch (error) {
// Initialize empty profiles if file doesn't exist
profilesData = {
version: '1.0.0',
profiles: {}
}
}
// Check if profile already exists
if (profilesData.profiles[name]) {
throw new Error(`Profile '${name}' already exists`)
}
// Create new profile first
const now = new Date().toISOString()
profilesData.profiles[name] = {
name,
description,
created: now,
lastModified: now
}
// Save updated profiles first
await FileManager.writeProfiles(profilesData)
// Now generate and cache address
try {
const address = await this.getProfileAddress(name)
await this.updateProfileAddress(name, address)
} catch (error) {
// If address generation fails, we still have the profile created
// Address will be generated on first access
console.warn(`Warning: Could not cache address during profile creation: ${error.message}`)
}
} catch (error) {
throw new Error(`Failed to create profile: ${error.message}`)
}
}
/**
* Delete a profile
* @param {string} name - Profile name to delete
* @returns {Promise<void>}
*/
static async deleteProfile(name) {
try {
// Check if identity exists
if (!this.identityExists()) {
throw new Error('Identity not found. Run "hyperident init" to create one.')
}
// Load existing profiles
const profilesData = await FileManager.readProfiles()
// Check if profile exists
if (!profilesData.profiles[name]) {
throw new Error(`Profile '${name}' not found`)
}
// Delete the profile
delete profilesData.profiles[name]
// Save updated profiles
await FileManager.writeProfiles(profilesData)
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error('No profiles found. Create a profile first with "hyperident create-profile".')
}
throw new Error(`Failed to delete profile: ${error.message}`)
}
}
/**
* List all profiles
* @returns {Promise<Object>} Profiles data
*/
static async listProfiles() {
try {
// Check if identity exists
if (!this.identityExists()) {
throw new Error('Identity not found. Run "hyperident init" to create one.')
}
// Load profiles
const profilesData = await FileManager.readProfiles()
// Sort profiles alphabetically by name for consistent output
const sortedProfiles = {}
const sortedNames = Object.keys(profilesData.profiles).sort()
for (const name of sortedNames) {
sortedProfiles[name] = profilesData.profiles[name]
}
return {
version: profilesData.version,
profiles: sortedProfiles
}
} catch (error) {
if (error.code === 'ENOENT') {
// Return empty profiles structure if file doesn't exist
return {
version: '1.0.0',
profiles: {}
}
}
throw new Error(`Failed to list profiles: ${error.message}`)
}
}
/**
* Get a specific profile
* @param {string} name - Profile name to get
* @returns {Promise<Object>} Profile data
*/
static async getProfile(name) {
try {
const profilesData = await this.listProfiles()
if (!profilesData.profiles[name]) {
throw new Error(`Profile '${name}' not found`)
}
return profilesData.profiles[name]
} catch (error) {
throw new Error(`Failed to get profile: ${error.message}`)
}
}
/**
* Update a profile
* @param {string} name - Profile name to update
* @param {Object} updates - Updates to apply
* @returns {Promise<void>}
*/
static async updateProfile(name, updates) {
try {
// Check if identity exists
if (!this.identityExists()) {
throw new Error('Identity not found. Run "hyperident init" to create one.')
}
// Load existing profiles
const profilesData = await FileManager.readProfiles()
// Check if profile exists
if (!profilesData.profiles[name]) {
throw new Error(`Profile '${name}' not found`)
}
// Apply updates
const profile = profilesData.profiles[name]
if (updates.description !== undefined) {
profile.description = updates.description
}
profile.lastModified = new Date().toISOString()
// Save updated profiles
await FileManager.writeProfiles(profilesData)
} catch (error) {
throw new Error(`Failed to update profile: ${error.message}`)
}
}
/**
* Check if a profile exists
* @param {string} name - Profile name to check
* @returns {Promise<boolean>} True if profile exists
*/
static async profileExists(name) {
try {
const profilesData = await this.listProfiles()
return !!profilesData.profiles[name]
} catch (error) {
return false
}
}
/**
* Validate profile name format
* @param {string} name - Profile name to validate
* @returns {boolean} True if valid
*/
static validateProfileName(name) {
// Check basic requirements
if (!name || typeof name !== 'string') {
return false
}
// Check length (1-32 characters)
if (name.length < 1 || name.length > 32) {
return false
}
// Check for reserved names
const reservedNames = ['default', 'config', 'temp', 'system', 'admin']
if (reservedNames.includes(name.toLowerCase())) {
return false
}
// Check format: alphanumeric and hyphens only
const validFormat = /^[a-zA-Z0-9-]+$/
if (!validFormat.test(name)) {
return false
}
// Cannot start or end with hyphen
if (name.startsWith('-') || name.endsWith('-')) {
return false
}
// Cannot have consecutive hyphens
if (name.includes('--')) {
return false
}
return true
}
/**
* Derive hyperswarm topic from profile name
* @param {string} profileName - Profile name
* @param {string|null} passphrase - Optional passphrase for encrypted identities
* @returns {Promise<Buffer>} 32-byte topic for hyperswarm
*/
static async deriveTopicFromProfile(profileName, passphrase = null) {
try {
// Validate inputs
if (!profileName || typeof profileName !== 'string') {
throw new Error('Profile name is required and must be a string')
}
// Check if identity exists
if (!this.identityExists()) {
throw new Error('Identity not found. Run "hyperident init" to create one.')
}
// Check if profile exists
const profileExists = await this.profileExists(profileName)
if (!profileExists) {
throw new Error(`Profile '${profileName}' not found. Create it first with "hyperident create-profile ${profileName}".`)
}
// Load identity (handle encrypted identities)
const identity = await this.loadIdentity(passphrase)
// Create IdentityKey instance from mnemonic
const identityKey = await IdentityKey.from({ mnemonic: identity.mnemonic })
// Hash profile name with SHA256
const profileHash = CryptoManager.hash(Buffer.from(profileName, 'utf8'))
// Use keet-identity-key to derive encryption key from profile hash
const encryptionKey = identityKey.getEncryptionKey(profileHash)
// Return first 32 bytes as hyperswarm topic
return encryptionKey.subarray(0, 32)
} catch (error) {
throw new Error(`Failed to derive topic from profile: ${error.message}`)
}
}
/**
* Get hyperswarm address string for a profile
* @param {string} profileName - Profile name
* @param {string|null} passphrase - Optional passphrase for encrypted identities
* @returns {Promise<string>} Hyperswarm address as hex string
*/
static async getProfileAddress(profileName, passphrase = null) {
try {
const topic = await this.deriveTopicFromProfile(profileName, passphrase)
return topic.toString('hex')
} catch (error) {
throw new Error(`Failed to get profile address: ${error.message}`)
}
}
/**
* Get cached profile address (fast path)
* @param {string} profileName - Profile name
* @param {string|null} passphrase - Optional passphrase for encrypted identities
* @returns {Promise<string>} Cached address as hex string
*/
static async getCachedProfileAddress(profileName, passphrase = null) {
try {
const profilesData = await this.listProfiles()
const profile = profilesData.profiles[profileName]
if (!profile) {
throw new Error(`Profile '${profileName}' not found`)
}
// Return cached address if available, otherwise generate and cache
if (profile.address) {
return profile.address
} else {
// Address not cached, generate and cache it
const address = await this.getProfileAddress(profileName, passphrase)
await this.updateProfileAddress(profileName, address)
return address
}
} catch (error) {
throw new Error(`Failed to get cached profile address: ${error.message}`)
}
}
/**
* Update cached address for a profile
* @param {string} profileName - Profile name
* @param {string} address - Address as hex string
* @returns {Promise<void>}
*/
static async updateProfileAddress(profileName, address) {
try {
const profilesData = await FileManager.readProfiles()
if (!profilesData.profiles[profileName]) {
throw new Error(`Profile '${profileName}' not found`)
}
profilesData.profiles[profileName].address = address
profilesData.profiles[profileName].lastModified = new Date().toISOString()
await FileManager.writeProfiles(profilesData)
} catch (error) {
throw new Error(`Failed to update profile address: ${error.message}`)
}
}
/**
* Refresh all cached addresses
* @param {string|null} passphrase - Optional passphrase for encrypted identities
* @returns {Promise<void>}
*/
static async refreshAllAddresses(passphrase = null) {
try {
const profilesData = await this.listProfiles()
const profileNames = Object.keys(profilesData.profiles)
for (const profileName of profileNames) {
const address = await this.getProfileAddress(profileName, passphrase)
await this.updateProfileAddress(profileName, address)
}
} catch (error) {
throw new Error(`Failed to refresh all addresses: ${error.message}`)
}
}
/**
* Export addresses for external programs
* @returns {Promise<Object>} Addresses data
*/
static async exportAddresses() {
try {
const profilesData = await this.listProfiles()
const addresses = {}
for (const [name, profile] of Object.entries(profilesData.profiles)) {
addresses[name] = {
address: profile.address || null,
description: profile.description,
created: profile.created,
lastModified: profile.lastModified
}
}
return {
version: '1.0.0',
exported: new Date().toISOString(),
addresses
}
} catch (error) {
throw new Error(`Failed to export addresses: ${error.message}`)
}
}
/**
* Export addresses as JSON string
* @returns {Promise<string>} JSON string of addresses
*/
static async exportAddressesJSON() {
try {
const data = await this.exportAddresses()
return JSON.stringify(data, null, 2)
} catch (error) {
throw new Error(`Failed to export addresses as JSON: ${error.message}`)
}
}
/**
* Export enhanced addresses with multiple formats for application integration
* @param {string|null} passphrase - Optional passphrase for encrypted identities
* @returns {Promise<Object>} Enhanced addresses data with multiple formats
*/
static async exportAddressesEnhanced(passphrase = null) {
try {
const profilesData = await this.listProfiles()
const identity = await this.loadIdentity(passphrase)
const addresses = {}
const envVars = {}
const hyperswarmTopics = {}
for (const [name, profile] of Object.entries(profilesData.profiles)) {
// Get or generate address
let address = profile.address
if (!address) {
address = await this.getProfileAddress(name, passphrase)
await this.updateProfileAddress(name, address)
}
// Generate topic buffer for hyperswarm
const topic = await this.deriveTopicFromProfile(name, passphrase)
addresses[name] = {
address: address,
topic: topic.toString('hex'),
description: profile.description,
created: profile.created,
lastModified: profile.lastModified,
formats: {
hex: address,
buffer: `Buffer.from('${address}', 'hex')`,
uint8Array: `new Uint8Array(Buffer.from('${address}', 'hex'))`,
base64: Buffer.from(address, 'hex').toString('base64')
}
}
// Environment variable format
const envName = name.toUpperCase().replace(/[^A-Z0-9]/g, '_')
envVars[`HYPERIDENT_${envName}_ADDRESS`] = address
envVars[`HYPERIDENT_${envName}_TOPIC`] = topic.toString('hex')
// Hyperswarm topic format
hyperswarmTopics[name] = {
topic: topic.toString('hex'),
buffer: Array.from(topic),
usage: `hyperswarm.join(Buffer.from('${topic.toString('hex')}', 'hex'))`
}
}
return {
version: '1.1.0',
exported: new Date().toISOString(),
identity: {
publicKey: identity.publicKey,
algorithm: identity.algorithm,
encrypted: identity.encrypted
},
addresses,
formats: {
envVars,
hyperswarmTopics
},
usage: {
javascript: {
import: "import { IdentityManager } from 'hyperident'",
getAddress: "const address = await IdentityManager.getProfileAddress('profileName')",
getTopic: "const topic = await IdentityManager.deriveTopicFromProfile('profileName')"
},
hyperswarm: {
join: "swarm.join(Buffer.from(address, 'hex'))",
listen: "swarm.join(Buffer.from(address, 'hex'), { server: true, client: false })",
connect: "swarm.join(Buffer.from(address, 'hex'), { server: false, client: true })"
},
environment: {
description: "Set these environment variables in your application",
example: "export HYPERIDENT_WORK_ADDRESS=abc123..."
}
}
}
} catch (error) {
throw new Error(`Failed to export enhanced addresses: ${error.message}`)
}
}
/**
* Export enhanced addresses as JSON string
* @param {string|null} passphrase - Optional passphrase for encrypted identities
* @returns {Promise<string>} JSON string of enhanced addresses
*/
static async exportAddressesEnhancedJSON(passphrase = null) {
try {
const data = await this.exportAddressesEnhanced(passphrase)
return JSON.stringify(data, null, 2)
} catch (error) {
throw new Error(`Failed to export enhanced addresses as JSON: ${error.message}`)
}
}
/**
* Validate profile exists before operations
* @param {string} profileName - Profile name to validate
* @returns {Promise<void>}
*/
static async validateProfile(profileName) {
if (!profileName || typeof profileName !== 'string') {
throw new Error('Profile name is required and must be a string')
}
if (!this.identityExists()) {
throw new Error('Identity not found. Run "hyperident init" to create one.')
}
const exists = await this.profileExists(profileName)
if (!exists) {
throw new Error(`Profile '${profileName}' not found. Create it first with "hyperident create-profile ${profileName}".`)
}
}
// Data Signing Methods (following schema-sheets-cli patterns)
/**
* Sign arbitrary data using device identity
* @param {Buffer} data - Data to sign
* @param {string|null} passphrase - Optional passphrase for encrypted identities
* @returns {Promise<Buffer>} Signature proof
*/
static async signData(data, passphrase = null) {
try {
if (!Buffer.isBuffer(data)) {
throw new Error('Data must be a Buffer')
}
// Load device chain for signing
const deviceChain = await this.loadDeviceChain()
// Load bootstrap proof for signing
const bootstrapProof = await FileManager.readBootstrapProof()
// Verify device chain is valid
const verification = await this.verifyDeviceProof(deviceChain.proof, passphrase)
if (!verification) {
throw new Error('Device chain is not valid for signing')
}
// Sign data using keet-identity-key attestData pattern
const proof = IdentityKey.attestData(
data,
deviceChain.keyPair,
bootstrapProof
)
return proof
} catch (error) {
throw new Error(`Failed to sign data: ${error.message}`)
}
}
/**
* Verify a signature against data
* @param {Buffer} proof - Signature proof to verify
* @param {Buffer} data - Original data that was signed
* @returns {Promise<Object|null>} Verification result or null if invalid
*/
static async verifySignature(proof, data) {
try {
if (!Buffer.isBuffer(proof)) {
throw new Error('Proof must be a Buffer')
}
if (!Buffer.isBuffer(data)) {
throw new Error('Data must be a Buffer')
}
// Verify signature using keet-identity-key
const verification = IdentityKey.verify(proof, data)
return verification
} catch (error) {
throw new Error(`Failed to verify signature: ${error.message}`)
}
}
/**
* Export signing configuration for applications (following schema-sheets-cli pattern)
* @param {string|null} passphrase - Optional passphrase for encrypted identities
* @returns {Promise<Object>} Signing configuration
*/
static async exportSigningConfig(passphrase = null) {
try {
// Load identity
const identity = await this.loadIdentity(passphrase)
const identityKey = await IdentityKey.from({ mnemonic: identity.mnemonic })
// Load device chain
const deviceChain = await this.loadDeviceChain()
// Load bootstrap proof
const bootstrapProof = await FileManager.readBootstrapProof()
// Verify device chain is valid
const verification = await this.verifyDeviceProof(deviceChain.proof, passphrase)
if (!verification) {
throw new Error('Device chain is not valid for signing configuration export')
}
// Return signing configuration in schema-sheets-cli format
return {
identityPublicKey: identityKey.identityKeyPair.publicKey.toString('hex'),
devicePublicKey: deviceChain.keyPair.publicKey.toString('hex'),
deviceSecretKey: deviceChain.keyPair.secretKey.toString('hex'),
bootstrapProof: bootstrapProof.toString('hex')
}
} catch (error) {
throw new Error(`Failed to export signing config: ${error.message}`)
}
}
// Device Chain Management Methods
/**
* Save device chain (keys, proof, metadata)
* @param {Object} deviceKeyPair - Device keypair with publicKey and secretKey
* @param {Buffer} deviceProof - Device attestation proof
* @param {string} deviceName - Name for this device
* @returns {Promise<void>}
*/
static async saveDeviceChain(deviceKeyPair, deviceProof, deviceName) {
try {
// Save device keys
await FileManager.writeDevicePrivateKey(deviceKeyPair.secretKey)
await FileManager.writeDevicePublicKey(deviceKeyPair.publicKey)
// Save device proof (for device chain verification)
await FileManager.writeDeviceProof(deviceProof)
// Save bootstrap proof (for data signing operations)
// This is the same as device proof for root devices, but enables signing
await FileManager.writeBootstrapProof(deviceProof)
// Save device metadata
const deviceMetadata = {
name: deviceName,
created: new Date().toISOString(),
publicKey: deviceKeyPair.publicKey.toString('hex'),
version: '1.0.0'
}
await FileManager.writeDeviceMetadata(deviceMetadata)
} catch (error) {
throw new Error(`Failed to save device chain: ${error.message}`)
}
}
/**
* Load device chain data
* @returns {Promise<Object>} Device chain data
*/
static async loadDeviceChain() {
try {
const secretKey = await FileManager.readDevicePrivateKey()
const publicKey = await FileManager.readDevicePublicKey()
const proof = await FileManager.readDeviceProof()
const metadata = await FileManager.readDeviceMetadata()
return {
keyPair: { secretKey, publicKey },
proof,
metadata
}
} catch (error) {
throw new Error(`Failed to load device chain: ${error.message}`)
}
}
/**
* Verify device proof chain
* @param {Buffer} proof - Device proof to verify
* @param {string|null} passphrase - Optional passphrase for encrypted identities
* @returns {Promise<Object|null>} Verification result or null if invalid
*/
static async verifyDeviceProof(proof, passphrase = null) {
try {
// Load identity to get expected root key
const identity = await this.loadIdentity(passphrase)
const identityKey = await IdentityKey.from({ mnemonic: identity.mnemonic })
const expectedRootKey = identityKey.identityKeyPair.publicKey
// Verify the proof using keet-identity-key
const verification = IdentityKey.verify(proof)
if (!verification) {
return null // Invalid proof
}
// Check if proof links back to our root identity
if (!verification.identityPublicKey.equals(expectedRootKey)) {
return null // Proof is valid but for different identity
}
return verification
} catch (error) {
throw new Error(`Failed to verify device proof: ${error.message}`)
}
}
/**
* Check if device chain exists and is valid
* @param {string|null} passphrase - Optional passphrase for encrypted identities
* @returns {Promise<boolean>} True if device chain exists and is valid
*/
static async deviceChainExists(passphrase = null) {
try {
// Check if all device files exist
const filesExist = await FileManager.deviceChainExists()
if (!filesExist) {
return false
}
// Load and verify device proof
const deviceChain = await this.loadDeviceChain()
const verification = await this.verifyDeviceProof(deviceChain.proof, passphrase)
return verification !== null
} catch (error) {
return false
}
}
/**
* Attest a new device using existing device
* @param {Buffer} newDevicePublicKey - Public key of new device to attest
* @param {string|null} passphrase - Optional passphrase for encrypted identities
* @returns {Promise<Buffer>} New device proof
*/
static async attestNewDevice(newDevicePublicKey, passphrase = null) {
try {
// Load current device chain
const currentDevice = await this.loadDeviceChain()
// Verify current device is valid
const verification = await this.verifyDeviceProof(currentDevice.proof, passphrase)
if (!verification) {
throw new Error('Current device is not properly attested')
}
// Create attestation for new device
const newProof = IdentityKey.attestDevice(
newDevicePublicKey,
currentDevice.keyPair,
currentDevice.proof
)
return newProof
} catch (error) {
throw new Error(`Failed to attest new device: ${error.message}`)
}
}
/**
* Export device bundle for cross-device sync
* @param {string|null} passphrase - Optional passphrase for encrypted identities
* @returns {Promise<Object>} Device bundle for import on new device
*/
static async exportDeviceBundle(passphrase = null) {
try {
// Load identity and device chain
const identity = await this.loadIdentity(passphrase)
const deviceChain = await this.loadDeviceChain()
const profiles = await this.listProfiles()
// Verify device chain is valid
const verification = await this.verifyDeviceProof(deviceChain.proof, passphrase)
if (!verification) {
throw new Error('Device chain is not valid')
}
return {
version: '1.0.0',
exported: new Date().toISOString(),
identity: {
mnemonic: identity.mnemonic,
publicKey: identity.publicKey,
created: identity.created,
algorithm: identity.algorithm
},
deviceChain: {
keyPair: {
publicKey: deviceChain.keyPair.publicKey.toString('hex'),
secretKey: deviceChain.keyPair.secretKey.toString('hex')
},
proof: deviceChain.proof.toString('hex'),
metadata: deviceChain.metadata
},
profiles: profiles.profiles
}
} catch (error) {
throw new Error(`Failed to export device bundle: ${error.message}`)
}
}
/**
* Import device bundle and create new device attestation
* @param {Object} bundle - Device bundle from export
* @param {string} newDeviceName - Name for the new device
* @param {string|null} passphrase - Optional passphrase for encryption
* @returns {Promise<void>}
*/
static async importDeviceBundle(bundle, newDeviceName, passphrase = null) {
try {
// Validate bundle format
if (!bundle || !bundle.identity || !bundle.deviceChain) {
throw new Error('Invalid device bundle format')
}
// Generate new device keypair
const newDeviceKeyPair = await CryptoManager.generateRawDeviceKeyPair()
// Reconstruct parent device data
const parentKeyPair = {
publicKey: Buffer.from(bundle.deviceChain.keyPair.publicKey, 'hex'),
secretKey: Buffer.from(bundle.deviceChain.keyPair.secretKey, 'hex')
}
const parentProof = Buffer.from(bundle.deviceChain.proof, 'hex')
// Create attestation for new device
const newDeviceProof = IdentityKey.attestDevice(
newDeviceKeyPair.publicKey,
parentKeyPair,
parentProof
)
// Save identity
await this.saveIdentity(bundle.identity.mnemonic, passphrase)
// Save new device chain
await this.saveDeviceChain(newDeviceKeyPair, newDeviceProof, newDeviceName)
// Import profiles
if (bundle.profiles && Object.keys(bundle.profiles).length > 0) {
const profilesData = {
version: '1.0.0',
profiles: bundle.profiles
}
await FileManager.writeProfiles(profilesData)
}
} catch (error) {
throw new Error(`Failed to import device bundle: ${error.message}`)
}
}
}