220 lines
7.2 KiB
JavaScript
220 lines
7.2 KiB
JavaScript
/*
|
|
* vault1984 — shared crypto module
|
|
* Runs in both QuickJS (CLI) and browser (extension).
|
|
*
|
|
* In CLI (QuickJS): native_* functions provided by jsbridge.c via BearSSL.
|
|
* All calls are synchronous.
|
|
* In browser: Web Crypto API used directly (async).
|
|
*
|
|
* This file is the single source of truth for L2/L3 field crypto.
|
|
*/
|
|
|
|
/* Detect environment */
|
|
const IS_BROWSER = typeof globalThis.crypto !== 'undefined'
|
|
&& typeof globalThis.crypto.subtle !== 'undefined';
|
|
|
|
/* --- base64 helpers --- */
|
|
|
|
function uint8_to_base64(bytes) {
|
|
if (IS_BROWSER) {
|
|
return btoa(String.fromCharCode.apply(null, bytes));
|
|
} else {
|
|
return native_base64_encode(bytes);
|
|
}
|
|
}
|
|
|
|
function base64_to_uint8(str) {
|
|
if (IS_BROWSER) {
|
|
const bin = atob(str);
|
|
const bytes = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
return bytes;
|
|
} else {
|
|
return native_base64_decode(str);
|
|
}
|
|
}
|
|
|
|
/* --- AES-GCM --- */
|
|
|
|
/**
|
|
* Encrypt plaintext with AES-GCM.
|
|
* @param {Uint8Array} key - 16 bytes (AES-128) or 32 bytes (AES-256)
|
|
* @param {Uint8Array} plaintext
|
|
* @returns {Uint8Array|Promise<Uint8Array>} nonce(12) || ciphertext || tag(16)
|
|
*/
|
|
function aes_gcm_encrypt(key, plaintext) {
|
|
if (IS_BROWSER) {
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
return crypto.subtle.importKey(
|
|
'raw', key, { name: 'AES-GCM' }, false, ['encrypt']
|
|
).then(function(cryptoKey) {
|
|
return crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, cryptoKey, plaintext);
|
|
}).then(function(ct) {
|
|
const result = new Uint8Array(12 + ct.byteLength);
|
|
result.set(iv, 0);
|
|
result.set(new Uint8Array(ct), 12);
|
|
return result;
|
|
});
|
|
} else {
|
|
/* QuickJS: synchronous BearSSL binding */
|
|
return native_aes_gcm_encrypt(key, plaintext);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decrypt AES-GCM ciphertext.
|
|
* @param {Uint8Array} key - 16 or 32 bytes
|
|
* @param {Uint8Array} data - nonce(12) || ciphertext || tag(16)
|
|
* @returns {Uint8Array|Promise<Uint8Array>} plaintext
|
|
*/
|
|
function aes_gcm_decrypt(key, data) {
|
|
if (data.length < 28) throw new Error('ciphertext too short');
|
|
|
|
/* Use subarray for typed array compatibility (QuickJS) */
|
|
var iv, ct;
|
|
if (typeof data.subarray === 'function') {
|
|
iv = new Uint8Array(data.subarray(0, 12));
|
|
ct = new Uint8Array(data.subarray(12));
|
|
} else {
|
|
iv = data.slice(0, 12);
|
|
ct = data.slice(12);
|
|
}
|
|
|
|
if (IS_BROWSER) {
|
|
return crypto.subtle.importKey(
|
|
'raw', key, { name: 'AES-GCM' }, false, ['decrypt']
|
|
).then(function(cryptoKey) {
|
|
return crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, cryptoKey, ct);
|
|
}).then(function(pt) {
|
|
return new Uint8Array(pt);
|
|
});
|
|
} else {
|
|
/* Pass full data blob to native — C splits nonce/ct internally */
|
|
return native_aes_gcm_decrypt_blob(key, data);
|
|
}
|
|
}
|
|
|
|
/* --- HKDF-SHA256 --- */
|
|
|
|
/**
|
|
* HKDF-SHA256 extract + expand.
|
|
* @param {Uint8Array} ikm - input key material
|
|
* @param {Uint8Array|null} salt - optional salt
|
|
* @param {Uint8Array} info - context info
|
|
* @param {number} length - output length in bytes
|
|
* @returns {Uint8Array|Promise<Uint8Array>}
|
|
*/
|
|
function hkdf_sha256(ikm, salt, info, length) {
|
|
if (IS_BROWSER) {
|
|
return crypto.subtle.importKey(
|
|
'raw', ikm, 'HKDF', false, ['deriveBits']
|
|
).then(function(cryptoKey) {
|
|
return crypto.subtle.deriveBits(
|
|
{ name: 'HKDF', hash: 'SHA-256', salt: salt || new Uint8Array(0), info: info },
|
|
cryptoKey, length * 8
|
|
);
|
|
}).then(function(bits) {
|
|
return new Uint8Array(bits);
|
|
});
|
|
} else {
|
|
return native_hkdf_sha256(ikm, salt, info, length);
|
|
}
|
|
}
|
|
|
|
/* --- Field encryption/decryption --- */
|
|
|
|
/**
|
|
* Encrypt a field value.
|
|
* Key length determines tier: 16 bytes = L2 (AES-128), 32 bytes = L3 (AES-256).
|
|
* @param {Uint8Array} key - 16 or 32 bytes
|
|
* @param {string} field_label - field label (for per-field key derivation)
|
|
* @param {string} plaintext - field value to encrypt
|
|
* @returns {string|Promise<string>} base64-encoded ciphertext
|
|
*/
|
|
/*
|
|
* Normalize key for AES: 8-byte keys are doubled to 16 bytes.
|
|
* AES requires 16, 24, or 32 byte keys.
|
|
* HKDF output length matches the (normalized) key length.
|
|
*/
|
|
function normalize_key(key) {
|
|
if (key.length === 8) {
|
|
var doubled = new Uint8Array(16);
|
|
doubled.set(key, 0);
|
|
doubled.set(key, 8);
|
|
return doubled;
|
|
}
|
|
return key;
|
|
}
|
|
|
|
function encrypt_field(key, field_label, plaintext) {
|
|
var info_str = 'vault1984-field-' + field_label;
|
|
var nkey = normalize_key(key);
|
|
var aes_len = nkey.length; /* 16 or 32 */
|
|
|
|
if (IS_BROWSER) {
|
|
var enc = new TextEncoder();
|
|
var info = enc.encode(info_str);
|
|
return hkdf_sha256(nkey, null, info, aes_len).then(function(field_key) {
|
|
return aes_gcm_encrypt(field_key, enc.encode(plaintext));
|
|
}).then(function(ct) {
|
|
return uint8_to_base64(ct);
|
|
});
|
|
} else {
|
|
var info = native_encode_utf8(info_str);
|
|
var field_key = native_hkdf_sha256(nkey, null, info, aes_len);
|
|
var pt_bytes = native_encode_utf8(plaintext);
|
|
var ct = native_aes_gcm_encrypt(field_key, pt_bytes);
|
|
return native_base64_encode(ct);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decrypt a field value.
|
|
* Key length determines tier: 16 bytes = L2, 32 bytes = L3.
|
|
* @param {Uint8Array} key - 16 or 32 bytes
|
|
* @param {string} field_label - field label
|
|
* @param {string} ciphertext_b64 - base64-encoded ciphertext
|
|
* @returns {string|Promise<string>} plaintext
|
|
*/
|
|
function decrypt_field(key, field_label, ciphertext_b64) {
|
|
var info_str = 'vault1984-field-' + field_label;
|
|
var nkey = normalize_key(key);
|
|
var aes_len = nkey.length;
|
|
|
|
if (IS_BROWSER) {
|
|
var enc = new TextEncoder();
|
|
var dec = new TextDecoder();
|
|
var info = enc.encode(info_str);
|
|
return hkdf_sha256(nkey, null, info, aes_len).then(function(field_key) {
|
|
var ct = base64_to_uint8(ciphertext_b64);
|
|
return aes_gcm_decrypt(field_key, ct);
|
|
}).then(function(pt) {
|
|
return dec.decode(pt);
|
|
});
|
|
} else {
|
|
var info = native_encode_utf8(info_str);
|
|
var field_key = native_hkdf_sha256(nkey, null, info, aes_len);
|
|
var ct = native_base64_decode(ciphertext_b64);
|
|
var pt = native_aes_gcm_decrypt_blob(field_key, ct);
|
|
return native_decode_utf8(pt);
|
|
}
|
|
}
|
|
|
|
/* Backward compat aliases */
|
|
function l2_encrypt_field(key, entry_id, label, pt) { return encrypt_field(key, label, pt); }
|
|
function l2_decrypt_field(key, entry_id, label, ct) { return decrypt_field(key, label, ct); }
|
|
|
|
/* Export for both environments */
|
|
if (typeof globalThis.vault1984 === 'undefined') globalThis.vault1984 = {};
|
|
globalThis.vault1984.crypto = {
|
|
aes_gcm_encrypt: aes_gcm_encrypt,
|
|
aes_gcm_decrypt: aes_gcm_decrypt,
|
|
hkdf_sha256: hkdf_sha256,
|
|
encrypt_field: encrypt_field,
|
|
decrypt_field: decrypt_field,
|
|
l2_encrypt_field: l2_encrypt_field,
|
|
l2_decrypt_field: l2_decrypt_field,
|
|
uint8_to_base64: uint8_to_base64,
|
|
base64_to_uint8: base64_to_uint8
|
|
};
|