/**
* File Operations Manager - Secure file operations for Hyperident
* Uses bare-fs for all file system operations with proper permissions
*/
import fs from 'node:fs/promises'
import path from 'node:path'
import os from 'node:os'
/**
* FileManager class for secure file operations
*/
export class FileManager {
/**
* Ensure ~/.hyperident/ directory exists with proper permissions
* @returns {Promise<void>}
*/
static async ensureHyperidentDir() {
const hyperidentPath = this.getHyperidentPath()
try {
await fs.mkdir(hyperidentPath, { mode: 0o700, recursive: true })
} catch (error) {
if (error.code !== 'EEXIST') {
throw error
}
}
// Ensure proper permissions even if directory already exists
await this.setPermissions(hyperidentPath, 0o700)
}
/**
* Write identity file with proper permissions
* @param {Object} data - Identity data to write
* @param {boolean} encrypted - Whether the data is encrypted
* @returns {Promise<void>}
*/
static async writeIdentityFile(data, encrypted) {
if (typeof data !== 'object' || data === null) {
throw new Error('Data must be an object')
}
if (typeof encrypted !== 'boolean') {
throw new Error('Encrypted flag must be a boolean')
}
const hyperidentPath = this.getHyperidentPath()
const identityPath = path.join(hyperidentPath, 'identity')
const jsonData = JSON.stringify(data, null, 2)
await this.atomicWrite(identityPath, jsonData, 0o600)
}
/**
* Read and parse identity file
* @returns {Promise<Object>} Parsed identity data
*/
static async readIdentityFile() {
const hyperidentPath = this.getHyperidentPath()
const identityPath = path.join(hyperidentPath, 'identity')
try {
const data = await fs.readFile(identityPath, 'utf8')
return JSON.parse(data)
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error('Identity not found. Run "hyperident init" to create one.')
}
if (error instanceof SyntaxError) {
throw new Error('Identity file is corrupted or invalid JSON')
}
throw error
}
}
/**
* Write public key file with proper permissions
* @param {string} publicKey - Public key as hex string
* @returns {Promise<void>}
*/
static async writePublicKey(publicKey) {
if (typeof publicKey !== 'string') {
throw new Error('Public key must be a string')
}
const hyperidentPath = this.getHyperidentPath()
const pubKeyPath = path.join(hyperidentPath, 'identity.pub')
await this.atomicWrite(pubKeyPath, publicKey, 0o644)
}
/**
* Read public key file
* @returns {Promise<string>} Public key as hex string
*/
static async readPublicKey() {
const hyperidentPath = this.getHyperidentPath()
const pubKeyPath = path.join(hyperidentPath, 'identity.pub')
try {
const data = await fs.readFile(pubKeyPath, 'utf8')
return data.trim()
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error('Public key file not found')
}
throw error
}
}
/**
* Write profiles data with proper permissions
* @param {Object} profilesData - Profiles data to write
* @returns {Promise<void>}
*/
static async writeProfiles(profilesData) {
if (typeof profilesData !== 'object' || profilesData === null) {
throw new Error('Profiles data must be an object')
}
const hyperidentPath = this.getHyperidentPath()
const profilesPath = path.join(hyperidentPath, 'profiles.json')
const jsonData = JSON.stringify(profilesData, null, 2)
await this.atomicWrite(profilesPath, jsonData, 0o644)
}
/**
* Read and parse profiles file
* @returns {Promise<Object>} Parsed profiles data
*/
static async readProfiles() {
const hyperidentPath = this.getHyperidentPath()
const profilesPath = path.join(hyperidentPath, 'profiles.json')
try {
const data = await fs.readFile(profilesPath, 'utf8')
return JSON.parse(data)
} catch (error) {
if (error.code === 'ENOENT') {
// Return empty profiles structure if file doesn't exist
return {
version: '1.0.0',
profiles: {}
}
}
if (error instanceof SyntaxError) {
throw new Error('Profiles file is corrupted or invalid JSON')
}
throw error
}
}
/**
* Override the default vault path. Call before any identity operations.
* @param {string} customPath - absolute path to use instead of ~/.hyperident
*/
static setBasePath(customPath) {
FileManager._customPath = customPath
}
/**
* Get the hyperident directory path.
* Returns custom path if set via setBasePath(), otherwise ~/.hyperident/
* @returns {string} Full path to hyperident directory
*/
static getHyperidentPath() {
if (FileManager._customPath) return FileManager._customPath
const homeDir = os.homedir()
return path.join(homeDir, '.hyperident')
}
/**
* Check if a file exists
* @param {string} filePath - Path to check
* @returns {Promise<boolean>} True if file exists
*/
static async fileExists(filePath) {
if (typeof filePath !== 'string') {
throw new Error('File path must be a string')
}
try {
await fs.access(filePath)
return true
} catch (error) {
if (error.code === 'ENOENT') {
return false
}
throw error
}
}
/**
* Atomic write operation (write to temp file, then rename)
* @param {string} filePath - Target file path
* @param {string} data - Data to write
* @param {number} permissions - File permissions (octal)
* @returns {Promise<void>}
*/
static async atomicWrite(filePath, data, permissions) {
if (typeof filePath !== 'string') {
throw new Error('File path must be a string')
}
if (typeof data !== 'string') {
throw new Error('Data must be a string')
}
if (typeof permissions !== 'number') {
throw new Error('Permissions must be a number')
}
const tempPath = filePath + '.tmp'
try {
// Write to temporary file
await fs.writeFile(tempPath, data, { mode: permissions })
// Atomically rename to final location
await fs.rename(tempPath, filePath)
} catch (error) {
// Clean up temp file if it exists
try {
await fs.unlink(tempPath)
} catch (cleanupError) {
// Ignore cleanup errors
}
throw error
}
}
/**
* Set file permissions
* @param {string} filePath - File path
* @param {number} permissions - Permissions in octal (e.g., 0o600)
* @returns {Promise<void>}
*/
static async setPermissions(filePath, permissions) {
if (typeof filePath !== 'string') {
throw new Error('File path must be a string')
}
if (typeof permissions !== 'number') {
throw new Error('Permissions must be a number')
}
await fs.chmod(filePath, permissions)
}
// Device Chain Management Methods
/**
* Write device secret key to file
* @param {Buffer} secretKey - Device secret key (64 bytes for Ed25519)
* @returns {Promise<void>}
*/
static async writeDevicePrivateKey(secretKey) {
if (!Buffer.isBuffer(secretKey)) {
throw new Error('Secret key must be a Buffer')
}
const hyperidentPath = this.getHyperidentPath()
const deviceKeyPath = path.join(hyperidentPath, 'device.key')
await this.atomicWrite(deviceKeyPath, secretKey.toString('hex'), 0o600)
}
/**
* Read device secret key from file
* @returns {Promise<Buffer>} Device secret key (64 bytes for Ed25519)
*/
static async readDevicePrivateKey() {
const hyperidentPath = this.getHyperidentPath()
const deviceKeyPath = path.join(hyperidentPath, 'device.key')
try {
const data = await fs.readFile(deviceKeyPath, 'utf8')
return Buffer.from(data.trim(), 'hex')
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error('Device private key not found')
}
throw error
}
}
/**
* Write device public key with proper permissions
* @param {Buffer} publicKey - Device public key
* @returns {Promise<void>}
*/
static async writeDevicePublicKey(publicKey) {
if (!Buffer.isBuffer(publicKey)) {
throw new Error('Public key must be a Buffer')
}
const hyperidentPath = this.getHyperidentPath()
const devicePubPath = path.join(hyperidentPath, 'device.pub')
await this.atomicWrite(devicePubPath, publicKey.toString('hex'), 0o644)
}
/**
* Read device public key
* @returns {Promise<Buffer>} Device public key
*/
static async readDevicePublicKey() {
const hyperidentPath = this.getHyperidentPath()
const devicePubPath = path.join(hyperidentPath, 'device.pub')
try {
const data = await fs.readFile(devicePubPath, 'utf8')
return Buffer.from(data.trim(), 'hex')
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error('Device public key not found')
}
throw error
}
}
/**
* Write device proof with proper permissions
* @param {Buffer} proof - Device attestation proof
* @returns {Promise<void>}
*/
static async writeDeviceProof(proof) {
if (!Buffer.isBuffer(proof)) {
throw new Error('Proof must be a Buffer')
}
const hyperidentPath = this.getHyperidentPath()
const proofPath = path.join(hyperidentPath, 'device.proof')
await this.atomicWrite(proofPath, proof.toString('hex'), 0o644)
}
/**
* Read device proof
* @returns {Promise<Buffer>} Device attestation proof
*/
static async readDeviceProof() {
const hyperidentPath = this.getHyperidentPath()
const proofPath = path.join(hyperidentPath, 'device.proof')
try {
const data = await fs.readFile(proofPath, 'utf8')
return Buffer.from(data.trim(), 'hex')
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error('Device proof not found')
}
throw error
}
}
/**
* Write device metadata with proper permissions
* @param {Object} metadata - Device metadata
* @returns {Promise<void>}
*/
static async writeDeviceMetadata(metadata) {
if (typeof metadata !== 'object' || metadata === null) {
throw new Error('Metadata must be an object')
}
const hyperidentPath = this.getHyperidentPath()
const metadataPath = path.join(hyperidentPath, 'device.json')
const jsonData = JSON.stringify(metadata, null, 2)
await this.atomicWrite(metadataPath, jsonData, 0o644)
}
/**
* Read device metadata
* @returns {Promise<Object>} Device metadata
*/
static async readDeviceMetadata() {
const hyperidentPath = this.getHyperidentPath()
const metadataPath = path.join(hyperidentPath, 'device.json')
try {
const data = await fs.readFile(metadataPath, 'utf8')
return JSON.parse(data)
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error('Device metadata not found')
}
if (error instanceof SyntaxError) {
throw new Error('Device metadata file is corrupted or invalid JSON')
}
throw error
}
}
/**
* Check if device chain files exist
* @returns {Promise<boolean>} True if all device files exist
*/
static async deviceChainExists() {
const hyperidentPath = this.getHyperidentPath()
const deviceKeyPath = path.join(hyperidentPath, 'device.key')
const devicePubPath = path.join(hyperidentPath, 'device.pub')
const deviceProofPath = path.join(hyperidentPath, 'device.proof')
const deviceMetadataPath = path.join(hyperidentPath, 'device.json')
try {
await Promise.all([
fs.access(deviceKeyPath),
fs.access(devicePubPath),
fs.access(deviceProofPath),
fs.access(deviceMetadataPath)
])
return true
} catch (error) {
return false
}
}
/**
* Write bootstrap proof with proper permissions
* @param {Buffer} bootstrapProof - Bootstrap proof for signing
* @returns {Promise<void>}
*/
static async writeBootstrapProof(bootstrapProof) {
if (!Buffer.isBuffer(bootstrapProof)) {
throw new Error('Bootstrap proof must be a Buffer')
}
const hyperidentPath = this.getHyperidentPath()
const bootstrapProofPath = path.join(hyperidentPath, 'bootstrap.proof')
await this.atomicWrite(bootstrapProofPath, bootstrapProof.toString('hex'), 0o644)
}
/**
* Read bootstrap proof
* @returns {Promise<Buffer>} Bootstrap proof for signing
*/
static async readBootstrapProof() {
const hyperidentPath = this.getHyperidentPath()
const bootstrapProofPath = path.join(hyperidentPath, 'bootstrap.proof')
try {
const data = await fs.readFile(bootstrapProofPath, 'utf8')
return Buffer.from(data.trim(), 'hex')
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error('Bootstrap proof not found')
}
throw error
}
}
/**
* Check if file exists in hyperident directory
* @param {string} filename - Filename to check
* @returns {Promise<boolean>} True if file exists
*/
static async fileExistsInHyperident(filename) {
if (typeof filename !== 'string') {
throw new Error('Filename must be a string')
}
const hyperidentPath = this.getHyperidentPath()
const filePath = path.join(hyperidentPath, filename)
return await this.fileExists(filePath)
}
}