diff --git a/docs/e2ee-device-verification.md b/docs/e2ee-device-verification.md new file mode 100644 index 00000000..81746dee --- /dev/null +++ b/docs/e2ee-device-verification.md @@ -0,0 +1,227 @@ +# E2EE Device Verification – Proposal + +## Problem + +Currently ODIN uses TOFU (Trust on First Use) for device trust. This means any device claiming to be a project participant is trusted without verification. An attacker who compromises the homeserver or performs a MITM attack could inject a rogue device and intercept encrypted content. + +For military/government use cases, this is insufficient. Users need to verify that they're communicating with the genuine devices of their collaborators. + +## Matrix SAS Verification + +Matrix specifies [Short Authentication String (SAS)](https://spec.matrix.org/v1.12/client-server-api/#short-authentication-string-sas-verification) verification, where two users compare a set of 7 emojis displayed on their screens. If the emojis match, the devices are mutually verified. + +The `@matrix-org/matrix-sdk-crypto-wasm` SDK fully supports this: + +- `VerificationRequest` — initiates/receives a verification flow +- `Sas` — the SAS verification state machine +- `Sas.emoji()` — returns 7 `Emoji` objects (symbol + description) +- `Sas.confirm()` — confirms match, sends `m.key.verification.done` +- `Sas.cancel()` — cancels if emojis don't match + +## Proposed Flow for ODIN + +### When: Verification happens at **project join** time + +1. **Alice** shares an E2EE project with **Bob** (invite). +2. **Bob** accepts the invitation and joins. +3. ODIN detects Bob's new device (via `m.room.member` join + `device_lists.changed` in sync). +4. ODIN shows a **verification prompt** to Alice: _"Bob has joined. Verify Bob's device?"_ +5. Alice initiates verification. +6. Both Alice and Bob see **7 emojis** in a modal dialog. +7. They compare emojis out-of-band (voice call, in person, secure messenger). +8. Both confirm → devices are marked as verified. + +### Where in the UI + +- **Verification prompt** appears in the project's sharing panel or as a notification bar at the top of the map view. +- **Emoji comparison dialog** is a modal overlay showing the 7 emojis in a grid, with "They match" and "They don't match" buttons. +- **Verification status** is shown per-member in the sharing properties panel (✅ verified / ⚠️ unverified). + +### What changes if a device is unverified? + +Two possible strategies: + +**Option A: Warn but allow (recommended for V1)** +- Unverified devices get a ⚠️ warning in the sharing panel. +- All operations work normally. +- Users can verify at any time. +- Pragmatic for field use where verification might be deferred. + +**Option B: Block until verified** +- Unverified devices cannot decrypt content. +- Keys are only shared with verified devices. +- More secure, but may break workflows if verification is delayed. + +**Recommendation:** Start with Option A. The WASM SDK already has `TrustRequirement` settings that can switch behavior later. + +## Implementation: matrix-client-api + +### New CryptoManager Methods + +```javascript +/** + * Request verification of another user's device. + * @param {string} userId + * @returns {VerificationRequest} the request object to track the flow + */ +async requestVerification(userId) + +/** + * Accept an incoming verification request. + * @param {string} userId + * @param {string} flowId + * @returns {VerificationRequest} + */ +async acceptVerification(userId, flowId) + +/** + * Start SAS verification on an accepted request. + * @param {VerificationRequest} request + * @returns {Sas} the SAS state machine + */ +async startSas(request) + +/** + * Get the 7 emojis for comparison. + * @param {Sas} sas + * @returns {Array<{symbol: string, description: string}>} + */ +getEmojis(sas) + +/** + * Confirm that emojis match. + * @param {Sas} sas + * @returns {OutgoingRequest[]} requests to send + */ +async confirmSas(sas) + +/** + * Cancel the verification. + * @param {Sas} sas + * @returns {OutgoingRequest|undefined} + */ +cancelSas(sas) + +/** + * Check if a user's device is verified. + * @param {string} userId + * @param {string} deviceId + * @returns {boolean} + */ +async isDeviceVerified(userId, deviceId) + +/** + * Get verification status for all devices of a user. + * @param {string} userId + * @returns {Array<{deviceId: string, verified: boolean}>} + */ +async getDeviceVerificationStatus(userId) +``` + +### Verification Event Handling + +The SAS flow uses `to_device` events: +- `m.key.verification.request` +- `m.key.verification.ready` +- `m.key.verification.start` +- `m.key.verification.accept` +- `m.key.verification.key` +- `m.key.verification.mac` +- `m.key.verification.done` +- `m.key.verification.cancel` + +These are already routed through `receiveSyncChanges()` and handled by the OlmMachine internally. We need to: + +1. **Detect incoming requests** — poll `getVerificationRequests(userId)` after sync. +2. **Surface them to ODIN** — emit events that ODIN can listen to (e.g., `verificationRequested`, `verificationReady`, `emojisAvailable`, `verificationDone`). +3. **Send outgoing requests** — `accept()`, `confirm()`, `cancel()` return `OutgoingRequest` objects that need to be sent via HTTP. + +### Event Emission + +Add verification events to the existing stream handler pattern: + +```javascript +// In project.mjs or a new verification-handler.mjs +streamHandler.verificationRequested({ userId, flowId, request }) +streamHandler.emojisAvailable({ userId, flowId, emojis }) +streamHandler.verificationDone({ userId, deviceId }) +streamHandler.verificationCancelled({ userId, flowId, reason }) +``` + +## Implementation: ODIN (Electron) + +### UI Components + +1. **VerificationPrompt** — Notification bar or toast: _"New device detected for Bob. [Verify]"_ +2. **EmojiComparisonDialog** — Modal showing 7 emojis in a grid with confirm/cancel buttons. +3. **MemberVerificationBadge** — ✅/⚠️ icon next to member names in sharing properties. + +### Electron-specific + +- The verification flow involves multiple async steps (request → accept → emojis → confirm). +- Use a React state machine or reducer to track the verification phase. +- Emojis are Unicode — no custom graphics needed. + +## Protocol Sequence + +``` + Alice Homeserver Bob + │ │ │ + │ m.key.verification.request │ │ + ├───────────────────────────────►│ to_device │ + │ ├──────────────────────────────►│ + │ │ │ + │ │ m.key.verification.ready │ + │ to_device │◄──────────────────────────────┤ + │◄───────────────────────────────┤ │ + │ │ │ + │ m.key.verification.start │ │ + ├───────────────────────────────►│ to_device │ + │ ├──────────────────────────────►│ + │ │ │ + │ m.key.verification.accept │ │ + │ to_device │◄──────────────────────────────┤ + │◄───────────────────────────────┤ │ + │ │ │ + │ m.key.verification.key │ (both exchange DH keys) │ + │◄──────────────────────────────►│◄─────────────────────────────►│ + │ │ │ + │ ┌─────────────────────┐ │ ┌─────────────────────┐ │ + │ │ 🐶 🔑 🎵 🌍 🎩 ☂️ 🌻 │ │ │ 🐶 🔑 🎵 🌍 🎩 ☂️ 🌻 │ │ + │ │ "Do these match?" │ │ │ "Do these match?" │ │ + │ └────────┬────────────┘ │ └────────┬────────────┘ │ + │ │ [Yes!] │ │ [Yes!] │ + │ │ │ + │ m.key.verification.mac │ (both send MACs) │ + │◄──────────────────────────────►│◄─────────────────────────────►│ + │ │ │ + │ m.key.verification.done │ │ + │◄──────────────────────────────►│◄─────────────────────────────►│ + │ │ │ + │ ✅ Bob's device verified │ ✅ Alice verified │ +``` + +## Scope & Phases + +### Phase 1 (minimal viable) +- CryptoManager methods for SAS verification +- Verification event emission in stream handler +- Basic ODIN UI: prompt + emoji dialog + status badge +- Manual verification (user clicks "Verify" in sharing properties) + +### Phase 2 (polish) +- Auto-prompt on new device detection +- Verification status persists across restarts (already in OlmMachine store) +- Block key sharing to unverified devices (Option B) +- QR code verification as alternative to emoji + +### Phase 3 (advanced) +- Cross-signing (verify user, not individual devices) +- Verification via room events (instead of to_device) for audit trail + +## Open Questions + +1. **When to block?** Should we ever refuse to share keys with unverified devices, or always warn-only? +2. **Verification UI location** — Modal? Side panel? Notification? +3. **Re-verification** — What happens when a user gets a new device? Auto-detect and re-prompt? +4. **Offline verification** — If Bob is offline when Alice initiates, the request waits. Timeout? diff --git a/docs/e2ee-key-sharing-scenarios.md b/docs/e2ee-key-sharing-scenarios.md new file mode 100644 index 00000000..0607efe3 --- /dev/null +++ b/docs/e2ee-key-sharing-scenarios.md @@ -0,0 +1,178 @@ +# E2EE Key Sharing Scenarios + +This document describes the key sharing scenarios for ODIN's end-to-end encrypted collaboration. It covers when and how Megolm session keys must be distributed to ensure all participants can decrypt layer content. + +## Background + +ODIN uses Matrix E2EE (Megolm) for encrypted layers. Each encrypted message is encrypted with a Megolm session key. To decrypt, a participant needs the corresponding session key. Keys are distributed via `to_device` messages (encrypted per-device with Olm). + +**Critical constraint:** ODIN replays all events in a layer when a user joins ("catch-up"). Without the Megolm session keys for historical events, the replay fails and the layer appears empty or broken. + +--- + +## Scenarios + +### 1. Alice creates an encrypted layer and shares it with Bob + +**Precondition:** Alice creates a layer with content, then shares it (Bob gets invited to the layer room). + +**Flow:** +1. Alice creates layer, adds content (features on the map). +2. Each `io.syncpoint.odin.operation` is encrypted with a Megolm session. Keys are shared with current room members (only Alice at this point). +3. Alice invites Bob to the layer. +4. **At invite time, Alice MUST share all existing Megolm session keys with Bob** via `to_device`. Alice is guaranteed to be online (she initiated the invite). +5. Bob accepts the invitation. +6. Bob performs the replay (catches up on all events). He can decrypt because he received the keys at step 4. + +**Status:** ❌ Not implemented. Currently, keys are only shared when sending a new message (`command-api.mjs`), not at invite time. + +**Required fix:** When inviting a user to an encrypted room, proactively share all Megolm session keys for that room with the invited user. + +--- + +### 2. Alice shares an empty layer, Bob joins, then Alice adds content + +**Precondition:** Layer has no content when Bob joins. + +**Flow:** +1. Alice creates and shares an empty layer, invites Bob. +2. Bob accepts. +3. Alice adds content. The Megolm session key is shared with all room members (Alice + Bob) at send time. +4. Bob receives the events and can decrypt. + +**Status:** ✅ Works. `command-api.mjs` already shares keys with all joined members when sending. + +--- + +### 3. Bob joins a layer that already has content from multiple participants + +**Precondition:** Alice and Carol have both contributed encrypted content. Bob is invited later. + +**Flow:** +1. Alice and Carol add content to the layer over time. Multiple Megolm sessions may exist (sessions rotate periodically). +2. Alice invites Bob. +3. **Alice must share ALL Megolm session keys she holds for this room** — including sessions originally created by Carol (Alice received Carol's keys when Carol sent messages). +4. Bob accepts and replays. He can decrypt all historical content. + +**Status:** ❌ Not implemented. + +**Note:** The inviting user shares keys they possess. If Alice somehow doesn't have Carol's keys (e.g., Alice joined after Carol left), those events remain undecryptable for Bob. This is an edge case; in practice, all active participants hold all session keys for events they've received. + +--- + +### 4. Real-time collaboration (steady state) + +**Precondition:** All participants have joined. Content is added in real-time. + +**Flow:** +1. Any participant sends an operation. +2. `command-api.mjs` shares the Megolm session key with all room members before encrypting. +3. All participants receive the key via `to_device` and can decrypt. + +**Status:** ✅ Works. + +--- + +### 5. Role change: Alice demotes Bob to READER, then promotes back to CONTRIBUTOR + +**Precondition:** Bob was CONTRIBUTOR, gets demoted to READER, then promoted again. + +**Flow:** +1. Alice changes Bob's power level to READER. +2. Bob's ODIN instance detects `m.room.power_levels` change → layer is restricted (locked). +3. Bob cannot add content (UI enforces restriction). +4. Alice promotes Bob back to CONTRIBUTOR. +5. Bob's layer is unlocked. +6. Bob can add content again. New Megolm session keys are shared normally. + +**Status:** ⚠️ Partially works. Role changes propagate but the layer restriction/locking needs verification with Tuwunel (see power_levels state event delivery issue). + +**Note:** Demotion to READER does not require key revocation — Bob can still decrypt existing content, he just can't write. Megolm doesn't support key revocation; a new session is created when membership changes. + +--- + +### 6. Events between invite and join + +**Precondition:** Alice invites Bob. Before Bob accepts, Alice (or Carol) sends new content. + +**Flow:** +1. Alice invites Bob and shares existing keys (Scenario 1). +2. Alice sends new content. Key is shared with all room members — but Bob hasn't joined yet, so he may not be in the member list. +3. Bob accepts the invite and replays. + +**Status:** ❌ Potential gap. Events sent between invite and join may use a new Megolm session that wasn't shared with Bob. + +**Required fix:** After Bob joins, either: +- Alice detects `m.room.member` join event and re-shares all session keys, OR +- Bob sends a key request (`m.room_key_request`) for any sessions he can't decrypt. + +**Recommended approach:** Combine both — proactive share on invite + reactive share on join for any gaps. + +--- + +### 7. Participant goes offline and comes back + +**Precondition:** Bob is offline while Alice sends content. + +**Flow:** +1. Bob goes offline. +2. Alice sends content. Key sharing via `to_device` is queued server-side. +3. Bob comes back online, syncs, receives `to_device` messages with keys. +4. Bob receives the encrypted events and can decrypt. + +**Status:** ✅ Works (Matrix handles `to_device` delivery when recipient comes online). + +--- + +### 8. New device / fresh user-data-dir + +**Precondition:** Bob opens the project on a new device (or with `--user-data-dir=/tmp/bob2`). + +**Flow:** +1. Bob's new device has no Megolm session keys. +2. Bob syncs and tries to replay layer content → fails, no keys. +3. Bob needs to obtain keys from somewhere. + +**Options:** +- **Server-side Key Backup:** Bob's keys are backed up encrypted. New device restores from backup. Only works for Bob's **own** keys, not keys from other sessions he received. +- **Key forwarding:** Bob's old device (if still active) forwards keys to the new device. +- **Re-share from peers:** Other room members re-share keys when they see Bob's new device. + +**Status:** ❌ Not implemented. This is a future concern (single-device model for now). + +--- + +## Implementation Priority + +| Priority | Scenario | Action | +|----------|----------|--------| +| **P0** | #1, #3 | Share all room session keys on invite | +| **P0** | #6 | Re-share keys on member join (catch gaps) | +| **P1** | #5 | Verify role changes work with Tuwunel | +| **P2** | #8 | Key backup / multi-device (future) | + +## Key Sharing Implementation Notes + +### Where to implement key share on invite + +The invite happens in `structure-api.mjs` → `invite()` which just calls `httpAPI.invite()`. After a successful invite, we need to: + +1. Get all Megolm session keys for the room from `CryptoManager` / `OlmMachine` +2. Export the session keys (via `OlmMachine.exportRoomKeys()` or equivalent) +3. Encrypt them for the invited user's devices (Olm) +4. Send via `to_device` + +The `OlmMachine.shareRoomKey()` in `crypto.mjs` already handles the Olm encryption and `to_device` sending. The question is whether it shares **all** historical session keys or only the current session. + +### Matrix SDK Crypto WASM API + +- `shareRoomKey(roomId, userIds)` — shares the **current** Megolm session. May NOT include historical sessions. +- `exportRoomKeys()` — exports all session keys (for backup). Could be used to get historical keys, but they'd need to be re-imported on the recipient side via a custom mechanism. +- **Alternative:** Check if `shareRoomKey` with a freshly tracked user triggers sharing of all known sessions for that room. + +### Testing + +Each scenario above should have a corresponding integration test in `test-e2e/`. Tests should run against Tuwunel (Docker) with two users (Alice, Bob) and verify: +- Events can be decrypted after the described flow +- Layer content replay works completely +- No `Failed to decrypt` errors in the log diff --git a/package-lock.json b/package-lock.json index 715dcef1..f8cc18c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "dependencies": { "@mdi/js": "^7.0.96", "@mdi/react": "^1.6.0", - "@syncpoint/matrix-client-api": "^1.11.1", + "@syncpoint/matrix-client-api": "^2.2.0", "@syncpoint/signal": "^1.3.0", "@syncpoint/signs": "^1.1.0", "@syncpoint/wkx": "^0.5.2", @@ -3093,6 +3093,15 @@ "node": ">= 10.0.0" } }, + "node_modules/@matrix-org/matrix-sdk-crypto-wasm": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-17.1.0.tgz", + "integrity": "sha512-yKPqBvKlHSqkt/UJh+Z+zLKQP8bd19OxokXYXh3VkKbW0+C44nPHsidSwd3SH+RxT+Ck2PDRwVcVXEnUft+/2g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 18" + } + }, "node_modules/@mdi/js": { "version": "7.4.47", "resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz", @@ -3521,11 +3530,12 @@ } }, "node_modules/@syncpoint/matrix-client-api": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/@syncpoint/matrix-client-api/-/matrix-client-api-1.13.0.tgz", - "integrity": "sha512-kb71iJEoe5kq7q/bbeUkaQRblnGh1cVspMlW1bNgnQgA89nly0KH0Oe6NK78Fmvq+gXGtSfnlQF3TbdytIplyw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@syncpoint/matrix-client-api/-/matrix-client-api-2.2.0.tgz", + "integrity": "sha512-GYf81z8D37glOpmbSQy/457tK1HGCjtQPkY3tTsv9LTdxy3LFpB+pyOEPAI7vr6FwU1fp6KE0eM54gXG9EMNhQ==", "license": "MIT", "dependencies": { + "@matrix-org/matrix-sdk-crypto-wasm": "^17.1.0", "js-base64": "^3.7.7", "ky": "^1.7.2" } diff --git a/package.json b/package.json index f0bd8ded..ce04c8a5 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "dependencies": { "@mdi/js": "^7.0.96", "@mdi/react": "^1.6.0", - "@syncpoint/matrix-client-api": "^1.11.1", + "@syncpoint/matrix-client-api": "^2.2.0", "@syncpoint/signal": "^1.3.0", "@syncpoint/signs": "^1.1.0", "@syncpoint/wkx": "^0.5.2", diff --git a/specs/e2ee.md b/specs/e2ee.md new file mode 100644 index 00000000..2613d148 --- /dev/null +++ b/specs/e2ee.md @@ -0,0 +1,271 @@ +# ODIN E2EE – End-to-End Encryption for Project Replication + +## Overview + +Add end-to-end encryption (E2EE) to ODIN's Matrix-based project replication. Encrypted rooms ensure that feature data exchanged between project participants cannot be read by the homeserver or unauthorized third parties. + +## Scope + +- **In scope:** E2EE for project rooms (feature data replication) +- **Out of scope:** PROJECT-LIST device (space management, invitations, permissions — no sensitive payload) + +## Background + +ODIN uses `@syncpoint/matrix-client-api` to replicate project data via Matrix rooms. Each project creates a dedicated Matrix device (`device_id: projectUUID`). The PROJECT-LIST uses `device_id: 'PROJECT-LIST'` for structural operations (sharing spaces, invitations). + +Currently, all room communication is unencrypted. The Matrix homeserver can read all replicated feature data. + +## Architecture + +### Crypto SDK + +**Package:** `@matrix-org/matrix-sdk-crypto-wasm` (already a dependency) + +The Wasm bindings run natively in Electron's renderer process (Chromium), where IndexedDB is available as a persistent store. No package change required. + +**Why not `matrix-sdk-crypto-nodejs`?** +The native Node.js bindings would require moving all crypto logic to the main process and proxying via IPC. This is a larger architectural change with no clear benefit, since the `MatrixClient` already lives in the renderer. + +### Persistence + +Each project gets its own IndexedDB-backed crypto store: + +``` +IndexedDB: 'crypto-' +``` + +The `StoreHandle.open()` API creates and manages the IndexedDB database internally. The Wasm library controls the schema — there is no custom storage adapter needed. + +### Passphrase Management + +The IndexedDB crypto store is encrypted with a per-project passphrase. + +**Flow:** + +1. **Project is shared (first time):** + - Generate a random passphrase: `crypto.randomBytes(32).toString('base64')` + - Encrypt via Electron's `safeStorage.encryptString(passphrase)` + - Store the encrypted passphrase in the project's LevelDB: `session` sublevel, key `crypto:passphrase` + +2. **Project is opened:** + - Read encrypted passphrase from LevelDB (`session['crypto:passphrase']`) + - Decrypt via `safeStorage.decryptString(encryptedPassphrase)` (main process, exposed via preload/IPC) + - Open crypto store: `StoreHandle.open('crypto-' + projectUUID, passphrase)` + - Initialize OlmMachine: `OlmMachine.initFromStore(userId, deviceId, storeHandle)` + +3. **Project is deleted:** + - Delete the IndexedDB database `crypto-` (via `indexedDB.deleteDatabase()`) + - The LevelDB (including encrypted passphrase) is already deleted with the project directory + +### IPC for safeStorage + +`safeStorage` is a main-process-only API. The decrypted passphrase is passed to the renderer via the existing preload/IPC bridge. This is acceptable because: + +- The passphrase protects the local IndexedDB store only +- An attacker with renderer access already has access to IndexedDB directly +- The passphrase never leaves the local machine + +**Preload addition:** + +```javascript +// preload: expose decryptPassphrase to renderer +replication: { + decryptPassphrase: (encrypted) => ipcRenderer.invoke('replication:decrypt-passphrase', encrypted) +} +``` + +```javascript +// main process: handle decryption +ipcMain.handle('replication:decrypt-passphrase', (event, encrypted) => { + return safeStorage.decryptString(Buffer.from(encrypted)) +}) +``` + +## Integration with matrix-client-api + +### CryptoManager Changes + +The existing `CryptoManager` in `matrix-client-api/src/crypto.mjs` needs to be updated: + +1. **`initialize()` → `initializeWithStore()`**: Accept a `storePath` (IndexedDB name) and passphrase instead of creating an in-memory OlmMachine. + +2. **Sync integration**: `receiveSyncChanges()` must be called with every `/sync` response to process to-device messages and update device tracking. + +3. **Outgoing request processing**: After each sync cycle, `outgoingRequests()` must be polled and sent via HTTP (key uploads, key queries, key claims, to-device messages). + +4. **Room encryption setup**: When a room has `m.room.encryption` state, call `setRoomEncryption()` to register it with the OlmMachine. + +5. **Encrypt before send**: `command-api` messages must be encrypted via `encryptRoomEvent()` before sending. + +6. **Decrypt on receive**: `timeline-api` must decrypt `m.room.encrypted` events via `decryptRoomEvent()`. + +7. **Key sharing**: When a user joins a project room, room keys must be shared via `shareRoomKey()`. + +### Enabling Encryption on Room Creation + +**Default: E2EE enabled (secure by default, opt-out)** + +When a project is shared, encryption is enabled by default. The user can explicitly opt out during the sharing dialog (e.g. a checkbox "Encrypt project data (recommended)" — checked by default). + +The opt-out choice is stored in the project's LevelDB (`session['crypto:enabled']`): +- `true` (default) → rooms are created with encryption +- `false` (user opted out) → rooms are created without encryption + +When E2EE is enabled, new project rooms (layers) are created with the `m.room.encryption` state event: + +```javascript +{ + type: 'm.room.encryption', + content: { + algorithm: 'm.megolm.v1.aes-sha2' + } +} +``` + +This is done in the `structure-api` when creating rooms for shared projects. + +**Note:** Once a room is encrypted, it cannot be un-encrypted (Matrix protocol constraint). The opt-out decision applies at project-share time and affects all subsequently created rooms/layers. + +## Data Flow + +``` +Project Open + │ + ├─ Read encrypted passphrase from LevelDB + ├─ Decrypt via safeStorage (IPC to main process) + ├─ StoreHandle.open('crypto-', passphrase) + ├─ OlmMachine.initFromStore(userId, deviceId, storeHandle) + ├─ Process outgoing requests (key upload) + │ + ▼ +Sync Loop + │ + ├─ /sync response received + ├─ receiveSyncChanges(toDevice, deviceLists, otkeyCounts, fallbackKeys) + ├─ Process outgoing requests (key queries, claims, to-device) + ├─ Decrypt m.room.encrypted events → pass to timeline-api + │ + ▼ +Send Message + │ + ├─ shareRoomKey(roomId, userIds) if needed + ├─ encryptRoomEvent(roomId, eventType, content) + ├─ Send encrypted payload via command-api + │ + ▼ +Project Close + │ + └─ OlmMachine is dropped, IndexedDB persists automatically +``` + +## Project Deletion Cleanup + +When a project is deleted, the following must be cleaned up: + +1. Project LevelDB directory (existing behavior) +2. IndexedDB database `crypto-` (new: `indexedDB.deleteDatabase('crypto-' + projectUUID)`) + +## Migration + +Existing shared projects are unencrypted. Migration strategy: + +- **New rooms** created after E2EE is enabled will have `m.room.encryption` state → encrypted +- **Existing rooms** remain unencrypted (no retroactive encryption possible in Matrix) +- The `timeline-api` must handle both encrypted and unencrypted events (check for `m.room.encrypted` type) +- Optional: provide a "re-share project" action that creates new encrypted rooms and migrates data + +## Acceptance Criteria + +1. New shared project rooms are created with `m.room.encryption` state event +2. Feature data sent to project rooms is encrypted (Megolm) +3. Feature data received from project rooms is decrypted transparently +4. Crypto keys persist across ODIN restarts (IndexedDB + passphrase in LevelDB) +5. `safeStorage` protects the passphrase at rest +6. Project deletion removes both LevelDB and IndexedDB crypto store +7. Existing unencrypted projects continue to work without modification +8. PROJECT-LIST remains unencrypted + +## Design Decisions + +### Key Verification + +**Decision: TOFU (Trust on First Use) for V1.** + +Devices are trusted on first contact. ODIN projects are typically shared within organizations where the homeserver is trusted. The existing `CryptoManager` already uses `TrustRequirement.Untrusted`, which is de facto TOFU. + +Cross-signing and interactive verification (emoji comparison) can be added as a future enhancement. + +### Key Backup + +**Decision: Server-side key backup is required.** + +ODIN uses delta-based replication: every change produces a separate message. There are no snapshots. When a new device joins a project room, it must **replay the entire message history** to reconstruct the local state. Without access to the Megolm session keys that encrypted those messages, the replay fails and the project is unusable. + +Therefore, encrypted server-side key backup (`m.megolm_backup.v1.curve25519-aes-sha2`) must be implemented: + +1. **Backup creation:** When the first E2EE project is shared, generate a backup key and store it encrypted (via `safeStorage`) in the master DB. +2. **Continuous backup:** After each Megolm session rotation, back up the new session keys to the homeserver. +3. **Key restore:** When a new device opens an existing encrypted project, download and decrypt the backed-up keys before starting the history replay. +4. **Recovery key:** Provide the user with a human-readable recovery key (e.g., base58-encoded) for disaster recovery. This can be shown once during setup and stored by the user. + +**Note:** Without key backup, E2EE would effectively break project sharing for any new device — which contradicts the core use case. + +### Megolm Session Rotation + +**Decision: Use library defaults (100 messages or 1 week).** + +ODIN produces many small state updates, so rotation will happen frequently. This is acceptable — the key-sharing overhead is minimal (one `m.room_key` to-device event per rotation per participant), and frequent rotation provides better forward secrecy. Can be tuned later if performance issues arise. + +### Multi-Device and Message Filter + +**Current limitation:** ODIN does not support the same user participating in the same project from multiple physical devices simultaneously. The timeline message filter excludes all events where the current user is the sender. + +**Impact on E2EE:** The crypto layer uses to-device messages (`m.room.encrypted` to-device events) for key exchange. These are **not** room events and are not affected by the timeline filter. However, the following must be verified: + +1. **To-device events** (key sharing, key requests) must **not** be filtered — they are processed in `receiveSyncChanges()` before the timeline filter runs. +2. **Room events from self** are currently filtered out. With E2EE, encrypted events from self (`m.room.encrypted` with own sender) must still be filtered the same way as unencrypted self-events — the filter should apply **after** decryption, based on the decrypted sender, not on the encrypted envelope. +3. If multi-device support is added later, the self-filter must be revisited: same user on a different device is a legitimate source of changes. + +### Sync Filter Changes (matrix-client-api/src/project.mjs) + +The Matrix sync filter is applied **server-side**, before events reach the client. With E2EE, the server only sees `m.room.encrypted` as the event type — not the original type (e.g. `io.syncpoint.odin.operation`). This means the current `types` filter would **drop all encrypted events**. + +**Current filter (two locations):** + +1. `content()` (history replay): `types: [ODINv2_MESSAGE_TYPE]` +2. `filterProvider()` (live sync): `types: [M_ROOM_NAME, M_ROOM_POWER_LEVELS, M_SPACE_CHILD, M_ROOM_MEMBER, ODINv2_MESSAGE_TYPE, ODINv2_EXTENSION_MESSAGE_TYPE]` + +**Required change:** Add `m.room.encrypted` to the `types` array when E2EE is active: + +```javascript +// In filterProvider(): +const EVENT_TYPES = [ + M_ROOM_NAME, + M_ROOM_POWER_LEVELS, + M_SPACE_CHILD, + M_ROOM_MEMBER, + ODINv2_MESSAGE_TYPE, + ODINv2_EXTENSION_MESSAGE_TYPE, + 'm.room.encrypted' // NEW: let encrypted events through for client-side decryption +] + +// In content(): +types: [ODINv2_MESSAGE_TYPE, 'm.room.encrypted'] +``` + +**Post-decryption filtering:** After decryption in the `timeline-api`, the original event type is restored. However, `m.room.encrypted` is a catch-all — it could contain any event type, including types not in the original filter list. Therefore, a **client-side type filter** must run after decryption to ensure only the expected event types are processed: + +```javascript +// timeline-api.mjs, after decryption block: +// Re-apply type filter on decrypted events +if (filter?.types) { + events[roomId] = roomEvents.filter(event => filter.types.includes(event.type)) +} +``` + +**`not_senders` is unaffected:** The sender is event metadata (not encrypted), so the server-side `not_senders` filter continues to work correctly with encrypted events. + +## Open Questions + +1. **Snapshot mechanism:** A snapshot/checkpoint feature would reduce dependence on full history replay and make key backup less critical for day-to-day usage. Worth considering as a separate feature. +2. **Multi-device:** If same-user multi-device support is planned, the self-message filter and device key management need to be designed accordingly from the start. diff --git a/src/main/ipc.js b/src/main/ipc.js index 7a950c9e..723c3b31 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -1,6 +1,6 @@ import path from 'path' import { promises as fs } from 'fs' -import { app } from 'electron' +import { app, safeStorage } from 'electron' import { leveldb, sessionDB } from '../shared/level' import { initPaths } from './paths' @@ -83,4 +83,38 @@ export const ipc = (ipcMain, projectStore) => { console.error(error) } }) + + // E2EE: store crypto:enabled flag in project's session DB + ipcMain.handle('ipc:put:project:crypto/enabled', async (_, id, enabled) => { + try { + const uuid = id.split(':')[1] + const location = path.join(paths.databases, uuid) + const db = leveldb({ location }) + const session = sessionDB(db) + await session.put('crypto:enabled', enabled) + await db.close() + } catch (error) { + console.error('Failed to store crypto:enabled:', error) + } + }) + + // E2EE: encrypt/decrypt passphrases via Electron's safeStorage API. + // safeStorage uses the OS keychain (DPAPI on Windows, Keychain on macOS, libsecret on Linux) + // to protect the passphrase at rest. + + ipcMain.handle('ipc:replication/encryptPassphrase', (_, passphrase) => { + if (!safeStorage.isEncryptionAvailable()) { + throw new Error('safeStorage encryption is not available on this platform') + } + // Returns a Buffer; convert to base64 for storage in LevelDB + return safeStorage.encryptString(passphrase).toString('base64') + }) + + ipcMain.handle('ipc:replication/decryptPassphrase', (_, encryptedBase64) => { + if (!safeStorage.isEncryptionAvailable()) { + throw new Error('safeStorage encryption is not available on this platform') + } + const encrypted = Buffer.from(encryptedBase64, 'base64') + return safeStorage.decryptString(encrypted) + }) } diff --git a/src/main/preload/modules/replication.js b/src/main/preload/modules/replication.js index 17d72b52..d5ce7cbd 100644 --- a/src/main/preload/modules/replication.js +++ b/src/main/preload/modules/replication.js @@ -6,5 +6,10 @@ module.exports = { getCredentials: (id) => ipcRenderer.invoke('ipc:get:replication/credentials', id), putCredentials: (id, credentials) => ipcRenderer.invoke('ipc:put:replication/credentials', id, credentials), delCredentials: (id) => ipcRenderer.invoke('ipc:del:replication/credentials', id), - putReplicationSeed: (id, seed) => ipcRenderer.invoke('ipc:put:project:replication/seed', id, seed) + putReplicationSeed: (id, seed) => ipcRenderer.invoke('ipc:put:project:replication/seed', id, seed), + + // E2EE: passphrase management via safeStorage (main process only) + encryptPassphrase: (passphrase) => ipcRenderer.invoke('ipc:replication/encryptPassphrase', passphrase), + decryptPassphrase: (encrypted) => ipcRenderer.invoke('ipc:replication/decryptPassphrase', encrypted), + setCryptoEnabled: (id, enabled) => ipcRenderer.invoke('ipc:put:project:crypto/enabled', id, enabled) } diff --git a/src/renderer/components/OSD.js b/src/renderer/components/OSD.js index 97154616..5f954f97 100644 --- a/src/renderer/components/OSD.js +++ b/src/renderer/components/OSD.js @@ -25,13 +25,13 @@ export const OSD = () => { return
{ state.A1 }
-
+
{ state.B1 }
{ state.C1 }
{ state.A2 }
-
+
{ state.B2 }
{ state.C2 }
-
-
+
{ state.A3 }
+
{ state.B3 }
{ state.C3 }
} diff --git a/src/renderer/components/Project-services.js b/src/renderer/components/Project-services.js index 2081912c..48e24021 100644 --- a/src/renderer/components/Project-services.js +++ b/src/renderer/components/Project-services.js @@ -131,12 +131,41 @@ export default async projectUUID => { const isRemoteProject = projectTags.includes('SHARED') const credentials = await projectStore.getCredentials('default') - services.replicationProvider = (isRemoteProject && credentials) - ? MatrixClient({ + if (isRemoteProject && credentials) { + // Check if E2EE is enabled for this project + const cryptoEnabled = await sessionStore.get('crypto:enabled', false) + let encryption = null + + if (cryptoEnabled) { + let passphrase + const encryptedPassphrase = await sessionStore.get('crypto:passphrase', null) + + if (encryptedPassphrase) { + // Decrypt existing passphrase via safeStorage (main process) + passphrase = await window.odin.replication.decryptPassphrase(encryptedPassphrase) + } else { + // First time: generate random passphrase, encrypt and store it + passphrase = crypto.randomUUID() + crypto.randomUUID() // 72 chars of randomness + const encrypted = await window.odin.replication.encryptPassphrase(passphrase) + await sessionStore.put('crypto:passphrase', encrypted) + } + + encryption = { + enabled: true, + storeName: `crypto-${projectUUID}`, + passphrase + } + } + + services.replicationProvider = MatrixClient({ ...credentials, - device_id: projectUUID + device_id: projectUUID, + db: L.leveldb({ up: db, encoding: 'json', prefix: 'command-queue' }), + ...(encryption && { encryption }) }) - : { disabled: true } + } else { + services.replicationProvider = { disabled: true } + } services.signals = {} services.signals['replication/operational'] = Signal.of(false) diff --git a/src/renderer/components/ProjectList-services.js b/src/renderer/components/ProjectList-services.js index dcd9acdb..deccd0b6 100644 --- a/src/renderer/components/ProjectList-services.js +++ b/src/renderer/components/ProjectList-services.js @@ -1,3 +1,6 @@ +import levelup from 'levelup' +import memdown from 'memdown' +import sublevel from 'subleveldown' import ProjectStore from '../store/ProjectStore' import { Selection } from '../Selection' import { MatrixClient } from '@syncpoint/matrix-client-api' @@ -13,7 +16,8 @@ export default async () => { services.replicationProvider = credentials ? MatrixClient({ ...credentials, - device_id: 'PROJECT-LIST' + device_id: 'PROJECT-LIST', + db: sublevel(levelup(memdown()), 'command-queue', { valueEncoding: 'json' }) }) : { disabled: true diff --git a/src/renderer/components/projectlist/ProjectList.css b/src/renderer/components/projectlist/ProjectList.css index 6aec927f..d14e6c3f 100644 --- a/src/renderer/components/projectlist/ProjectList.css +++ b/src/renderer/components/projectlist/ProjectList.css @@ -62,3 +62,81 @@ padding: 4px; } + +/* ShareDialog */ +.share-dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.share-dialog { + background: var(--color-bg, #fff); + border-radius: 8px; + padding: 24px; + max-width: 420px; + width: 90%; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); +} + +.share-dialog h3 { + margin: 0 0 12px 0; +} + +.share-dialog p { + margin: 8px 0; + line-height: 1.4; +} + +.share-dialog-checkbox { + display: flex; + align-items: center; + gap: 8px; + margin: 16px 0 4px 0; + cursor: pointer; + font-weight: 500; +} + +.share-dialog-checkbox input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; +} + +.share-dialog-hint { + font-size: 0.85em; + opacity: 0.7; + margin: 4px 0 16px 26px; +} + +.share-dialog-buttons { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; +} + +.share-dialog-buttons button { + padding: 6px 16px; + border-radius: 4px; + border: 1px solid var(--color-border, #d9d9d9); + cursor: pointer; + font-size: 14px; +} + +.share-dialog-primary { + background: #1890ff; + color: #fff; + border-color: #1890ff !important; +} + +.share-dialog-primary:hover { + background: #40a9ff; +} diff --git a/src/renderer/components/projectlist/ProjectList.js b/src/renderer/components/projectlist/ProjectList.js index 6bedb761..8951348f 100644 --- a/src/renderer/components/projectlist/ProjectList.js +++ b/src/renderer/components/projectlist/ProjectList.js @@ -7,6 +7,7 @@ import { Card } from './Card' import { useList, useServices } from '../hooks' import { militaryFormat } from '../../../shared/datetime' import MemberManagement from './MemberManagement' +import ShareDialog from './ShareDialog' /** * @@ -120,6 +121,7 @@ export const ProjectList = () => { const [replication, setReplication] = React.useState(undefined) const [managedProject, setManagedProject] = React.useState(null) + const [shareProject, setShareProject] = React.useState(null) /* system/OS level notifications */ const notifications = React.useRef(new Set()) @@ -334,6 +336,24 @@ export const ProjectList = () => { const handleFilterChange = React.useCallback(value => setFilter(value), []) const handleCreate = () => projectStore.createProject() + const doShare = async ({ encrypted }) => { + if (!shareProject) return + const project = shareProject + const options = encrypted ? { encrypted: true } : {} + const seed = await replication.share(project.id, project.name, project.description || '', options) + await projectStore.addTag(project.id, 'SHARED') + await projectStore.putReplicationSeed(project.id, seed) + + // Store E2EE preference in the project's session DB (via IPC to main process). + // The crypto:enabled flag is read by Project-services.js when opening the project. + if (encrypted) { + await window.odin.replication.setCryptoEnabled(project.id, true) + } + + setShareProject(null) + fetch(project.id) + } + /* eslint-disable react/prop-types */ const child = React.useCallback(props => { const { entry: project } = props @@ -352,13 +372,14 @@ export const ProjectList = () => { // createProject requires the id to be a UUID without prefix await projectStore.createProject(project.id.split(':')[1], project.name, ['SHARED']) await projectStore.putReplicationSeed(project.id, seed) + // Persist the project's E2EE setting so Project-services.js picks it up on open. + if (seed.encrypted) { + await window.odin.replication.setCryptoEnabled(project.id, true) + } } - const handleShare = async () => { - const seed = await replication.share(project.id, project.name, project.description || '') - await projectStore.addTag(project.id, 'SHARED') - await projectStore.putReplicationSeed(project.id, seed) - fetch(project.id) + const handleShare = () => { + setShareProject(project) } /* const handleMembers = async () => { @@ -429,6 +450,7 @@ export const ProjectList = () => { return (
{ managedProject && setManagedProject(null)}/>} + { shareProject && setShareProject(null)}/>}
{ + const [encrypted, setEncrypted] = React.useState(true) + + const handleConfirm = () => { + onConfirm({ encrypted }) + } + + return ( +
+
+

Share Project

+

+ Share {projectName} with other users? + Once shared, other users can be invited to collaborate. +

+ + +

+ {encrypted + ? 'All project data will be end-to-end encrypted. Only project participants can read the data — not even the server.' + : 'Project data will be sent without encryption. The server can read all replicated data.' + } +

+ +
+ + +
+
+
+ ) +} + +ShareDialog.propTypes = { + projectName: PropTypes.string.isRequired, + onConfirm: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired +} + +export default ShareDialog diff --git a/src/renderer/replication/handler/toolbar.js b/src/renderer/replication/handler/toolbar.js index 8a495d20..1f335bdd 100644 --- a/src/renderer/replication/handler/toolbar.js +++ b/src/renderer/replication/handler/toolbar.js @@ -1,4 +1,20 @@ import * as ID from '../../ids' +import { rolesReducer } from '../shared' + +/** + * Import operations into the store, respecting layer restrictions. + * Used by both the initial content load (join) and the stream handler (received). + */ +const importOperations = async (store, id, operations, CREATOR_ID) => { + const [restricted] = await store.collect(id, [ID.restrictedId]) + await store.import(operations, { creatorId: CREATOR_ID }) + if (restricted) { + const operationKeys = operations.map(o => o.key) + await store.restrict(operationKeys) + } +} + +export { importOperations } export default ({ store, replicatedProject, CREATOR_ID }) => { return async ({ action, id, parameter }) => { @@ -14,19 +30,25 @@ export default ({ store, replicatedProject, CREATOR_ID }) => { ], { creatorId: CREATOR_ID }) await store.delete(id) // invitation ID /* - We load the entire existing content. This may be huge, especially - if you join long running rooms. Unless we have a solid solution - for managing snapshots: this is the way. + Load the entire existing content. The join HTTP call is synchronous — + once it returns 200, the messages endpoint should have the content. */ - const operations = await replicatedProject.content(layer.id) - console.log(`Initial sync has ${operations.length} operations`) - await store.import(operations, { creatorId: CREATOR_ID }) - // TODO: check the powerlevel and apply restrictions if required + // Apply layer restrictions based on the user's role + const permissions = [layer].reduce(rolesReducer, { restrict: [], permit: [] }) + if (permissions.restrict.length > 0) await store.restrict(permissions.restrict) + if (permissions.permit.length > 0) await store.permit(permissions.permit) + + // Content is NOT fetched here. It will arrive via the sync-gated + // mechanism in matrix-client-api: Project.start() detects the room + // in the next sync cycle and delivers operations through the + // received() stream handler. break } case 'share': { const { name } = await store.value(id) - const layer = await replicatedProject.shareLayer(id, name) + // Inherit encryption setting from the project (set during handleShare in ProjectList) + const cryptoEnabled = replicatedProject.cryptoManager !== null + const layer = await replicatedProject.shareLayer(id, name, '', { encrypted: cryptoEnabled }) if (!layer) { console.log('layer is already shared') return @@ -40,7 +62,11 @@ export default ({ store, replicatedProject, CREATOR_ID }) => { const keys = await store.collectKeys([id], [ID.STYLE, ID.LINK, ID.TAGS, ID.FEATURE]) const tuples = await store.tuples(keys) const operations = tuples.map(([key, value]) => ({ type: 'put', key, value })) - replicatedProject.post(id, operations) + await replicatedProject.post(id, operations) + + /* Share Megolm session keys with all project members so they can + decrypt this layer's content even if they join later (offline). */ + await replicatedProject.shareHistoricalKeys(id) break } case 'leave': { diff --git a/src/renderer/replication/handler/upstream.js b/src/renderer/replication/handler/upstream.js index 3b02d54f..ae389a1c 100644 --- a/src/renderer/replication/handler/upstream.js +++ b/src/renderer/replication/handler/upstream.js @@ -1,5 +1,6 @@ import * as ID from '../../ids' import { KEYS, rolesReducer } from '../shared' +import { importOperations } from './toolbar' export default ({ sessionStore, setOffline, store, CREATOR_ID }) => { /* @@ -16,12 +17,7 @@ export default ({ sessionStore, setOffline, store, CREATOR_ID }) => { await store.import([content], { creatorId: CREATOR_ID }) }, received: async ({ id, operations }) => { - const [restricted] = await store.collect(id, [ID.restrictedId]) - await store.import(operations, { creatorId: CREATOR_ID }) - if (restricted) { - const operationKeys = operations.map(o => o.key) - await store.restrict(operationKeys) - } + await importOperations(store, id, operations, CREATOR_ID) }, renamed: async (renamed) => { /* diff --git a/test-e2e/docker-compose.yml b/test-e2e/docker-compose.yml new file mode 100644 index 00000000..b15c0f9c --- /dev/null +++ b/test-e2e/docker-compose.yml @@ -0,0 +1,18 @@ +services: + homeserver: + image: jevolk/tuwunel:latest + ports: + - "8008:8008" + volumes: + - ./tuwunel.toml:/etc/tuwunel.toml:ro + - tuwunel-data:/var/lib/tuwunel + command: ["-c", "/etc/tuwunel.toml"] + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8008/_matrix/client/versions"] + interval: 2s + timeout: 5s + retries: 15 + start_period: 5s + +volumes: + tuwunel-data: diff --git a/test-e2e/setup.sh b/test-e2e/setup.sh new file mode 100755 index 00000000..00a92b5b --- /dev/null +++ b/test-e2e/setup.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# Start Tuwunel and register test users for ODIN E2EE testing +# +# Usage: ./setup.sh +# +# After setup: +# - Alice: @alice:odin.battlefield / password: Alice +# - Bob: @bob:odin.battlefield / password: Bob +# - Server: http://localhost:8008 + +set -e +HOMESERVER="http://localhost:8008" + +echo "Starting Tuwunel..." +docker compose up -d + +echo "Waiting for homeserver..." +for i in $(seq 1 30); do + if curl -sf "$HOMESERVER/_matrix/client/versions" > /dev/null 2>&1; then + echo "Homeserver ready!" + break + fi + sleep 1 +done + +# Check if homeserver is up +if ! curl -sf "$HOMESERVER/_matrix/client/versions" > /dev/null 2>&1; then + echo "ERROR: Homeserver failed to start" + docker compose logs + exit 1 +fi + +echo "" +echo "Registering Alice..." +ALICE=$(curl -sf -X POST "$HOMESERVER/_matrix/client/v3/register" \ + -H 'Content-Type: application/json' \ + -d '{"username":"alice","password":"Alice","auth":{"type":"m.login.dummy"}}' 2>&1) || true + +if echo "$ALICE" | grep -q "user_id"; then + echo " ✓ @alice:odin.battlefield" +elif echo "$ALICE" | grep -q "M_USER_IN_USE"; then + echo " ✓ @alice:odin.battlefield (already exists)" +else + echo " ✗ Failed: $ALICE" +fi + +echo "Registering Bob..." +BOB=$(curl -sf -X POST "$HOMESERVER/_matrix/client/v3/register" \ + -H 'Content-Type: application/json' \ + -d '{"username":"bob","password":"Bob","auth":{"type":"m.login.dummy"}}' 2>&1) || true + +if echo "$BOB" | grep -q "user_id"; then + echo " ✓ @bob:odin.battlefield" +elif echo "$BOB" | grep -q "M_USER_IN_USE"; then + echo " ✓ @bob:odin.battlefield (already exists)" +else + echo " ✗ Failed: $BOB" +fi + +echo "" +echo "=== ODIN E2EE Test Environment Ready ===" +echo "" +echo " Homeserver: $HOMESERVER" +echo " Server name: odin.battlefield" +echo "" +echo " Alice: @alice:odin.battlefield / Alice" +echo " Bob: @bob:odin.battlefield / Bob" +echo "" +echo " In ODIN's login dialog:" +echo " Homeserver URL: $HOMESERVER" +echo " Username: @alice:odin.battlefield" +echo " Password: Alice" +echo "" +echo "To stop: cd test-e2e && docker compose down -v" diff --git a/test-e2e/tuwunel.toml b/test-e2e/tuwunel.toml new file mode 100644 index 00000000..803da347 --- /dev/null +++ b/test-e2e/tuwunel.toml @@ -0,0 +1,13 @@ +# Tuwunel config for ODIN E2EE testing +# Local only, no federation, open registration + +[global] +server_name = "odin.battlefield" +database_path = "/var/lib/tuwunel" +address = ["0.0.0.0"] +port = 8008 + +allow_registration = true +yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse = true +allow_federation = false +new_user_displayname_suffix = "" diff --git a/webpack.config.js b/webpack.config.js index 6824cd26..bffb5e4f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -81,6 +81,17 @@ const rendererConfig = (env, argv) => ({ mode: mode(env), stats: 'errors-only', module: { rules: rules() }, + resolve: { + // Force the browser/wasm entrypoint for matrix-sdk-crypto-wasm. + // Without this, Webpack's electron-renderer target resolves the 'node' export condition + // which loads node.mjs (uses fileURLToPath, incompatible with Webpack bundling). + // The Wasm bindings run natively in Chromium's renderer (IndexedDB available). + alias: { + '@matrix-org/matrix-sdk-crypto-wasm': path.resolve( + __dirname, 'node_modules/@matrix-org/matrix-sdk-crypto-wasm/index.mjs' + ) + } + }, entry: { renderer: ['./index.js'] },