Prompt for recovery key on login rather than passphrase

Only show passphrase options at all if the user has a passphrase on
their SSSS key.
This commit is contained in:
David Baker 2020-06-02 16:32:15 +01:00
parent 631184c661
commit e06ba2003b
9 changed files with 224 additions and 20 deletions

View file

@ -70,6 +70,10 @@ limitations under the License.
} }
} }
.mx_CompleteSecurity_recoveryKeyInput {
width: 368px;
}
.mx_CompleteSecurity_heroIcon { .mx_CompleteSecurity_heroIcon {
width: 128px; width: 128px;
height: 128px; height: 128px;

View file

@ -30,6 +30,8 @@ import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib";
// operation ends. // operation ends.
let secretStorageKeys = {}; let secretStorageKeys = {};
let secretStorageBeingAccessed = false; let secretStorageBeingAccessed = false;
// Stores the 'passphraseOnly' option for the active storage access operation
let passphraseOnlyOption = null;
function isCachingAllowed() { function isCachingAllowed() {
return ( return (
@ -99,6 +101,7 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
const key = await inputToKey(input); const key = await inputToKey(input);
return await MatrixClientPeg.get().checkSecretStorageKey(key, info); return await MatrixClientPeg.get().checkSecretStorageKey(key, info);
}, },
passphraseOnly: passphraseOnlyOption,
}, },
/* className= */ null, /* className= */ null,
/* isPriorityModal= */ false, /* isPriorityModal= */ false,
@ -213,19 +216,27 @@ export async function promptForBackupPassphrase() {
* *
* @param {Function} [func] An operation to perform once secret storage has been * @param {Function} [func] An operation to perform once secret storage has been
* bootstrapped. Optional. * bootstrapped. Optional.
* @param {bool} [forceReset] Reset secret storage even if it's already set up * @param {object} [opts] Named options
* @param {bool} [opts.forceReset] Reset secret storage even if it's already set up
* @param {object} [opts.withKeys] Map of key ID to key for SSSS keys that the client
* already has available. If a key is not supplied here, the user will be prompted.
* @param {bool} [opts.passphraseOnly] If true, do not prompt for recovery key or to reset keys
*/ */
export async function accessSecretStorage(func = async () => { }, forceReset = false) { export async function accessSecretStorage(
func = async () => { }, opts = {},
) {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
secretStorageBeingAccessed = true; secretStorageBeingAccessed = true;
passphraseOnlyOption = opts.passphraseOnly;
secretStorageKeys = Object.assign({}, opts.withKeys || {});
try { try {
if (!await cli.hasSecretStorageKey() || forceReset) { if (!await cli.hasSecretStorageKey() || opts.forceReset) {
// This dialog calls bootstrap itself after guiding the user through // This dialog calls bootstrap itself after guiding the user through
// passphrase creation. // passphrase creation.
const { finished } = Modal.createTrackedDialogAsync('Create Secret Storage dialog', '', const { finished } = Modal.createTrackedDialogAsync('Create Secret Storage dialog', '',
import("./async-components/views/dialogs/secretstorage/CreateSecretStorageDialog"), import("./async-components/views/dialogs/secretstorage/CreateSecretStorageDialog"),
{ {
force: forceReset, force: opts.forceReset,
}, },
null, /* priority = */ false, /* static = */ true, null, /* priority = */ false, /* static = */ true,
); );
@ -263,5 +274,6 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
if (!isCachingAllowed()) { if (!isCachingAllowed()) {
secretStorageKeys = {}; secretStorageKeys = {};
} }
passphraseOnlyOption = null;
} }
} }

View file

@ -21,6 +21,7 @@ import * as sdk from '../../../index';
import { import {
SetupEncryptionStore, SetupEncryptionStore,
PHASE_INTRO, PHASE_INTRO,
PHASE_RECOVERY_KEY,
PHASE_BUSY, PHASE_BUSY,
PHASE_DONE, PHASE_DONE,
PHASE_CONFIRM_SKIP, PHASE_CONFIRM_SKIP,
@ -61,6 +62,9 @@ export default class CompleteSecurity extends React.Component {
if (phase === PHASE_INTRO) { if (phase === PHASE_INTRO) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />; icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("Verify this login"); title = _t("Verify this login");
} else if (phase === PHASE_RECOVERY_KEY) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_verified" />;
title = _t("Recovery Key");
} else if (phase === PHASE_DONE) { } else if (phase === PHASE_DONE) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_verified" />; icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_verified" />;
title = _t("Session verified"); title = _t("Session verified");

View file

@ -19,15 +19,26 @@ import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import withValidation from '../../views/elements/Validation';
import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey';
import { import {
SetupEncryptionStore, SetupEncryptionStore,
PHASE_INTRO, PHASE_INTRO,
PHASE_RECOVERY_KEY,
PHASE_BUSY, PHASE_BUSY,
PHASE_DONE, PHASE_DONE,
PHASE_CONFIRM_SKIP, PHASE_CONFIRM_SKIP,
PHASE_FINISHED, PHASE_FINISHED,
} from '../../../stores/SetupEncryptionStore'; } from '../../../stores/SetupEncryptionStore';
function keyHasPassphrase(keyInfo) {
return (
keyInfo.passphrase &&
keyInfo.passphrase.salt &&
keyInfo.passphrase.iterations
);
}
export default class SetupEncryptionBody extends React.Component { export default class SetupEncryptionBody extends React.Component {
static propTypes = { static propTypes = {
onFinished: PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
@ -45,6 +56,11 @@ export default class SetupEncryptionBody extends React.Component {
// Because of the latter, it lives in the state. // Because of the latter, it lives in the state.
verificationRequest: store.verificationRequest, verificationRequest: store.verificationRequest,
backupInfo: store.backupInfo, backupInfo: store.backupInfo,
recoveryKey: '',
// whether the recovery key is a valid recovery key
recoveryKeyValid: null,
// whether the recovery key is the correct key or not
recoveryKeyCorrect: null,
}; };
} }
@ -67,9 +83,14 @@ export default class SetupEncryptionBody extends React.Component {
store.stop(); store.stop();
} }
_onUsePassphraseClick = async () => { _onUseRecoveryKeyClick = async () => {
const store = SetupEncryptionStore.sharedInstance(); const store = SetupEncryptionStore.sharedInstance();
store.usePassPhrase(); store.useRecoveryKey();
}
_onRecoveryKeyCancelClick() {
const store = SetupEncryptionStore.sharedInstance();
store.cancelUseRecoveryKey();
} }
onSkipClick = () => { onSkipClick = () => {
@ -92,6 +113,66 @@ export default class SetupEncryptionBody extends React.Component {
store.done(); store.done();
} }
_onUsePassphraseClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.usePassPhrase();
}
_onRecoveryKeyChange = (e) => {
this.setState({recoveryKey: e.target.value});
}
_onRecoveryKeyValidate = async (fieldState) => {
const result = await this._validateRecoveryKey(fieldState);
this.setState({recoveryKeyValid: result.valid});
return result;
}
_validateRecoveryKey = withValidation({
rules: [
{
key: "required",
test: async (state) => {
try {
const decodedKey = decodeRecoveryKey(state.value);
const correct = await MatrixClientPeg.get().checkSecretStorageKey(
decodedKey, SetupEncryptionStore.sharedInstance().keyInfo,
);
this.setState({
recoveryKeyValid: true,
recoveryKeyCorrect: correct,
});
return correct;
} catch (e) {
this.setState({
recoveryKeyValid: false,
recoveryKeyCorrect: false,
});
return false;
}
},
invalid: function() {
if (this.state.recoveryKeyValid) {
return _t("This isn't the recovery key for your account");
} else {
return _t("This isn't a valid recovery key");
}
},
valid: function() {
return _t("Looks good!");
},
},
],
})
_onRecoveryKeyFormSubmit = (e) => {
e.preventDefault();
if (!this.state.recoveryKeyCorrect) return;
const store = SetupEncryptionStore.sharedInstance();
store.setupWithRecoveryKey(decodeRecoveryKey(this.state.recoveryKey));
}
render() { render() {
const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
@ -108,6 +189,13 @@ export default class SetupEncryptionBody extends React.Component {
member={MatrixClientPeg.get().getUser(this.state.verificationRequest.otherUserId)} member={MatrixClientPeg.get().getUser(this.state.verificationRequest.otherUserId)}
/>; />;
} else if (phase === PHASE_INTRO) { } else if (phase === PHASE_INTRO) {
const store = SetupEncryptionStore.sharedInstance();
let recoveryKeyPrompt;
if (keyHasPassphrase(store.keyInfo)) {
recoveryKeyPrompt = _t("Use Recovery Key or Passphrase");
} else {
recoveryKeyPrompt = _t("Use Recovery Key");
}
return ( return (
<div> <div>
<p>{_t( <p>{_t(
@ -131,8 +219,8 @@ export default class SetupEncryptionBody extends React.Component {
</div> </div>
<div className="mx_CompleteSecurity_actionRow"> <div className="mx_CompleteSecurity_actionRow">
<AccessibleButton kind="link" onClick={this._onUsePassphraseClick}> <AccessibleButton kind="link" onClick={this._onUseRecoveryKeyClick}>
{_t("Use Recovery Passphrase or Key")} {recoveryKeyPrompt}
</AccessibleButton> </AccessibleButton>
<AccessibleButton kind="danger" onClick={this.onSkipClick}> <AccessibleButton kind="danger" onClick={this.onSkipClick}>
{_t("Skip")} {_t("Skip")}
@ -140,6 +228,47 @@ export default class SetupEncryptionBody extends React.Component {
</div> </div>
</div> </div>
); );
} else if (phase === PHASE_RECOVERY_KEY) {
const store = SetupEncryptionStore.sharedInstance();
let keyPrompt;
if (keyHasPassphrase(store.keyInfo)) {
keyPrompt = _t(
"Enter your Recovery Key or enter a <a>Recovery Passphrase</a> to continue.", {},
{
a: sub => <AccessibleButton
element="span"
className="mx_linkButton"
onClick={this._onUsePassphraseClick}
>{sub}</AccessibleButton>,
},
);
} else {
keyPrompt = _t("Enter your Recovery Key to continue.");
}
const Field = sdk.getComponent('elements.Field');
return <form onSubmit={this._onRecoveryKeyFormSubmit}>
<p>{keyPrompt}</p>
<div className="mx_CompleteSecurity_recoveryKeyEntry">
<Field
type="text"
label={_t('Recovery Key')}
value={this.state.recoveryKey}
onChange={this._onRecoveryKeyChange}
onValidate={this._onRecoveryKeyValidate}
/>
</div>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton kind="secondary" onClick={this._onRecoveryKeyCancelClick}>
{_t("Cancel")}
</AccessibleButton>
<AccessibleButton kind="primary"
disabled={!this.state.recoveryKeyCorrect}
>
{_t("Continue")}
</AccessibleButton>
</div>
</form>;
} else if (phase === PHASE_DONE) { } else if (phase === PHASE_DONE) {
let message; let message;
if (this.state.backupInfo) { if (this.state.backupInfo) {

View file

@ -94,7 +94,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
if (SettingsStore.getValue("feature_cross_signing")) { if (SettingsStore.getValue("feature_cross_signing")) {
// If cross-signing is enabled, we reset the SSSS recovery passphrase (and cross-signing keys) // If cross-signing is enabled, we reset the SSSS recovery passphrase (and cross-signing keys)
this.props.onFinished(false); this.props.onFinished(false);
accessSecretStorage(() => {}, /* forceReset = */ true); accessSecretStorage(() => {}, {forceReset: true});
} else { } else {
Modal.createTrackedDialogAsync('Key Backup', 'Key Backup', Modal.createTrackedDialogAsync('Key Backup', 'Key Backup',
import('../../../../async-components/views/dialogs/keybackup/CreateKeyBackupDialog'), import('../../../../async-components/views/dialogs/keybackup/CreateKeyBackupDialog'),

View file

@ -32,6 +32,9 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
keyInfo: PropTypes.object.isRequired, keyInfo: PropTypes.object.isRequired,
// Function from one of { passphrase, recoveryKey } -> boolean // Function from one of { passphrase, recoveryKey } -> boolean
checkPrivateKey: PropTypes.func.isRequired, checkPrivateKey: PropTypes.func.isRequired,
// If true, only prompt for a passphrase and do not offer to restore with
// a recovery key or reset keys.
passphraseOnly: PropTypes.bool,
} }
constructor(props) { constructor(props) {
@ -58,7 +61,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
_onResetRecoveryClick = () => { _onResetRecoveryClick = () => {
// Re-enter the access flow, but resetting storage this time around. // Re-enter the access flow, but resetting storage this time around.
this.props.onFinished(false); this.props.onFinished(false);
accessSecretStorage(() => {}, /* forceReset = */ true); accessSecretStorage(() => {}, {forceReset: true});
} }
_onRecoveryKeyChange = (e) => { _onRecoveryKeyChange = (e) => {
@ -164,7 +167,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
primaryDisabled={this.state.passPhrase.length === 0} primaryDisabled={this.state.passPhrase.length === 0}
/> />
</form> </form>
{_t( {this.props.passphraseOnly ? null : _t(
"If you've forgotten your recovery passphrase you can "+ "If you've forgotten your recovery passphrase you can "+
"<button1>use your recovery key</button1> or " + "<button1>use your recovery key</button1> or " +
"<button2>set up new recovery options</button2>." "<button2>set up new recovery options</button2>."
@ -234,7 +237,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
primaryDisabled={!this.state.recoveryKeyValid} primaryDisabled={!this.state.recoveryKeyValid}
/> />
</form> </form>
{_t( {this.props.passphraseOnly ? null : _t(
"If you've forgotten your recovery key you can "+ "If you've forgotten your recovery key you can "+
"<button>set up new recovery options</button>." "<button>set up new recovery options</button>."
, {}, { , {}, {

View file

@ -113,7 +113,7 @@ export default class CrossSigningPanel extends React.PureComponent {
_bootstrapSecureSecretStorage = async (forceReset=false) => { _bootstrapSecureSecretStorage = async (forceReset=false) => {
this.setState({ error: null }); this.setState({ error: null });
try { try {
await accessSecretStorage(() => undefined, forceReset); await accessSecretStorage(() => undefined, {forceReset});
} catch (e) { } catch (e) {
this.setState({ error: e }); this.setState({ error: e });
console.error("Error bootstrapping secret storage", e); console.error("Error bootstrapping secret storage", e);

View file

@ -2083,6 +2083,7 @@
"Uploading %(filename)s and %(count)s others|one": "Uploading %(filename)s and %(count)s other", "Uploading %(filename)s and %(count)s others|one": "Uploading %(filename)s and %(count)s other",
"Could not load user profile": "Could not load user profile", "Could not load user profile": "Could not load user profile",
"Verify this login": "Verify this login", "Verify this login": "Verify this login",
"Recovery Key": "Recovery Key",
"Session verified": "Session verified", "Session verified": "Session verified",
"Failed to send email": "Failed to send email", "Failed to send email": "Failed to send email",
"The email address linked to your account must be entered.": "The email address linked to your account must be entered.", "The email address linked to your account must be entered.": "The email address linked to your account must be entered.",
@ -2136,10 +2137,16 @@
"You can now close this window or <a>log in</a> to your new account.": "You can now close this window or <a>log in</a> to your new account.", "You can now close this window or <a>log in</a> to your new account.": "You can now close this window or <a>log in</a> to your new account.",
"Registration Successful": "Registration Successful", "Registration Successful": "Registration Successful",
"Create your account": "Create your account", "Create your account": "Create your account",
"This isn't the recovery key for your account": "This isn't the recovery key for your account",
"This isn't a valid recovery key": "This isn't a valid recovery key",
"Looks good!": "Looks good!",
"Use Recovery Key or Passphrase": "Use Recovery Key or Passphrase",
"Use Recovery Key": "Use Recovery Key",
"Confirm your identity by verifying this login from one of your other sessions, granting it access to encrypted messages.": "Confirm your identity by verifying this login from one of your other sessions, granting it access to encrypted messages.", "Confirm your identity by verifying this login from one of your other sessions, granting it access to encrypted messages.": "Confirm your identity by verifying this login from one of your other sessions, granting it access to encrypted messages.",
"This requires the latest Riot on your other devices:": "This requires the latest Riot on your other devices:", "This requires the latest Riot on your other devices:": "This requires the latest Riot on your other devices:",
"or another cross-signing capable Matrix client": "or another cross-signing capable Matrix client", "or another cross-signing capable Matrix client": "or another cross-signing capable Matrix client",
"Use Recovery Passphrase or Key": "Use Recovery Passphrase or Key", "Enter your Recovery Key or enter a <a>Recovery Passphrase</a> to continue.": "Enter your Recovery Key or enter a <a>Recovery Passphrase</a> to continue.",
"Enter your Recovery Key to continue.": "Enter your Recovery Key to continue.",
"Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.", "Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.",
"Your new session is now verified. Other users will see it as trusted.": "Your new session is now verified. Other users will see it as trusted.", "Your new session is now verified. Other users will see it as trusted.": "Your new session is now verified. Other users will see it as trusted.",
"Without completing security on this session, it wont have access to encrypted messages.": "Without completing security on this session, it wont have access to encrypted messages.", "Without completing security on this session, it wont have access to encrypted messages.": "Without completing security on this session, it wont have access to encrypted messages.",
@ -2182,9 +2189,9 @@
"Import": "Import", "Import": "Import",
"Confirm encryption setup": "Confirm encryption setup", "Confirm encryption setup": "Confirm encryption setup",
"Click the button below to confirm setting up encryption.": "Click the button below to confirm setting up encryption.", "Click the button below to confirm setting up encryption.": "Click the button below to confirm setting up encryption.",
"Enter your account password to confirm the upgrade:": "Enter your account password to confirm the upgrade:",
"Restore your key backup to upgrade your encryption": "Restore your key backup to upgrade your encryption", "Restore your key backup to upgrade your encryption": "Restore your key backup to upgrade your encryption",
"Restore": "Restore", "Restore": "Restore",
"Enter your account password to confirm the upgrade:": "Enter your account password to confirm the upgrade:",
"You'll need to authenticate with the server to confirm the upgrade.": "You'll need to authenticate with the server to confirm the upgrade.", "You'll need to authenticate with the server to confirm the upgrade.": "You'll need to authenticate with the server to confirm the upgrade.",
"Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.": "Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.", "Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.": "Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.",
"Store your Recovery Key somewhere safe, it can be used to unlock your encrypted messages & data.": "Store your Recovery Key somewhere safe, it can be used to unlock your encrypted messages & data.", "Store your Recovery Key somewhere safe, it can be used to unlock your encrypted messages & data.": "Store your Recovery Key somewhere safe, it can be used to unlock your encrypted messages & data.",

View file

@ -20,10 +20,11 @@ import { accessSecretStorage, AccessCancelledError } from '../CrossSigningManage
import { PHASE_DONE as VERIF_PHASE_DONE } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import { PHASE_DONE as VERIF_PHASE_DONE } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
export const PHASE_INTRO = 0; export const PHASE_INTRO = 0;
export const PHASE_BUSY = 1; export const PHASE_RECOVERY_KEY = 1;
export const PHASE_DONE = 2; //final done stage, but still showing UX export const PHASE_BUSY = 2;
export const PHASE_CONFIRM_SKIP = 3; export const PHASE_DONE = 3; //final done stage, but still showing UX
export const PHASE_FINISHED = 4; //UX can be closed export const PHASE_CONFIRM_SKIP = 4;
export const PHASE_FINISHED = 5; //UX can be closed
export class SetupEncryptionStore extends EventEmitter { export class SetupEncryptionStore extends EventEmitter {
static sharedInstance() { static sharedInstance() {
@ -36,11 +37,19 @@ export class SetupEncryptionStore extends EventEmitter {
return; return;
} }
this._started = true; this._started = true;
this.phase = PHASE_INTRO; this.phase = PHASE_BUSY;
this.verificationRequest = null; this.verificationRequest = null;
this.backupInfo = null; this.backupInfo = null;
// ID of the key that the secrets we want are encrypted with
this.keyId = null;
// Descriptor of the key that the secrets we want are encrypted with
this.keyInfo = null;
MatrixClientPeg.get().on("crypto.verification.request", this.onVerificationRequest); MatrixClientPeg.get().on("crypto.verification.request", this.onVerificationRequest);
MatrixClientPeg.get().on('userTrustStatusChanged', this._onUserTrustStatusChanged); MatrixClientPeg.get().on('userTrustStatusChanged', this._onUserTrustStatusChanged);
this.fetchKeyInfo();
} }
stop() { stop() {
@ -57,7 +66,40 @@ export class SetupEncryptionStore extends EventEmitter {
} }
} }
async fetchKeyInfo() {
const keys = await MatrixClientPeg.get().isSecretStored('m.cross_signing.master', false);
if (Object.keys(keys).length === 0) {
this.keyId = null;
this.keyInfo = null;
} else {
// If the secret is stored under more than one key, we just pick an arbitrary one
this.keyId = Object.keys(keys)[0];
this.keyInfo = keys[this.keyId];
}
this.phase = PHASE_INTRO;
this.emit("update");
}
async useRecoveryKey() {
this.phase = PHASE_RECOVERY_KEY;
this.emit("update");
}
cancelUseRecoveryKey() {
this.phase = PHASE_INTRO;
this.emit("update");
}
async setupWithRecoveryKey(recoveryKey) {
this.startTrustCheck({[this.keyId]: recoveryKey});
}
async usePassPhrase() { async usePassPhrase() {
this.startTrustCheck();
}
async startTrustCheck(withKeys) {
this.phase = PHASE_BUSY; this.phase = PHASE_BUSY;
this.emit("update"); this.emit("update");
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
@ -84,6 +126,9 @@ export class SetupEncryptionStore extends EventEmitter {
// to advance before this. // to advance before this.
await cli.restoreKeyBackupWithSecretStorage(backupInfo); await cli.restoreKeyBackupWithSecretStorage(backupInfo);
} }
}, {
withKeys,
passphraseOnly: true,
}).catch(reject); }).catch(reject);
} catch (e) { } catch (e) {
console.error(e); console.error(e);