Add SSSS and key backup
This commit is contained in:
parent
89f69ed5b4
commit
101134e313
7 changed files with 188 additions and 62 deletions
117
src/app/organisms/settings/AuthRequest.jsx
Normal file
117
src/app/organisms/settings/AuthRequest.jsx
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import './AuthRequest.scss';
|
||||||
|
|
||||||
|
import initMatrix from '../../../client/initMatrix';
|
||||||
|
import { openReusableDialog } from '../../../client/action/navigation';
|
||||||
|
|
||||||
|
import Text from '../../atoms/text/Text';
|
||||||
|
import Button from '../../atoms/button/Button';
|
||||||
|
import Input from '../../atoms/input/Input';
|
||||||
|
import Spinner from '../../atoms/spinner/Spinner';
|
||||||
|
|
||||||
|
import { useStore } from '../../hooks/useStore';
|
||||||
|
|
||||||
|
let lastUsedPassword;
|
||||||
|
const getAuthId = (password) => ({
|
||||||
|
type: 'm.login.password',
|
||||||
|
password,
|
||||||
|
identifier: {
|
||||||
|
type: 'm.id.user',
|
||||||
|
user: initMatrix.matrixClient.getUserId(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function AuthRequest({ onComplete, makeRequest }) {
|
||||||
|
const [status, setStatus] = useState(false);
|
||||||
|
const mountStore = useStore();
|
||||||
|
|
||||||
|
const handleForm = async (e) => {
|
||||||
|
mountStore.setItem(true);
|
||||||
|
e.preventDefault();
|
||||||
|
const password = e.target.password.value;
|
||||||
|
if (password.trim() === '') return;
|
||||||
|
try {
|
||||||
|
setStatus({ ongoing: true });
|
||||||
|
await makeRequest(getAuthId(password));
|
||||||
|
lastUsedPassword = password;
|
||||||
|
if (!mountStore.getItem()) return;
|
||||||
|
onComplete(true);
|
||||||
|
} catch (err) {
|
||||||
|
lastUsedPassword = undefined;
|
||||||
|
if (!mountStore.getItem()) return;
|
||||||
|
if (err.errcode === 'M_FORBIDDEN') {
|
||||||
|
setStatus({ error: 'Wrong password. Please enter correct password.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus({ error: 'Request failed!' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = () => {
|
||||||
|
setStatus(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-request">
|
||||||
|
<form onSubmit={handleForm}>
|
||||||
|
<Input
|
||||||
|
name="password"
|
||||||
|
label="Account password"
|
||||||
|
type="password"
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{status.ongoing && <Spinner size="small" />}
|
||||||
|
{status.error && <Text variant="b3">{status.error}</Text>}
|
||||||
|
{(status === false || status.error) && <Button variant="primary" type="submit" disabled={!!status.error}>Continue</Button>}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
AuthRequest.propTypes = {
|
||||||
|
onComplete: PropTypes.func.isRequired,
|
||||||
|
makeRequest: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} title Title of dialog
|
||||||
|
* @param {(auth) => void} makeRequest request to make
|
||||||
|
* @returns {Promise<boolean>} whether the request succeed or not.
|
||||||
|
*/
|
||||||
|
export const authRequest = async (title, makeRequest) => {
|
||||||
|
try {
|
||||||
|
const auth = lastUsedPassword ? getAuthId(lastUsedPassword) : undefined;
|
||||||
|
await makeRequest(auth);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
lastUsedPassword = undefined;
|
||||||
|
if (e.httpStatus !== 401 || e.data?.flows === undefined) return false;
|
||||||
|
|
||||||
|
const { flows } = e.data;
|
||||||
|
const canUsePassword = flows.find((f) => f.stages.includes('m.login.password'));
|
||||||
|
if (!canUsePassword) return false;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let isCompleted = false;
|
||||||
|
openReusableDialog(
|
||||||
|
<Text variant="s1" weight="medium">{title}</Text>,
|
||||||
|
(requestClose) => (
|
||||||
|
<AuthRequest
|
||||||
|
onComplete={(done) => {
|
||||||
|
isCompleted = true;
|
||||||
|
resolve(done);
|
||||||
|
requestClose();
|
||||||
|
}}
|
||||||
|
makeRequest={makeRequest}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
() => {
|
||||||
|
if (!isCompleted) resolve(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuthRequest;
|
12
src/app/organisms/settings/AuthRequest.scss
Normal file
12
src/app/organisms/settings/AuthRequest.scss
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
.auth-request {
|
||||||
|
padding: var(--sp-normal);
|
||||||
|
|
||||||
|
& form > *:not(:first-child) {
|
||||||
|
margin-top: var(--sp-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .text-b3 {
|
||||||
|
color: var(--tc-danger-high);
|
||||||
|
margin-top: var(--sp-ultra-tight) !important;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,4 @@
|
||||||
|
/* eslint-disable react/jsx-one-expression-per-line */
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import './CrossSigning.scss';
|
import './CrossSigning.scss';
|
||||||
import FileSaver from 'file-saver';
|
import FileSaver from 'file-saver';
|
||||||
|
@ -6,7 +7,6 @@ import { twemojify } from '../../../util/twemojify';
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
import { openReusableDialog } from '../../../client/action/navigation';
|
import { openReusableDialog } from '../../../client/action/navigation';
|
||||||
import { hasCrossSigningAccountData } from '../../../util/matrixUtil';
|
|
||||||
import { copyToClipboard } from '../../../util/common';
|
import { copyToClipboard } from '../../../util/common';
|
||||||
import { clearSecretStorageKeys } from '../../../client/state/secretStorageKeys';
|
import { clearSecretStorageKeys } from '../../../client/state/secretStorageKeys';
|
||||||
|
|
||||||
|
@ -16,6 +16,24 @@ import Input from '../../atoms/input/Input';
|
||||||
import Spinner from '../../atoms/spinner/Spinner';
|
import Spinner from '../../atoms/spinner/Spinner';
|
||||||
import SettingTile from '../../molecules/setting-tile/SettingTile';
|
import SettingTile from '../../molecules/setting-tile/SettingTile';
|
||||||
|
|
||||||
|
import { authRequest } from './AuthRequest';
|
||||||
|
import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
|
||||||
|
|
||||||
|
const failedDialog = () => {
|
||||||
|
const renderFailure = (requestClose) => (
|
||||||
|
<div className="cross-signing__failure">
|
||||||
|
<Text variant="h1">{twemojify('❌')}</Text>
|
||||||
|
<Text weight="medium">Failed to setup cross signing. Please try again.</Text>
|
||||||
|
<Button onClick={requestClose}>Close</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
openReusableDialog(
|
||||||
|
<Text variant="s1" weight="medium">Setup cross signing</Text>,
|
||||||
|
renderFailure,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const securityKeyDialog = (key) => {
|
const securityKeyDialog = (key) => {
|
||||||
const downloadKey = () => {
|
const downloadKey = () => {
|
||||||
const blob = new Blob([key.encodedPrivateKey], {
|
const blob = new Blob([key.encodedPrivateKey], {
|
||||||
|
@ -66,28 +84,19 @@ function CrossSigningSetup() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const authUploadDeviceSigningKeys = async (makeRequest) => {
|
const authUploadDeviceSigningKeys = async (makeRequest) => {
|
||||||
try {
|
const isDone = await authRequest('Setup cross signing', async (auth) => {
|
||||||
const password = prompt('Password', '');
|
await makeRequest(auth);
|
||||||
await makeRequest({
|
});
|
||||||
type: 'm.login.password',
|
setTimeout(() => {
|
||||||
password,
|
if (isDone) securityKeyDialog(recoveryKey);
|
||||||
identifier: {
|
else failedDialog();
|
||||||
type: 'm.id.user',
|
|
||||||
user: mx.getUserId(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
|
||||||
console.log(e);
|
|
||||||
// TODO: handle error
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await mx.bootstrapCrossSigning({
|
await mx.bootstrapCrossSigning({
|
||||||
authUploadDeviceSigningKeys,
|
authUploadDeviceSigningKeys,
|
||||||
setupNewCrossSigning: true,
|
setupNewCrossSigning: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
securityKeyDialog(recoveryKey);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const validator = (values) => {
|
const validator = (values) => {
|
||||||
|
@ -112,7 +121,7 @@ function CrossSigningSetup() {
|
||||||
<div className="cross-signing__setup">
|
<div className="cross-signing__setup">
|
||||||
<div className="cross-signing__setup-entry">
|
<div className="cross-signing__setup-entry">
|
||||||
<Text>
|
<Text>
|
||||||
We will generate a Security Key,
|
We will generate a <b>Security Key</b>,
|
||||||
which you can use to manage messages backup and session verification.
|
which you can use to manage messages backup and session verification.
|
||||||
</Text>
|
</Text>
|
||||||
{genWithPhrase !== false && <Button variant="primary" onClick={() => setup()} disabled={genWithPhrase !== undefined}>Generate Key</Button>}
|
{genWithPhrase !== false && <Button variant="primary" onClick={() => setup()} disabled={genWithPhrase !== undefined}>Generate Key</Button>}
|
||||||
|
@ -133,7 +142,7 @@ function CrossSigningSetup() {
|
||||||
disabled={genWithPhrase !== undefined}
|
disabled={genWithPhrase !== undefined}
|
||||||
>
|
>
|
||||||
<Text>
|
<Text>
|
||||||
Alternatively you can also set a Security Phrase
|
Alternatively you can also set a <b>Security Phrase </b>
|
||||||
so you don't have to remember long Security Key,
|
so you don't have to remember long Security Key,
|
||||||
and optionally save the Key as backup.
|
and optionally save the Key as backup.
|
||||||
</Text>
|
</Text>
|
||||||
|
@ -181,7 +190,8 @@ function CrossSigningReset() {
|
||||||
<Text>
|
<Text>
|
||||||
Anyone you have verified with will see security alerts and your message backup will lost.
|
Anyone you have verified with will see security alerts and your message backup will lost.
|
||||||
You almost certainly do not want to do this,
|
You almost certainly do not want to do this,
|
||||||
unless you have lost Security Key or Phrase and every session you can cross-sign from.
|
unless you have lost <b>Security Key</b> or <b>Phrase</b> and
|
||||||
|
every session you can cross-sign from.
|
||||||
</Text>
|
</Text>
|
||||||
<Button variant="danger" onClick={setupDialog}>Reset</Button>
|
<Button variant="danger" onClick={setupDialog}>Reset</Button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -196,12 +206,13 @@ const resetDialog = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
function CrossSignin() {
|
function CrossSignin() {
|
||||||
|
const isCSEnabled = useCrossSigningStatus();
|
||||||
return (
|
return (
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Cross signing"
|
title="Cross signing"
|
||||||
content={<Text variant="b3">Setup to verify and keep track of all your sessions. Also required to backup encrypted message.</Text>}
|
content={<Text variant="b3">Setup to verify and keep track of all your sessions. Also required to backup encrypted message.</Text>}
|
||||||
options={(
|
options={(
|
||||||
hasCrossSigningAccountData()
|
isCSEnabled
|
||||||
? <Button variant="danger" onClick={resetDialog}>Reset</Button>
|
? <Button variant="danger" onClick={resetDialog}>Reset</Button>
|
||||||
: <Button variant="primary" onClick={setupDialog}>Setup</Button>
|
: <Button variant="primary" onClick={setupDialog}>Setup</Button>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -45,10 +45,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cross-signing__failure,
|
||||||
.cross-signing__reset {
|
.cross-signing__reset {
|
||||||
padding: var(--sp-normal);
|
padding: var(--sp-normal);
|
||||||
padding-top: var(--sp-extra-loose);
|
padding-top: var(--sp-extra-loose);
|
||||||
& > .text {
|
& > .text {
|
||||||
padding-bottom: var(--sp-loose);
|
padding-bottom: var(--sp-normal);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -17,6 +17,8 @@ import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
|
||||||
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
|
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
|
||||||
import InfoIC from '../../../../public/res/ic/outlined/info.svg';
|
import InfoIC from '../../../../public/res/ic/outlined/info.svg';
|
||||||
|
|
||||||
|
import { authRequest } from './AuthRequest';
|
||||||
|
|
||||||
import { useStore } from '../../hooks/useStore';
|
import { useStore } from '../../hooks/useStore';
|
||||||
import { useDeviceList } from '../../hooks/useDeviceList';
|
import { useDeviceList } from '../../hooks/useDeviceList';
|
||||||
import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
|
import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
|
||||||
|
@ -71,39 +73,16 @@ function DeviceManage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemove = async (device, auth = undefined) => {
|
const handleRemove = async (device) => {
|
||||||
if (auth === undefined
|
if (window.confirm(`You are about to logout "${device.display_name}" session.`)) {
|
||||||
? window.confirm(`You are about to logout "${device.display_name}" session.`)
|
|
||||||
: true
|
|
||||||
) {
|
|
||||||
addToProcessing(device);
|
addToProcessing(device);
|
||||||
try {
|
await authRequest(`Logout "${device.display_name}"`, async (auth) => {
|
||||||
await mx.deleteDevice(device.device_id, auth);
|
await mx.deleteDevice(device.device_id, auth);
|
||||||
} catch (e) {
|
|
||||||
if (e.httpStatus === 401 && e.data?.flows) {
|
|
||||||
const { flows } = e.data;
|
|
||||||
const flow = flows.find((f) => f.stages.includes('m.login.password'));
|
|
||||||
if (flow) {
|
|
||||||
const password = window.prompt('Please enter account password', '');
|
|
||||||
if (password && password.trim() !== '') {
|
|
||||||
handleRemove(device, {
|
|
||||||
session: e.data.session,
|
|
||||||
type: 'm.login.password',
|
|
||||||
password,
|
|
||||||
identifier: {
|
|
||||||
type: 'm.id.user',
|
|
||||||
user: mx.getUserId(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.alert('Failed to remove session!');
|
|
||||||
if (!mountStore.getItem()) return;
|
if (!mountStore.getItem()) return;
|
||||||
removeFromProcessing(device);
|
removeFromProcessing(device);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderDevice = (device, isVerified) => {
|
const renderDevice = (device, isVerified) => {
|
||||||
|
|
|
@ -82,7 +82,7 @@ CreateKeyBackupDialog.propTypes = {
|
||||||
keyData: PropTypes.shape({}).isRequired,
|
keyData: PropTypes.shape({}).isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
function RestoreKeyBackupDialog({ keyData, backupInfo }) {
|
function RestoreKeyBackupDialog({ keyData }) {
|
||||||
const [status, setStatus] = useState(false);
|
const [status, setStatus] = useState(false);
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
const mountStore = useStore();
|
const mountStore = useStore();
|
||||||
|
@ -103,6 +103,7 @@ function RestoreKeyBackupDialog({ keyData, backupInfo }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const backupInfo = await mx.getKeyBackupVersion();
|
||||||
const info = await mx.restoreKeyBackupWithSecretStorage(
|
const info = await mx.restoreKeyBackupWithSecretStorage(
|
||||||
backupInfo,
|
backupInfo,
|
||||||
undefined,
|
undefined,
|
||||||
|
@ -115,7 +116,7 @@ function RestoreKeyBackupDialog({ keyData, backupInfo }) {
|
||||||
if (!mountStore.getItem()) return;
|
if (!mountStore.getItem()) return;
|
||||||
if (e.errcode === 'RESTORE_BACKUP_ERROR_BAD_KEY') {
|
if (e.errcode === 'RESTORE_BACKUP_ERROR_BAD_KEY') {
|
||||||
deletePrivateKey(keyData.keyId);
|
deletePrivateKey(keyData.keyId);
|
||||||
setStatus({ error: 'Failed to restore backup. Key is invalid', errorCode: 'BAD_KEY' });
|
setStatus({ error: 'Failed to restore backup. Key is invalid!', errorCode: 'BAD_KEY' });
|
||||||
} else {
|
} else {
|
||||||
setStatus({ error: 'Failed to restore backup.', errCode: 'UNKNOWN' });
|
setStatus({ error: 'Failed to restore backup.', errCode: 'UNKNOWN' });
|
||||||
}
|
}
|
||||||
|
@ -152,10 +153,9 @@ function RestoreKeyBackupDialog({ keyData, backupInfo }) {
|
||||||
}
|
}
|
||||||
RestoreKeyBackupDialog.propTypes = {
|
RestoreKeyBackupDialog.propTypes = {
|
||||||
keyData: PropTypes.shape({}).isRequired,
|
keyData: PropTypes.shape({}).isRequired,
|
||||||
backupInfo: PropTypes.shape({}).isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function DeleteKeyBackupDialog({ version, requestClose }) {
|
function DeleteKeyBackupDialog({ requestClose }) {
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
const mountStore = useStore();
|
const mountStore = useStore();
|
||||||
|
@ -164,7 +164,8 @@ function DeleteKeyBackupDialog({ version, requestClose }) {
|
||||||
const deleteBackup = async () => {
|
const deleteBackup = async () => {
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
try {
|
try {
|
||||||
await mx.deleteKeyBackupVersion(version);
|
const backupInfo = await mx.getKeyBackupVersion();
|
||||||
|
if (backupInfo) await mx.deleteKeyBackupVersion(backupInfo.version);
|
||||||
if (!mountStore.getItem()) return;
|
if (!mountStore.getItem()) return;
|
||||||
requestClose(true);
|
requestClose(true);
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -187,7 +188,6 @@ function DeleteKeyBackupDialog({ version, requestClose }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
DeleteKeyBackupDialog.propTypes = {
|
DeleteKeyBackupDialog.propTypes = {
|
||||||
version: PropTypes.string.isRequired,
|
|
||||||
requestClose: PropTypes.func.isRequired,
|
requestClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -217,7 +217,7 @@ function KeyBackup() {
|
||||||
return () => {
|
return () => {
|
||||||
mx.removeListener('accountData', handleAccountData);
|
mx.removeListener('accountData', handleAccountData);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [isCSEnabled]);
|
||||||
|
|
||||||
const openCreateKeyBackup = async () => {
|
const openCreateKeyBackup = async () => {
|
||||||
const keyData = await accessSecretStorage('Create Key Backup');
|
const keyData = await accessSecretStorage('Create Key Backup');
|
||||||
|
@ -236,7 +236,7 @@ function KeyBackup() {
|
||||||
|
|
||||||
openReusableDialog(
|
openReusableDialog(
|
||||||
<Text variant="s1" weight="medium">Restore Key Backup</Text>,
|
<Text variant="s1" weight="medium">Restore Key Backup</Text>,
|
||||||
() => <RestoreKeyBackupDialog keyData={keyData} backupInfo={keyBackup} />,
|
() => <RestoreKeyBackupDialog keyData={keyData} />,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -244,7 +244,6 @@ function KeyBackup() {
|
||||||
<Text variant="s1" weight="medium">Delete Key Backup</Text>,
|
<Text variant="s1" weight="medium">Delete Key Backup</Text>,
|
||||||
(requestClose) => (
|
(requestClose) => (
|
||||||
<DeleteKeyBackupDialog
|
<DeleteKeyBackupDialog
|
||||||
version={keyBackup.version}
|
|
||||||
requestClose={(isDone) => {
|
requestClose={(isDone) => {
|
||||||
if (isDone) setKeyBackup(null);
|
if (isDone) setKeyBackup(null);
|
||||||
requestClose();
|
requestClose();
|
||||||
|
|
|
@ -116,7 +116,14 @@ export const accessSecretStorage = (title) => new Promise((resolve) => {
|
||||||
|
|
||||||
openReusableDialog(
|
openReusableDialog(
|
||||||
<Text variant="s1" weight="medium">{title}</Text>,
|
<Text variant="s1" weight="medium">{title}</Text>,
|
||||||
() => <SecretStorageAccess onComplete={handleComplete} />,
|
(requestClose) => (
|
||||||
|
<SecretStorageAccess
|
||||||
|
onComplete={(keyData) => {
|
||||||
|
handleComplete(keyData);
|
||||||
|
requestClose(requestClose);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
() => {
|
() => {
|
||||||
if (!isCompleted) resolve(null);
|
if (!isCompleted) resolve(null);
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in a new issue