OverviewFilesCommitsIssuesDocsReleasesPeers

hyperident / lib / file-ops.js

475 lines · javascript ref:
/**
 * 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
    // HYPERIDENT_HOME redirects the vault WITHOUT a code change, so a test run or CI job can be
    // isolated from the operator's real identity. This existed only as setBasePath(), which the test
    // suite never called — so `npm test` deleted the real ~/.hyperident of whoever ran it. That cost a
    // live vault on 2026-08-17 (hyper-social #47).
    if (process.env.HYPERIDENT_HOME) return path.join(process.env.HYPERIDENT_HOME, '.hyperident')
    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)
  }
}