/**
* 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