Add an option to store(and delete) encrypted private key passphrase in the browser across multiple sessions

This commit is contained in:
Sergey Mosin 2024-12-09 11:18:41 -05:00
parent 486acde2e3
commit 5973d71e2f
13 changed files with 227 additions and 18 deletions

View file

@ -260,7 +260,8 @@ AskPopupView.password = function(sAskDesc, btnText, ask) {
view => resolve({ view => resolve({
password:view.passphrase(), password:view.passphrase(),
username:/*ask & 2 ? */view.username(), username:/*ask & 2 ? */view.username(),
remember:/*ask & 4 ? */view.remember() remember:/*ask & 4 ? */view.remember(),
rememberPermanent: view.rememberPermanent(),
}), }),
() => resolve(null), () => resolve(null),
true, true,

4
dev/External/ko.js vendored
View file

@ -176,6 +176,6 @@ ko.extenders.falseTimeout = (target, option) => {
// functions // functions
ko.observable.fn.askDeleteHelper = function() { ko.observable.fn.askDeleteHelper = function(prop='askDelete') {
return this.extend({ falseTimeout: 3000, toggleSubscribeProperty: [this, 'askDelete'] }); return this.extend({ falseTimeout: 3000, toggleSubscribeProperty: [this, prop] });
}; };

View file

@ -1,21 +1,155 @@
import { AskPopupView } from 'View/Popup/Ask'; import { AskPopupView } from 'View/Popup/Ask';
import { SettingsUserStore } from 'Stores/User/Settings'; import { SettingsUserStore } from 'Stores/User/Settings';
import { SettingsGet } from '../Common/Globals';
import { isArray } from '../Common/Utils';
export const Passphrases = new WeakMap(); export const Passphrases = new WeakMap();
Passphrases.ask = async (key, sAskDesc, btnText) => Passphrases.ask = async (key, sAskDesc, btnText) => {
Passphrases.has(key) if (Passphrases.has(key)) {
? {password:Passphrases.handle(key)/*, remember:false*/} return { password: Passphrases.handle(key)/*, remember:false*/ };
: await AskPopupView.password(sAskDesc, btnText, 5); } 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 = {}; const timeouts = {};
// get/set accessor to control deletion after N minutes of inactivity // get/set accessor to control deletion after N minutes of inactivity
Passphrases.handle = (key, pass) => { Passphrases.handle = (key, pass) => {
const timeout = SettingsUserStore.keyPassForget(); const timeout = SettingsUserStore.keyPassForget();
if (timeout && !timeouts[key]) { 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); pass && Passphrases.set(key, pass);
timeout && timeouts[key](); timeout && timeouts[key]();
return Passphrases.get(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']
);
});
};

View file

@ -51,6 +51,13 @@ export const GnuPGUserStore = new class {
key.for = email => aEmails.includes(IDN.toASCII(email)); key.for = email => aEmails.includes(IDN.toASCII(email));
key.askDelete = ko.observable(false); key.askDelete = ko.observable(false);
key.openForDeletion = ko.observable(null).askDeleteHelper(); 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 = () => { key.remove = () => {
if (key.askDelete()) { if (key.askDelete()) {
Remote.request('GnupgDeleteKey', Remote.request('GnupgDeleteKey',
@ -69,6 +76,7 @@ export const GnuPGUserStore = new class {
isPrivate: isPrivate isPrivate: isPrivate
} }
); );
isPrivate && key.forgetPass();
} }
}; };
if (isPrivate) { if (isPrivate) {

View file

@ -91,6 +91,9 @@ class OpenPgpKeyModel {
this.armor = armor; this.armor = armor;
this.askDelete = ko.observable(false); this.askDelete = ko.observable(false);
this.openForDeletion = ko.observable(null).askDeleteHelper(); 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.getUserIDs()
// key.getPrimaryUser() // key.getPrimaryUser()
} }
@ -116,6 +119,7 @@ class OpenPgpKeyModel {
if (this.key.isPrivate()) { if (this.key.isPrivate()) {
OpenPGPUserStore.privateKeys.remove(this); OpenPGPUserStore.privateKeys.remove(this);
storeOpenPgpKeys(OpenPGPUserStore.privateKeys, privateKeysItem); storeOpenPgpKeys(OpenPGPUserStore.privateKeys, privateKeysItem);
this.forgetPass()
} else { } else {
OpenPGPUserStore.publicKeys.remove(this); OpenPGPUserStore.publicKeys.remove(this);
storeOpenPgpKeys(OpenPGPUserStore.publicKeys, publicKeysItem); storeOpenPgpKeys(OpenPGPUserStore.publicKeys, publicKeysItem);
@ -133,6 +137,11 @@ class OpenPgpKeyModel {
); );
} }
} }
forgetPass() {
Passphrases.delete(this);
this.hasStoredPass(false);
}
/* /*
toJSON() { toJSON() {
return this.armor; return this.armor;

View file

@ -15,10 +15,14 @@ export class AskPopupView extends AbstractViewPopup {
askUsername: false, askUsername: false,
passphrase: '', passphrase: '',
askPass: false, askPass: false,
remember: true, remember: true, // remember for session
askRemeber: false askRemeber: false,
rememberPermanent: false,
askRememberPermanent: false,
}); });
this.rememberPermanent.subscribe(value => value && this.remember(true));
this.fYesAction = null; this.fYesAction = null;
this.fNoAction = null; this.fNoAction = null;
@ -48,10 +52,12 @@ export class AskPopupView extends AbstractViewPopup {
this.askDesc(sAskDesc || ''); this.askDesc(sAskDesc || '');
this.askUsername(ask & 2); this.askUsername(ask & 2);
this.askPass(ask & 1); this.askPass(ask & 1);
this.askRemeber(ask & 4); this.askRemeber(ask & 4); // 0b0100
this.askRememberPermanent(ask & 0b1000)
this.username(''); this.username('');
this.passphrase(''); this.passphrase('');
this.remember(true); this.remember(true);
this.rememberPermanent(false);
this.yesButton(i18n(btnText || 'GLOBAL/YES')); this.yesButton(i18n(btnText || 'GLOBAL/YES'));
this.noButton(i18n(ask ? 'GLOBAL/CANCEL' : 'GLOBAL/NO')); this.noButton(i18n(ask ? 'GLOBAL/CANCEL' : 'GLOBAL/NO'));
this.fYesAction = fYesFunc; this.fYesAction = fYesFunc;

View file

@ -1592,6 +1592,7 @@ export class ComposePopupView extends AbstractViewPopup {
*/ */
break; break;
} catch (e) { } catch (e) {
Passphrases.delete(signOptions[i][1])
console.error(e); console.error(e);
} }
} else if ('GnuPG' == signOptions[i][0]) { } else if ('GnuPG' == signOptions[i][0]) {

View file

@ -27,6 +27,8 @@
"USERNAME": "Nutzername", "USERNAME": "Nutzername",
"PASSWORD": "Passwort", "PASSWORD": "Passwort",
"REMEMBER": "Zugangsdaten merken", "REMEMBER": "Zugangsdaten merken",
"REMEMBER_FOR_SESSION": "Aktuelle Sitzung merken",
"REMEMBER_PERMANENT": "Dauerhaft merken",
"REPLY_TO": "Antwort an", "REPLY_TO": "Antwort an",
"SAVE": "Speichern", "SAVE": "Speichern",
"SAVE_CHANGES": "Änderungen speichern?", "SAVE_CHANGES": "Änderungen speichern?",
@ -458,7 +460,8 @@
"LEGEND_SECURITY": "Sicherheit", "LEGEND_SECURITY": "Sicherheit",
"LABEL_AUTOLOGOUT": "Automatische Abmeldung", "LABEL_AUTOLOGOUT": "Automatische Abmeldung",
"FORGET_KEY_PASS": "Passphrase für privaten Schlüssel vergessen", "FORGET_KEY_PASS": "Passphrase für privaten Schlüssel vergessen",
"NEVER": "Nie" "NEVER": "Nie",
"LABEL_STORED_PASS": "Passphrase gespeichert"
}, },
"SETTINGS_GENERAL": { "SETTINGS_GENERAL": {
"LANGUAGE": "Sprache", "LANGUAGE": "Sprache",

View file

@ -27,6 +27,8 @@
"USERNAME": "Username", "USERNAME": "Username",
"PASSWORD": "Passphrase", "PASSWORD": "Passphrase",
"REMEMBER": "Remember", "REMEMBER": "Remember",
"REMEMBER_FOR_SESSION": "Remember for this Session",
"REMEMBER_PERMANENT": "Remember Permanently",
"REPLY_TO": "Reply-To", "REPLY_TO": "Reply-To",
"SAVE": "Save", "SAVE": "Save",
"SAVE_CHANGES": "Save changes?", "SAVE_CHANGES": "Save changes?",
@ -458,7 +460,8 @@
"LEGEND_SECURITY": "Security", "LEGEND_SECURITY": "Security",
"LABEL_AUTOLOGOUT": "Auto Logout", "LABEL_AUTOLOGOUT": "Auto Logout",
"FORGET_KEY_PASS": "Forget private key passphrase", "FORGET_KEY_PASS": "Forget private key passphrase",
"NEVER": "Never" "NEVER": "Never",
"LABEL_STORED_PASS": "Remembered Passphrase"
}, },
"SETTINGS_GENERAL": { "SETTINGS_GENERAL": {
"LANGUAGE": "Language", "LANGUAGE": "Language",

View file

@ -27,6 +27,8 @@
"USERNAME": "Gebruikersnaam", "USERNAME": "Gebruikersnaam",
"PASSWORD": "Wachtwoord", "PASSWORD": "Wachtwoord",
"REMEMBER": "Onthouden", "REMEMBER": "Onthouden",
"REMEMBER_FOR_SESSION": "Onthoud voor deze sessie",
"REMEMBER_PERMANENT": "Onthoud permanent",
"REPLY_TO": "Antwoordadres", "REPLY_TO": "Antwoordadres",
"SAVE": "Opslaan", "SAVE": "Opslaan",
"SAVE_CHANGES": "Wijzigingen opslaan?", "SAVE_CHANGES": "Wijzigingen opslaan?",
@ -458,7 +460,8 @@
"LEGEND_SECURITY": "Beveiliging", "LEGEND_SECURITY": "Beveiliging",
"LABEL_AUTOLOGOUT": "Automatisch uitloggen", "LABEL_AUTOLOGOUT": "Automatisch uitloggen",
"FORGET_KEY_PASS": "Vergeet privésleutel wachtwoord", "FORGET_KEY_PASS": "Vergeet privésleutel wachtwoord",
"NEVER": "Nooit" "NEVER": "Nooit",
"LABEL_STORED_PASS": "Wachtwoordzin opgeslagen"
}, },
"SETTINGS_GENERAL": { "SETTINGS_GENERAL": {
"LANGUAGE": "Taal", "LANGUAGE": "Taal",

View file

@ -27,6 +27,8 @@
"USERNAME": "Имя пользователя", "USERNAME": "Имя пользователя",
"PASSWORD": "Пароль", "PASSWORD": "Пароль",
"REMEMBER": "Запомнить", "REMEMBER": "Запомнить",
"REMEMBER_FOR_SESSION": "Запомнить Временное",
"REMEMBER_PERMANENT": "Запомнить Навсегда",
"REPLY_TO": "Ответить-на", "REPLY_TO": "Ответить-на",
"SAVE": "Сохранить", "SAVE": "Сохранить",
"SAVE_CHANGES": "Сохранить изменения?", "SAVE_CHANGES": "Сохранить изменения?",
@ -458,7 +460,8 @@
"LEGEND_SECURITY": "Безопасность", "LEGEND_SECURITY": "Безопасность",
"LABEL_AUTOLOGOUT": "Автоматический выход", "LABEL_AUTOLOGOUT": "Автоматический выход",
"FORGET_KEY_PASS": "Forget private key passphrase", "FORGET_KEY_PASS": "Forget private key passphrase",
"NEVER": "Никогда" "NEVER": "Никогда",
"LABEL_STORED_PASS": "Remembered Passphrase"
}, },
"SETTINGS_GENERAL": { "SETTINGS_GENERAL": {
"LANGUAGE": "Язык", "LANGUAGE": "Язык",

View file

@ -9,13 +9,23 @@
<label data-i18n="GLOBAL/PASSWORD"></label> <label data-i18n="GLOBAL/PASSWORD"></label>
<input type="password" data-bind="value: passphrase"> <input type="password" data-bind="value: passphrase">
</div> </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', name: 'Checkbox',
params: { params: {
label: 'GLOBAL/REMEMBER', label: 'GLOBAL/REMEMBER_FOR_SESSION',
value: remember value: remember,
enable: !rememberPermanent()
}
}"></div>
<div data-bind="visible: askRememberPermanent, component: {
name: 'Checkbox',
params: {
label: 'GLOBAL/REMEMBER_PERMANENT',
value: rememberPermanent
} }
}"></div> }"></div>
</div>
</form> </form>
</div> </div>
<footer> <footer>

