210 lines
5.5 KiB
JavaScript
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
|
|
}
|