Files
Warp/apps/web/src/components/EncryptionDialog.vue
T
tajniak81andClaude Opus 5.5 b6f3be6ac4
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s
Web client (warp-web) with E2EE, and two sync fixes
apps/web: a Matrix client for the browser on Vue 3, Tailwind and
matrix-js-sdk, built on @warp/ui. Like the admin panel it is an optional,
separate service: warp-web embeds the built app, serves /config.json and
proxies only /_matrix/client and /_matrix/media to the homeserver (or,
with -public-homeserver, lets the browser call it directly).
Sign-in and sign-up with registration tokens, room list, timeline with
back-pagination, text, images, video and files over authenticated media,
local echo with sent/read/failed states, receipts, typing, invites,
creating, joining and leaving rooms, room info, settings, phone layout.
End-to-end encryption through the Rust crypto: encrypted messages and
files, emoji verification, key backup setup and restore.

apps/ui: WRoomItem, WMessageBubble and WComposer from the brand package,
and the icons the client needs.

syncapi fixes found with the client:
- A room joined inside the sync window was sent as the join alone with
  limited: false, so clients thought they had the whole room. It is now
  read like an initial sync; a profile change is not mistaken for a join.
- A live typing notification was reported on every sync, so every long
  poll returned at once while anybody typed. Typing now has a position
  in the sync token and is reported when it changes, including the empty
  list when the last person stops.

Also: Dockerfile, a `web` compose profile, Makefile targets, a CI job
with a proxy smoke test, docs. PLAN.md moves out of the repository.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:22:11 +02:00