View file

@ -58,6 +58,20 @@
<span class="delete-key fontastic" data-bind="visible: !askDelete(), click: openForDeletion">🗑</span> <span class="delete-key fontastic" data-bind="visible: !askDelete(), click: openForDeletion">🗑</span>
</td> </td>
</tr> </tr>
<tr data-bind="visible: hasStoredPass">
<td>
<span class="fontastic" data-i18n="[title]SETTINGS_OPENPGP/TITLE_PRIVATE">🔒</span>
<span class="key-id" data-bind="text: id"></span>
<span>🔑</span><span class="key-user" data-i18n="SETTINGS_SECURITY/LABEL_STORED_PASS"></span>
</td>
<td>
<a class="btn btn-small btn-danger button-confirm-delete" data-bind="css: {'delete-access': askForgetPass()}, click: forgetPass"
data-i18n="GLOBAL/ARE_YOU_SURE"></a>
</td>
<td>
<span class="delete-key fontastic" data-bind="visible: !askForgetPass(), click: openForPassForget">🗑</span>
</td>
</tr>
</tbody> </tbody>
<tbody><tr><th colspan="4" data-i18n="SETTINGS_OPENPGP/TITLE_PUBLIC">Public keys</th></tr></tbody> <tbody><tr><th colspan="4" data-i18n="SETTINGS_OPENPGP/TITLE_PUBLIC">Public keys</th></tr></tbody>
<tbody data-bind="foreach: gnupgPublicKeys, i18nUpdate: gnupgPublicKeys"> <tbody data-bind="foreach: gnupgPublicKeys, i18nUpdate: gnupgPublicKeys">
@ -105,6 +119,20 @@
<span class="delete-key fontastic" data-bind="visible: !askDelete(), click: openForDeletion">🗑</span> <span class="delete-key fontastic" data-bind="visible: !askDelete(), click: openForDeletion">🗑</span>
</td> </td>
</tr> </tr>
<tr data-bind="visible: hasStoredPass">
<td>
<span class="fontastic" data-i18n="[title]SETTINGS_OPENPGP/TITLE_PRIVATE">🔒</span>
<span class="key-id" data-bind="text: id"></span>
<span>🔑</span><span class="key-user" data-i18n="SETTINGS_SECURITY/LABEL_STORED_PASS"></span>
</td>
<td>
<a class="btn btn-small btn-danger button-confirm-delete" data-bind="css: {'delete-access': askForgetPass()}, click: forgetPass"
data-i18n="GLOBAL/ARE_YOU_SURE"></a>
</td>
<td>
<span class="delete-key fontastic" data-bind="visible: !askForgetPass(), click: openForPassForget">🗑</span>
</td>
</tr>
</tbody> </tbody>
<tbody><tr><th colspan="4" data-i18n="SETTINGS_OPENPGP/TITLE_PUBLIC">Public keys</th></tr></tbody> <tbody><tr><th colspan="4" data-i18n="SETTINGS_OPENPGP/TITLE_PUBLIC">Public keys</th></tr></tbody>
<tbody data-bind="foreach: openpgpkeysPublic, i18nUpdate: openpgpkeysPublic"> <tbody data-bind="foreach: openpgpkeysPublic, i18nUpdate: openpgpkeysPublic">