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
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:
1 parent
b6f3be6ac4
commit
69ce927b6d
19 files changed
+845
-112
No files matched your search
+29
-27
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Generated
+16
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user