From 69ce927b6d509bdffcb6c84d60509736168c6600 Mon Sep 17 00:00:00 2001
From: tajniak81 <13187254+tajniak81@users.noreply.github.com>
Date: Sun, 18 Oct 2026 16:56:28 +0200
Subject: [PATCH] Web client: E2EE on by default, trust shields, identity
verification, QR
- 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
---
apps/web/README.md | 56 +++---
apps/web/package.json | 2 +
apps/web/src/components/CreateRoomDialog.vue | 9 +-
apps/web/src/components/QrCode.vue | 30 ++++
apps/web/src/components/QrScanner.vue | 73 ++++++++
apps/web/src/components/RoomTimeline.vue | 17 +-
apps/web/src/components/SessionsDialog.vue | 35 +++-
.../web/src/components/VerificationDialog.vue | 143 +++++++++++++--
apps/web/src/main.ts | 3 +-
apps/web/src/matrix/client.ts | 106 ++++++++++-
apps/web/src/matrix/crypto.ts | 164 ++++++++++++++----
apps/web/src/matrix/send.ts | 55 +++++-
apps/web/src/matrix/timeline.ts | 14 ++
apps/web/src/matrix/trust.ts | 108 ++++++++++++
apps/web/src/stores/chat.ts | 27 ++-
apps/web/src/stores/security.ts | 20 ++-
apps/web/src/stores/session.ts | 21 ++-
apps/web/src/views/RoomView.vue | 58 +++++++
pnpm-lock.yaml | 16 ++
19 files changed, 845 insertions(+), 112 deletions(-)
create mode 100644 apps/web/src/components/QrCode.vue
create mode 100644 apps/web/src/components/QrScanner.vue
create mode 100644 apps/web/src/matrix/trust.ts
diff --git a/apps/web/README.md b/apps/web/README.md
index d5ef2e9..1d46536 100644
--- a/apps/web/README.md
+++ b/apps/web/README.md
@@ -48,44 +48,44 @@ that one origin.
- Read receipts, typing notifications, deleting messages.
- End-to-end encryption, with the Rust crypto stack matrix-js-sdk loads
(`matrix-sdk-crypto-wasm`): reading and sending in encrypted rooms,
- files included (encrypted in the browser before they are uploaded).
- A message this session can't decrypt says why, such as "sent before
- this session signed in", not just "encrypted".
-- Verification: verify someone's session from the room's member list,
- or this session from one of your others, by comparing emoji (SAS).
- Requests from others open a dialog to accept or decline.
+ files included (encrypted in the browser before they are uploaded, with
+ an encrypted thumbnail for large images). A message this session can't
+ decrypt says why, such as "sent before this session signed in", not
+ just "encrypted".
+- Trust on every message: one from a session you haven't verified says
+ *Not verified*; one with something wrong (an unsigned session,
+ mismatched keys, plain text in an encrypted room) says what.
+- Verification: verify a person once (in a room you share, your direct
+ message first) and every session they verify is trusted; or one session
+ at a time; or this session from one of your others. Either side scans
+ the other's QR code (the camera, in the browser) or both compare emoji.
+- Warnings when someone's identity changes: a reset since you first saw
+ them asks you to acknowledge it; a reset since you *verified* them says
+ their sessions are no longer trusted, with Verify again or Withdraw
+ verification.
- Encryption setup in Settings: cross-signing keys, secret storage and a
key backup in one step, behind a recovery key shown once. A new session
verifies itself with the recovery key (or another session) and restores
older messages from the backup. Reset encryption when the key is lost.
-- New private rooms and direct messages are end-to-end encrypted by
- default (a direct message only when the other person has a session with
- keys); Create room has the switch.
-- Create a room (private, or public with an address), start a direct chat,
- join by address or from the room directory, accept or decline invites,
- leave.
-- Room info: topic, members and their roles, invite, edit name and topic.
+- New rooms (public ones too) and direct messages are end-to-end
+ encrypted by default; Create room has the switch for turning it off.
- Settings: display name, avatar, theme, encryption.
- Phone layout: one pane at a time, the room list is the home screen.
-**Not yet:** per-message trust shields (a message from an unverified
-session looks like any other), verifying a person's identity in a direct
-message rather than one session at a time, QR-code verification, a warning
-when someone's identity changes, encrypted thumbnails for images you send
-(the receiving side shows the whole image), and a passphrase for the
-crypto store. After that: replies, reactions, threads, spaces,
-notifications.
+**Not yet:** replies, reactions, threads, spaces, notifications.
### Encryption keys in the browser
Each session keeps its keys in IndexedDB under its own name
-(`warp-web-crypto::`), unencrypted at rest like any web
-client's without a passphrase. Signing out deletes them; a session that
-ends some other way (signed out elsewhere, the token revoked) has them
-deleted when the page next starts. A session kept only for the tab
-("Stay signed in" off) leaves its store behind when the tab closes;
-the session can't be reused, but the keys stay until site data is
-cleared. The `.wasm` file is most of the download (about 2 MB
+(`warp-web-crypto::`). Signing out deletes them; a session
+that ends some other way (signed out elsewhere, the token revoked) has
+them deleted when the page next starts. A session kept only for the tab
+("Stay signed in" off) encrypts its store with a random key that lives
+with its credentials in `sessionStorage`: when the tab closes the key goes
+with it, and the store left behind can't be read; it is deleted after two
+weeks unused. A "Stay signed in" session's store is not encrypted at rest,
+like any web client's without a passphrase.
+The `.wasm` file is most of the download (about 2 MB
compressed) and needs `script-src 'wasm-unsafe-eval'`, which warp-web's
Content-Security-Policy allows; it allows no JavaScript `eval`.
@@ -132,6 +132,8 @@ empty URL meaning "this origin".
Content-Security-Policy, `X-Frame-Options: DENY` and
`Referrer-Policy: no-referrer` with the client's pages.
- Keep the proxy's read timeout above 30 seconds: `/sync` long-polls.
+- Serve it over HTTPS: browsers only give the page a camera (for scanning
+ QR codes when verifying) and WebCrypto on a secure origin.
- Allow request bodies as large as the homeserver's
`media.max_upload_bytes`; uploads pass through warp-web.
- warp-web adds `X-Forwarded-For`. The homeserver uses it for rate limits
diff --git a/apps/web/package.json b/apps/web/package.json
index 551d707..7e79c38 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -12,8 +12,10 @@
},
"dependencies": {
"@warp/ui": "workspace:*",
+ "jsqr": "1.4.0",
"matrix-js-sdk": "^44.0.0",
"pinia": "^3.0.3",
+ "qrcode-generator": "1.4.4",
"vue": "^3.5.22",
"vue-router": "^4.5.1"
},
diff --git a/apps/web/src/components/CreateRoomDialog.vue b/apps/web/src/components/CreateRoomDialog.vue
index f16331a..74a8a66 100644
--- a/apps/web/src/components/CreateRoomDialog.vue
+++ b/apps/web/src/components/CreateRoomDialog.vue
@@ -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() {
End-to-end encryption didn’t start in this browser.
+
+ 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.
+
Only the people in the room can read its messages, not the homeserver. It can’t be turned off later.
The homeserver can read this room’s messages.
Each place {{ userId }} 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.
+ {{ req.isSelfVerification ? 'Your other session' : otherName }} scanned the code. Does
+ {{ req.isSelfVerification ? 'it' : 'their screen' }} show a shield with a check mark?
+
+
Finishing…
+
+
+
-
Getting the emoji ready…
+
{{ req.chosenMethod === 'm.reciprocate.v1' ? 'Checking the code…' : 'Getting the emoji ready…' }}
@@ -194,6 +294,7 @@ const title = computed(() => (req.value?.isSelfVerification ? 'Verify this sessi
Verified
This session is verified. Your other sessions trust it, and it can read your encrypted messages.
+ You verified {{ otherName }}. Every session they verify is trusted too.
You verified {{ otherName }}’s session {{ req.otherDeviceId }}.
@@ -211,6 +312,10 @@ const title = computed(() => (req.value?.isSelfVerification ? 'Verify this sessi
DeclineAccept
+
+ No
+ Yes, it does
+
They don’t matchThey match
diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts
index 41517aa..aadf1a2 100644
--- a/apps/web/src/main.ts
+++ b/apps/web/src/main.ts
@@ -3,6 +3,7 @@ import { createApp } from 'vue'
import App from './App.vue'
import { loadConfig } from './config'
import { forgetEndedSessions } from './matrix/client'
+import { savedCryptoStores } from './stores/session'
import { router } from './router'
import './style.css'
@@ -10,6 +11,6 @@ import './style.css'
// before anything talks to it.
loadConfig().then(() => {
// Encryption keys of sessions that ended while their store was open.
- void forgetEndedSessions()
+ void forgetEndedSessions(savedCryptoStores())
createApp(App).use(createPinia()).use(router).mount('#app')
})
diff --git a/apps/web/src/matrix/client.ts b/apps/web/src/matrix/client.ts
index 940b333..2a6c42e 100644
--- a/apps/web/src/matrix/client.ts
+++ b/apps/web/src/matrix/client.ts
@@ -15,6 +15,22 @@ export interface Credentials {
userId: string
deviceId: string
accessToken: string
+ /**
+ * A session kept only for the tab encrypts its crypto store with this
+ * key (base64, 32 bytes), which lives with the credentials in
+ * sessionStorage: once the tab is gone, so is any way to read the store.
+ */
+ storageKey?: string
+}
+
+/** A new random key for a tab-only session's crypto store. */
+export function newStorageKey(): string {
+ const bytes = crypto.getRandomValues(new Uint8Array(32))
+ return btoa(String.fromCharCode(...bytes))
+}
+
+function decodeKey(b64: string): Uint8Array {
+ return Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0))
}
let current: MatrixClient | null = null
@@ -53,8 +69,9 @@ export async function startClient(creds: Credentials): Promise {
timelineSupport: true,
useAuthorizationHeader: true,
cryptoCallbacks,
- // Emoji (SAS) only: there is no camera to scan a QR code with here.
- verificationMethods: ['m.sas.v1'],
+ // Emoji (SAS), and QR codes both ways: shown here, or scanned with
+ // the camera.
+ verificationMethods: ['m.sas.v1', 'm.qr_code.show.v1', 'm.qr_code.scan.v1', 'm.reciprocate.v1'],
})
current = c
// End-to-end encryption: the Rust crypto stack (matrix-sdk-crypto-wasm),
@@ -62,8 +79,13 @@ export async function startClient(creds: Credentials): Promise {
// in one browser never share a store. It loads before the first sync so
// the to-device messages and room keys in it are not missed.
cryptoFailure = ''
+ const prefix = cryptoDatabasePrefix(creds.userId, creds.deviceId)
+ startHeartbeat(prefix)
try {
- await c.initRustCrypto({ cryptoDatabasePrefix: cryptoDatabasePrefix(creds.userId, creds.deviceId) })
+ await c.initRustCrypto({
+ cryptoDatabasePrefix: prefix,
+ storageKey: creds.storageKey ? decodeKey(creds.storageKey) : undefined,
+ })
bindCrypto(c)
} catch (e) {
console.error('crypto: ', e)
@@ -81,6 +103,7 @@ export async function startClient(creds: Credentials): Promise {
}
export function stopClient() {
+ stopHeartbeat()
if (!current) return
bindCrypto(null)
current.stopClient()
@@ -142,13 +165,80 @@ export async function destroyClient(session?: { userId: string; deviceId: string
if (!userId || !deviceId) return
const prefix = cryptoDatabasePrefix(userId, deviceId)
setPendingForget([...new Set([...pendingForget(), prefix])])
+ setAlive(prefix, null)
await deleteStore(c, prefix)
}
-/** Finishes deleting the crypto stores of sessions that ended. Runs before any client starts. */
-export async function forgetEndedSessions() {
- const list = pendingForget()
- if (!list.length) return
+// Each running session says it is alive every minute, so a page starting
+// later can tell a store in use (another tab) from one left behind by a tab
+// that closed. A store unused this long is deleted.
+const aliveKey = 'warp.web.crypto.alive'
+const abandonedAfterMs = 14 * 24 * 3600_000
+let heartbeat = 0
+let heartbeatPrefix = ''
+
+function aliveMap(): Record {
+ try {
+ const v = JSON.parse(localStorage.getItem(aliveKey) ?? '{}')
+ return v && typeof v === 'object' ? (v as Record) : {}
+ } catch {
+ return {}
+ }
+}
+
+function setAlive(prefix: string, ts: number | null) {
+ try {
+ const m = aliveMap()
+ if (ts === null) delete m[prefix]
+ else m[prefix] = ts
+ localStorage.setItem(aliveKey, JSON.stringify(m))
+ } catch {
+ // Without it, a store left behind is only deleted when site data is.
+ }
+}
+
+function startHeartbeat(prefix: string) {
+ stopHeartbeat()
+ heartbeatPrefix = prefix
+ setAlive(prefix, Date.now())
+ heartbeat = window.setInterval(() => setAlive(prefix, Date.now()), 60_000)
+}
+
+function stopHeartbeat() {
+ window.clearInterval(heartbeat)
+ heartbeat = 0
+ heartbeatPrefix = ''
+}
+
+const storeSuffix = '::matrix-sdk-crypto'
+
+/**
+ * Housekeeping before any client starts: finishes deleting the stores of
+ * sessions that ended, and deletes stores a closed tab left behind (a
+ * tab-only session's, unreadable without the tab's key anyway).
+ * `keep` lists the stores of sessions this browser still has credentials for.
+ */
+export async function forgetEndedSessions(keep: string[]) {
const c = anonymousClient()
- for (const prefix of list) await deleteStore(c, prefix)
+ for (const prefix of pendingForget()) await deleteStore(c, prefix)
+ let names: string[] = []
+ try {
+ names = (await indexedDB.databases()).map((d) => d.name ?? '')
+ } catch {
+ return
+ }
+ const alive = aliveMap()
+ const now = Date.now()
+ for (const name of names) {
+ if (!name.startsWith('warp-web-crypto:') || !name.endsWith(storeSuffix)) continue
+ const prefix = name.slice(0, -storeSuffix.length)
+ if (keep.includes(prefix) || prefix === heartbeatPrefix) continue
+ // Never seen alive (a store from before this was kept): start its clock now.
+ if (alive[prefix] === undefined) {
+ setAlive(prefix, now)
+ continue
+ }
+ if (now - alive[prefix]! < abandonedAfterMs) continue
+ if (await deleteStore(c, prefix)) setAlive(prefix, null)
+ }
}
diff --git a/apps/web/src/matrix/crypto.ts b/apps/web/src/matrix/crypto.ts
index e5fc6ff..f6d4010 100644
--- a/apps/web/src/matrix/crypto.ts
+++ b/apps/web/src/matrix/crypto.ts
@@ -73,13 +73,22 @@ export interface EncryptionStatus {
backupKey: boolean
}
+let settingUp = false
+
+/** Encryption is being set up or reset: status checks wait for it. */
+export function isSettingUp(): boolean {
+ return settingUp
+}
+
export async function encryptionStatus(): Promise {
const api = crypto()
const c = current!
const userId = c.getUserId()!
const deviceId = c.getDeviceId()!
const [crossSigning, device, secretStorage, backupInfo, backupActive, backupKey] = await Promise.all([
- api.userHasCrossSigningKeys(userId, true),
+ // The local copy: sync keeps our own keys current, and asking the
+ // homeserver mid-setup races the upload of new ones.
+ api.userHasCrossSigningKeys(userId, false),
api.getDeviceVerificationStatus(userId, deviceId),
api.isSecretStorageReady(),
api.getKeyBackupInfo().catch(() => null),
@@ -117,47 +126,83 @@ function uiaSession(e: unknown): string | undefined | null {
* verified this account has to verify it again.
*/
export async function setUpEncryption(opts: { password?: string; reset?: boolean }): Promise {
+ settingUp = true
+ try {
+ return await setUp(opts)
+ } finally {
+ settingUp = false
+ }
+}
+
+async function setUp(opts: { password?: string; reset?: boolean }): Promise {
const api = crypto()
const c = current!
- await api.bootstrapCrossSigning({
- setupNewCrossSigning: opts.reset ?? false,
- authUploadDeviceSigningKeys: async (makeRequest) => {
- try {
- await makeRequest(null)
- } catch (e) {
- const session = uiaSession(e)
- if (session === null) throw e
- if (!opts.password) throw new PasswordNeededError()
- const auth: AuthDict = {
- type: 'm.login.password',
- identifier: { type: 'm.id.user', user: c.getUserId()! },
- password: opts.password,
- session,
- }
- try {
- await makeRequest(auth)
- } catch (e2) {
- if (uiaSession(e2) !== null || (e2 instanceof MatrixError && e2.errcode === 'M_FORBIDDEN')) {
- throw new Error('That password isn’t right.')
- }
- throw e2
- }
+ const authUploadDeviceSigningKeys = async (makeRequest: (auth: AuthDict | null) => Promise) => {
+ try {
+ await makeRequest(null)
+ } catch (e) {
+ const session = uiaSession(e)
+ if (session === null) throw e
+ if (!opts.password) throw new PasswordNeededError()
+ const auth: AuthDict = {
+ type: 'm.login.password',
+ identifier: { type: 'm.id.user', user: c.getUserId()! },
+ password: opts.password,
+ session,
}
- },
- })
+ try {
+ await makeRequest(auth)
+ } catch (e2) {
+ if (uiaSession(e2) !== null || (e2 instanceof MatrixError && e2.errcode === 'M_FORBIDDEN')) {
+ throw new Error('That password isn’t right.')
+ }
+ throw e2
+ }
+ }
+ }
let recoveryKey = ''
- await api.bootstrapSecretStorage({
- setupNewSecretStorage: true,
- setupNewKeyBackup: true,
- createSecretStorageKey: async () => {
- const key = await api.createRecoveryKeyFromPassphrase()
- recoveryKey = key.encodedPrivateKey ?? ''
- return key
- },
- })
+ const createSecretStorageKey = async () => {
+ const key = await api.createRecoveryKeyFromPassphrase()
+ recoveryKey = key.encodedPrivateKey ?? ''
+ return key
+ }
+ if (opts.reset) {
+ // New secret storage (and a new key backup) first, under a new key the
+ // page then holds: the new cross-signing keys are saved into it before
+ // they are uploaded, so nothing is lost if an answer about the old
+ // identity arrives in between.
+ await api.bootstrapSecretStorage({ setupNewSecretStorage: true, setupNewKeyBackup: true, createSecretStorageKey })
+ await api.bootstrapCrossSigning({ setupNewCrossSigning: true, authUploadDeviceSigningKeys })
+ } else {
+ await api.bootstrapCrossSigning({ authUploadDeviceSigningKeys })
+ await api.bootstrapSecretStorage({ setupNewSecretStorage: true, setupNewKeyBackup: true, createSecretStorageKey })
+ }
+ await ensureSelfSigned()
return recoveryKey
}
+/**
+ * Makes sure this session ended up signed by the account's new identity.
+ * A `/keys/query` answer still carrying the old keys can arrive after the
+ * new ones were made, and the crypto stack then drops the new private
+ * keys; they are in the new secret storage, whose key is cached, so they
+ * are read back from there and this session is signed again.
+ */
+async function ensureSelfSigned() {
+ const api = crypto()
+ const c = current!
+ for (let attempt = 0; attempt < 3; attempt++) {
+ const device = await api.getDeviceVerificationStatus(c.getUserId()!, c.getDeviceId()!)
+ if (device?.crossSigningVerified) return
+ const status = await api.getCrossSigningStatus()
+ if (!status.privateKeysInSecretStorage) return
+ // Reads the keys back from secret storage when they are missing here.
+ await api.bootstrapCrossSigning({})
+ await api.crossSignDevice(c.getDeviceId()!).catch(() => undefined)
+ await new Promise((r) => setTimeout(r, 1000))
+ }
+}
+
async function keyFromInput(input: string, info: SecretStorageKeyDescription): Promise> {
const text = input.trim()
try {
@@ -252,3 +297,52 @@ export function recoveryKeyKnown(): boolean {
export async function createKeyBackup(): Promise {
await crypto().bootstrapSecretStorage({ setupNewKeyBackup: true })
}
+
+/** Whether you verified the person's identity, and whether they have one to verify. */
+export interface IdentityInfo {
+ /** They have cross-signing keys: their identity can be verified. */
+ hasIdentity: boolean
+ verified: boolean
+}
+
+export async function identityOf(userId: string): Promise {
+ const api = crypto()
+ const [hasIdentity, status] = await Promise.all([api.userHasCrossSigningKeys(userId, true), api.getUserVerificationStatus(userId)])
+ return { hasIdentity, verified: status.isCrossSigningVerified() }
+}
+
+/** Verifies a person (their identity, so all their verified sessions) in a room you share, normally your DM. */
+export function verifyUserInRoom(userId: string, roomId: string): Promise {
+ return crypto().requestVerificationDM(userId, roomId)
+}
+
+export interface IdentityWarning {
+ userId: string
+ /** `reset`: a new identity since you first saw them; `verifiedReset`: since you verified them. */
+ kind: 'reset' | 'verifiedReset'
+}
+
+/** People among `userIds` whose identity changed, and who you haven't acknowledged yet. */
+export async function identityWarnings(userIds: string[]): Promise {
+ const api = current?.getCrypto()
+ if (!api) return []
+ const me = current!.getUserId()
+ const out: IdentityWarning[] = []
+ for (const userId of userIds) {
+ if (userId === me) continue
+ const s = await api.getUserVerificationStatus(userId)
+ if (s.wasCrossSigningVerified() && !s.isCrossSigningVerified()) out.push({ userId, kind: 'verifiedReset' })
+ else if (s.needsUserApproval) out.push({ userId, kind: 'reset' })
+ }
+ return out
+}
+
+/** Accepts someone's new identity (after a reset you weren't warned about by them). */
+export function acceptIdentity(userId: string): Promise {
+ return crypto().pinCurrentUserIdentity(userId)
+}
+
+/** Stops requiring that someone you verified stay verified, after they reset their identity. */
+export function withdrawVerification(userId: string): Promise {
+ return crypto().withdrawVerificationRequirement(userId)
+}
diff --git a/apps/web/src/matrix/send.ts b/apps/web/src/matrix/send.ts
index 29be9ab..db9e70c 100644
--- a/apps/web/src/matrix/send.ts
+++ b/apps/web/src/matrix/send.ts
@@ -33,6 +33,40 @@ function imageSize(file: File): Promise<{ w: number; h: number } | undefined> {
})
}
+// Images larger than this get a thumbnail made here, so the timeline loads
+// that instead of the whole file. In an encrypted room the homeserver can't
+// make one, so this is the only thumbnail there is.
+const thumbMax = { w: 800, h: 600 }
+
+interface Thumbnail {
+ blob: Blob
+ w: number
+ h: number
+}
+
+async function makeThumbnail(file: File, size: { w: number; h: number } | undefined): Promise {
+ if (!size || (size.w <= thumbMax.w && size.h <= thumbMax.h && file.size < 256 * 1024)) return undefined
+ if (file.type === 'image/gif' || file.type === 'image/svg+xml') return undefined
+ try {
+ const bitmap = await createImageBitmap(file)
+ const scale = Math.min(1, thumbMax.w / bitmap.width, thumbMax.h / bitmap.height)
+ const w = Math.max(1, Math.round(bitmap.width * scale))
+ const h = Math.max(1, Math.round(bitmap.height * scale))
+ const canvas = document.createElement('canvas')
+ canvas.width = w
+ canvas.height = h
+ canvas.getContext('2d')?.drawImage(bitmap, 0, 0, w, h)
+ bitmap.close()
+ // PNG keeps transparency; anything else is a photo, and JPEG is smaller.
+ const type = file.type === 'image/png' ? 'image/png' : 'image/jpeg'
+ const blob = await new Promise((resolve) => canvas.toBlob(resolve, type, 0.82))
+ if (!blob || blob.size >= file.size) return undefined
+ return { blob, w, h }
+ } catch {
+ return undefined
+ }
+}
+
function msgtypeFor(file: File): MsgType {
if (file.type.startsWith('image/')) return MsgType.Image
if (file.type.startsWith('video/')) return MsgType.Video
@@ -54,11 +88,28 @@ export async function sendFile(
) {
const msgtype = msgtypeFor(file)
const info: Record = { mimetype: file.type || 'application/octet-stream', size: file.size }
- if (msgtype === MsgType.Image) Object.assign(info, (await imageSize(file)) ?? {})
+ let thumb: Thumbnail | undefined
+ if (msgtype === MsgType.Image) {
+ const size = await imageSize(file)
+ Object.assign(info, size ?? {})
+ thumb = await makeThumbnail(file, size)
+ }
+ const encrypted = room.hasEncryptionStateEvent()
const progressHandler = (p: { loaded: number; total: number }) => onProgress?.({ name: file.name, loaded: p.loaded, total: p.total })
+ if (thumb) {
+ const thumbInfo = { w: thumb.w, h: thumb.h, mimetype: thumb.blob.type, size: thumb.blob.size }
+ if (encrypted) {
+ const { data, file: keys } = await encryptAttachment(await thumb.blob.arrayBuffer())
+ const up = await c.uploadContent(new Blob([data]), { type: 'application/octet-stream', includeFilename: false })
+ Object.assign(info, { thumbnail_file: { ...keys, url: up.content_uri }, thumbnail_info: thumbInfo })
+ } else {
+ const up = await c.uploadContent(thumb.blob, { type: thumb.blob.type, includeFilename: false })
+ Object.assign(info, { thumbnail_url: up.content_uri, thumbnail_info: thumbInfo })
+ }
+ }
let source: { url: string } | { file: object }
- if (room.hasEncryptionStateEvent()) {
+ if (encrypted) {
const { data, file: keys } = await encryptAttachment(await file.arrayBuffer())
// The name and type stay inside the encrypted message.
const upload = await c.uploadContent(new Blob([data]), { type: 'application/octet-stream', includeFilename: false, progressHandler })
diff --git a/apps/web/src/matrix/timeline.ts b/apps/web/src/matrix/timeline.ts
index 3dd6aef..2b74dcf 100644
--- a/apps/web/src/matrix/timeline.ts
+++ b/apps/web/src/matrix/timeline.ts
@@ -13,6 +13,7 @@ import type { MessageStatus } from '@warp/ui'
import { sameDay } from '@/lib/format'
import type { EncryptedFile } from './attachments'
import type { MediaSource } from './media'
+import { shieldOf, type Shield } from './trust'
// Turns a room's live timeline into what the timeline view draws: messages
// grouped by sender, day separators and one-line notices for state changes.
@@ -53,6 +54,8 @@ export interface MessageItem {
utd?: string
/** Sent end-to-end encrypted. */
e2ee: boolean
+ /** Why this message may not be from who it says, when that's in doubt. */
+ shield?: Shield
status?: MessageStatus
/** First of a run of messages from one sender: shows the name. */
first: boolean
@@ -229,6 +232,8 @@ function displayEvents(room: Room): MatrixEvent[] {
export function buildTimeline(c: MatrixClient, room: Room): TimelineItem[] {
const me = c.getUserId() ?? ''
const cryptoRunning = Boolean(c.getCrypto())
+ // Messages after this were meant to be end-to-end encrypted.
+ const encryptionSince = room.currentState.getStateEvents(EventType.RoomEncryption, '')?.getTs() ?? Infinity
const events = displayEvents(room)
// An event is read when someone else's receipt is on it or on anything
@@ -276,6 +281,14 @@ export function buildTimeline(c: MatrixClient, room: Room): TimelineItem[] {
continue
}
+ // A verification request in a direct message is a notice, not a message.
+ if (type === EventType.RoomMessage && ev.getContent().msgtype === 'm.key.verification.request') {
+ const to = String(ev.getContent().to ?? '')
+ items.push({ kind: 'notice', key, ts, text: `${nameOf(room, ev.getSender() ?? '')} started verifying ${nameOf(room, to)}` })
+ prev = null
+ continue
+ }
+
const sender = ev.getSender() ?? ''
const own = sender === me
const content = ev.getContent()
@@ -301,6 +314,7 @@ export function buildTimeline(c: MatrixClient, room: Room): TimelineItem[] {
encrypted,
utd: encrypted ? utdText(ev, cryptoRunning) : undefined,
e2ee: ev.isEncrypted(),
+ shield: cryptoRunning && !redacted ? shieldOf(c, ev, ts > encryptionSince) : undefined,
status: status === 'sent' && ev.getId() && readByOthers.has(ev.getId()!) ? 'read' : status,
first: true,
last: true,
diff --git a/apps/web/src/matrix/trust.ts b/apps/web/src/matrix/trust.ts
new file mode 100644
index 0000000..f46b275
--- /dev/null
+++ b/apps/web/src/matrix/trust.ts
@@ -0,0 +1,108 @@
+import type { MatrixClient, MatrixEvent } from 'matrix-js-sdk'
+import { EventShieldColour, EventShieldReason } from 'matrix-js-sdk/lib/crypto-api'
+
+// Per-message trust: whether a decrypted message really comes from a
+// session this session trusts. The crypto stack answers asynchronously, so
+// answers are cached per event and the timeline is redrawn when one comes
+// in. A change in anybody's trust throws the cache away.
+
+export interface Shield {
+ /** `warning`: not verified; `danger`: something is wrong with the sender. */
+ tone: 'warning' | 'danger'
+ text: string
+}
+
+const cache = new Map()
+const pending = new Set()
+let changed: () => void = () => undefined
+let generation = 0
+
+/** Called when a shield arrives, to redraw. */
+export function onShieldsChanged(fn: () => void) {
+ changed = fn
+}
+
+/** Forgets every shield: trust changed (a verification, new keys). */
+export function resetShields() {
+ generation++
+ cache.clear()
+ pending.clear()
+ changed()
+}
+
+function reasonText(reason: EventShieldReason | null): Shield {
+ switch (reason) {
+ case EventShieldReason.UNSIGNED_DEVICE:
+ return { tone: 'danger', text: 'Sent from a session its owner hasn’t verified' }
+ case EventShieldReason.UNKNOWN_DEVICE:
+ return { tone: 'danger', text: 'Sent from a session that no longer exists or was never announced' }
+ case EventShieldReason.MISMATCHED_SENDER_KEY:
+ case EventShieldReason.MISMATCHED_SENDER:
+ return { tone: 'danger', text: 'The sender’s keys don’t match: it may not be from them' }
+ case EventShieldReason.VERIFICATION_VIOLATION:
+ return { tone: 'danger', text: 'The sender’s identity changed since you verified them' }
+ case EventShieldReason.AUTHENTICITY_NOT_GUARANTEED:
+ return { tone: 'warning', text: 'Its authenticity can’t be guaranteed on this session (restored from a backup)' }
+ case EventShieldReason.UNVERIFIED_IDENTITY:
+ return { tone: 'warning', text: 'Sent by someone you haven’t verified' }
+ case EventShieldReason.SENT_IN_CLEAR:
+ return { tone: 'danger', text: 'Not encrypted' }
+ default:
+ return { tone: 'warning', text: 'Can’t confirm who sent this' }
+ }
+}
+
+async function compute(c: MatrixClient, ev: MatrixEvent): Promise {
+ const crypto = c.getCrypto()
+ if (!crypto) return null
+ const info = await crypto.getEncryptionInfoForEvent(ev)
+ if (!info) return null
+ if (info.shieldColour !== EventShieldColour.NONE) {
+ const s = reasonText(info.shieldReason)
+ return info.shieldColour === EventShieldColour.RED ? { ...s, tone: 'danger' } : s
+ }
+ // The crypto stack is quiet about sessions nobody verified. This
+ // session says so: which of the sender's sessions sent it, and is it one
+ // you (or the sender's verified identity) vouch for?
+ const sender = ev.getSender() ?? ''
+ const senderKey = ev.getSenderKey()
+ if (!senderKey) return null
+ if (sender === c.getUserId()) {
+ const own = await crypto.getOwnDeviceKeys()
+ if (own.curve25519 === senderKey) return null
+ }
+ const devices = await crypto.getUserDeviceInfo([sender])
+ const device = [...(devices.get(sender)?.values() ?? [])].find((d) => d.getIdentityKey() === senderKey)
+ if (!device) return null
+ const status = await crypto.getDeviceVerificationStatus(sender, device.deviceId)
+ if (status?.isVerified()) return null
+ return { tone: 'warning', text: sender === c.getUserId() ? 'Sent from one of your sessions you haven’t verified' : 'Sent from a session you haven’t verified' }
+}
+
+/**
+ * The shield a message shows, or undefined for none (or not known yet:
+ * it is worked out in the background and the timeline redrawn).
+ */
+export function shieldOf(c: MatrixClient, ev: MatrixEvent, expectEncrypted: boolean): Shield | undefined {
+ const id = ev.getId()
+ if (!id || ev.status !== null || ev.isRedacted()) return undefined
+ if (!ev.isEncrypted()) {
+ // A plain message sent after the room turned encryption on.
+ return expectEncrypted ? { tone: 'danger', text: 'Not encrypted' } : undefined
+ }
+ if (ev.isDecryptionFailure() || ev.isBeingDecrypted()) return undefined
+ if (cache.has(id)) return cache.get(id) ?? undefined
+ if (!pending.has(id)) {
+ pending.add(id)
+ const gen = generation
+ compute(c, ev)
+ .catch(() => null)
+ .then((s) => {
+ if (gen !== generation) return
+ pending.delete(id)
+ cache.set(id, s)
+ if (s) changed()
+ })
+ }
+ return undefined
+}
diff --git a/apps/web/src/stores/chat.ts b/apps/web/src/stores/chat.ts
index 68e033d..50bf2d8 100644
--- a/apps/web/src/stores/chat.ts
+++ b/apps/web/src/stores/chat.ts
@@ -19,6 +19,7 @@ import {
import { defineStore } from 'pinia'
import { computed, ref, shallowRef, watch } from 'vue'
import { client } from '@/matrix/client'
+import { onShieldsChanged } from '@/matrix/trust'
// The room list and the client's connection state, as plain reactive data
// derived from the SDK. The SDK's objects change in place and emit events;
@@ -61,6 +62,7 @@ export function describeEvent(ev: MatrixEvent, me: string, room: Room, withSende
if (type !== EventType.RoomMessage && type !== EventType.Sticker) return null
if (ev.isRelation('m.replace')) return null
const content = ev.getContent()
+ if (content.msgtype === 'm.key.verification.request') return 'Verification request'
let text: string
switch (content.msgtype) {
case MsgType.Image:
@@ -196,6 +198,7 @@ export const useChat = defineStore('chat', () => {
ready.value = c.isInitialSyncComplete()
syncState.value = c.getSyncState()
c.on(ClientEvent.Sync, onSync)
+ onShieldsChanged(bump)
for (const e of [
ClientEvent.Room,
ClientEvent.DeleteRoom,
@@ -303,14 +306,9 @@ export const useChat = defineStore('chat', () => {
}
// Fails early with a clear error when the person doesn't exist.
await c.getProfileInfo(userId)
- // End-to-end encrypted when the other person has a session that can
- // read it: encrypting to nobody would only lock them out.
- let encrypt = false
- const crypto = c.getCrypto()
- if (crypto) {
- const devices = await crypto.getUserDeviceInfo([userId], true).catch(() => null)
- encrypt = (devices?.get(userId)?.size ?? 0) > 0
- }
+ // Always end-to-end encrypted while this session can encrypt. Someone
+ // with no session yet reads what is sent after they sign in.
+ const encrypt = Boolean(c.getCrypto())
const res = await c.createRoom({
is_direct: true,
invite: [userId],
@@ -322,6 +320,18 @@ export const useChat = defineStore('chat', () => {
return res.room_id
}
+ /** A room you and the person are both in: your direct message first, else the smallest. */
+ function sharedRoomWith(userId: string): string | undefined {
+ const c = client()
+ const joined = (r: Room | null) => r?.getMyMembership() === KnownMembership.Join && r.getMember(userId)?.membership === KnownMembership.Join
+ const content = (c.getAccountData(EventType.Direct)?.getContent() ?? {}) as Record
+ for (const roomId of content[userId] ?? []) if (joined(c.getRoom(roomId))) return roomId
+ return c
+ .getRooms()
+ .filter(joined)
+ .sort((a, b) => a.getJoinedMemberCount() - b.getJoinedMemberCount())[0]?.roomId
+ }
+
async function join(idOrAlias: string): Promise {
const room = await client().joinRoom(idOrAlias.trim())
return room.roomId
@@ -366,6 +376,7 @@ export const useChat = defineStore('chat', () => {
bump,
createRoom,
startDirect,
+ sharedRoomWith,
join,
acceptInvite,
leave,
diff --git a/apps/web/src/stores/security.ts b/apps/web/src/stores/security.ts
index f54bcea..03f073f 100644
--- a/apps/web/src/stores/security.ts
+++ b/apps/web/src/stores/security.ts
@@ -11,10 +11,14 @@ import { client } from '@/matrix/client'
import {
enableBackupIfPossible,
encryptionStatus,
+ isSettingUp,
verifyDevice,
verifyOwnSession,
+ verifyUserInRoom,
type EncryptionStatus,
} from '@/matrix/crypto'
+import { resetShields } from '@/matrix/trust'
+import { useChat } from '@/stores/chat'
// End-to-end encryption as the views see it: whether this session is
// verified, the key backup, and the one verification on screen at a time,
@@ -32,7 +36,8 @@ export const useSecurity = defineStore('security', () => {
let again = false
async function refresh() {
- if (!attached?.getCrypto()) return
+ // During setup the dialog refreshes once it is done.
+ if (!attached?.getCrypto() || isSettingUp()) return
// One refresh at a time; changes during one ask for another after it.
if (refreshing) {
again = true
@@ -49,6 +54,7 @@ export const useSecurity = defineStore('security', () => {
function changed() {
version.value++
+ resetShields()
void refresh()
}
@@ -120,5 +126,15 @@ export const useSecurity = defineStore('security', () => {
show(await verifyDevice(userId, deviceId))
}
- return { status, request, version, attach, refresh, dismiss, verifyThisSession, verifySession }
+ /**
+ * Verifies a person's identity in a room you are both in: your direct
+ * message, else the smallest room you share, else a new direct message
+ * (the request waits there until they accept the invite).
+ */
+ async function verifyUser(userId: string) {
+ const roomId = useChat().sharedRoomWith(userId) ?? (await useChat().startDirect(userId))
+ show(await verifyUserInRoom(userId, roomId))
+ }
+
+ return { status, request, version, attach, refresh, dismiss, verifyThisSession, verifySession, verifyUser }
})
diff --git a/apps/web/src/stores/session.ts b/apps/web/src/stores/session.ts
index 6bb5cfa..e649ff2 100644
--- a/apps/web/src/stores/session.ts
+++ b/apps/web/src/stores/session.ts
@@ -2,7 +2,8 @@ import { HttpApiEvent, MatrixError, type AuthDict, type LoginResponse, type Regi
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { config } from '@/config'
-import { anonymousClient, client, destroyClient, startClient, stopClient, type Credentials } from '@/matrix/client'
+import { anonymousClient, client, destroyClient, newStorageKey, startClient, stopClient, type Credentials } from '@/matrix/client'
+import { cryptoDatabasePrefix } from '@/matrix/crypto'
import { clearMediaCache } from '@/matrix/media'
// The person's Matrix session. It is kept in localStorage while "Stay
@@ -25,6 +26,22 @@ function load(): Credentials | null {
return null
}
+/** The crypto stores of the sessions this browser has credentials for, in either storage. */
+export function savedCryptoStores(): string[] {
+ const out: string[] = []
+ for (const store of [localStorage, sessionStorage]) {
+ try {
+ const raw = store.getItem(storageKey)
+ if (!raw) continue
+ const c = JSON.parse(raw) as Credentials
+ if (c.userId && c.deviceId) out.push(cryptoDatabasePrefix(c.userId, c.deviceId))
+ } catch {
+ // Unreadable: nothing to keep.
+ }
+ }
+ return out
+}
+
function save(c: Credentials | null, remember: boolean) {
for (const store of [localStorage, sessionStorage]) {
try {
@@ -127,6 +144,8 @@ export const useSession = defineStore('session', () => {
userId: res.user_id,
deviceId: res.device_id,
accessToken: res.access_token,
+ // Kept only for the tab: its encryption keys are unreadable once it closes.
+ storageKey: keep ? undefined : newStorageKey(),
}
save(creds, keep)
await begin(creds)
diff --git a/apps/web/src/views/RoomView.vue b/apps/web/src/views/RoomView.vue
index 9870df0..fd2f254 100644
--- a/apps/web/src/views/RoomView.vue
+++ b/apps/web/src/views/RoomView.vue
@@ -9,13 +9,16 @@ import RoomTimeline from '@/components/RoomTimeline.vue'
import { drafts } from '@/lib/drafts'
import { errorMessage, formatBytes } from '@/lib/format'
import { client, cryptoError } from '@/matrix/client'
+import { acceptIdentity, identityWarnings, withdrawVerification, type IdentityWarning } from '@/matrix/crypto'
import { sendFile, sendText } from '@/matrix/send'
import { useChat } from '@/stores/chat'
+import { useSecurity } from '@/stores/security'
import { useToasts } from '@/stores/toasts'
const props = defineProps<{ roomId: string }>()
const chat = useChat()
+const security = useSecurity()
const toasts = useToasts()
const router = useRouter()
const me = client().getUserId() ?? ''
@@ -49,6 +52,44 @@ const canSend = computed(() => {
const infoOpen = ref(false)
+// Someone in an encrypted room whose identity changed: said before you
+// write to them, until you acknowledge it.
+const warnings = ref([])
+let warningsRun = 0
+watch(
+ () => [encrypted.value, memberCount.value, security.version, props.roomId] as const,
+ async () => {
+ const run = ++warningsRun
+ const r = room.value
+ if (!encrypted.value || !r || !chat.canEncrypt) {
+ warnings.value = []
+ return
+ }
+ const ids = r.getJoinedMembers().map((m) => m.userId).slice(0, 500)
+ const found = await identityWarnings(ids).catch(() => [])
+ if (run === warningsRun) warnings.value = found
+ },
+ { immediate: true },
+)
+const warning = computed(() => warnings.value[0])
+const warningName = computed(() => (warning.value ? (room.value?.getMember(warning.value.userId)?.name ?? warning.value.userId) : ''))
+const acking = ref(false)
+async function ackWarning(verifyAgain = false) {
+ const w = warning.value
+ if (!w) return
+ acking.value = true
+ try {
+ if (verifyAgain) await security.verifyUser(w.userId)
+ else if (w.kind === 'verifiedReset') await withdrawVerification(w.userId)
+ else await acceptIdentity(w.userId)
+ warnings.value = warnings.value.filter((x) => x.userId !== w.userId)
+ } catch (e) {
+ toasts.error(errorMessage(e))
+ } finally {
+ acking.value = false
+ }
+}
+
const text = ref(drafts.get(props.roomId) ?? '')
watch(text, (v) => (v ? drafts.set(props.roomId, v) : drafts.delete(props.roomId)))
@@ -251,6 +292,23 @@ const placeholder = computed(() => `Message ${summary.value?.name ?? 'this room'
{{ u.total ? Math.round((u.loaded / u.total) * 100) : 0 }}%
+
+
+ Their sessions are no longer trusted. If they didn’t reset their encryption themselves, someone may be pretending to be them.
+ Verify them again, or stop requiring them to be verified.
+
+ They reset their encryption, or signed in somewhere new without verifying. Check with them if you weren’t expecting it.
+
+
+ Verify {{ warningName }}
+
+ {{ warning.kind === 'verifiedReset' ? 'Withdraw verification' : 'OK' }}
+
+
This browser can’t read or send messages in this room. Reload the page to try again{{ cryptoError() ? ` (${cryptoError()})` : '' }}.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7870a68..8311747 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -68,12 +68,18 @@ importers:
'@warp/ui':
specifier: workspace:*
version: link:../ui
+ jsqr:
+ specifier: 1.4.0
+ version: 1.4.0
matrix-js-sdk:
specifier: ^44.0.0
version: 44.0.0
pinia:
specifier: ^3.0.3
version: 3.0.4(typescript@5.9.3)(vue@3.5.44(typescript@5.9.3))
+ qrcode-generator:
+ specifier: 1.4.4
+ version: 1.4.4
vue:
specifier: ^3.5.22
version: 3.5.44(typescript@5.9.3)
@@ -670,6 +676,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
+ jsqr@1.4.0:
+ resolution: {integrity: sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==}
+
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
@@ -798,6 +807,9 @@ packages:
resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==}
engines: {node: ^10 || ^12 || >=14}
+ qrcode-generator@1.4.4:
+ resolution: {integrity: sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==}
+
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
@@ -1355,6 +1367,8 @@ snapshots:
jiti@2.7.0: {}
+ jsqr@1.4.0: {}
+
lightningcss-android-arm64@1.32.0:
optional: true
@@ -1462,6 +1476,8 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.2
+ qrcode-generator@1.4.4: {}
+
rfdc@1.4.1: {}
rollup@4.64.9: