210 lines
5.5 KiB
JavaScript

/**
* Crypto module — Web Crypto API wrapper.
*
* Uses PBKDF2 for key derivation and AES-GCM for symmetric encryption.
* All operations are async and use the browser's native Web Crypto API.
*
* The derived encryption key is kept in memory only — never written to disk.
*/
import { generateId } from '../models/schema.js'
const PBKDF2_ITERATIONS = 600_000
const SALT_LENGTH = 16 // bytes
const IV_LENGTH = 12 // bytes (recommended for AES-GCM)
/**
* Generate a random salt.
* @returns {Uint8Array}
*/
export function generateSalt() {
return crypto.getRandomValues(new Uint8Array(SALT_LENGTH))
}
/**
* Derive an AES-GCM encryption key from a master password and salt.
*
* @param {string} masterPassword
* @param {Uint8Array} salt
* @returns {Promise<CryptoKey>}
*/
export async function deriveKey(masterPassword, salt) {
// Step 1: Import the password as raw key material
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(masterPassword),
'PBKDF2',
false,
['deriveKey']
)
// Step 2: Derive an AES-GCM key using PBKDF2
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations: PBKDF2_ITERATIONS,
hash: 'SHA-256',
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false, // not extractable — stays in memory
['encrypt', 'decrypt']
)
}
/**
* Encrypt a plaintext string.
*
* @param {string} plaintext
* @param {CryptoKey} key
* @returns {Promise<string>} JSON string containing { iv, ciphertext }
* (salt is stored separately in the app store)
*/
export async function encrypt(plaintext, key) {
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH))
const encoded = new TextEncoder().encode(plaintext)
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
encoded
)
// Return as JSON with base64-encoded iv and ciphertext
return JSON.stringify({
iv: uint8ArrayToBase64(iv),
ciphertext: uint8ArrayToBase64(new Uint8Array(ciphertext)),
})
}
/**
* Decrypt an encrypted blob back to plaintext.
*
* @param {string} encryptedJson - JSON string from encrypt()
* @param {CryptoKey} key
* @returns {Promise<string>}
*/
export async function decrypt(encryptedJson, key) {
const { iv, ciphertext } = JSON.parse(encryptedJson)
const ciphertextBuffer = base64ToUint8Array(ciphertext)
const ivBuffer = base64ToUint8Array(iv)
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: ivBuffer },
key,
ciphertextBuffer
)
return new TextDecoder().decode(decrypted)
}
/**
* Verify that a master password is correct by attempting to decrypt a test payload.
*
* @param {string} masterPassword
* @param {Uint8Array} salt
* @param {string} testEncrypted - A known encrypted test string
* @param {string} testPlaintext - The expected plaintext
* @returns {Promise<boolean>}
*/
export async function verifyPassword(masterPassword, salt, testEncrypted, testPlaintext) {
try {
const key = await deriveKey(masterPassword, salt)
const decrypted = await decrypt(testEncrypted, key)
return decrypted === testPlaintext
} catch {
return false
}
}
/**
* Create a test payload for password verification on first setup.
*
* @param {string} masterPassword
* @returns {Promise<{ salt: Uint8Array, testEncrypted: string }>}
*/
export async function createTestPayload(masterPassword) {
const salt = generateSalt()
const key = await deriveKey(masterPassword, salt)
const testPlaintext = 'vault_test_' + generateId()
const testEncrypted = await encrypt(testPlaintext, key)
return { salt, testEncrypted, testPlaintext }
}
// --- Utility: Uint8Array ↔ Base64 ---
export function uint8ArrayToBase64(buffer) {
const bytes = new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}
export function base64ToUint8Array(base64) {
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}
/**
* Generate a random password.
*
* @param {Object} options
* @param {number} [options.length=16]
* @param {boolean} [options.uppercase=true]
* @param {boolean} [options.lowercase=true]
* @param {boolean} [options.digits=true]
* @param {boolean} [options.symbols=true]
* @param {string} [options.exclude='']
* @returns {string}
*/
export function generatePassword({
length = 16,
uppercase = true,
lowercase = true,
digits = true,
symbols = true,
exclude = '',
} = {}) {
let charset = ''
if (uppercase) charset += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
if (lowercase) charset += 'abcdefghijklmnopqrstuvwxyz'
if (digits) charset += '0123456789'
if (symbols) charset += '!@#$%^&*()_+-=[]{}|;:,.<>?'
// Remove excluded characters
if (exclude) {
const excludeSet = new Set(exclude.split(''))
charset = charset.split('').filter(c => !excludeSet.has(c)).join('')
}
if (!charset) {
throw new Error('Password charset is empty — enable at least one character type')
}
const charsetLength = charset.length
const maxValid = 256 - (256 % charsetLength)
const randomBytes = new Uint8Array(length * 2)
let password = ''
let byteIdx = 0
while (password.length < length) {
if (byteIdx >= randomBytes.length) {
crypto.getRandomValues(randomBytes)
byteIdx = 0
}
const byte = randomBytes[byteIdx++]
if (byte < maxValid) {
password += charset[byte % charsetLength]
}
}
return password
}