fix(release): URL-encode release asset names #5

Closed
adrianbonpin wants to merge 6 commits from fix/release-asset-name-encoding into prod
4 changed files with 103 additions and 13 deletions
Showing only changes of commit 7adad7cbc8 - Show all commits
+5 -1
View File
@@ -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.
+48
View File
@@ -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) {
+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');
+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;