From 5973d71e2f41ce65d7f3f9e92aae1769e530721a Mon Sep 17 00:00:00 2001 From: Sergey Mosin Date: Mon, 9 Dec 2024 11:18:41 -0500 Subject: [PATCH] Add an option to store(and delete) encrypted private key passphrase in the browser across multiple sessions --- dev/App/User.js | 3 +- dev/External/ko.js | 4 +- dev/Storage/Passphrases.js | 144 +++++++++++++++++- dev/Stores/User/GnuPG.js | 8 + dev/Stores/User/OpenPGP.js | 9 ++ dev/View/Popup/Ask.js | 12 +- dev/View/Popup/Compose.js | 1 + .../v/0.0.0/app/localization/de/user.json | 5 +- .../v/0.0.0/app/localization/en/user.json | 5 +- .../v/0.0.0/app/localization/nl/user.json | 5 +- .../v/0.0.0/app/localization/ru/user.json | 5 +- .../app/templates/Views/Common/PopupsAsk.html | 16 +- .../Views/User/SettingsSecurity.html | 28 ++++ 13 files changed, 227 insertions(+), 18 deletions(-) diff --git a/dev/App/User.js b/dev/App/User.js index 7e87ebffb1..5e28164ac9 100644 --- a/dev/App/User.js +++ b/dev/App/User.js @@ -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, diff --git a/dev/External/ko.js b/dev/External/ko.js index d5837d6395..d783ae1dfe 100644 --- a/dev/External/ko.js +++ b/dev/External/ko.js @@ -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] }); }; diff --git a/dev/Storage/Passphrases.js b/dev/Storage/Passphrases.js index 079c567ddf..0242ddd451 100644 --- a/dev/Storage/Passphrases.js +++ b/dev/Storage/Passphrases.js @@ -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'] + ); + }); +}; diff --git a/dev/Stores/User/GnuPG.js b/dev/Stores/User/GnuPG.js index 3b9c857857..f70b9a3c39 100644 --- a/dev/Stores/User/GnuPG.js +++ b/dev/Stores/User/GnuPG.js @@ -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', @@ -69,6 +76,7 @@ export const GnuPGUserStore = new class { isPrivate: isPrivate } ); + isPrivate && key.forgetPass(); } }; if (isPrivate) { diff --git a/dev/Stores/User/OpenPGP.js b/dev/Stores/User/OpenPGP.js index a4437edc15..d4488c760d 100644 --- a/dev/Stores/User/OpenPGP.js +++ b/dev/Stores/User/OpenPGP.js @@ -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() } @@ -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); @@ -133,6 +137,11 @@ class OpenPgpKeyModel { ); } } + + forgetPass() { + Passphrases.delete(this); + this.hasStoredPass(false); + } /* toJSON() { return this.armor; diff --git a/dev/View/Popup/Ask.js b/dev/View/Popup/Ask.js index 8d7c7bca0c..8392dfcbd0 100644 --- a/dev/View/Popup/Ask.js +++ b/dev/View/Popup/Ask.js @@ -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; @@ -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; diff --git a/dev/View/Popup/Compose.js b/dev/View/Popup/Compose.js index d6eca1baca..015905f147 100644 --- a/dev/View/Popup/Compose.js +++ b/dev/View/Popup/Compose.js @@ -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]) { diff --git a/snappymail/v/0.0.0/app/localization/de/user.json b/snappymail/v/0.0.0/app/localization/de/user.json index fcc16a6872..7a24d6341b 100644 --- a/snappymail/v/0.0.0/app/localization/de/user.json +++ b/snappymail/v/0.0.0/app/localization/de/user.json @@ -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?", @@ -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", diff --git a/snappymail/v/0.0.0/app/localization/en/user.json b/snappymail/v/0.0.0/app/localization/en/user.json index ef8da14527..bedf958901 100644 --- a/snappymail/v/0.0.0/app/localization/en/user.json +++ b/snappymail/v/0.0.0/app/localization/en/user.json @@ -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?", @@ -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", diff --git a/snappymail/v/0.0.0/app/localization/nl/user.json b/snappymail/v/0.0.0/app/localization/nl/user.json index e94df562b6..36e8cbd3d9 100644 --- a/snappymail/v/0.0.0/app/localization/nl/user.json +++ b/snappymail/v/0.0.0/app/localization/nl/user.json @@ -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?", @@ -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", diff --git a/snappymail/v/0.0.0/app/localization/ru/user.json b/snappymail/v/0.0.0/app/localization/ru/user.json index 2a0a16c182..285ddba9e4 100644 --- a/snappymail/v/0.0.0/app/localization/ru/user.json +++ b/snappymail/v/0.0.0/app/localization/ru/user.json @@ -27,6 +27,8 @@ "USERNAME": "Имя пользователя", "PASSWORD": "Пароль", "REMEMBER": "Запомнить", + "REMEMBER_FOR_SESSION": "Запомнить Временное", + "REMEMBER_PERMANENT": "Запомнить Навсегда", "REPLY_TO": "Ответить-на", "SAVE": "Сохранить", "SAVE_CHANGES": "Сохранить изменения?", @@ -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": "Язык", diff --git a/snappymail/v/0.0.0/app/templates/Views/Common/PopupsAsk.html b/snappymail/v/0.0.0/app/templates/Views/Common/PopupsAsk.html index b8f08dae16..8c5e2a30aa 100644 --- a/snappymail/v/0.0.0/app/templates/Views/Common/PopupsAsk.html +++ b/snappymail/v/0.0.0/app/templates/Views/Common/PopupsAsk.html @@ -9,13 +9,23 @@ -
+
+
+