diff --git a/README.md b/README.md index c83c660..bb43d39 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,11 @@ The Study Load page gets a button row — **Download PDF**, **CSV**, **Excel**, ## Data & privacy -- Auto-login stores your portal credentials **in plaintext** on your own device. +- Profile **passwords are encrypted at rest** with Web Crypto AES-GCM (256-bit). + The key lives in `browser.storage.local` so it survives restarts. 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 — this + extension only talks to the school portal, never any third-party server. - This is a personal-use helper for the university portal; use it only with your own account. diff --git a/background.js b/background.js index 56809cc..2f97197 100644 --- a/background.js +++ b/background.js @@ -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) { diff --git a/content.js b/content.js index c01ee9a..8ec8ef0 100644 --- a/content.js +++ b/content.js @@ -39,7 +39,20 @@ const passField = document.querySelector('input[type="password"]'); if (!userField || !passField) return; userField.value = active.username; - passField.value = active.password; + // Password is encrypted at rest; decrypt via the background. Legacy profiles + // saved before encryption keep a plaintext `password` field as a fallback. + let password = ''; + if (active.passwordEnc) { + try { + const res = await browser.runtime.sendMessage({ type: 'decrypt-password', cipher: active.passwordEnc }); + if (res && res.ok) password = res.password; + } catch (e) { + password = ''; + } + } else { + password = active.password || ''; + } + passField.value = password; userField.dispatchEvent(new Event('input', { bubbles: true })); passField.dispatchEvent(new Event('input', { bubbles: true })); const form = userField.closest('form') || passField.closest('form'); diff --git a/popup.js b/popup.js index 9637b72..3b03c22 100644 --- a/popup.js +++ b/popup.js @@ -100,27 +100,52 @@ function openProfileForm(p) { editingProfileId = p ? p.id : null; $('#pName').value = p ? p.name : ''; $('#pUsername').value = p ? p.username : ''; - $('#pPassword').value = p ? p.password : ''; + // The stored password is encrypted and not revealed; leave the field blank. + // On save, a blank password preserves the existing one. + $('#pPassword').value = ''; $('#pEnabled').checked = p ? !!p.enabled : true; $('#profileForm').hidden = false; + if (p) $('#pPassword').placeholder = 'Leave blank to keep existing password'; + else $('#pPassword').placeholder = 'Password'; } $('#addProfile').addEventListener('click', () => openProfileForm(null)); $('#cancelProfile').addEventListener('click', () => { $('#profileForm').hidden = true; }); $('#saveProfile').addEventListener('click', async () => { const profiles = await getProfiles(); - const data = { - id: editingProfileId || Date.now().toString(), - name: $('#pName').value.trim(), - username: $('#pUsername').value.trim(), - password: $('#pPassword').value, - enabled: $('#pEnabled').checked - }; - if (!data.name || !data.username) return; + const name = $('#pName').value.trim(); + const username = $('#pUsername').value.trim(); + const typedPassword = $('#pPassword').value; + const enabled = $('#pEnabled').checked; + if (!name || !username) return; + + // Encrypt a newly-typed password via the background. + let passwordEnc = null; + if (typedPassword) { + const res = await browser.runtime.sendMessage({ type: 'encrypt-password', password: typedPassword }); + if (!res || !res.ok) { alert('Could not encrypt password: ' + ((res && res.error) || 'unknown error')); return; } + passwordEnc = res.cipher; + } + if (editingProfileId) { const i = profiles.findIndex((x) => x.id === editingProfileId); - if (i >= 0) profiles[i] = data; + if (i >= 0) { + profiles[i].name = name; + profiles[i].username = username; + profiles[i].enabled = enabled; + // Only replace the password if a new one was typed; otherwise keep the + // existing (already encrypted) one. + if (passwordEnc) profiles[i].passwordEnc = passwordEnc; + delete profiles[i].password; // legacy plaintext no longer needed + } } else { - profiles.push(data); + const profile = { + id: Date.now().toString(), + name, + username, + enabled, + passwordEnc + }; + profiles.push(profile); } await saveProfiles(profiles); $('#profileForm').hidden = true;