Web client (warp-web) with E2EE, and two sync fixes
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

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>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-18 16:22:11 +02:00
1 parent adc18e7cf3
commit b6f3be6ac4
90 files changed
+6608 -472

No files matched your search

+254
View File
@@ -0,0 +1,254 @@
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 })
}