Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-backup-key-recovery-verified-device.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

# Fix being unable to restore key backup on a verified device
100 changes: 100 additions & 0 deletions src/app/components/BackupRestore.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TypedEventEmitter } from 'matrix-js-sdk/lib/models/typed-event-emitter';
import type { CryptoApi, KeyBackupInfo } from '$types/matrix-sdk';
import type { SecretStorageKeyContent } from '$types/matrix/accountData';
import { BackupRestoreTile } from './BackupRestore';

const emitter = new TypedEventEmitter<string, Record<string, (...args: never[]) => void>>();
const mockClient = Object.assign(emitter, {
secretStorage: { checkKey: vi.fn<() => Promise<boolean>>().mockResolvedValue(true) },
});

vi.mock('$hooks/useMatrixClient', () => ({
useMatrixClient: () => mockClient,
}));

vi.mock('$client/secretStorageKeys', () => ({
storePrivateKey: vi.fn<() => void>(),
}));

const KEY_ID = 'key-id';
const KEY_CONTENT = { algorithm: 'm.secret_storage.v1.aes-hmac-sha2' } as SecretStorageKeyContent;
const BACKUP_INFO = { version: '3', count: 42 } as KeyBackupInfo;

type CryptoOverrides = {
backupInfo?: KeyBackupInfo | null;
backupKey?: Uint8Array | null;
};

const createCrypto = ({ backupInfo = BACKUP_INFO, backupKey = null }: CryptoOverrides = {}) =>
({
getActiveSessionBackupVersion: vi
.fn<CryptoApi['getActiveSessionBackupVersion']>()
.mockResolvedValue(backupInfo ? '3' : null),
getKeyBackupInfo: vi.fn<CryptoApi['getKeyBackupInfo']>().mockResolvedValue(backupInfo),
getSessionBackupPrivateKey: vi
.fn<CryptoApi['getSessionBackupPrivateKey']>()
.mockResolvedValue(backupKey),
isKeyBackupTrusted: vi
.fn<CryptoApi['isKeyBackupTrusted']>()
.mockResolvedValue({ trusted: true, matchesDecryptionKey: true }),
restoreKeyBackup: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
loadSessionBackupPrivateKeyFromSecretStorage: vi
.fn<CryptoApi['loadSessionBackupPrivateKeyFromSecretStorage']>()
.mockResolvedValue(undefined),
}) as unknown as CryptoApi;

const recoveryPrompt = () => screen.queryByText(/does not hold the backup decryption key/i);

