OverviewFilesCommitsIssuesDocsReleasesPeers

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

191 lines · javascript ref:
/**
 * Platform Key Management API
 * =============================
 * Device key generation, storage, and hierarchical key derivation.
 *
 * Key hierarchy:
 *   deviceKey (root, hardware-backed in production)
 *     └── appKey = HKDF(deviceKey, "app:" + appName)
 *           └── dataKey = HKDF(appKey, "data:" + purpose)
 *           └── syncKey = HKDF(appKey, "sync:" + peerId)
 *
 * Uses HKDF via BLAKE2b-keyed-hash (sodium_crypto_generichash with key).
 * In production, deviceKey would come from Secure Enclave (iOS) or
 * Keystore (Android). This implementation stores it on disk with
 * restricted permissions as a development fallback.
 */

import sodium from 'sodium-universal'
import b4a from 'b4a'
import { readFile, writeFile, rename, mkdir } from 'fs/promises'
import { join } from 'path'

const KEY_BYTES = 32

/**
 * KeyManager — manages the device key and derives child keys.
 */
export class KeyManager {
  /**
   * @param {string} storagePath - Directory for persisting keys
   */
  constructor (storagePath) {
    this.storagePath = storagePath
    this.deviceKey = null
    this.derivedKeys = new Map() // cache: context string → derived key
    this._ready = false
  }

  /**
   * Initialize — load or generate the device key.
   */
  async init () {
    await mkdir(this.storagePath, { recursive: true })
    const keyFile = join(this.storagePath, 'device-key.json')

    try {
      const data = await readFile(keyFile, 'utf8')
      const parsed = JSON.parse(data)
      this.deviceKey = b4a.from(parsed.deviceKey, 'hex')
    } catch (err) {
      if (err.code !== 'ENOENT') throw err

      // Generate new device key
      this.deviceKey = b4a.alloc(KEY_BYTES)
      sodium.randombytes_buf(this.deviceKey)

      // Persist with restricted permissions
      const tmp = keyFile + '.tmp'
      await writeFile(tmp, JSON.stringify({
        deviceKey: b4a.toString(this.deviceKey, 'hex'),
        createdAt: new Date().toISOString(),
        warning: 'KEEP SECRET — this is your device root key'
      }, null, 2), { mode: 0o600 })
      await rename(tmp, keyFile)
    }

    this._ready = true
    return this.deviceKey
  }

  /**
   * Get the device root key.
   * @returns {Buffer} 32-byte device key
   */
  device () {
    if (!this._ready) throw new Error('KeyManager not initialized — call init() first')
    return this.deviceKey
  }

  /**
   * Derive a child key using HKDF-like construction (BLAKE2b keyed hash).
   *
   * @param {Buffer} parentKey - Parent key (32 bytes)
   * @param {string} context - Derivation context (e.g. "app:sanduq", "data:transactions")
   * @returns {Buffer} 32-byte derived key
   */
  derive (parentKey, context) {
    if (!b4a.isBuffer(parentKey) || parentKey.length !== KEY_BYTES) {
      throw new Error('Parent key must be 32 bytes')
    }
    if (typeof context !== 'string' || context.length === 0) {
      throw new Error('Context must be a non-empty string')
    }

    const cacheKey = b4a.toString(parentKey, 'hex') + ':' + context
    if (this.derivedKeys.has(cacheKey)) {
      return this.derivedKeys.get(cacheKey)
    }

    const derived = b4a.alloc(KEY_BYTES)
    const input = b4a.from(context)
    sodium.crypto_generichash(derived, input, parentKey)

    this.derivedKeys.set(cacheKey, derived)
    return derived
  }

  /**
   * Derive an app-specific key from the device key.
   *
   * @param {string} appName - Application identifier
   * @returns {Buffer} 32-byte app key
   */
  appKey (appName) {
    return this.derive(this.device(), 'app:' + appName)
  }

  /**
   * Derive a data encryption key for a specific purpose within an app.
   *
   * @param {string} appName - Application identifier
   * @param {string} purpose - Data purpose (e.g. "transactions", "profile", "messages")
   * @returns {Buffer} 32-byte data encryption key
   */
  dataKey (appName, purpose) {
    const app = this.appKey(appName)
    return this.derive(app, 'data:' + purpose)
  }

  /**
   * Derive a sync key for peer-to-peer encrypted sync.
   *
   * @param {string} appName - Application identifier
   * @param {string} peerId - Peer identifier (hex pubkey or friendly name)
   * @returns {Buffer} 32-byte sync key
   */
  syncKey (appName, peerId) {
    const app = this.appKey(appName)
    return this.derive(app, 'sync:' + peerId)
  }

  /**
   * Generate an ephemeral key (not persisted, not derived).
   * Used for session keys, one-time operations.
   *
   * @returns {Buffer} 32-byte random key
   */
  ephemeral () {
    const key = b4a.alloc(KEY_BYTES)
    sodium.randombytes_buf(key)
    return key
  }

  /**
   * Generate an Ed25519 signing keypair.
   *
   * @returns {{ publicKey: Buffer, secretKey: Buffer }}
   */
  signingKeypair () {
    const publicKey = b4a.alloc(sodium.crypto_sign_PUBLICKEYBYTES)
    const secretKey = b4a.alloc(sodium.crypto_sign_SECRETKEYBYTES)
    sodium.crypto_sign_keypair(publicKey, secretKey)
    return { publicKey, secretKey }
  }

  /**
   * Wipe derived key cache (call on app close or key rotation).
   */
  clearCache () {
    // Zero out cached keys before clearing
    for (const key of this.derivedKeys.values()) {
      sodium.sodium_memzero(key)
    }
    this.derivedKeys.clear()
  }

  /**
   * Destroy — zero out all key material.
   */
  destroy () {
    this.clearCache()
    if (this.deviceKey) {
      sodium.sodium_memzero(this.deviceKey)
      this.deviceKey = null
    }
    this._ready = false
  }
}

export default KeyManager