Web client: E2EE on by default, trust shields, identity verification, QR
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

- New rooms (public too) and direct messages are end-to-end encrypted by
  default.
- Per-message trust: "Not verified" for sessions you haven't verified,
  and what is wrong for unsigned sessions, mismatched keys or plain text
  in an encrypted room (src/matrix/trust.ts).
- Verify a person's identity in a room you share (your DM first), and
  QR-code verification both ways: show ours, scan theirs with the camera
  (qrcode-generator, jsqr).
- Warnings when someone's identity changes, with acknowledge, verify
  again or withdraw verification.
- Encrypted thumbnails for large images.
- A tab-only session encrypts its crypto store with a key kept in
  sessionStorage; stores unused for two weeks are deleted.
- Reset encryption fixed: new secret storage first, so the new
  cross-signing keys survive a stale /keys/query answer; status checks
  no longer query our own keys mid-setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-18 16:56:28 +02:00
1 parent b6f3be6ac4
commit 69ce927b6d
19 files changed
+845 -112

No files matched your search

+6 -3
View File
@@ -26,9 +26,8 @@ watch(open, (v) => {
error.value = ''
})
// End-to-end encrypted by default for a private room. A public one is
// readable by whoever joins anyway, and history is lost to newcomers.
watch(isPublic, (v) => (encrypted.value = !v && chat.canEncrypt))
// End-to-end encrypted by default, public rooms too; turning it off is a
// choice made here, once: it can't be turned off later.
// A public room's address, from its name: "Homelab ops" → homelab-ops.
watch(name, (n, old) => {
@@ -97,6 +96,10 @@ async function submit() {
</p>
<p class="small m-0 text-ink-muted">
<template v-if="!chat.canEncrypt">End-to-end encryption didn’t start in this browser.</template>
<template v-else-if="encrypted && isPublic">
Only the people in the room can read its messages, not the homeserver. People who join later can’t read what was sent
before they joined. It can’t be turned off later.
</template>
<template v-else-if="encrypted">Only the people in the room can read its messages, not the homeserver. It can’t be turned off later.</template>
<template v-else>The homeserver can read this room’s messages.</template>
</p>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
import qrcode from 'qrcode-generator'
import { computed } from 'vue'
// A QR code of raw bytes, drawn as one SVG path: dark modules on white,
// with the quiet zone scanners need, in either theme.
const props = withDefaults(defineProps<{ data: Uint8ClampedArray; size?: number; label: string }>(), { size: 220 })
const code = computed(() => {
const qr = qrcode(0, 'L')
let s = ''
for (const b of props.data) s += String.fromCharCode(b)
qr.addData(s, 'Byte')
qr.make()
const n = qr.getModuleCount()
let d = ''
for (let r = 0; r < n; r++) {
for (let c = 0; c < n; c++) if (qr.isDark(r, c)) d += `M${c + 4} ${r + 4}h1v1h-1z`
}
return { n: n + 8, d }
})
</script>
<template>
<svg :width="size" :height="size" :viewBox="`0 0 ${code.n} ${code.n}`" role="img" :aria-label="label" shape-rendering="crispEdges" class="rounded-md">
<rect :width="code.n" :height="code.n" fill="#ffffff" />
<path :d="code.d" fill="#000000" />
</svg>
</template>
+73
View File
@@ -0,0 +1,73 @@
<script setup lang="ts">
import jsQR from 'jsqr'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { WBanner, WSpinner } from '@warp/ui'
// The camera, looking for a QR code. Frames are read on a timer, decoded
// here in the page, and the camera is switched off as soon as a code is
// found or the scanner goes away.
const emit = defineEmits<{ found: [data: Uint8ClampedArray] }>()
const video = ref<HTMLVideoElement>()
const error = ref('')
const starting = ref(true)
let stream: MediaStream | null = null
let timer = 0
const canvas = document.createElement('canvas')
function stop() {
window.clearInterval(timer)
stream?.getTracks().forEach((t) => t.stop())
stream = null
}
function scan() {
const v = video.value
if (!v || v.readyState < 2 || !v.videoWidth) return
// Scaled down: decoding is the slow part, and codes are large on screen.
const scale = Math.min(1, 640 / v.videoWidth)
canvas.width = Math.round(v.videoWidth * scale)
canvas.height = Math.round(v.videoHeight * scale)
const g = canvas.getContext('2d', { willReadFrequently: true })
if (!g) return
g.drawImage(v, 0, 0, canvas.width, canvas.height)
const img = g.getImageData(0, 0, canvas.width, canvas.height)
const code = jsQR(img.data, img.width, img.height, { inversionAttempts: 'dontInvert' })
if (code?.binaryData.length) {
stop()
emit('found', Uint8ClampedArray.from(code.binaryData))
}
}
onMounted(async () => {
try {
if (!navigator.mediaDevices?.getUserMedia) throw new Error('This browser can’t use a camera here.')
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false })
if (!video.value) return stop()
video.value.srcObject = stream
await video.value.play()
timer = window.setInterval(scan, 250)
} catch (e) {
error.value =
e instanceof DOMException && e.name === 'NotAllowedError'
? 'Camera access was turned down. Allow it in the browser’s site settings, or compare emoji instead.'
: e instanceof Error
? e.message
: 'Can’t start the camera.'
} finally {
starting.value = false
}
})
onBeforeUnmount(stop)
</script>
<template>
<div class="flex flex-col items-center gap-3">
<WBanner v-if="error" tone="warning" class="w-full">{{ error }}</WBanner>
<div v-else class="relative aspect-square w-full max-w-[280px] overflow-hidden rounded-md bg-surface-sunken">
<video ref="video" class="h-full w-full object-cover" muted playsinline />
<span v-if="starting" class="absolute inset-0 flex items-center justify-center"><WSpinner /></span>
</div>
</div>
</template>
+14 -3
View File
@@ -213,9 +213,20 @@ async function confirmDelete() {
:title="formatDateTime(item.ts)"
>
<MessageBody :item="item" @view="(source, name) => (viewing = { source, name })" />
<template v-if="item.status === 'failed'" #meta>
<button type="button" class="font-semibold text-teal-text hover:underline" @click="retry(item)">Retry</button>
<button type="button" class="font-semibold text-ink-muted hover:underline" @click="discard(item)">Delete</button>
<template v-if="item.status === 'failed' || item.shield" #meta>
<span
v-if="item.shield"
:class="['inline-flex items-center gap-1', item.shield.tone === 'danger' ? 'text-danger' : 'text-warning']"
:title="item.shield.text"
>
<WIcon name="shield-alert" :size="14" />
<span>{{ item.shield.tone === 'danger' ? item.shield.text : 'Not verified' }}</span>
<span v-if="item.shield.tone !== 'danger'" class="wp-sr">: {{ item.shield.text }}</span>
</span>
<template v-if="item.status === 'failed'">
<button type="button" class="font-semibold text-teal-text hover:underline" @click="retry(item)">Retry</button>
<button type="button" class="font-semibold text-ink-muted hover:underline" @click="discard(item)">Delete</button>
</template>
</template>
</WMessageBubble>
+32 -3
View File
@@ -3,7 +3,7 @@ import { computed, ref, watch } from 'vue'
import { WBadge, WBanner, WButton, WDialog, WIcon, WSpinner } from '@warp/ui'
import { errorMessage } from '@/lib/format'
import { client } from '@/matrix/client'
import { sessionsOf, type SessionInfo } from '@/matrix/crypto'
import { identityOf, sessionsOf, type IdentityInfo, type SessionInfo } from '@/matrix/crypto'
import { useSecurity } from '@/stores/security'
// Someone's sessions, as their published keys list them, and whether this
@@ -14,6 +14,8 @@ const props = defineProps<{ userId: string; name: string }>()
const security = useSecurity()
const sessions = ref<SessionInfo[] | null>(null)
const identity = ref<IdentityInfo | null>(null)
const verifyingUser = ref(false)
const error = ref('')
const starting = ref('')
const mine = computed(() => props.userId === client().getUserId())
@@ -21,7 +23,9 @@ const mine = computed(() => props.userId === client().getUserId())
async function load() {
error.value = ''
try {
sessions.value = await sessionsOf(props.userId)
const [s, id] = await Promise.all([sessionsOf(props.userId), mine.value ? null : identityOf(props.userId)])
sessions.value = s
identity.value = id
} catch (e) {
error.value = errorMessage(e)
}
@@ -38,6 +42,19 @@ watch(
() => open.value && void load(),
)
async function verifyUser() {
verifyingUser.value = true
error.value = ''
try {
await security.verifyUser(props.userId)
open.value = false
} catch (e) {
error.value = errorMessage(e)
} finally {
verifyingUser.value = false
}
}
async function verify(s: SessionInfo) {
starting.value = s.deviceId
error.value = ''
@@ -59,8 +76,20 @@ async function verify(s: SessionInfo) {
</p>
<p v-else class="small m-0 text-ink-muted">
Each place <span class="mxid">{{ userId }}</span> is signed in has its own keys. Verify a session to be sure messages from it are
really theirs: you’ll both compare seven emoji, so do it together, in person or on a call.
really theirs: one of you scans the other’s QR code, or you both compare seven emoji, so do it together, in person or on a call.
</p>
<!-- The person, all their sessions at once -->
<div v-if="identity?.hasIdentity" class="flex items-center gap-3 rounded-md bg-surface-sunken px-3 py-3">
<WIcon :name="identity.verified ? 'shield-check' : 'shield-alert'" :class="identity.verified ? 'text-teal-text' : 'text-ink-muted'" />
<div class="min-w-0 flex-1">
<p class="body-strong m-0">{{ identity.verified ? `You verified ${name}` : `You haven’t verified ${name}` }}</p>
<p class="small m-0 text-ink-muted">
<template v-if="identity.verified">Every session they verify is trusted too.</template>
<template v-else>Verify them once, in your direct message, and every session they verify is trusted too.</template>
</p>
</div>
<WButton v-if="!identity.verified" size="sm" variant="primary" :loading="verifyingUser" @click="verifyUser">Verify {{ name }}</WButton>
</div>
<div v-if="!sessions && !error" class="flex justify-center py-4"><WSpinner /></div>
<p v-else-if="sessions && !sessions.length" class="body m-0 text-ink-muted">
No sessions with encryption keys. They can’t read end-to-end encrypted messages until they sign in with a client that supports it.
+124 -19
View File
@@ -2,18 +2,22 @@
import {
VerificationPhase,
VerifierEvent,
type ShowQrCodeCallbacks,
type ShowSasCallbacks,
type Verifier,
} from 'matrix-js-sdk/lib/crypto-api'
import { computed, ref, shallowRef, watch } from 'vue'
import { WBanner, WButton, WDialog, WIcon, WSpinner } from '@warp/ui'
import QrCode from '@/components/QrCode.vue'
import QrScanner from '@/components/QrScanner.vue'
import { errorMessage } from '@/lib/format'
import { client } from '@/matrix/client'
import { useSecurity } from '@/stores/security'
// One verification, from the request to the emoji to "Verified": either
// this session asked (Verify session) or someone else did. Both sides see
// the same seven emoji when nobody is in the middle.
// One verification, from the request to "Verified": either this session
// asked (Verify session) or someone else did. Once both are ready, either
// side scans the other's QR code, or both compare seven emoji, which match
// when nobody is in the middle.
const security = useSecurity()
@@ -33,6 +37,11 @@ const open = computed({
})
const sas = shallowRef<ShowSasCallbacks | null>(null)
const reciprocate = shallowRef<ShowQrCodeCallbacks | null>(null)
const qr = shallowRef<Uint8ClampedArray | null>(null)
const scanning = ref(false)
/** We scanned their code: waiting for them to confirm. */
const scanned = ref(false)
const confirmed = ref(false)
const busy = ref(false)
const error = ref('')
@@ -40,6 +49,10 @@ let verifier: Verifier | null = null
function reset() {
sas.value = null
reciprocate.value = null
qr.value = null
scanning.value = false
scanned.value = false
confirmed.value = false
busy.value = false
error.value = ''
@@ -59,34 +72,80 @@ const otherName = computed(() => {
return r.otherUserId
})
// Runs the verifier once there is one: the initiator starts emoji
// verification as soon as the other side is ready.
// What the other side can do with QR codes.
const theyScan = computed(() => {
void security.version
return Boolean(req.value?.otherPartySupportsMethod('m.qr_code.scan.v1'))
})
const theyShow = computed(() => {
void security.version
return Boolean(req.value?.otherPartySupportsMethod('m.qr_code.show.v1')) && Boolean(navigator.mediaDevices?.getUserMedia)
})
// Once ready: our QR code for them to scan, if they can. Without QR codes
// either way, the initiator goes straight to the emoji.
let qrFor: unknown = null
watch(
[req, phase],
async () => {
const r = req.value
if (!r) return
if (r.phase === VerificationPhase.Ready && r.initiatedByMe && !r.verifier && !busy.value) {
busy.value = true
try {
await r.startVerification('m.sas.v1')
} catch (e) {
error.value = errorMessage(e)
} finally {
busy.value = false
}
if (r.phase === VerificationPhase.Ready && qrFor !== r) {
qrFor = r
if (theyScan.value) qr.value = (await r.generateQRCode().catch(() => undefined)) ?? null
if (!qr.value && !theyShow.value && r.initiatedByMe && !r.verifier) await startEmoji()
}
const v = r.verifier
if (v && v !== verifier) {
verifier = v
v.on(VerifierEvent.ShowSas, (cb) => (sas.value = cb))
v.on(VerifierEvent.ShowReciprocateQr, (cb) => (reciprocate.value = cb))
if (v.getShowSasCallbacks()) sas.value = v.getShowSasCallbacks()
if (v.getReciprocateQrCodeCallbacks()) reciprocate.value = v.getReciprocateQrCodeCallbacks()
v.verify().catch(() => undefined)
}
},
{ immediate: true },
)
async function startEmoji() {
const r = req.value
if (!r || r.verifier || busy.value) return
busy.value = true
error.value = ''
try {
await r.startVerification('m.sas.v1')
} catch (e) {
error.value = errorMessage(e)
} finally {
busy.value = false
}
}
async function onScanned(data: Uint8ClampedArray) {
scanning.value = false
busy.value = true
error.value = ''
try {
// The verifier that comes of it is picked up and run above.
await req.value?.scanQRCode(data)
scanned.value = true
} catch (e) {
error.value = `That code didn’t work: ${errorMessage(e)}`
} finally {
busy.value = false
}
}
function reciprocateYes() {
confirmed.value = true
reciprocate.value?.confirm()
}
function reciprocateNo() {
reciprocate.value?.cancel()
}
async function accept() {
busy.value = true
error.value = ''
@@ -134,7 +193,12 @@ const cancelText = computed(() => {
}
})
const title = computed(() => (req.value?.isSelfVerification ? 'Verify this session' : 'Verify session'))
const title = computed(() => {
const r = req.value
if (r?.isSelfVerification) return 'Verify this session'
// In a room: the person, all their sessions; to-device: one session.
return r?.roomId ? `Verify ${otherName.value}` : 'Verify session'
})
</script>
<template>
@@ -159,16 +223,52 @@ const title = computed(() => (req.value?.isSelfVerification ? 'Verify this sessi
</span>
<span v-else>
<span class="body-strong">{{ otherName }}</span><template v-if="otherName !== req.otherUserId">
(<span class="mxid">{{ req.otherUserId }}</span>)</template> wants to verify your session.
(<span class="mxid">{{ req.otherUserId }}</span>)</template> wants to verify {{ req.roomId ? 'you' : 'your session' }}.
</span>
</p>
<p class="small m-0 text-ink-muted">You’ll both compare seven emoji. Accept only if you expected this.</p>
<p class="small m-0 text-ink-muted">One of you scans the other’s QR code, or you both compare seven emoji. Accept only if you expected this.</p>
</div>
<!-- Ready, about to compare -->
<!-- We scanned their code -->
<div v-else-if="scanned && phase !== VerificationPhase.Done && phase !== VerificationPhase.Cancelled" class="flex flex-col items-center gap-3 py-2 text-center">
<WIcon name="shield-check" :size="40" class="text-teal-text" />
<p class="body m-0">The code checks out. Waiting for {{ otherName }} to confirm on their side…</p>
</div>
<!-- Ready: pick a way, unless one was picked already -->
<div v-else-if="phase === VerificationPhase.Ready && !req.verifier && (qr || theyShow)" class="flex flex-col items-center gap-4 text-center">
<template v-if="scanning">
<p class="body m-0">Point the camera at the QR code {{ otherName }} shows.</p>
<QrScanner @found="onScanned" />
</template>
<template v-else>
<template v-if="qr">
<p class="body m-0">Scan this code with {{ req.isSelfVerification ? 'your other session' : `${otherName}’s session` }}.</p>
<QrCode :data="qr" label="QR code to verify this session" />
</template>
<p v-else class="body m-0">Scan the QR code on {{ req.isSelfVerification ? 'your other session' : `${otherName}’s screen` }}, or compare emoji.</p>
</template>
<div class="flex flex-wrap justify-center gap-2">
<WButton v-if="theyShow && !scanning" icon="image" :disabled="busy" @click="scanning = true">Scan their code</WButton>
<WButton v-if="scanning" variant="ghost" @click="scanning = false">Stop scanning</WButton>
<WButton :loading="busy" @click="startEmoji">Compare emoji instead</WButton>
</div>
</div>
<!-- They scanned our code: did it work on their side? -->
<div v-else-if="phase === VerificationPhase.Started && reciprocate" class="flex flex-col items-center gap-3 py-2 text-center">
<WIcon name="shield-check" :size="40" class="text-teal-text" />
<p class="body m-0">
{{ req.isSelfVerification ? 'Your other session' : otherName }} scanned the code. Does
{{ req.isSelfVerification ? 'it' : 'their screen' }} show a shield with a check mark?
</p>
<p v-if="confirmed" class="small m-0 flex items-center gap-2 text-ink-muted"><WSpinner :size="14" /> Finishing…</p>
</div>
<!-- Working -->
<div v-else-if="(phase === VerificationPhase.Ready || phase === VerificationPhase.Started) && !sas" class="flex flex-col items-center gap-4 py-2 text-center">
<WSpinner :size="28" />
<p class="body m-0">Getting the emoji ready…</p>
<p class="body m-0">{{ req.chosenMethod === 'm.reciprocate.v1' ? 'Checking the code…' : 'Getting the emoji ready…' }}</p>
</div>
<!-- Compare the emoji -->
@@ -194,6 +294,7 @@ const title = computed(() => (req.value?.isSelfVerification ? 'Verify this sessi
<p class="body-strong m-0">Verified</p>
<p class="body m-0 text-ink-muted">
<template v-if="req.isSelfVerification">This session is verified. Your other sessions trust it, and it can read your encrypted messages.</template>
<template v-else-if="req.roomId">You verified {{ otherName }}. Every session they verify is trusted too.</template>
<template v-else>
You verified {{ otherName }}’s session<template v-if="req.otherDeviceId"> <span class="mxid">{{ req.otherDeviceId }}</span></template>.
</template>
@@ -211,6 +312,10 @@ const title = computed(() => (req.value?.isSelfVerification ? 'Verify this sessi
<WButton variant="ghost" :disabled="busy" @click="open = false">Decline</WButton>
<WButton variant="primary" :loading="busy" @click="accept">Accept</WButton>
</template>
<template v-else-if="phase === VerificationPhase.Started && reciprocate && !confirmed">
<WButton variant="danger" @click="reciprocateNo">No</WButton>
<WButton variant="primary" icon="check" @click="reciprocateYes">Yes, it does</WButton>
</template>
<template v-else-if="phase === VerificationPhase.Started && sas && !confirmed">
<WButton variant="danger" :disabled="busy" @click="mismatch">They don’t match</WButton>
<WButton variant="primary" icon="check" :loading="busy" @click="match">They match</WButton>