OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / core / platform / storage.js

341 lines · javascript ref:
/**
 * Platform Local Storage API
 * ============================
 * Encrypted key-value storage on the local device.
 *
 * Data is encrypted at rest using XChaCha20-Poly1305 with an app-derived key.
 * Storage is namespaced per app — each app gets its own isolated store.
 *
 * Persistence: JSON file per namespace with atomic writes.
 * In production, this would back onto iOS Keychain / Android Keystore / IndexedDB.
 *
 * Usage:
 *   const storage = new LocalStorage({ path: './data', name: 'my-app', key: appKey })
 *   await storage.init()
 *   await storage.set('transactions', txData)
 *   const data = await storage.get('transactions')
 */

import b4a from 'b4a'
import { readFile, writeFile, rename, mkdir, unlink } from 'fs/promises'
import { join } from 'path'
import { encrypt, decrypt } from './crypto.js'

const DEFAULT_QUOTA = 100 * 1024 * 1024 // 100 MB

export class LocalStorage {
  /**
   * @param {object} opts
   * @param {string} opts.path - Base storage directory
   * @param {string} opts.name - Namespace (app identifier)
   * @param {Buffer} opts.key - 32-byte encryption key (from KeyManager.dataKey)
   * @param {number} [opts.quota] - Max storage in bytes (default 100MB)
   */
  constructor (opts) {
    if (!opts.path) throw new Error('opts.path required')
    if (!opts.name) throw new Error('opts.name required')
    if (!opts.key || opts.key.length !== 32) throw new Error('opts.key must be 32 bytes')

    this.basePath = opts.path
    this.name = opts.name
    this.key = opts.key
    this.quota = opts.quota || DEFAULT_QUOTA
    this.storePath = join(this.basePath, this.name)
    this.indexFile = join(this.storePath, 'index.json')

    // In-memory index: entryKey → { file, size, createdAt, updatedAt }
    this._index = new Map()
    this._totalBytes = 0
    this._ready = false
  }

  /**
   * Initialize storage — create directory and load index.
   */
  async init () {
    await mkdir(this.storePath, { recursive: true })

    try {
      const raw = await readFile(this.indexFile, 'utf8')
      const entries = JSON.parse(raw)
      for (const [k, v] of Object.entries(entries)) {
        this._index.set(k, v)
        this._totalBytes += v.size || 0
      }
    } catch (err) {
      if (err.code !== 'ENOENT') throw err
      // Fresh store
    }

    this._ready = true
  }

  /**
   * Store a value. Data is encrypted before writing to disk.
   *
   * @param {string} entryKey - Key name
   * @param {Buffer|string|object} value - Data to store (objects are JSON-serialized)
   * @returns {{ size: number }} bytes written
   */
  async set (entryKey, value) {
    this._ensureReady()
    if (typeof entryKey !== 'string' || entryKey.length === 0) {
      throw new Error('Entry key must be a non-empty string')
    }

    // Serialize value
    let plaintext
    if (b4a.isBuffer(value)) {
      plaintext = value
    } else if (typeof value === 'string') {
      plaintext = b4a.from(value)
    } else {
      plaintext = b4a.from(JSON.stringify(value))
    }

    // Check quota (subtract old entry size if updating)
    const oldEntry = this._index.get(entryKey)
    const oldSize = oldEntry ? oldEntry.size : 0
    const newTotalBytes = this._totalBytes - oldSize + plaintext.length
    if (newTotalBytes > this.quota) {
      throw new Error(`Storage quota exceeded (${this.quota} bytes). Current: ${this._totalBytes}, new entry: ${plaintext.length}`)
    }

    // Encrypt
    const sealed = encrypt(plaintext, this.key)

    // Write encrypted data to file
    const fileName = this._fileNameFor(entryKey)
    const filePath = join(this.storePath, fileName)
    const tmpPath = filePath + '.tmp'

    await writeFile(tmpPath, sealed, { mode: 0o600 })
    await rename(tmpPath, filePath)

    // Update index
    const now = Date.now()
    const entry = {
      file: fileName,
      size: plaintext.length,
      encryptedSize: sealed.length,
      createdAt: oldEntry ? oldEntry.createdAt : now,
      updatedAt: now
    }
    this._index.set(entryKey, entry)
    this._totalBytes = newTotalBytes

    // Persist index
    await this._saveIndex()

    return { size: plaintext.length }
  }

  /**
   * Retrieve and decrypt a value.
   *
   * @param {string} entryKey - Key name
   * @returns {Buffer|null} Decrypted data, or null if not found
   */
  async get (entryKey) {
    this._ensureReady()

    const entry = this._index.get(entryKey)
    if (!entry) return null

    const filePath = join(this.storePath, entry.file)
    try {
      const sealed = await readFile(filePath)
      return decrypt(sealed, this.key)
    } catch (err) {
      if (err.code === 'ENOENT') {
        // File missing — remove from index
        this._index.delete(entryKey)
        this._totalBytes -= entry.size || 0
        await this._saveIndex()
        return null
      }
      throw err
    }
  }

