Refactor the code related to storing SCRAM keys

- No need to create a new storage mechanism, just use `persistent`.
- Store SCRAM keys per JID
- Upon succesfull login, store the current session JID, so that we know who to fetch SCRAM keys for
- Only store SCRAM keys when the device is trusted

Fixes #3001
This commit is contained in:
JC Brand 2022-12-27 21:48:17 +01:00
parent 8ab0b718de
commit 447fe8ba08
9 changed files with 136 additions and 169 deletions

View File

@ -5,8 +5,11 @@
- #326: Add the ability to reset your password
- #2816: Chat highlight behaves odd
- #2925: File upload is not always enabled
- #3001: Add option to save SCRAM details and to use them to stay logged in upon reload
- Add a "Add to Contacts" button in MUC occupant modals
- New config option [save_scram_keys](https://conversejs.org/docs/html/configuration.html#save-scram-keys)
## 10.0.0 (2022-10-30)
- Update to Strophe.js 1.6.0 which adds support for SCRAM-SHA-256 and SCRAM-SHA-512

View File

@ -32,6 +32,7 @@
auto_away: 300,
enable_smacks: true,
loglevel: 'debug',
reuse_scram_keys: true,
prune_messages_above: 100,
message_archiving: 'always',
muc_respect_autojoin: true,

View File

@ -407,48 +407,6 @@ in to their XMPP account.
So currently if EITHER ``keepalive`` or ``auto_login`` is ``true`` and
`authentication`_ is set to ``login``, then Converse will try to log the user in.
save_scram_keys
---------------
* Default: ``false``
Most XMPP servers enable the Salted Challenge Response Authentication Mechanism
or SCRAM for short. This allows the user and the server to mutually
authenticate *without* the need to transmit the user's password in plaintext.
Coincidentally, assuming the server does not alter the user's password or the
storage parameters, we can authenticate with the same SCRAM key multiple times.
This opens an opportunity: we can store the user's login credentials in the
browser without the need to store their sensitive plaintext password, or the
need to set up complicated third party backends, like oauth.
Enabling this option will cause converse to save the SCRAM keys on successful
login into browser storage. This information can be recovered from the public
API method ``converse.savedLoginInfo()``, which returns on success a Promise
which resolves to an object whose ``attributes`` object contains the following
information:
::
{ 'id': 'converse.savedLoginInfo',
'users': Usermap Object
}
Where the ``Usermap`` Object has keys corresponding to users and values
which are valid login credentials (which can be passed in as the
``password`` field on login), like so:
::
{ 'user1@xmpp.org': Credentials,
'user2@opkode.com': Credentials,
...
}
From here, one may configure their client to simply choose one of the logins,
depending on their needs, and pass the username and credentials into the
settings.
Note well that this method will only work once converse has been loaded.
If you need the utilities provided here before login, call
`window.converse.load()`.
auto_away
---------
@ -1861,6 +1819,27 @@ Based on the OGP metadata Converse will render a URL preview (also known as an
the ``show_images_inline``, ``embed_audio`` and ``embed_videos`` settings.
reuse_scram_keys
----------------
* Default: ``false``
Most XMPP servers enable the Salted Challenge Response Authentication Mechanism
or SCRAM for short. This allows the user and the server to mutually
authenticate *without* the need to transmit the user's password in plaintext.
Assuming the server does not alter the user's password or the
storage parameters, we can authenticate with the same SCRAM key multiple times.
This opens an opportunity: we can store the user's login credentials in the
browser without storing the sensitive plaintext password, or the
need to set up complicated third party backends, like OAuth.
Enabling this option will let Converse save a user's SCRAM keys upon successful
login, and next time Converse is loaded the user will be automatically logged in
with those SCRAM keys.
.. _`roomconfig_whitelist`:
roomconfig_whitelist

1
package-lock.json generated
View File

@ -18098,6 +18098,7 @@
}
},
"src/headless": {
"name": "@converse/headless",
"version": "10.0.0",
"dev": true,
"license": "MPL-2.0",

View File

@ -14,7 +14,6 @@ import log from '@converse/headless/log.js';
import pluggable from 'pluggable.js/src/pluggable.js';
import sizzle from 'sizzle';
import u, { setUnloadEvent, replacePromise } from '@converse/headless/utils/core.js';
import { initStorage } from './utils/storage.js';
import { CHAT_STATES, KEYCODES } from './shared/constants.js';
import { Collection } from "@converse/skeletor/src/collection";
import { Connection, MockConnection } from '@converse/headless/shared/connection/index.js';
@ -38,7 +37,6 @@ import {
initClientConfig,
initPlugins,
initSessionStorage,
initScramStorage,
registerGlobalEventHandlers,
setUserJID,
} from './utils/init.js';
@ -473,23 +471,7 @@ export const api = _converse.api = {
}
api.trigger('send', stanza);
return promise;
},
/**
* Fetch previously used login information, username and SCRAM keys if available
* @method _converse.api.savedLoginInfo
* @returns {Promise} A promise which resolves (or potentially rejects) once we
* fetch the previously used login keys.
*/
async savedLoginInfo () {
const id = "converse.savedLoginInfo";
const login_info = new Model({id});
initStorage(login_info, id, 'scramStorage');
await new Promise(f => login_info.fetch({'success': f, 'error': f}));
return login_info;
},
}
};
@ -693,38 +675,6 @@ Object.assign(converse, {
}
},
/**
* Fetch previously used login information, username and SCRAM keys if available
* @method _converse.api.getSavedLoginInfo
* @returns {Promise} A promise which resolves (or potentially rejects) once
* we fetch the previously used login keys. The object returned on success
* has an attributes object of the following form:
* { 'id': 'converse.savedLoginInfo',
* 'users': Usermap Object
* }
* Where the Usermap Object has keys corresponding to users and values
* which are valid login credentials (which can be passed in as the
* password field on login), like so:
* { 'user1@xmpp.org': Credentials,
* 'user2@opkode.com': Credentials,
* ...
* }
* It should be noted that these Credentials will *NEVER* store the user's
* plaintext password, nor any material from which the user's plaintext
* password could be recovered. It uses SASL SCRAM internally, which
* secures the user's login information and ensures* the authenticating
* server is the server which was supplied the credentials initially.
*
* *With some caveats, we don't yet actively protect against active MITM
* attacks.
*/
savedLoginInfo: async () => {
if (!_converse.storage) {
await initScramStorage(_converse);
}
return _converse.api.savedLoginInfo()
},
/**
* Exposes methods for adding and removing plugins. You'll need to write a plugin
* if you want to have access to the private API methods defined further down below.

View File

@ -181,6 +181,12 @@ export class Connection extends Strophe.Connection {
this.flush(); // Solves problem of returned PubSub BOSH response not received by browser
await setUserJID(this.jid);
// Save the current JID in persistent storage so that we can attempt to
// recreate the session from SCRAM keys
if (_converse.config.get('trusted')) {
localStorage.setItem('conversejs-session-jid', _converse.bare_jid);
}
/**
* Synchronous event triggered after we've sent an IQ to bind the
* user's JID resource for this session.

View File

@ -6,7 +6,7 @@
* @property { String } [assets_path='/dist']
* @property { ('login'|'prebind'|'anonymous'|'external') } [authentication='login']
* @property { Boolean } [auto_login=false] - Currently only used in connection with anonymous login
* @property { Boolean } [save_scram_keys=false] - Save SCRAM keys after login to allow for future auto login
* @property { Boolean } [reuse_scram_keys=false] - Save SCRAM keys after login to allow for future auto login
* @property { Boolean } [auto_reconnect=true]
* @property { Array<String>} [blacklisted_plugins]
* @property { Boolean } [clear_cache_on_logout=false]
@ -38,7 +38,7 @@ export const DEFAULT_SETTINGS = {
assets_path: '/dist',
authentication: 'login', // Available values are "login", "prebind", "anonymous" and "external".
auto_login: false, // Currently only used in connection with anonymous login
save_scram_keys: false,
reuse_scram_keys: false,
auto_reconnect: true,
blacklisted_plugins: [],
clear_cache_on_logout: false,

View File

@ -14,7 +14,6 @@ import sizzle from "sizzle";
import { Model } from '@converse/skeletor/src/model.js';
import { Strophe } from 'strophe.js/src/strophe.js';
import { getOpenPromise } from '@converse/openpromise';
import { setUserJID, } from '@converse/headless/utils/init.js';
import { settings_api } from '@converse/headless/shared/settings/api.js';
import { stx , toStanza } from './stanza.js';
@ -125,12 +124,12 @@ u.getLongestSubstring = function (string, candidates) {
return candidates.reduce(reducer, '');
}
u.isValidJID = function (jid) {
export function isValidJID (jid) {
if (typeof jid === 'string') {
return compact(jid.split('@')).length === 2 && !jid.startsWith('@') && !jid.endsWith('@');
}
return false;
};
}
u.isValidMUCJID = function (jid) {
return !jid.startsWith('@') && !jid.endsWith('@');
@ -553,18 +552,6 @@ export function setUnloadEvent () {
}
}
export async function getLoginCredentialsFromBrowser () {
try {
const creds = await navigator.credentials.get({'password': true});
if (creds && creds.type == 'password' && u.isValidJID(creds.id)) {
await setUserJID(creds.id);
return {'jid': creds.id, 'password': creds.password};
}
} catch (e) {
log.error(e);
}
}
export function replacePromise (name) {
const existing_promise = _converse.promises[name];
if (!existing_promise) {
@ -591,9 +578,10 @@ export function decodeHTMLEntities (str) {
}
export default Object.assign({
prefixMentions,
isEmptyMessage,
getUniqueId,
toStanza,
isEmptyMessage,
isValidJID,
prefixMentions,
stx,
toStanza,
}, u);

View File

@ -9,7 +9,7 @@ import { Connection } from '@converse/headless/shared/connection/index.js';
import { Model } from '@converse/skeletor/src/model.js';
import { Strophe } from 'strophe.js/src/strophe';
import { createStore, initStorage } from '@converse/headless/utils/storage.js';
import { getLoginCredentialsFromBrowser } from '@converse/headless/utils/core.js';
import { isValidJID } from './core.js';
export function initPlugins (_converse) {
@ -87,17 +87,6 @@ export async function initSessionStorage (_converse) {
};
}
export async function initScramStorage (_converse) {
_converse.storage = {
..._converse.storage,
'scramStorage': Storage.localForage.createInstance({
'name': 'converse-scram',
'description': 'SCRAM storage driver',
'driver': Storage.localForage.INDEXEDDB
})
};
}
function initPersistentStorage (_converse, store_name) {
if (_converse.api.settings.get('persistent_store') === 'sessionStorage') {
return;
@ -130,6 +119,7 @@ function initPersistentStorage (_converse, store_name) {
_converse.storage['persistent'] = Storage.localForage.createInstance(config);
}
function saveJIDtoSession (_converse, jid) {
jid = _converse.session.get('jid') || jid;
if (_converse.api.settings.get("authentication") !== _converse.ANONYMOUS && !Strophe.getResourceFromJid(jid)) {
@ -169,6 +159,7 @@ function saveJIDtoSession (_converse, jid) {
*/
export async function setUserJID (jid) {
await initSession(_converse, jid);
/**
* Triggered whenever the user's JID has been updated
* @event _converse#setUserJID
@ -177,6 +168,7 @@ export async function setUserJID (jid) {
return jid;
}
export async function initSession (_converse, jid) {
const is_shared_session = _converse.api.settings.get('connection_options').worker;
@ -212,6 +204,7 @@ export async function initSession (_converse, jid) {
}
}
export function registerGlobalEventHandlers (_converse) {
document.addEventListener("visibilitychange", _converse.saveWindowState);
_converse.saveWindowState({'type': document.hidden ? "blur" : "focus"}); // Set initial state
@ -233,6 +226,7 @@ function unregisterGlobalEventHandlers (_converse) {
api.trigger('unregisteredGlobalEventHandlers');
}
// Make sure everything is reset in case this is a subsequent call to
// converse.initialize (happens during tests).
export async function cleanup (_converse) {
@ -248,23 +242,6 @@ export async function cleanup (_converse) {
}
}
async function getLoginCredentials () {
let credentials;
let wait = 0;
while (!credentials) {
try {
credentials = await fetchLoginCredentials(wait); // eslint-disable-line no-await-in-loop
} catch (e) {
log.error('Could not fetch login credentials');
log.error(e);
}
// If unsuccessful, we wait 2 seconds between subsequent attempts to
// fetch the credentials.
wait = 2000;
}
return credentials;
}
function fetchLoginCredentials (wait=0) {
return new Promise(
@ -296,6 +273,50 @@ function fetchLoginCredentials (wait=0) {
);
}
async function getLoginCredentialsFromURL () {
let credentials;
let wait = 0;
while (!credentials) {
try {
credentials = await fetchLoginCredentials(wait); // eslint-disable-line no-await-in-loop
} catch (e) {
log.error('Could not fetch login credentials');
log.error(e);
}
// If unsuccessful, we wait 2 seconds between subsequent attempts to
// fetch the credentials.
wait = 2000;
}
return credentials;
}
async function getLoginCredentialsFromBrowser () {
try {
const creds = await navigator.credentials.get({'password': true});
if (creds && creds.type == 'password' && isValidJID(creds.id)) {
await setUserJID(creds.id);
return {'jid': creds.id, 'password': creds.password};
}
} catch (e) {
log.error(e);
}
}
async function getLoginCredentialsFromSCRAMKeys () {
const jid = localStorage.getItem('conversejs-session-jid');
if (!jid) return null;
await setUserJID(jid);
const login_info = await savedLoginInfo(jid);
const scram_keys = login_info.get('scram_keys');
return scram_keys ? { jid , 'password': scram_keys } : null;
}
export async function attemptNonPreboundSession (credentials, automatic) {
const { api } = _converse;
if (api.settings.get("authentication") === _converse.LOGIN) {
@ -306,18 +327,24 @@ export async function attemptNonPreboundSession (credentials, automatic) {
// automatically setting up a new session (``auto_login``).
// So we can't do the check (!automatic || _converse.api.settings.get("auto_login")) here.
if (credentials) {
connect(credentials);
return connect(credentials);
} else if (api.settings.get("credentials_url")) {
// We give credentials_url preference, because
// _converse.connection.pass might be an expired token.
connect(await getLoginCredentials());
return connect(await getLoginCredentialsFromURL());
} else if (_converse.jid && (api.settings.get("password") || _converse.connection.pass)) {
connect();
} else if (!_converse.isTestEnv() && 'credentials' in navigator) {
connect(await getLoginCredentialsFromBrowser());
} else {
!_converse.isTestEnv() && log.warn("attemptNonPreboundSession: Couldn't find credentials to log in with");
return connect();
}
if (api.settings.get('reuse_scram_keys')) {
const credentials = await getLoginCredentialsFromSCRAMKeys();
if (credentials) return connect(credentials);
}
if (!_converse.isTestEnv() && 'credentials' in navigator) {
return connect(await getLoginCredentialsFromBrowser());
}
!_converse.isTestEnv() && log.warn("attemptNonPreboundSession: Couldn't find credentials to log in with");
} else if (
[_converse.ANONYMOUS, _converse.EXTERNAL].includes(api.settings.get("authentication")) &&
(!automatic || api.settings.get("auto_login"))
@ -338,7 +365,26 @@ export function getConnectionServiceURL () {
}
function connect (credentials) {
/**
* Fetch the stored SCRAM keys for the given JID, if available.
*
* The user's plaintext password is not stored, nor any material from which
* the user's plaintext password could be recovered.
*
* @param { String } JID - The XMPP address for which to fetch the SCRAM keys
* @returns { Promise } A promise which resolves once we've fetched the previously
* used login keys.
*/
export async function savedLoginInfo (jid) {
const id = `converse.scram-keys-${Strophe.getBareJidFromJid(jid)}`;
const login_info = new Model({ id });
initStorage(login_info, id, 'persistent');
await new Promise(f => login_info.fetch({'success': f, 'error': f}));
return login_info;
}
async function connect (credentials) {
const { api } = _converse;
if ([_converse.ANONYMOUS, _converse.EXTERNAL].includes(api.settings.get("authentication"))) {
if (!_converse.jid) {
@ -369,26 +415,19 @@ function connect (credentials) {
let callback;
if (api.settings.get("save_scram_keys") && !password.ck) {
// Don't save the SCRAM data if we already logged in with SCRAM
const login_info = await _converse.api.savedLoginInfo();
// Save the SCRAM data if we're not already logged in with SCRAM
if (
_converse.config.get('trusted') &&
_converse.jid &&
api.settings.get("reuse_scram_keys") &&
!password?.ck
) {
// Store scram keys in scram storage
const login_info = await savedLoginInfo(_converse.jid);
callback = async (status) => {
// Store scram keys in scram storage
if (!_converse?.storage?.scramStorage) {
await initScramStorage(_converse);
}
const newScramKeys = _converse.connection.scramKeys;
if (newScramKeys) {
try {
const new_users_info = login_info.users ?? { };
new_users_info[_converse.connection.authzid] = newScramKeys;
login_info.save({'users': new_users_info });
} catch (e) { // Could not find local storage }
log.error("No storage method found: ", e);
}
}
callback = (status) => {
const { scram_keys } = _converse.connection;
if (scram_keys) login_info.save({ scram_keys });
_converse.connection.onConnectStatusChanged(status);
};
}