253 lines
9.3 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { WBanner, WButton, WCheckbox, WDialog, WIcon, WSpinner, WTextField } from '@warp/ui'
import { errorMessage } from '@/lib/format'
import {
createKeyBackup,
PasswordNeededError,
recoveryKeyKnown,
restoreKeyBackup,
setUpEncryption,
unlockWithRecoveryKey,
} from '@/matrix/crypto'
import { useSecurity } from '@/stores/security'
import { useToasts } from '@/stores/toasts'
// Encryption for the account and this session, one task per mode:
// setup – first time: cross-signing, secret storage, key backup; shows the recovery key
// reset – the recovery key is lost: new keys, everyone verifies again
// unlock – verify this session with the recovery key, or with another session
// restore – read older messages from the key backup
// backup – start a key backup on an account that has none
export type EncryptionMode = 'setup' | 'reset' | 'unlock' | 'restore' | 'backup'
const open = defineModel<boolean>('open', { default: false })
const props = defineProps<{ mode: EncryptionMode }>()
const security = useSecurity()
const toasts = useToasts()
const step = ref<EncryptionMode | 'saveKey' | 'restoring' | 'restored'>(props.mode)
const password = ref('')
const needPassword = ref(false)
const recoveryKey = ref('')
const savedKey = ref(false)
const input = ref('')
const busy = ref(false)
const error = ref('')
const progress = ref<{ done: number; total: number } | null>(null)
const restored = ref({ imported: 0, total: 0 })
watch(open, (v) => {
if (!v) return
step.value = props.mode
password.value = ''
needPassword.value = false
recoveryKey.value = ''
savedKey.value = false
input.value = ''
error.value = ''
progress.value = null
if (props.mode === 'restore') void restore()
})
const title = computed(() => {
switch (step.value) {
case 'setup':
case 'saveKey':
return 'Set up encryption'
case 'reset':
return 'Reset encryption'
case 'unlock':
return 'Verify this session'
case 'backup':
return 'Set up key backup'
default:
return 'Restore from key backup'
}
})
async function run(fn: () => Promise<void>) {
busy.value = true
error.value = ''
try {
await fn()
} catch (e) {
if (e instanceof PasswordNeededError) needPassword.value = true
error.value = errorMessage(e)
} finally {
busy.value = false
void security.refresh()
}
}
function setUp() {
return run(async () => {
recoveryKey.value = await setUpEncryption({ password: password.value || undefined, reset: step.value === 'reset' })
step.value = 'saveKey'
})
}
function unlock() {
return run(async () => {
await unlockWithRecoveryKey(input.value)
toasts.success('This session is verified.')
await restore()
})
}
function backup() {
return run(async () => {
if (!recoveryKeyKnown()) await unlockWithRecoveryKey(input.value)
await createKeyBackup()
toasts.success('Key backup is on.')
open.value = false
})
}
async function restore() {
step.value = 'restoring'
progress.value = null
error.value = ''
try {
const res = await restoreKeyBackup((done, total) => (progress.value = { done, total }))
restored.value = { imported: res.imported, total: res.total }
step.value = 'restored'
} catch (e) {
error.value = errorMessage(e)
step.value = 'restored'
restored.value = { imported: 0, total: 0 }
} finally {
void security.refresh()
}
}
async function withOtherSession() {
error.value = ''
try {
await security.verifyThisSession()
open.value = false
} catch (e) {
error.value = errorMessage(e)
}
}
function copyKey() {
navigator.clipboard.writeText(recoveryKey.value).then(() => toasts.success('Copied.'))
}
function downloadKey() {
const blob = new Blob([`Warp recovery key\n\n${recoveryKey.value}\n`], { type: 'text/plain' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = 'warp-recovery-key.txt'
a.click()
URL.revokeObjectURL(a.href)
}
const needsKey = computed(() => step.value === 'unlock' || (step.value === 'backup' && !recoveryKeyKnown()))
</script>
<template>
<WDialog v-model:open="open" :title="title" :persistent="busy || step === 'saveKey' || step === 'restoring'">
<!-- First time, or starting over -->
<form v-if="step === 'setup' || step === 'reset'" id="enc-setup" class="flex flex-col gap-4" @submit.prevent="setUp">
<p v-if="step === 'setup'" class="body m-0">
Encryption keys for your account let you verify your sessions, and keep a backup of your message keys on the homeserver,
locked with a recovery key only you have. A new session can then read your older messages.
</p>
<WBanner v-else tone="warning" title="Everyone will need to verify you again">
This replaces your account’s keys and starts a new key backup. Messages only your old keys could read stay unreadable here.
</WBanner>
<WTextField
v-if="needPassword || step === 'reset'"
v-model="password"
wide
type="password"
label="Account password"
autocomplete="current-password"
hint="The homeserver asks for it before it takes new keys."
autofocus
/>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
</form>
<!-- The new recovery key, shown once -->
<div v-else-if="step === 'saveKey'" class="flex flex-col gap-4">
<WBanner tone="success" title="Encryption is set up">This session is verified, and key backup is on.</WBanner>
<p class="body m-0">
Save your recovery key somewhere safe, like a password manager. You need it to verify a new session when no other session is
signed in. Warp can’t show it to you again.
</p>
<p class="mxid m-0 rounded-md bg-surface-sunken p-3 text-center break-all select-all">{{ recoveryKey }}</p>
<div class="flex flex-wrap gap-2">
<WButton size="sm" icon="copy" @click="copyKey">Copy</WButton>
<WButton size="sm" icon="download" @click="downloadKey">Download</WButton>
</div>
<WCheckbox v-model="savedKey" label="I’ve saved my recovery key" />
</div>
<!-- Unlock with the recovery key -->
<form v-else-if="step === 'unlock' || step === 'backup'" id="enc-unlock" class="flex flex-col gap-4" @submit.prevent="step === 'unlock' ? unlock() : backup()">
<template v-if="step === 'unlock'">
<p class="body m-0">
Verify this session to read your encrypted messages here, and so others can trust it. Use one of your other sessions, or
your recovery key.
</p>
<WButton icon="shield-check" class="self-start" :disabled="busy" @click="withOtherSession">Verify with another session</WButton>
</template>
<p v-else class="body m-0">The key backup is locked with your recovery key.</p>
<WTextField
v-if="needsKey"
v-model="input"
wide
mono
type="password"
label="Recovery key"
hint="Starts with “Es”, in groups of four. A recovery passphrase works too."
autocomplete="off"
spellcheck="false"
/>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
<button v-if="step === 'unlock'" type="button" class="small self-start text-ink-muted hover:text-ink hover:underline" @click="step = 'reset'">
Lost your recovery key and other sessions? Reset encryption
</button>
</form>
<!-- Restoring -->
<div v-else-if="step === 'restoring'" class="flex flex-col items-center gap-3 py-2 text-center">
<WSpinner :size="28" />
<p class="body m-0">
Restoring message keys<template v-if="progress && progress.total"> · {{ progress.done }} of {{ progress.total }}</template>…
</p>
</div>
<div v-else-if="step === 'restored'" class="flex flex-col gap-3">
<WBanner v-if="error" tone="danger" title="Can’t restore from the key backup">{{ error }}</WBanner>
<p v-else class="body m-0 flex items-start gap-2">
<WIcon name="shield-check" class="mt-[2px] shrink-0 text-teal-text" />
<span v-if="restored.total">Restored {{ restored.imported }} of {{ restored.total }} message keys. Older messages decrypt as they load.</span>
<span v-else>The key backup has no message keys yet.</span>
</p>
</div>
<template #actions>
<template v-if="step === 'setup' || step === 'reset'">
<WButton variant="ghost" :disabled="busy" @click="open = false">Cancel</WButton>
<WButton type="submit" form="enc-setup" :variant="step === 'reset' ? 'danger' : 'primary'" :loading="busy" :disabled="step === 'reset' && !password">
{{ step === 'reset' ? 'Reset encryption' : 'Set up encryption' }}
</WButton>
</template>
<WButton v-else-if="step === 'saveKey'" variant="primary" :disabled="!savedKey" @click="open = false">Done</WButton>
<template v-else-if="step === 'unlock' || step === 'backup'">
<WButton variant="ghost" :disabled="busy" @click="open = false">Cancel</WButton>
<WButton type="submit" form="enc-unlock" variant="primary" icon="key" :loading="busy" :disabled="needsKey && !input.trim()">
{{ step === 'unlock' ? 'Verify session' : 'Set up key backup' }}
</WButton>
</template>
<WButton v-else-if="step === 'restored'" variant="primary" @click="open = false">Done</WButton>
</template>
</WDialog>
</template>