feat(security): encrypt profile passwords at rest with AES-GCM
ci / test (push) Successful in 57s
ci / test (pull_request) Successful in 52s

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:
2026-08-20 12:27:57 +08:00
parent eb7087d506
commit 7adad7cbc8
4 changed files with 103 additions and 13 deletions
+14 -1
View File
@@ -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');