/**
* Crypto Manager - Cryptographic operations for Hyperident
* Uses bare-crypto for all cryptographic functions
*/
import crypto from 'node:crypto'
import sodium from 'sodium-native'
/**
* CryptoManager class for encryption, decryption, and key derivation
*/
export class CryptoManager {
/**
* Generate a cryptographically secure random salt
* @returns {Buffer} 32-byte random salt
*/
static generateSalt() {
return crypto.randomBytes(32)
}
/**
* Derive encryption key from passphrase and salt
* @param {string} passphrase - User passphrase
* @param {Buffer} salt - 32-byte salt
* @returns {Buffer} 32-byte derived key
*/
// ── vault secret encryption ────────────────────────────────────────────────────────────────────
//
// This replaces a scheme that was sha256(passphrase||salt) + repeating-key XOR, unauthenticated
// (hyper-social #47). Against a BIP39 mnemonic that was not protection: the plaintext is lowercase
// words from a PUBLIC 2048-word list, ~150-200 bytes against a 32-byte key, so the keystream repeated
// five or six times and was recoverable WITHOUT the passphrase. A single SHA-256 made guessing the
// passphrase cheap regardless.
//
// The old deriveKey/encryptData/decryptData are REMOVED rather than deprecated. Leaving callable
// broken crypto beside the fixed version is how it gets reused.
//
// Parameters are returned rather than hardcoded at the call site so a vault records the cost it was
// written with, and the cost can be raised later without breaking existing vaults.
//
// INTERACTIVE (64 MB, 2 ops) not MODERATE (256 MB): the phone is a first-class target — favela runs
// as a FULL NODE on Android (#36) with a measured storage budget already in the hundreds of MB — and
// a 256 MB argon2 allocation during unlock is a real risk there. The memlimit is recorded per-vault,
// so raising it later is a per-vault decision, not a migration.
static kdfParams () {
return {
kdf: 'argon2id',
alg: sodium.crypto_pwhash_ALG_ARGON2ID13,
opslimit: sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
memlimit: sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
saltBytes: sodium.crypto_pwhash_SALTBYTES
}
}
// Memory-hard KDF. `salt` may be longer than the KDF consumes (generateSalt returns 32 for backward
// compatibility with existing callers); the first SALTBYTES are used.
static deriveVaultKey (passphrase, salt, params = CryptoManager.kdfParams()) {
if (typeof passphrase !== 'string' || passphrase.length === 0) throw new Error('Passphrase must be a non-empty string')
if (!Buffer.isBuffer(salt) || salt.length < sodium.crypto_pwhash_SALTBYTES) {
throw new Error('Salt must be a Buffer of at least ' + sodium.crypto_pwhash_SALTBYTES + ' bytes')
}
const key = Buffer.alloc(sodium.crypto_secretbox_KEYBYTES)
sodium.crypto_pwhash(
key,
Buffer.from(passphrase, 'utf8'),
salt.subarray(0, sodium.crypto_pwhash_SALTBYTES),
params.opslimit, params.memlimit, params.alg
)
return key
}
// XSalsa20-Poly1305. Returns nonce || ciphertext+MAC, so the box is self-contained and a fresh nonce
// is used per call — encrypting the same secret twice must not produce identical bytes.
static encryptSecret (data, passphrase, salt, params = CryptoManager.kdfParams()) {
if (!Buffer.isBuffer(data)) throw new Error('Data must be a Buffer')
const key = CryptoManager.deriveVaultKey(passphrase, salt, params)
const nonce = Buffer.alloc(sodium.crypto_secretbox_NONCEBYTES)
sodium.randombytes_buf(nonce)
const ct = Buffer.alloc(data.length + sodium.crypto_secretbox_MACBYTES)
sodium.crypto_secretbox_easy(ct, data, nonce, key)
sodium.sodium_memzero(key)
return Buffer.concat([nonce, ct])
}
// THROWS on a wrong passphrase or tampered bytes. The old scheme could not: an unauthenticated cipher
// returns a different plaintext for a different key and cannot tell you which happened.
static decryptSecret (box, passphrase, salt, params = CryptoManager.kdfParams()) {
if (!Buffer.isBuffer(box) || box.length <= sodium.crypto_secretbox_NONCEBYTES + sodium.crypto_secretbox_MACBYTES) {
throw new Error('Ciphertext is too short to be a valid vault box')
}
const nonce = box.subarray(0, sodium.crypto_secretbox_NONCEBYTES)
const ct = box.subarray(sodium.crypto_secretbox_NONCEBYTES)
const key = CryptoManager.deriveVaultKey(passphrase, salt, params)
const out = Buffer.alloc(ct.length - sodium.crypto_secretbox_MACBYTES)
const ok = sodium.crypto_secretbox_open_easy(out, ct, nonce, key)
sodium.sodium_memzero(key)
if (!ok) throw new Error('vault: wrong passphrase, or the vault is corrupt')
return out
}
static hash(data) {
if (!Buffer.isBuffer(data)) {
throw new Error('Data must be a Buffer')
}
return crypto.createHash('sha256').update(data).digest()
}
/**
* Generate random bytes
* @param {number} length - Number of bytes to generate
* @returns {Buffer} Random bytes
*/
static randomBytes(length) {
if (typeof length !== 'number' || length < 0) {
throw new Error('Length must be a non-negative number')
}
return crypto.randomBytes(length)
}
/**
* Generate Ed25519 keypair for device
* @returns {Object} Object with publicKey and privateKey as Buffers
*/
static generateDeviceKeyPair() {
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
return {
publicKey: publicKey.export({ type: 'spki', format: 'der' }),
privateKey: privateKey.export({ type: 'pkcs8', format: 'der' })
}
}
/**
* Convert DER format keys to raw Ed25519 format (32 bytes)
* @param {Buffer} derKey - DER formatted key
* @param {string} type - 'public' or 'private'
* @returns {Buffer} Raw Ed25519 key (32 bytes)
*/
static derToRawEd25519(derKey, type) {
if (!Buffer.isBuffer(derKey)) {
throw new Error('DER key must be a Buffer')
}
if (type !== 'public' && type !== 'private') {
throw new Error('Type must be "public" or "private"')
}
if (type === 'public') {
// For Ed25519 public keys in SPKI format, the raw key is the last 32 bytes
return derKey.slice(-32)
} else {
// For Ed25519 private keys in PKCS8 format, the raw key is at a specific offset
// PKCS8 Ed25519 private key structure has the 32-byte key at offset -32
return derKey.slice(-32)
}
}
/**
* Generate raw Ed25519 keypair using sodium-universal (compatible with keet-identity-key)
* @returns {Object} Object with publicKey (32 bytes) and secretKey (64 bytes) as Buffers
*/
static async generateRawDeviceKeyPair() {
// Import sodium-universal dynamically to avoid issues if not installed
let sodium;
try {
const sodiumModule = await import('sodium-universal');
sodium = sodiumModule.default;
} catch (error) {
throw new Error('sodium-universal is required for device key generation. Run: npm install sodium-universal');
}
const publicKey = Buffer.alloc(sodium.crypto_sign_PUBLICKEYBYTES);
const secretKey = Buffer.alloc(sodium.crypto_sign_SECRETKEYBYTES);
sodium.crypto_sign_keypair(publicKey, secretKey);
return {
publicKey,
secretKey // Note: keet-identity-key uses 'secretKey' not 'privateKey'
};
}
}