Commit 4929dcd34937
tarball9 files changed+282-4
@@ -244,6 +244,15 @@ pub enum Request {
244244
Recover {245245
recovery_key: String,246246
},247
+ CreateKeyBackup,248
+ EnableKeyBackup,249
+ DisableKeyBackup {250
+ #[serde(default)]251
+ delete_from_server: bool,252
+ },253
+ RecoverKeyBackup {254
+ recovery_key: String,255
+ },247256
StartVerification,248257
Verify {249258
action: String,@@ -925,6 +934,25 @@ pub async fn dispatch(
925934
handle.recover(recovery_key).await?;926935
Ok(Reply::Done)927936
}937
+ Request::CreateKeyBackup => {938
+ handle.create_key_backup().await?;939
+ Ok(Reply::Done)940
+ }941
+ Request::EnableKeyBackup => {942
+ handle.enable_key_backup().await?;943
+ Ok(Reply::Done)944
+ }945
+ Request::DisableKeyBackup { delete_from_server } => {946
+ handle.disable_key_backup(delete_from_server).await?;947
+ Ok(Reply::Done)948
+ }949
+ Request::RecoverKeyBackup { recovery_key } => {950
+ if recovery_key.trim().is_empty() {951
+ return Err(CoreError::bad_request("recovery_key is required"));952
+ }953
+ handle.recover_key_backup(recovery_key).await?;954
+ Ok(Reply::Done)955
+ }928956
Request::StartVerification => {929957
handle930958
.start_verification(VerificationTarget::OwnIdentity)@@ -1480,6 +1508,22 @@ mod tests {
14801508
}14811509
14821510
#[test]1511
+ fn key_backup_requests_parse() {1512
+ let create: Request = serde_json::from_str(r#"{"op":"create_key_backup"}"#).unwrap();1513
+ assert!(matches!(create, Request::CreateKeyBackup));1514
+ let disable: Request = serde_json::from_str(r#"{"op":"disable_key_backup"}"#).unwrap();1515
+ assert!(matches!(1516
+ disable,1517
+ Request::DisableKeyBackup {1518
+ delete_from_server: false1519
+ }1520
+ ));1521
+ let recover: Request =1522
+ serde_json::from_str(r#"{"op":"recover_key_backup","recovery_key":"k"}"#).unwrap();1523
+ assert!(matches!(recover, Request::RecoverKeyBackup { .. }));1524
+ }1525
+1526
+ #[test]14831527
fn reaction_keys() {14841528
assert!(valid_reaction_key("👍"));14851529
assert!(!valid_reaction_key(""));@@ -566,6 +566,10 @@ async fn post_command(
566566
| Request::SetMentionsRead { .. }567567
| Request::MarkMentionsReadThrough { .. }568568
| Request::RecoverMentions569
+ | Request::CreateKeyBackup570
+ | Request::EnableKeyBackup571
+ | Request::DisableKeyBackup { .. }572
+ | Request::RecoverKeyBackup { .. }569573
| Request::RetryRecentHistory { .. }570574
| Request::EventContext { .. }571575
| Request::EventContextBefore { .. }@@ -1,8 +1,10 @@
11
<script lang="ts">2
- import { createEventDispatcher } from 'svelte';2
+ import { createEventDispatcher, onMount } from 'svelte';33
import {44
recoverEncryption,5
+ runKeyBackup,56
startVerification,7
+ type KeyBackupAction,68
verifyEncryption,79
type VerifyAction810
} from './api';@@ -20,7 +22,28 @@
2022
export let onEncryptedPreviews: (enabled: boolean) => void = () => undefined;2123
2224
let recoveryKey = '';23
- let busy: VerifyAction | 'recover' | 'start' | null = null;25
+ let recoveryInput: HTMLInputElement | undefined;26
+27
+ onMount(() => {28
+ if (encryptionState && encryptionState.recovery !== 'Enabled') recoveryInput?.focus();29
+ });30
+ let busy: VerifyAction | KeyBackupAction | 'recover' | 'start' | null = null;31
+32
+ let confirmOff = false;33
+34
+ async function backup(op: KeyBackupAction, deleteFromServer = false): Promise<void> {35
+ if (busy) return;36
+ busy = op;37
+ try {38
+ await runKeyBackup(op, deleteFromServer);39
+ confirmOff = false;40
+ onAction();41
+ } catch (e) {42
+ pushBanner(e instanceof Error ? e.message : 'Key backup failed');43
+ } finally {44
+ busy = null;45
+ }46
+ }2447
2548
2649
function verificationTitle(state: VerificationState): string {@@ -104,6 +127,33 @@
104127
{/if}105128
</section>106129
130
+ <section aria-label="Key backup">131
+ <h3 class="sec-h">Key backup</h3>132
+ {#if encryptionState?.key_backup === 'Enabled'}133
+ <p class="sec-line"><span class="pill ok">on</span></p>134
+ <div class="row">135
+ {#if confirmOff}136
+ <button type="button" class="ghost-btn danger" disabled={busy !== null} on:click={() => void backup('disable_key_backup')}>137
+ {busy === 'disable_key_backup' ? 'Turning off…' : 'Turn off'}138
+ </button>139
+ <button type="button" class="ghost-btn danger" disabled={busy !== null} on:click={() => void backup('disable_key_backup', true)}>140
+ Turn off and delete from server141
+ </button>142
+ <button type="button" class="ghost-btn" disabled={busy !== null} on:click={() => (confirmOff = false)}>Cancel</button>143
+ {:else}144
+ <button type="button" class="ghost-btn danger" on:click={() => (confirmOff = true)}>Turn off</button>145
+ {/if}146
+ </div>147
+ {:else if encryptionState}148
+ <p class="sec-line"><span class="pill warn">{encryptionState.key_backup === 'Disabled' ? 'off' : encryptionState.key_backup.toLowerCase()}</span></p>149
+ <div class="row">150
+ <button type="button" class="primary-btn" disabled={busy !== null} on:click={() => void backup('enable_key_backup')}>151
+ {busy === 'enable_key_backup' ? 'Turning on…' : 'Use existing backup'}152
+ </button>153
+ </div>154
+ {/if}155
+ </section>156
+107157
<section aria-label="Recovery">108158
<h3 class="sec-h">Recovery</h3>109159
<form on:submit|preventDefault={recover}>@@ -115,6 +165,7 @@
115165
spellcheck="false"116166
placeholder="EsT4 …"117167
bind:value={recoveryKey}168
+ bind:this={recoveryInput}118169
disabled={busy !== null}119170
/>120171
</label>@@ -209,8 +260,9 @@
209260
</div>210261
211262
<style>212
- .sec-h { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin: 16px 0 6px; }213
- .sec-h:first-child { margin-top: 0; }263
+ .sec-h { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin: 0 0 6px; }264
+ section + section { margin-top: 20px; }265
+ .primary-btn.wide { margin-top: 8px; }214266
.sec-line { font-size: 13px; margin: 0 0 8px; overflow-wrap: anywhere; }215267
.sec-line.muted, .sec-note { color: var(--muted); font-size: 12.5px; }216268
.sec-note { margin: 8px 0 0; }@@ -0,0 +1,15 @@
1
+<script lang="ts">2
+ import { createEventDispatcher } from 'svelte';3
+ import { dismissStorageWarning, storageUnprotected } from './protection';4
+ import { sessionUser } from './store';5
+6
+ const dispatch = createEventDispatcher<{ open: void }>();7
+</script>8
+9
+{#if $storageUnprotected}10
+ <div class="sec-banner warn" role="status">11
+ <span>Browser storage can be cleared</span>12
+ <button type="button" on:click={() => dispatch('open')}>Back up</button>13
+ <button type="button" aria-label="Dismiss" on:click={() => $sessionUser && dismissStorageWarning($sessionUser)}>✕</button>14
+ </div>15
+{/if}@@ -161,6 +161,12 @@ export function recoverEncryption(recovery_key: string): Promise<void> {
161161
return transport.recoverEncryption(recovery_key);162162
}163163
164
+export type KeyBackupAction = 'enable_key_backup' | 'disable_key_backup';165
+166
+export async function runKeyBackup(op: KeyBackupAction, deleteFromServer = false): Promise<void> {167
+ await transport.command(op === 'disable_key_backup' ? { op, delete_from_server: deleteFromServer } : { op });168
+}169
+164170
export function startVerification(): Promise<void> {165171
return transport.startVerification();166172
}@@ -0,0 +1,64 @@
1
+import type { EncryptionState } from './types';2
+3
+export type RecoveryPrompt = 'recover' | 'setup';4
+5
+export interface InstallContext {6
+ device: boolean;7
+ standalone: boolean;8
+}9
+10
+export interface PersistResult {11
+ persisted: boolean;12
+ usage: number | null;13
+ quota: number | null;14
+}15
+16
+export interface StorageLike {17
+ getItem(key: string): string | null;18
+ setItem(key: string, value: string): void;19
+}20
+21
+export interface PersistManager {22
+ persisted?(): Promise<boolean>;23
+ persist?(): Promise<boolean>;24
+ estimate?(): Promise<{ usage?: number; quota?: number }>;25
+}26
+27
+export function canDismissRecovery(ctx: InstallContext): boolean {28
+ return ctx.device && !ctx.standalone;29
+}30
+31
+export function recoveryPrompt(state: EncryptionState | null, ctx: InstallContext, dismissed: boolean): RecoveryPrompt | null {32
+ if (!state) return null;33
+ if (state.recovery === 'Incomplete') return 'recover';34
+ if (state.recovery === 'Disabled') return dismissed && canDismissRecovery(ctx) ? null : 'setup';35
+ return null;36
+}37
+38
+export function dismissalKey(kind: 'recovery' | 'storage', account: string): string {39
+ return `malice.dismissed.${kind}.${account}`;40
+}41
+42
+export function isDismissed(store: StorageLike, kind: 'recovery' | 'storage', account: string): boolean {43
+ try {44
+ return store.getItem(dismissalKey(kind, account)) === '1';45
+ } catch {46
+ return false;47
+ }48
+}49
+50
+export function setDismissed(store: StorageLike, kind: 'recovery' | 'storage', account: string): void {51
+ try {52
+ store.setItem(dismissalKey(kind, account), '1');53
+ } catch {54
+ // dismissal then lasts only for this page load55
+ }56
+}57
+58
+export async function requestPersistence(manager: PersistManager | undefined): Promise<PersistResult | null> {59
+ if (!manager?.persist || !manager.persisted) return null;60
+ let persisted = await manager.persisted();61
+ if (!persisted) persisted = await manager.persist();62
+ const estimate = manager.estimate ? await manager.estimate().catch(() => null) : null;63
+ return { persisted, usage: estimate?.usage ?? null, quota: estimate?.quota ?? null };64
+}@@ -0,0 +1,38 @@
1
+import { writable } from 'svelte/store';2
+import {3
+ isDismissed,4
+ requestPersistence,5
+ setDismissed,6
+ type InstallContext7
+} from './protection-model';8
+9
+export const storageUnprotected = writable(false);10
+export const recoveryDismissed = writable(false);11
+12
+export const isDeviceBuild = import.meta.env.VITE_MALICE_TARGET === 'device';13
+14
+export function installContext(): InstallContext {15
+ const standalone =16
+ window.matchMedia('(display-mode: standalone)').matches ||17
+ (navigator as Navigator & { standalone?: boolean }).standalone === true;18
+ return { device: isDeviceBuild, standalone };19
+}20
+21
+export function loadDismissals(account: string): void {22
+ recoveryDismissed.set(isDismissed(localStorage, 'recovery', account));23
+}24
+25
+export async function protectStorage(account: string): Promise<void> {26
+ const result = await requestPersistence(navigator.storage).catch(() => null);27
+ storageUnprotected.set(isDeviceBuild && result !== null && !result.persisted && !isDismissed(localStorage, 'storage', account));28
+}29
+30
+export function dismissStorageWarning(account: string): void {31
+ setDismissed(localStorage, 'storage', account);32
+ storageUnprotected.set(false);33
+}34
+35
+export function dismissRecoveryPrompt(account: string): void {36
+ setDismissed(localStorage, 'recovery', account);37
+ recoveryDismissed.set(true);38
+}@@ -37,6 +37,9 @@ export type CommandRequest =
3737
| { op: 'set_room_avatar'; room_id: string; mxc: string | null }3838
| { op: 'mute'; room_id: string; user_id: string; muted: boolean }3939
| { op: 'notification_keywords' }40
+ | { op: 'create_key_backup' | 'enable_key_backup' }41
+ | { op: 'disable_key_backup'; delete_from_server?: boolean }42
+ | { op: 'recover_key_backup'; recovery_key: string }4043
| { op: 'search'; query: string; scope: unknown; limit: number }4144
| { op: 'search_more'; search_id: string; cursor: string; limit: number }4245
| { op: 'close_search'; search_id: string }@@ -0,0 +1,52 @@
1
+import assert from 'node:assert/strict';2
+import { test } from 'node:test';3
+import { isDismissed, recoveryPrompt, requestPersistence, setDismissed } from '../src/lib/protection-model.ts';4
+5
+const state = (recovery: 'Unknown' | 'Disabled' | 'Incomplete' | 'Enabled') => ({6
+ device_id: 'D',7
+ device_verified: true,8
+ recovery,9
+ key_backup: 'Disabled'10
+});11
+12
+const browser = { device: false, standalone: false };13
+const device = { device: true, standalone: false };14
+const installed = { device: true, standalone: true };15
+16
+test('recovery prompt follows recovery state', () => {17
+ assert.equal(recoveryPrompt(null, device, false), null);18
+ assert.equal(recoveryPrompt(state('Unknown'), device, false), null);19
+ assert.equal(recoveryPrompt(state('Enabled'), device, false), null);20
+ assert.equal(recoveryPrompt(state('Incomplete'), device, true), 'recover');21
+ assert.equal(recoveryPrompt(state('Disabled'), device, false), 'setup');22
+});23
+24
+test('only the device build in a browser tab may dismiss the setup prompt', () => {25
+ assert.equal(recoveryPrompt(state('Disabled'), device, true), null);26
+ assert.equal(recoveryPrompt(state('Disabled'), installed, true), 'setup');27
+ assert.equal(recoveryPrompt(state('Disabled'), browser, true), 'setup');28
+});29
+30
+test('dismissals are per account', () => {31
+ const data = new Map<string, string>();32
+ const store = { getItem: (k: string) => data.get(k) ?? null, setItem: (k: string, v: string) => void data.set(k, v) };33
+ setDismissed(store, 'storage', '@a:x');34
+ assert.equal(isDismissed(store, 'storage', '@a:x'), true);35
+ assert.equal(isDismissed(store, 'storage', '@b:x'), false);36
+ assert.equal(isDismissed(store, 'recovery', '@a:x'), false);37
+});38
+39
+test('persistence is requested only when not already granted', async () => {40
+ let asked = 0;41
+ const denied = {42
+ persisted: async () => false,43
+ persist: async () => (asked++, false),44
+ estimate: async () => ({ usage: 5, quota: 100 })45
+ };46
+ assert.deepEqual(await requestPersistence(denied), { persisted: false, usage: 5, quota: 100 });47
+ assert.equal(asked, 1);48
+ const granted = { persisted: async () => true, persist: async () => (asked++, true) };49
+ assert.deepEqual(await requestPersistence(granted), { persisted: true, usage: null, quota: null });50
+ assert.equal(asked, 1);51
+ assert.equal(await requestPersistence(undefined), null);52
+});