atagen

malice

public
Star0
Watch0

Commit 4929dcd34937

tarball

atagen <boss@atagen.co>2026-10-26 04:43 UTC

9 files changed+282-4
web: protect encrypted storage and expose key backup controls

An iOS Home Screen install gets storage separate from Safari, so a device-build install is a new E2EE device whose keys die with that storage. The client now requests persistent storage and warns when it's refused, and Security can enable or turn off an existing backup. create_key_backup has no UI because it replaces any server-side backup under a new key, so recovery setup waits on macltok's enable_recovery.
Modified file: crates/malice-core/src/request.rs+44 additions
@@ -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 handle
930958 .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: false
1519+ }
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(""));
Modified file: crates/malice-server/src/routes.rs+4 additions
@@ -566,6 +566,10 @@ async fn post_command(
566566 | Request::SetMentionsRead { .. }
567567 | Request::MarkMentionsReadThrough { .. }
568568 | Request::RecoverMentions
569+ | Request::CreateKeyBackup
570+ | Request::EnableKeyBackup
571+ | Request::DisableKeyBackup { .. }
572+ | Request::RecoverKeyBackup { .. }
569573 | Request::RetryRecentHistory { .. }
570574 | Request::EventContext { .. }
571575 | Request::EventContextBefore { .. }
Modified file: web/src/lib/SecurityPanel.svelte+56 additions−4 deletions
@@ -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 VerifyAction
810 } 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 server
141+ </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; }
Added file: web/src/lib/StorageBanner.svelte+15 additions
@@ -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}
Modified file: web/src/lib/api.ts+6 additions
@@ -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 }
Added file: web/src/lib/protection-model.ts+64 additions
@@ -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 load
55+ }
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+}
Added file: web/src/lib/protection.ts+38 additions
@@ -0,0 +1,38 @@
1+import { writable } from 'svelte/store';
2+import {
3+ isDismissed,
4+ requestPersistence,
5+ setDismissed,
6+ type InstallContext
7+} 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+}
Modified file: web/src/lib/transport.ts+3 additions
@@ -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 }
Added file: web/test/protection.test.ts+52 additions
@@ -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+});