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

+29 -27
View File
@@ -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:<user>:<session>`), 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:<user>:<session>`). 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
+2
View File
@@ -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"
},
+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>
+2 -1
View File
@@ -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')
})
+98 -8
View File
@@ -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<MatrixClient> {
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<MatrixClient> {
// 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<MatrixClient> {
}
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<string, number> {
try {
const v = JSON.parse(localStorage.getItem(aliveKey) ?? '{}')
return v && typeof v === 'object' ? (v as Record<string, number>) : {}
} 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)
}
}
+129 -35
View File
@@ -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<EncryptionStatus> {
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<string> {
settingUp = true
try {
return await setUp(opts)
} finally {
settingUp = false
}
}
async function setUp(opts: { password?: string; reset?: boolean }): Promise<string> {
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<void>) => {
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<Uint8Array<ArrayBuffer>> {
const text = input.trim()
try {
@@ -252,3 +297,52 @@ export function recoveryKeyKnown(): boolean {
export async function createKeyBackup(): Promise<void> {
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<IdentityInfo> {
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<VerificationRequest> {
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<IdentityWarning[]> {
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<void> {
return crypto().pinCurrentUserIdentity(userId)
}
/** Stops requiring that someone you verified stay verified, after they reset their identity. */
export function withdrawVerification(userId: string): Promise<void> {
return crypto().withdrawVerificationRequirement(userId)
}
+53 -2
View File
@@ -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<Thumbnail | undefined> {
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<Blob | null>((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<string, unknown> = { 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 })
+14
View File
@@ -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<RoomMessageEventContent>()
@@ -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,
+108
View File
@@ -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<string, Shield | null>()
const pending = new Set<string>()
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<Shield | null> {
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
}
+19 -8
View File
@@ -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<string, string[]>
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<string> {
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,
+18 -2
View File
@@ -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 }
})
+20 -1
View File
@@ -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)
+58
View File
@@ -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<IdentityWarning[]>([])
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'
<span class="caption text-ink-muted tabular-nums">{{ u.total ? Math.round((u.loaded / u.total) * 100) : 0 }}%</span>
</div>
<WBanner
v-if="warning && !cryptoMissing"
:tone="warning.kind === 'verifiedReset' ? 'danger' : 'warning'"
:title="warning.kind === 'verifiedReset' ? `${warningName}’s identity changed since you verified them` : `${warningName}’s identity changed`"
>
<template v-if="warning.kind === 'verifiedReset'">
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.
</template>
<template v-else>They reset their encryption, or signed in somewhere new without verifying. Check with them if you weren’t expecting it.</template>
<span class="mt-2 flex flex-wrap gap-2">
<WButton v-if="warning.kind === 'verifiedReset'" size="sm" variant="primary" :disabled="acking" @click="ackWarning(true)">
Verify {{ warningName }}
</WButton>
<WButton size="sm" :loading="acking" @click="ackWarning()">{{ warning.kind === 'verifiedReset' ? 'Withdraw verification' : 'OK' }}</WButton>
</span>
</WBanner>
<WBanner v-if="cryptoMissing" tone="warning" title="End-to-end encryption didn’t start">
This browser can’t read or send messages in this room. Reload the page to try again{{ cryptoError() ? ` (${cryptoError()})` : '' }}.
</WBanner>
+16
View File
@@ -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: