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
+36 -11
View File
@@ -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;