OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / lib / notebook-crypto.js

205 lines · javascript ref:
/**
 * NotebookCrypto — Per-notebook encryption using XSalsa20-Poly1305
 *
 * Each notebook gets a random 32-byte key. That key is encrypted with
 * the owner's password via Argon2id, then stored in the Hyperbee registry.
 * Per-field encryption uses the notebook key directly (fast) with fresh nonces.
 *
 * Reuses the exact same primitives as SecureStorage (sodium-native).
 */

import sodium from 'sodium-native'

const SALT_BYTES = 16
const NONCE_BYTES = sodium.crypto_secretbox_NONCEBYTES // 24
const KEY_BYTES = sodium.crypto_secretbox_KEYBYTES // 32
const MAC_BYTES = sodium.crypto_secretbox_MACBYTES // 16

const OPSLIMIT = sodium.crypto_pwhash_OPSLIMIT_MODERATE
const MEMLIMIT = sodium.crypto_pwhash_MEMLIMIT_MODERATE
const ALG = sodium.crypto_pwhash_ALG_ARGON2ID13

const KEY_PREFIX = 'notebook-key:'

export class NotebookCrypto {
  constructor (registry) {
    this.registry = registry // Hyperbee instance
  }

  /**
   * Enable encryption for a notebook.
   * Generates a random 32-byte notebook key, encrypts it with the owner's
   * password via Argon2id + XSalsa20-Poly1305, stores in registry.
   *
   * @param {string} slug - Notebook slug
   * @param {Buffer|string} password - Owner's password
   * @returns {Buffer} The raw notebook key (caller should cache it)
   */
  async enableEncryption (slug, password) {
    const existing = await this.registry.get(`${KEY_PREFIX}${slug}`)
    if (existing) {
      throw new Error(`Notebook '${slug}' already has encryption enabled`)
    }

    const pwd = typeof password === 'string' ? Buffer.from(password) : password

    // Generate random notebook key
    const notebookKey = Buffer.alloc(KEY_BYTES)
    sodium.randombytes_buf(notebookKey)

    // Derive wrapping key from password
    const salt = Buffer.alloc(SALT_BYTES)
    sodium.randombytes_buf(salt)

    const wrapKey = Buffer.alloc(KEY_BYTES)
    sodium.crypto_pwhash(wrapKey, pwd, salt, OPSLIMIT, MEMLIMIT, ALG)

    // Encrypt the notebook key
    const nonce = Buffer.alloc(NONCE_BYTES)
    sodium.randombytes_buf(nonce)

    const ciphertext = Buffer.alloc(KEY_BYTES + MAC_BYTES)
    sodium.crypto_secretbox_easy(ciphertext, notebookKey, nonce, wrapKey)

    sodium.sodium_memzero(wrapKey)

    // Store encrypted key in registry
    await this.registry.put(`${KEY_PREFIX}${slug}`, {
      salt: salt.toString('hex'),
      nonce: nonce.toString('hex'),
      ciphertext: ciphertext.toString('hex'),
      opslimit: OPSLIMIT,
      memlimit: MEMLIMIT
    })

    return notebookKey
  }

  /**
   * Retrieve and decrypt the notebook key using the owner's password.
   *
   * @param {string} slug - Notebook slug
   * @param {Buffer|string} password - Owner's password
   * @returns {Buffer} The decrypted 32-byte notebook key
   */
  async getNotebookKey (slug, password) {
    const entry = await this.registry.get(`${KEY_PREFIX}${slug}`)
    if (!entry) {
      throw new Error(`No encryption key found for notebook '${slug}'`)
    }

    const pwd = typeof password === 'string' ? Buffer.from(password) : password
    const record = entry.value

    const salt = Buffer.from(record.salt, 'hex')
    const nonce = Buffer.from(record.nonce, 'hex')
    const ciphertext = Buffer.from(record.ciphertext, 'hex')
    const opslimit = record.opslimit || OPSLIMIT
    const memlimit = record.memlimit || MEMLIMIT

    const wrapKey = Buffer.alloc(KEY_BYTES)
    sodium.crypto_pwhash(wrapKey, pwd, salt, opslimit, memlimit, ALG)

    const notebookKey = Buffer.alloc(ciphertext.length - MAC_BYTES)

    let ok = false
    try {
      ok = sodium.crypto_secretbox_open_easy(notebookKey, ciphertext, nonce, wrapKey)
    } catch {
      ok = false
    }

    sodium.sodium_memzero(wrapKey)

    if (ok === false) {
      sodium.sodium_memzero(notebookKey)
      throw new Error('Decryption failed — wrong password')
    }

    return notebookKey
  }

  /**
   * Check if a notebook has encryption enabled (key exists in registry).
   *
   * @param {string} slug - Notebook slug
   * @returns {boolean}
   */
  async isEncrypted (slug) {
    const entry = await this.registry.get(`${KEY_PREFIX}${slug}`)
    return entry !== null && entry !== undefined
  }

  /**
   * Encrypt a string field (title or content) with the notebook key.
   * Format: hex(nonce) + ':' + hex(ciphertext)
   *
   * @param {string} plaintext - The string to encrypt
   * @param {Buffer} notebookKey - 32-byte notebook key
   * @returns {string} hex-encoded "nonce:ciphertext"
   */
  encryptField (plaintext, notebookKey) {
    const ptBuf = Buffer.from(plaintext, 'utf-8')

    const nonce = Buffer.alloc(NONCE_BYTES)
    sodium.randombytes_buf(nonce)

    const ciphertext = Buffer.alloc(ptBuf.length + MAC_BYTES)
    sodium.crypto_secretbox_easy(ciphertext, ptBuf, nonce, notebookKey)

    return nonce.toString('hex') + ':' + ciphertext.toString('hex')
  }

  /**
   * Decrypt a hex-encoded "nonce:ciphertext" field back to a string.
   *
   * @param {string} encrypted - hex "nonce:ciphertext"
   * @param {Buffer} notebookKey - 32-byte notebook key
   * @returns {string} The decrypted plaintext string
   */
  decryptField (encrypted, notebookKey) {
    const sepIdx = encrypted.indexOf(':')
    if (sepIdx < 0) {
      throw new Error('Invalid encrypted field format')
    }

    const nonce = Buffer.from(encrypted.substring(0, sepIdx), 'hex')
    const ciphertext = Buffer.from(encrypted.substring(sepIdx + 1), 'hex')

    const plaintext = Buffer.alloc(ciphertext.length - MAC_BYTES)

    let ok = false
    try {
      ok = sodium.crypto_secretbox_open_easy(plaintext, ciphertext, nonce, notebookKey)
    } catch {
      ok = false
    }

    if (ok === false) {
      throw new Error('Field decryption failed — wrong key or corrupted data')
    }

    return plaintext.toString('utf-8')
  }

  /**
   * Remove encryption key from registry (disables encryption).
   * Requires password confirmation to prevent accidental removal.
   *
   * @param {string} slug - Notebook slug
   * @param {Buffer|string} password - Owner's password (must decrypt successfully)
   * @returns {Buffer} The notebook key (so caller can decrypt existing notes)
   */
  async disableEncryption (slug, password) {
    // Verify password by decrypting the key
    const notebookKey = await this.getNotebookKey(slug, password)

    // Remove the key record
    await this.registry.del(`${KEY_PREFIX}${slug}`)

    return notebookKey
  }
}

export default NotebookCrypto