describe('BackupRestoreTile', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('offers recovery when a backup exists but its key is not in the crypto store', async () => {
render(
<BackupRestoreTile
crypto={createCrypto({ backupKey: null })}
secretStorageKeyId={KEY_ID}
secretStorageKeyContent={KEY_CONTENT}
/>
);

await waitFor(() => expect(recoveryPrompt()).not.toBeNull());
expect(screen.getByText('Recovery Key')).toBeTruthy();
});

it('stays out of the way once the key is cached', async () => {
render(
<BackupRestoreTile
crypto={createCrypto({ backupKey: new Uint8Array([1, 2, 3]) })}
secretStorageKeyId={KEY_ID}
secretStorageKeyContent={KEY_CONTENT}
/>
);

await waitFor(() => expect(screen.getByText('Encryption Backup')).toBeTruthy());
expect(recoveryPrompt()).toBeNull();
});

it('does not offer recovery when the server has no backup at all', async () => {
render(
<BackupRestoreTile
crypto={createCrypto({ backupInfo: null, backupKey: null })}
secretStorageKeyId={KEY_ID}
secretStorageKeyContent={KEY_CONTENT}
/>
);

await waitFor(() => expect(screen.getByText(/No backup present on server/i)).toBeTruthy());
expect(recoveryPrompt()).toBeNull();
});

it('cannot offer recovery without secret storage configured', async () => {
render(<BackupRestoreTile crypto={createCrypto({ backupKey: null })} />);

await waitFor(() => expect(screen.getByText('Encryption Backup')).toBeTruthy());
expect(recoveryPrompt()).toBeNull();
});
});
112 changes: 109 additions & 3 deletions src/app/components/BackupRestore.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { MouseEventHandler } from 'react';
import { useCallback, useState } from 'react';
import { useAtom } from 'jotai';
import { useAtom, useAtomValue } from 'jotai';
import type { CryptoApi, KeyBackupInfo } from '$types/matrix-sdk';
import type { RectCords } from 'folds';
import {
Badge,
Box,
Button,
Chip,
color,
config,
IconButton,
Expand All @@ -18,14 +19,23 @@ import {
Text,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { BackupProgressStatus, backupRestoreProgressAtom } from '$state/backupRestore';
import type { SecretStorageKeyContent } from '$types/matrix/accountData';
import { storePrivateKey } from '$client/secretStorageKeys';
import {
BackupProgressStatus,
backupRestoreErrorAtom,
backupRestoreProgressAtom,
isMissingBackupKeyError,
} from '$state/backupRestore';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import {
useKeyBackupInfo,
useKeyBackupStatus,
useKeyBackupSync,
useKeyBackupTrust,
useSessionBackupKeyUsable,
} from '$hooks/useKeyBackup';
import { SecretStorageKeyMethod, SecretStorageKeyPrompt } from './SecretStorage';
import { stopPropagation } from '$utils/keyboard';
import { useRestoreBackupOnVerification } from '$hooks/useRestoreBackupOnVerification';
import {
Expand All @@ -36,6 +46,75 @@ import {
} from '$components/icons/phosphor';
import { InfoCard } from './info-card';

type BackupKeyRecoveryProps = {
crypto: CryptoApi;
secretStorageKeyId: string;
secretStorageKeyContent: SecretStorageKeyContent;
};
function BackupKeyRecovery({
crypto,
secretStorageKeyId,
secretStorageKeyContent,
}: BackupKeyRecoveryProps) {
const hasPassphrase = !!secretStorageKeyContent.passphrase;
const [method, setMethod] = useState(
hasPassphrase ? SecretStorageKeyMethod.RecoveryPassphrase : SecretStorageKeyMethod.RecoveryKey
);

const [unlockState, unlockBackup] = useAsyncCallback<void, Error, [Uint8Array]>(
useCallback(
async (recoveryKey: Uint8Array) => {
storePrivateKey(secretStorageKeyId, recoveryKey);
// Emits KeyBackupDecryptionKeyCached, which drives the restore.
await crypto.loadSessionBackupPrivateKeyFromSecretStorage();
},
[crypto, secretStorageKeyId]
)
);

const otherMethod =
method === SecretStorageKeyMethod.RecoveryPassphrase
? SecretStorageKeyMethod.RecoveryKey
: SecretStorageKeyMethod.RecoveryPassphrase;

return (
<Box direction="Column" gap="200">
<Text size="T200">
This device does not hold the backup decryption key. Provide your recovery details to unlock
the backup and restore your message history.
</Text>
<SecretStorageKeyPrompt
method={method}
processing={unlockState.status === AsyncStatus.Loading}
keyContent={secretStorageKeyContent}
onDecodedRecoveryKey={unlockBackup}
/>
{hasPassphrase && (
<Box>
<Chip
type="button"
variant="Secondary"
fill="Soft"
radii="Pill"
onClick={() => setMethod(otherMethod)}
>
<Text as="span" size="B300">
{otherMethod === SecretStorageKeyMethod.RecoveryPassphrase
? 'Use Recovery Passphrase'
: 'Use Recovery Key'}
</Text>
</Chip>
</Box>
)}
{unlockState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>{unlockState.error.message}</b>
</Text>
)}
</Box>
);
}

type BackupStatusProps = {
enabled: boolean;
};
Expand Down Expand Up @@ -138,15 +217,23 @@ function BackupTrustInfo({ crypto, backupInfo }: BackupTrustInfoProps) {

type BackupRestoreTileProps = {
crypto: CryptoApi;
secretStorageKeyId?: string;
secretStorageKeyContent?: SecretStorageKeyContent;
};
export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
export function BackupRestoreTile({
crypto,
secretStorageKeyId,
secretStorageKeyContent,
}: BackupRestoreTileProps) {
const [restoreProgress, setRestoreProgress] = useAtom(backupRestoreProgressAtom);
const autoRestoreError = useAtomValue(backupRestoreErrorAtom);
const restoring =
restoreProgress.status === BackupProgressStatus.Fetching ||
restoreProgress.status === BackupProgressStatus.Loading;

const backupEnabled = useKeyBackupStatus(crypto);
const backupInfo = useKeyBackupInfo(crypto);
const backupKeyUsable = useSessionBackupKeyUsable(crypto);
const [remainingSession, syncFailure] = useKeyBackupSync();

const [menuCords, setMenuCords] = useState<RectCords>();
Expand All @@ -170,6 +257,13 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
restoreBackup();
};

// backupKeyUsable is the structural signal; the error match only covers a
// restore that failed for this reason before the lookup settled.
const needsBackupKey =
!!backupInfo &&
(backupKeyUsable === false ||
(restoreState.status === AsyncStatus.Error && isMissingBackupKeyError(restoreState.error)));

return (
<InfoCard
variant="Surface"
Expand Down Expand Up @@ -275,6 +369,18 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
<b>{restoreState.error.message}</b>
</Text>
)}
{autoRestoreError && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>{autoRestoreError}</b>
</Text>
)}
{needsBackupKey && secretStorageKeyId && secretStorageKeyContent && (
<BackupKeyRecovery
crypto={crypto}
secretStorageKeyId={secretStorageKeyId}
secretStorageKeyContent={secretStorageKeyContent}
/>
)}
</InfoCard>
);
}
Expand Down
50 changes: 17 additions & 33 deletions src/app/components/ManualVerification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,11 @@ import { useMatrixClient } from '$hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { AsyncError } from '$components/AsyncError';
import { SettingTile } from './setting-tile';
import { SecretStorageRecoveryKey, SecretStorageRecoveryPassphrase } from './SecretStorage';
import { SecretStorageKeyMethod, SecretStorageKeyPrompt } from './SecretStorage';

enum ManualVerificationMethod {
RecoveryPassphrase = 'passphrase',
RecoveryKey = 'key',
}
type ManualVerificationMethodSwitcherProps = {
value: ManualVerificationMethod;
onChange: (value: ManualVerificationMethod) => void;
value: SecretStorageKeyMethod;
onChange: (value: SecretStorageKeyMethod) => void;
};
function ManualVerificationMethodSwitcher({
value,
Expand All @@ -31,7 +27,7 @@ function ManualVerificationMethodSwitcher({
setMenuCords(evt.currentTarget.getBoundingClientRect());
};

const handleSelect = (method: ManualVerificationMethod) => {
const handleSelect = (method: SecretStorageKeyMethod) => {
setMenuCords(undefined);
onChange(method);
};
Expand All @@ -47,8 +43,8 @@ function ManualVerificationMethodSwitcher({
onClick={handleMenu}
>
<Text as="span" size="B300">
{value === ManualVerificationMethod.RecoveryPassphrase && 'Recovery Passphrase'}
{value === ManualVerificationMethod.RecoveryKey && 'Recovery Key'}
{value === SecretStorageKeyMethod.RecoveryPassphrase && 'Recovery Passphrase'}
{value === SecretStorageKeyMethod.RecoveryKey && 'Recovery Key'}
</Text>
</Chip>
<PopOut
Expand All @@ -74,9 +70,9 @@ function ManualVerificationMethodSwitcher({
<MenuItem
size="300"
variant="Surface"
aria-selected={value === ManualVerificationMethod.RecoveryPassphrase}
aria-selected={value === SecretStorageKeyMethod.RecoveryPassphrase}
radii="300"
onClick={() => handleSelect(ManualVerificationMethod.RecoveryPassphrase)}
onClick={() => handleSelect(SecretStorageKeyMethod.RecoveryPassphrase)}
>
<Box grow="Yes">
<Text size="T300">Recovery Passphrase</Text>
Expand All @@ -85,9 +81,9 @@ function ManualVerificationMethodSwitcher({
<MenuItem
size="300"
variant="Surface"
aria-selected={value === ManualVerificationMethod.RecoveryKey}
aria-selected={value === SecretStorageKeyMethod.RecoveryKey}
radii="300"
onClick={() => handleSelect(ManualVerificationMethod.RecoveryKey)}
onClick={() => handleSelect(SecretStorageKeyMethod.RecoveryKey)}
>
<Box grow="Yes">
<Text size="T300">Recovery Key</Text>
Expand Down Expand Up @@ -116,9 +112,7 @@ export function ManualVerificationTile({

const hasPassphrase = !!secretStorageKeyContent.passphrase;
const [method, setMethod] = useState(
hasPassphrase
? ManualVerificationMethod.RecoveryPassphrase
: ManualVerificationMethod.RecoveryKey
hasPassphrase ? SecretStorageKeyMethod.RecoveryPassphrase : SecretStorageKeyMethod.RecoveryKey
);

const verifyAndRestoreBackup = useCallback(
Expand Down Expand Up @@ -163,22 +157,12 @@ export function ManualVerificationTile({
</Text>
) : (
<Box direction="Column" gap="100">
{method === ManualVerificationMethod.RecoveryKey && (
<SecretStorageRecoveryKey
processing={verifying}
keyContent={secretStorageKeyContent}
onDecodedRecoveryKey={handleDecodedRecoveryKey}
/>
)}
{method === ManualVerificationMethod.RecoveryPassphrase &&
secretStorageKeyContent.passphrase && (
<SecretStorageRecoveryPassphrase
processing={verifying}
keyContent={secretStorageKeyContent}
passphraseContent={secretStorageKeyContent.passphrase}
onDecodedRecoveryKey={handleDecodedRecoveryKey}
/>
)}
<SecretStorageKeyPrompt
method={method}
processing={verifying}
keyContent={secretStorageKeyContent}
onDecodedRecoveryKey={handleDecodedRecoveryKey}
/>
<AsyncError state={verifyState} bold />
</Box>
)}
Expand Down
Loading