feat(security): encrypt profile passwords at rest with AES-GCM
Passwords are now encrypted with Web Crypto AES-GCM (256-bit) before being stored in browser.storage.local. The random key is persisted so it survives browser/service-worker restarts (otherwise old ciphertext could never be decrypted). Encryption/decryption happen in the background via encrypt-password/decrypt-password messages; autoLogin decrypts on the fly and falls back to a legacy plaintext password for pre-encryption profiles. The popup never reveals a stored password and preserves it when editing with a blank field. Removes the plaintext-password-at-rest smell flagged in the design review. Note: this is at-rest obfuscation-level security (the key ships with the extension); the extension only talks to the school portal.
This commit is contained in:
@@ -11,6 +11,18 @@ function errMsg(err) {
|
||||
}
|
||||
|
||||
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg && msg.type === 'encrypt-password') {
|
||||
encryptPassword(String(msg.password || ''))
|
||||
.then((cipher) => sendResponse({ ok: true, cipher }))
|
||||
.catch((err) => sendResponse({ ok: false, error: errMsg(err) }));
|
||||
return true;
|
||||
}
|
||||
if (msg && msg.type === 'decrypt-password') {
|
||||
decryptPassword(msg.cipher)
|
||||
.then((password) => sendResponse({ ok: true, password }))
|
||||
.catch((err) => sendResponse({ ok: false, error: errMsg(err) }));
|
||||
return true;
|
||||
}
|
||||
if (msg && msg.type === 'export') {
|
||||
runExport(msg.format, msg.data)
|
||||
.then(() => sendResponse({ ok: true }))
|
||||
@@ -25,6 +37,42 @@ browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Password encryption (at-rest) ----
|
||||
// AES-GCM with a random 256-bit key. The key is stored in browser.storage.local
|
||||
// so it survives browser/service-worker restarts (otherwise previously-encrypted
|
||||
// passwords could never be decrypted again). This removes the plaintext-password
|
||||
// smell from storage — it is NOT strong protection against an attacker who can
|
||||
// read both the storage and the extension source (the user's threat model is
|
||||
// only the school portal, no third-party exfiltration).
|
||||
const CRYPTO_KEY_STORE = 'ismisCryptoKey';
|
||||
|
||||
async function getCryptoKey() {
|
||||
const { [CRYPTO_KEY_STORE]: stored } = await browser.storage.local.get(CRYPTO_KEY_STORE);
|
||||
if (stored) {
|
||||
return crypto.subtle.importKey('raw', new Uint8Array(stored), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
||||
}
|
||||
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
||||
const raw = await crypto.subtle.exportKey('raw', key);
|
||||
await browser.storage.local.set({ [CRYPTO_KEY_STORE]: Array.from(new Uint8Array(raw)) });
|
||||
return key;
|
||||
}
|
||||
|
||||
async function encryptPassword(password) {
|
||||
const key = await getCryptoKey();
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const data = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv }, key, new TextEncoder().encode(password));
|
||||
return { iv: Array.from(iv), data: Array.from(new Uint8Array(data)) };
|
||||
}
|
||||
|
||||
async function decryptPassword(cipher) {
|
||||
if (!cipher || !cipher.iv || !cipher.data) throw new Error('Invalid encrypted password');
|
||||
const key = await getCryptoKey();
|
||||
const pt = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: new Uint8Array(cipher.iv) }, key, new Uint8Array(cipher.data));
|
||||
return new TextDecoder().decode(pt);
|
||||
}
|
||||
|
||||
// Generic downloader used by every export format. `filename` is used with
|
||||
// saveAs so the user picks the location, mirroring the PDF flow.
|
||||
async function downloadBytes(bytes, mime, filename, fallback) {
|
||||
|
||||
Reference in New Issue
Block a user