clavitor/clavis/clavis-vault/cmd/clavitor/web/crypto.js

220 lines
7.2 KiB
JavaScript

/*
* clavitor — 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 = 'clavitor-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 = 'clavitor-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.clavitor === 'undefined') globalThis.clavitor = {};
globalThis.clavitor.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
};