  /**
   * Retrieve and parse a JSON value.
   *
   * @param {string} entryKey
   * @returns {object|null}
   */
  async getJSON (entryKey) {
    const buf = await this.get(entryKey)
    if (!buf) return null
    return JSON.parse(buf.toString())
  }

  /**
   * Delete an entry.
   *
   * @param {string} entryKey
   * @returns {boolean} true if entry existed
   */
  async delete (entryKey) {
    this._ensureReady()

    const entry = this._index.get(entryKey)
    if (!entry) return false

    const filePath = join(this.storePath, entry.file)
    try {
      await unlink(filePath)
    } catch (err) {
      if (err.code !== 'ENOENT') throw err
    }

    this._totalBytes -= entry.size || 0
    this._index.delete(entryKey)
    await this._saveIndex()

    return true
  }

  /**
   * Check if an entry exists.
   * @param {string} entryKey
   * @returns {boolean}
   */
  has (entryKey) {
    this._ensureReady()
    return this._index.has(entryKey)
  }

  /**
   * List all entry keys.
   * @returns {string[]}
   */
  keys () {
    this._ensureReady()
    return Array.from(this._index.keys())
  }

  /**
   * Get storage stats.
   * @returns {{ entries: number, totalBytes: number, quota: number, usedPercent: number }}
   */
  stats () {
    this._ensureReady()
    return {
      entries: this._index.size,
      totalBytes: this._totalBytes,
      encryptedBytes: Array.from(this._index.values()).reduce((sum, e) => sum + (e.encryptedSize || 0), 0),
      quota: this.quota,
      usedPercent: Math.round((this._totalBytes / this.quota) * 100)
    }
  }

  /**
   * Export all entries as encrypted blobs for P2P backup.
   * Returns a map of entryKey → encrypted Buffer.
   * The relay or backup peer gets these blobs but cannot decrypt them.
   *
   * @returns {Map<string, Buffer>}
   */
  async exportEncrypted () {
    this._ensureReady()
    const result = new Map()

    for (const [entryKey, entry] of this._index) {
      const filePath = join(this.storePath, entry.file)
      try {
        const sealed = await readFile(filePath)
        result.set(entryKey, sealed)
      } catch (err) {
        if (err.code !== 'ENOENT') throw err
      }
    }

    return result
  }

  /**
   * Import encrypted blobs (from P2P backup restore).
   * Validates each blob can be decrypted with the current key.
   *
   * @param {Map<string, Buffer>} blobs - entryKey → encrypted Buffer
   * @returns {{ imported: number, failed: number }}
   */
  async importEncrypted (blobs) {
    this._ensureReady()
    let imported = 0
    let failed = 0

    for (const [entryKey, sealed] of blobs) {
      try {
        // Verify we can decrypt (validates key + integrity)
        const plaintext = decrypt(sealed, this.key)

        // Write the encrypted blob directly
        const fileName = this._fileNameFor(entryKey)
        const filePath = join(this.storePath, fileName)
        const tmpPath = filePath + '.tmp'
        await writeFile(tmpPath, sealed, { mode: 0o600 })
        await rename(tmpPath, filePath)

        const oldEntry = this._index.get(entryKey)
        if (oldEntry) this._totalBytes -= oldEntry.size || 0

        this._index.set(entryKey, {
          file: fileName,
          size: plaintext.length,
          encryptedSize: sealed.length,
          createdAt: Date.now(),
          updatedAt: Date.now()
        })
        this._totalBytes += plaintext.length
        imported++
      } catch (err) {
        failed++
      }
    }

    await this._saveIndex()
    return { imported, failed }
  }

  /**
   * Destroy all stored data (wipe).
   */
  async clear () {
    this._ensureReady()

    for (const entry of this._index.values()) {
      const filePath = join(this.storePath, entry.file)
      try { await unlink(filePath) } catch {}
    }

    this._index.clear()
    this._totalBytes = 0
    await this._saveIndex()
  }

  // ── Internal ──────────────────────────────────────────────

  _ensureReady () {
    if (!this._ready) throw new Error('LocalStorage not initialized — call init() first')
  }

  _fileNameFor (entryKey) {
    // Safe filename: replace any non-alphanumeric chars to prevent path traversal
    return entryKey.replace(/[^a-zA-Z0-9_-]/g, '_') + '.enc'
  }

  async _saveIndex () {
    const data = {}
    for (const [k, v] of this._index) {
      data[k] = v
    }
    const tmp = this.indexFile + '.tmp'
    await writeFile(tmp, JSON.stringify(data, null, 2), { mode: 0o600 })
    await rename(tmp, this.indexFile)
  }
}

export default LocalStorage