Skip to content

Commit

Permalink
Add an option to store(and delete) encrypted private key passphrase i…
Browse files Browse the repository at this point in the history
…n the browser across multiple sessions
  • Loading branch information
SergeyMosin committed Dec 9, 2024
1 parent 486acde commit 5973d71
Show file tree
Hide file tree
Showing 13 changed files with 227 additions and 18 deletions.
3 changes: 2 additions & 1 deletion dev/App/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,8 @@ AskPopupView.password = function(sAskDesc, btnText, ask) {
view => resolve({
password:view.passphrase(),
username:/*ask & 2 ? */view.username(),
remember:/*ask & 4 ? */view.remember()
remember:/*ask & 4 ? */view.remember(),
rememberPermanent: view.rememberPermanent(),
}),
() => resolve(null),
true,
Expand Down
4 changes: 2 additions & 2 deletions dev/External/ko.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,6 @@ ko.extenders.falseTimeout = (target, option) => {

// functions

ko.observable.fn.askDeleteHelper = function() {
return this.extend({ falseTimeout: 3000, toggleSubscribeProperty: [this, 'askDelete'] });
ko.observable.fn.askDeleteHelper = function(prop='askDelete') {
return this.extend({ falseTimeout: 3000, toggleSubscribeProperty: [this, prop] });
};
144 changes: 139 additions & 5 deletions dev/Storage/Passphrases.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,155 @@
import { AskPopupView } from 'View/Popup/Ask';
import { SettingsUserStore } from 'Stores/User/Settings';
import { SettingsGet } from '../Common/Globals';
import { isArray } from '../Common/Utils';

export const Passphrases = new WeakMap();

Passphrases.ask = async (key, sAskDesc, btnText) =>
Passphrases.has(key)
? {password:Passphrases.handle(key)/*, remember:false*/}
: await AskPopupView.password(sAskDesc, btnText, 5);
Passphrases.ask = async (key, sAskDesc, btnText) => {
if (Passphrases.has(key)) {
return { password: Passphrases.handle(key)/*, remember:false*/ };
} else if (Passphrases.hasInLocalStorage(key)) {
return { password: await getFromLocalStorage(key) };
} else {
const pass = await AskPopupView.password(sAskDesc, btnText,
window.crypto.subtle && canUseLocalStorage(key) ? 0b1101 : 0b0101);
pass.rememberPermanent && await saveToLocalStorage(key, pass.password);
return pass;
}
};

Passphrases._deleteFromSession = Passphrases.delete;

const timeouts = {};
// get/set accessor to control deletion after N minutes of inactivity
Passphrases.handle = (key, pass) => {
const timeout = SettingsUserStore.keyPassForget();
if (timeout && !timeouts[key]) {
timeouts[key] = (()=>Passphrases.delete(key)).debounce(timeout * 60 * 1000);
timeouts[key] = (() => Passphrases._deleteFromSession(key)).debounce(timeout * 60 * 1000);
}
pass && Passphrases.set(key, pass);
timeout && timeouts[key]();
return Passphrases.get(key);
};

const deleteFromLocalStorage = (key) => {
const keyId = getKeyId(key);
if (keyId) {
localStorage.removeItem(keyId);
}
};
Passphrases.delete = (key) => {
deleteFromLocalStorage(key);
return Passphrases._deleteFromSession(key);
};

Passphrases.hasInLocalStorage = (key) => {
const keyId = getKeyId(key);
return keyId && localStorage.getItem(keyId) !== null;
};

const saveToLocalStorage = async (key, pass) => {
const keyId = getKeyId(key);
if (!keyId) {
return;
}

if (!pass) {
localStorage.removeItem(keyId);
return;
}

try {
const salt = window.crypto.getRandomValues(new Uint8Array(16));
const derivedKey = await deriveKeyFromHash(SettingsGet('accountHash'), salt);

const iv = window.crypto.getRandomValues(new Uint8Array(12));
const encrypted = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv },
derivedKey,
new TextEncoder().encode(pass)
);
localStorage.setItem(keyId, JSON.stringify([
btoa(String.fromCharCode.apply(null, salt)),
btoa(String.fromCharCode.apply(null, iv)),
btoa(String.fromCharCode.apply(null, new Uint8Array(encrypted)))
]));
} catch (e) {
console.error('Passphrases.saveToLocalStorage failed', e);
}
};

const getFromLocalStorage = async (key) => {
const keyId = getKeyId(key);
if (!keyId) {
return undefined;
}

const jsonData = localStorage.getItem(keyId);
if (!jsonData) {
console.error('Passphrases.getFromLocalStorage failed: no data found');
return undefined;
}

try {
const saltIvData = JSON.parse(jsonData);
if (!saltIvData || !isArray(saltIvData) || saltIvData.length !== 3) {
// noinspection ExceptionCaughtLocallyJS
throw new Error('invalid passphrase data');
}
const toUint8 = (str) => new Uint8Array(atob(str).split('').map(c => c.charCodeAt(0)));

const derivedKey = await deriveKeyFromHash(SettingsGet('accountHash'), toUint8(saltIvData[0]));
const decrypted = await window.crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: toUint8(saltIvData[1])
},
derivedKey,
toUint8(saltIvData[2])
);
return String.fromCharCode.apply(null, new Uint8Array(decrypted));
} catch (e) {
localStorage.removeItem(keyId);
console.error('Passphrases.getFromLocalStorage failed', e);
return undefined;
}
};

