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

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

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

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

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

255 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { MatrixError, type AuthDict, type MatrixClient } from 'matrix-js-sdk'
import {
decodeRecoveryKey,
deriveRecoveryKeyFromPassphrase,
ImportRoomKeyStage,
type CryptoApi,
type CryptoCallbacks,
type KeyBackupRestoreResult,
type VerificationRequest,
} from 'matrix-js-sdk/lib/crypto-api'
import type { SecretStorageKeyDescription } from 'matrix-js-sdk/lib/secret-storage'
// End-to-end encryption, on top of the SDK's Rust crypto stack: whether
// this session is verified, the recovery key that unlocks secret storage
// (cross-signing keys and the key backup's key), setting it all up, and
// verification requests. The stores and dialogs call these; nothing else
// touches the crypto API.
let current: MatrixClient | null = null
/** Called by client.ts once the crypto stack is up, and with null when it stops. */
export function bindCrypto(c: MatrixClient | null) {
current = c
cachedKey = null
}
function crypto(): CryptoApi {
const api = current?.getCrypto()
if (!api) throw new Error('End-to-end encryption isn’t running in this session.')
return api
}
/** The IndexedDB name of a session's crypto store. */
export function cryptoDatabasePrefix(userId: string, deviceId: string): string {
return `warp-web-crypto:${userId}:${deviceId}`
}
// The secret storage key. The SDK asks for it, often several times in a
// row, while it reads or writes secret storage; it is kept in memory only
// for as long as this page runs.
let cachedKey: { keyId: string; key: Uint8Array<ArrayBuffer> } | null = null
export class WrongRecoveryKeyError extends Error {
constructor() {
super('That isn’t the recovery key for this account. Check it and try again.')
}
}
export const cryptoCallbacks: CryptoCallbacks = {
async getSecretStorageKey({ keys }) {
// Only a key the person entered (or one just created) is ever returned:
// unlockWithRecoveryKey checks it before it is cached.
if (cachedKey && keys[cachedKey.keyId]) return [cachedKey.keyId, cachedKey.key]
return null
},
cacheSecretStorageKey(keyId, _info, key) {
cachedKey = { keyId, key }
},
}
export interface EncryptionStatus {
/** The account has cross-signing keys: sessions can be verified. */
crossSigning: boolean
/** This session is signed by the account's own identity. */
sessionVerified: boolean
/** Secret storage holds the cross-signing keys: a recovery key unlocks them. */
secretStorage: boolean
/** The key backup on the homeserver, if there is one. */
backupVersion: string | null
/** This session backs its keys up to it. */
backupActive: boolean
/** This session holds the backup's key, so it can restore from it. */
backupKey: boolean
}
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),
api.getDeviceVerificationStatus(userId, deviceId),
api.isSecretStorageReady(),
api.getKeyBackupInfo().catch(() => null),
api.getActiveSessionBackupVersion(),
api.getSessionBackupPrivateKey(),
])
return {
crossSigning,
sessionVerified: Boolean(device?.crossSigningVerified),
secretStorage,
backupVersion: backupInfo?.version ?? null,
backupActive: backupActive !== null,
backupKey: backupKey !== null,
}
}
/** Raised when the homeserver wants the account password to go on. */
export class PasswordNeededError extends Error {
constructor() {
super('Enter your account password to continue.')
}
}
function uiaSession(e: unknown): string | undefined | null {
if (e instanceof MatrixError && e.httpStatus === 401 && Array.isArray(e.data?.flows)) return (e.data.session as string) ?? undefined
return null
}
/**
* Sets up encryption for the account: cross-signing keys (this session
* signs them, so it is verified), secret storage under a new recovery key,
* and a new key backup. Returns the recovery key to show the person once.
*
* With `reset`, replaces cross-signing keys that already exist: anyone who
* verified this account has to verify it again.
*/
export async function setUpEncryption(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
}
}
},
})
let recoveryKey = ''
await api.bootstrapSecretStorage({
setupNewSecretStorage: true,
setupNewKeyBackup: true,
createSecretStorageKey: async () => {
const key = await api.createRecoveryKeyFromPassphrase()
recoveryKey = key.encodedPrivateKey ?? ''
return key
},
})
return recoveryKey
}
async function keyFromInput(input: string, info: SecretStorageKeyDescription): Promise<Uint8Array<ArrayBuffer>> {
const text = input.trim()
try {
return decodeRecoveryKey(text)
} catch {
// Not a recovery key: it may be the passphrase the key was made from.
}
const pp = info.passphrase
if (!pp?.salt || !pp.iterations) throw new WrongRecoveryKeyError()
return deriveRecoveryKeyFromPassphrase(text, pp.salt, pp.iterations, pp.bits)
}
/**
* Unlocks secret storage with the recovery key (or its passphrase): this
* session gets the cross-signing keys and signs itself, which verifies
* it, and takes the key backup's key so older messages can be restored.
*/
export async function unlockWithRecoveryKey(input: string): Promise<void> {
const api = crypto()
const c = current!
const keyId = await c.secretStorage.getDefaultKeyId()
const tuple = keyId ? await c.secretStorage.getKey(keyId) : null
if (!tuple) throw new Error('This account has no secret storage. Set up encryption instead.')
const key = await keyFromInput(input, tuple[1])
if (!(await c.secretStorage.checkKey(key, tuple[1]))) throw new WrongRecoveryKeyError()
cachedKey = { keyId: tuple[0], key }
await api.bootstrapCrossSigning({})
await api.loadSessionBackupPrivateKeyFromSecretStorage().catch(() => undefined)
await api.checkKeyBackupAndEnable()
}
/** Fetches and imports every room key in the backup this session can read. */
export async function restoreKeyBackup(onProgress?: (done: number, total: number) => void): Promise<KeyBackupRestoreResult> {
const api = crypto()
await api.checkKeyBackupAndEnable()
return api.restoreKeyBackup({
progressCallback: (p) => {
if (p.stage === ImportRoomKeyStage.LoadKeys) onProgress?.(p.successes + p.failures, p.total)
},
})
}
/** After this session was verified by another, it may have been sent the backup key. */
export async function enableBackupIfPossible() {
const api = current?.getCrypto()
if (!api) return
await api.checkKeyBackupAndEnable().catch(() => undefined)
}
export function verifyOwnSession(): Promise<VerificationRequest> {
return crypto().requestOwnUserVerification()
}
export function verifyDevice(userId: string, deviceId: string): Promise<VerificationRequest> {
return crypto().requestDeviceVerification(userId, deviceId)
}
export interface SessionInfo {
deviceId: string
name?: string
verified: boolean
/** This browser's own session. */
own: boolean
}
/** Someone's sessions as their keys say, with whether this session trusts each. */
export async function sessionsOf(userId: string): Promise<SessionInfo[]> {
const api = crypto()
const c = current!
const map = await api.getUserDeviceInfo([userId], true)
const devices = [...(map.get(userId)?.values() ?? [])].filter((d) => !d.dehydrated)
const out = await Promise.all(
devices.map(async (d) => {
const status = await api.getDeviceVerificationStatus(userId, d.deviceId)
return {
deviceId: d.deviceId,
name: d.displayName,
verified: Boolean(status?.isVerified()),
own: userId === c.getUserId() && d.deviceId === c.getDeviceId(),
}
}),
)
return out.sort((a, b) => Number(b.own) - Number(a.own) || (a.name ?? a.deviceId).localeCompare(b.name ?? b.deviceId))
}
/** The recovery key was entered (or created) in this page, so secret storage is open. */
export function recoveryKeyKnown(): boolean {
return cachedKey !== null
}
/** Starts a new key backup, its key kept in secret storage. Needs the recovery key first. */
export async function createKeyBackup(): Promise<void> {
await crypto().bootstrapSecretStorage({ setupNewKeyBackup: true })
}