const canUseLocalStorage = (key) => getKeyId(key) !== undefined;

const getKeyId = (key) => {
if (key && typeof key.id === 'string' && key.id.length > 4 && typeof key.forgetPass === 'function') {
// only deal with keys that we can forget (OpenPGB, GnuPG)
return key.id + '_local_key';
} else {
console.info('Passphrases.getKeyId: unsupported key type');
return undefined;
}
};

const deriveKeyFromHash = async (hash, salt) => {
if (!hash) {
throw new Error('empty accountHash');
}
return window.crypto.subtle.importKey(
'raw',
new TextEncoder().encode(hash),
{ 'name': 'PBKDF2' },
false,
['deriveKey']
).then(keyMaterial => {
return window.crypto.subtle.deriveKey(
{
'name': 'PBKDF2',
'salt': salt,
'iterations': 512,
'hash': 'SHA-256'
},
keyMaterial,
{ 'name': 'AES-GCM', 'length': 256 },
false,
['encrypt', 'decrypt']
);
});
};
8 changes: 8 additions & 0 deletions dev/Stores/User/GnuPG.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ export const GnuPGUserStore = new class {
key.for = email => aEmails.includes(IDN.toASCII(email));
key.askDelete = ko.observable(false);
key.openForDeletion = ko.observable(null).askDeleteHelper();
key.askForgetPass = ko.observable(false);
key.openForPassForget = ko.observable(null).askDeleteHelper('askForgetPass');
key.forgetPass = () => {
Passphrases.delete(key);
key.hasStoredPass(false);
};
key.hasStoredPass = ko.observable(Passphrases.hasInLocalStorage(key));
key.remove = () => {
if (key.askDelete()) {
Remote.request('GnupgDeleteKey',
Expand All @@ -69,6 +76,7 @@ export const GnuPGUserStore = new class {
isPrivate: isPrivate
}
);
isPrivate && key.forgetPass();
}
};
if (isPrivate) {
Expand Down
9 changes: 9 additions & 0 deletions dev/Stores/User/OpenPGP.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ class OpenPgpKeyModel {
this.armor = armor;
this.askDelete = ko.observable(false);
this.openForDeletion = ko.observable(null).askDeleteHelper();
this.hasStoredPass = ko.observable(Passphrases.hasInLocalStorage(this));
this.askForgetPass = ko.observable(false);
this.openForPassForget = ko.observable(null).askDeleteHelper('askForgetPass');
// key.getUserIDs()
// key.getPrimaryUser()
}
Expand All @@ -116,6 +119,7 @@ class OpenPgpKeyModel {
if (this.key.isPrivate()) {
OpenPGPUserStore.privateKeys.remove(this);
storeOpenPgpKeys(OpenPGPUserStore.privateKeys, privateKeysItem);
this.forgetPass()
} else {
OpenPGPUserStore.publicKeys.remove(this);
storeOpenPgpKeys(OpenPGPUserStore.publicKeys, publicKeysItem);
Expand All @@ -133,6 +137,11 @@ class OpenPgpKeyModel {
);
}
}

forgetPass() {
Passphrases.delete(this);
this.hasStoredPass(false);
}
/*
toJSON() {
return this.armor;
Expand Down
12 changes: 9 additions & 3 deletions dev/View/Popup/Ask.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,14 @@ export class AskPopupView extends AbstractViewPopup {
askUsername: false,
passphrase: '',
askPass: false,
remember: true,
askRemeber: false
remember: true, // remember for session
askRemeber: false,
rememberPermanent: false,
askRememberPermanent: false,
});

this.rememberPermanent.subscribe(value => value && this.remember(true));

this.fYesAction = null;
this.fNoAction = null;

Expand Down Expand Up @@ -48,10 +52,12 @@ export class AskPopupView extends AbstractViewPopup {
this.askDesc(sAskDesc || '');
this.askUsername(ask & 2);
this.askPass(ask & 1);
this.askRemeber(ask & 4);
this.askRemeber(ask & 4); // 0b0100
this.askRememberPermanent(ask & 0b1000)
this.username('');
this.passphrase('');
this.remember(true);
this.rememberPermanent(false);
this.yesButton(i18n(btnText || 'GLOBAL/YES'));
this.noButton(i18n(ask ? 'GLOBAL/CANCEL' : 'GLOBAL/NO'));
this.fYesAction = fYesFunc;
Expand Down
1 change: 1 addition & 0 deletions dev/View/Popup/Compose.js
Original file line number Diff line number Diff line change
Expand Up @@ -1592,6 +1592,7 @@ export class ComposePopupView extends AbstractViewPopup {
*/
break;
} catch (e) {
Passphrases.delete(signOptions[i][1])
console.error(e);
}
} else if ('GnuPG' == signOptions[i][0]) {
Expand Down
5 changes: 4 additions & 1 deletion snappymail/v/0.0.0/app/localization/de/user.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
"USERNAME": "Nutzername",
"PASSWORD": "Passwort",
"REMEMBER": "Zugangsdaten merken",
"REMEMBER_FOR_SESSION": "Aktuelle Sitzung merken",
"REMEMBER_PERMANENT": "Dauerhaft merken",
"REPLY_TO": "Antwort an",
"SAVE": "Speichern",
"SAVE_CHANGES": "Änderungen speichern?",
Expand Down Expand Up @@ -458,7 +460,8 @@
"LEGEND_SECURITY": "Sicherheit",
"LABEL_AUTOLOGOUT": "Automatische Abmeldung",
"FORGET_KEY_PASS": "Passphrase für privaten Schlüssel vergessen",
"NEVER": "Nie"
"NEVER": "Nie",
"LABEL_STORED_PASS": "Passphrase gespeichert"
},
"SETTINGS_GENERAL": {
"LANGUAGE": "Sprache",
Expand Down
5 changes: 4 additions & 1 deletion snappymail/v/0.0.0/app/localization/en/user.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
"USERNAME": "Username",
"PASSWORD": "Passphrase",
"REMEMBER": "Remember",
"REMEMBER_FOR_SESSION": "Remember for this Session",
"REMEMBER_PERMANENT": "Remember Permanently",
"REPLY_TO": "Reply-To",
"SAVE": "Save",
"SAVE_CHANGES": "Save changes?",
Expand Down Expand Up @@ -458,7 +460,8 @@
"LEGEND_SECURITY": "Security",
"LABEL_AUTOLOGOUT": "Auto Logout",
"FORGET_KEY_PASS": "Forget private key passphrase",
"NEVER": "Never"
"NEVER": "Never",
"LABEL_STORED_PASS": "Remembered Passphrase"
},
"SETTINGS_GENERAL": {
"LANGUAGE": "Language",
Expand Down
5 changes: 4 additions & 1 deletion snappymail/v/0.0.0/app/localization/nl/user.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
"USERNAME": "Gebruikersnaam",
"PASSWORD": "Wachtwoord",
"REMEMBER": "Onthouden",
"REMEMBER_FOR_SESSION": "Onthoud voor deze sessie",
"REMEMBER_PERMANENT": "Onthoud permanent",
"REPLY_TO": "Antwoordadres",
"SAVE": "Opslaan",
"SAVE_CHANGES": "Wijzigingen opslaan?",
Expand Down Expand Up @@ -458,7 +460,8 @@
"LEGEND_SECURITY": "Beveiliging",
"LABEL_AUTOLOGOUT": "Automatisch uitloggen",
"FORGET_KEY_PASS": "Vergeet privésleutel wachtwoord",
"NEVER": "Nooit"
"NEVER": "Nooit",
"LABEL_STORED_PASS": "Wachtwoordzin opgeslagen"
},
"SETTINGS_GENERAL": {
"LANGUAGE": "Taal",
Expand Down
5 changes: 4 additions & 1 deletion snappymail/v/0.0.0/app/localization/ru/user.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
"USERNAME": "Имя пользователя",
"PASSWORD": "Пароль",
"REMEMBER": "Запомнить",
"REMEMBER_FOR_SESSION": "Запомнить Временное",
"REMEMBER_PERMANENT": "Запомнить Навсегда",
"REPLY_TO": "Ответить-на",
"SAVE": "Сохранить",
"SAVE_CHANGES": "Сохранить изменения?",
Expand Down Expand Up @@ -458,7 +460,8 @@
"LEGEND_SECURITY": "Безопасность",
"LABEL_AUTOLOGOUT": "Автоматический выход",
"FORGET_KEY_PASS": "Forget private key passphrase",
"NEVER": "Никогда"
"NEVER": "Никогда",
"LABEL_STORED_PASS": "Remembered Passphrase"
},
"SETTINGS_GENERAL": {
"LANGUAGE": "Язык",
Expand Down
16 changes: 13 additions & 3 deletions snappymail/v/0.0.0/app/templates/Views/Common/PopupsAsk.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,23 @@
<label data-i18n="GLOBAL/PASSWORD"></label>
<input type="password" data-bind="value: passphrase">
</div>
<div data-bind="visible: askRemeber, component: {
<div class="control-group" style="display: inline-flex; align-items: start;flex-direction: column;">
<div data-bind="visible: askRemeber, component: {
name: 'Checkbox',
params: {
label: 'GLOBAL/REMEMBER',
value: remember
label: 'GLOBAL/REMEMBER_FOR_SESSION',
value: remember,
enable: !rememberPermanent()
}
}"></div>
<div data-bind="visible: askRememberPermanent, component: {
name: 'Checkbox',
params: {
label: 'GLOBAL/REMEMBER_PERMANENT',
value: rememberPermanent
}
}"></div>
</div>
</form>
</div>
<footer>
Expand Down
Loading

0 comments on commit 5973d71

Please sign in to comment.