Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: e2e

on:
push:
branches: [main, feat/security]
pull_request:

jobs:
smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 12.3.4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm xmpp:up
- run: sleep 5
- run: pnpm test:e2e
- run: pnpm build
- run: pnpm test:csp
- if: always()
run: pnpm xmpp:down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,24 @@ without a deprecation period.
- Read state survives a refresh: delivered/read receipts are stored locally and
re-applied when MAM replays the conversation. Add a Settings > Chats toggle to
stop sending read receipts.
- "Encrypt new direct chats by default" is now a guided switch: turning it on
before OMEMO is ready scrolls to setup/unlock and focuses the passphrase, and
the default is saved only once OMEMO can encrypt.
- Settings > Security keeps only secondary passphrase-storage mechanics in a
tooltip. Warnings, current state, and irreversible consequences stay visible.
- Added Repair keys (top up pre-keys and republish without changing identity) and
Reset OMEMO (new identity/device, fresh passphrase, old device removed from
the PEP list) to Security settings.
- Nested dialogs opened from Settings (such as Reset OMEMO) now render a
dimming overlay above the parent dialog instead of inheriting its muted layer.
- Added repo Playwright smoke suites: `pnpm test:e2e` covers mutual presence,
DM/group delivery, group invite/join, reactions (including refresh restore),
transcript scrolling, and panel restore; `pnpm test:csp` checks the static
build for CSP violations. Added a GitHub Actions workflow for both.
- OMEMO placeholders received while locked now decrypt automatically after
unlock or device verification. Outgoing encrypted carbons from your other
devices are decrypted and shown in the recipient conversation without
duplicating the originating device's copy.
- Reaction tooltips expose tooltip semantics, and the group "Not encrypted"
badge becomes icon-only on narrow screens so group names fit.
- README diagrams now show the federation trust boundary and the message/receipt
Expand Down
27 changes: 27 additions & 0 deletions dev/xmpp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,30 @@ pnpm test:integration
```

The test connects two Stanza clients, sends a message, and waits for the delivery receipt.

## End-to-end smoke

The live Playwright smoke exercises two accounts, mutual presence, DM delivery,
group invite/join, group reactions (including refresh restore), transcript
scrolling, the URL-backed info panel, and console errors.

```bash
pnpm exec playwright install chromium # once
pnpm xmpp:up
pnpm test:e2e
```

The script starts `pnpm dev` itself if the app is not already running. Set
`APP_URL`, `XMPP_WS`, or `XMPP_PASSWORD` to target a different setup. It uses
`phiey@localhost` and `sam@localhost` from the compose file.

CSP smoke runs against the static build:

```bash
pnpm build
pnpm test:csp
```

It serves `.output/public` locally, logs in with a test-only `ws://` allowance
for the local Prosody, opens Settings > Security, and fails on any
`securitypolicyviolation`, page error, or console error.
9 changes: 5 additions & 4 deletions docs/backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,11 @@ Deferred work lives here instead of `TODO` comments. Each item should include en
and sends only encrypted envelopes, settings/contact UI expose device
fingerprints and trust (including your other devices), and the composer has a
per-chat lock. Remaining:
outgoing carbon decryption (currently ignored to avoid duplicates),
re-decrypting locked placeholders after unlock, encrypted replies/edits,
forwarding, attachments, and MAM history, live device-list PEP subscriptions,
and independent audit. Per-chat encryption choices are persisted.
encrypted replies/edits, forwarding, attachments, and MAM history, live
device-list PEP subscriptions, and independent audit. Per-chat encryption
choices are persisted; locked placeholders retry automatically after unlock
or device verification; outgoing encrypted carbons are decrypted without
duplicating the originating device's copy.
- **OMEMO2 PEP (XEP-0384)** — legacy 0.3.0 device lists and bundles are
implemented. Add the `urn:xmpp:omemo:2` device-list/bundle nodes and
version-aware selection after 1:1 messaging works.
Expand Down
2 changes: 1 addition & 1 deletion docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Trust boundaries:
| T10 | Lost passphrase | Keys are unrecoverable by design. No backup. | Operational. Users must understand this before setup. |
| T11 | Contact's device key changes (MITM or new device) | Trust is bound to the reviewed fingerprint. Identity changes block reuse until reviewed. | Trust-on-review is only as good as the user's comparison. |
| T16 | Group messages inferred as encrypted | Group chats show a "Not encrypted" badge with a tooltip; the composer exposes no group encryption toggle. | None for group confidentiality until group OMEMO lands. |
| T12 | Silent downgrade to plaintext for OMEMO chats | No automatic plaintext fallback. Sending unencrypted requires a confirmation dialog and shows an **Unencrypted** marker. Per-chat encryption choices persist per account, including explicit off, so a refresh cannot silently reset a chat to the default. | Users can still choose plaintext. |
| T12 | Silent downgrade to plaintext for OMEMO chats | No automatic plaintext fallback. Sending unencrypted requires a confirmation dialog and shows an **Unencrypted** marker. Per-chat encryption choices persist per account, including explicit off, so a refresh cannot silently reset a chat to the default. The default itself can only be enabled once OMEMO is set up and unlocked. | Users can still choose plaintext. |
| T13 | Dependency compromise (`libomemo.js`, GPLv3, unaudited) | Isolated in `src/lib/omemo`. OMEMO labeled experimental. | High until audited. |
| T14 | Secrets in logs | Production code does not log passwords, passphrases, or raw stanzas. The render error boundary logs the error and component stack (`console.error`). | Low. Error objects could contain user text. Review before release. |
| T15 | Cross-account replay of OMEMO envelopes or unlock keys | Account JID bound as AAD; remembered keys are per account and fail on another account. | Low. Covered by tests. |
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@
"xmpp:down": "docker compose -f dev/xmpp/compose.yml down",
"xmpp:wipe": "docker compose -f dev/xmpp/compose.yml down -v",
"test:integration": "XMPP_INTEGRATION=1 vitest run src/lib/xmpp/client.integration.test.ts src/lib/xmpp/omemo-pep.integration.test.ts src/lib/xmpp/omemo-message.integration.test.ts src/lib/xmpp/group-invite.integration.test.ts src/lib/xmpp/group-reactions.integration.test.ts",
"react:doctor": "pnpm dlx react-doctor@latest ."
"react:doctor": "pnpm dlx react-doctor@latest .",
"test:e2e": "node scripts/e2e/live-smoke.mjs",
"test:csp": "node scripts/e2e/csp-smoke.mjs"
},
"dependencies": {
"@base-ui/react": "^1.9.0",
Expand Down Expand Up @@ -75,6 +77,7 @@
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^6.0.1",
"jsdom": "^30.1.3",
"playwright": "1.64.1",
"shadcn": "^4.22.1",
"typescript": "^6.0.2",
"vite": "^8.0.0",
Expand Down
19 changes: 19 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

134 changes: 134 additions & 0 deletions scripts/e2e/csp-smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { setTimeout as delay } from "node:timers/promises";
import { chromium } from "playwright";

const PORT = Number(process.env.CSP_PORT ?? 4173);
const BASE = `http://localhost:${PORT}`;
const WS = process.env.XMPP_WS ?? "ws://localhost:5280/xmpp-websocket";
const BUILD_INDEX = ".output/public/index.html";

async function waitFor(check, timeoutMs, label) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
if (await check()) return;
await delay(100);
}
throw new Error(`Timed out waiting for ${label}`);
}

async function isUp(url) {
try {
const response = await fetch(url);
return response.ok;
} catch {
return false;
}
}

function collect(page, violations, errors) {
page.on("pageerror", (error) => errors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error" && !message.text().includes("favicon")) {
errors.push(message.text());
}
});
page.on("framenavigated", async () => {
const found = await page.evaluate(() => window.__cspViolations ?? []).catch(() => []);
violations.push(...found);
});
}

async function login(page) {
await page.goto(`${BASE}/connect`, { waitUntil: "networkidle" });
await page.locator("#jid").fill("phiey@localhost");
await page.locator("#password").fill("bob123");
await page.getByRole("button", { name: "Connection options" }).click();
await page.locator("#websocket").fill(WS);
await page.getByRole("button", { name: "Done" }).click();
await page.getByRole("button", { name: "Connect", exact: true }).click();
await page.waitForURL(/\/chat/, { timeout: 20_000 });
await page.waitForTimeout(800);
}

async function run() {
if (!existsSync(BUILD_INDEX)) {
throw new Error("Missing static build. Run pnpm build first.");
}
if (await isUp(BASE)) throw new Error(`Port ${PORT} is already in use.`);
const server = spawn("node", ["scripts/serve-static.mjs"], {
env: { ...process.env, PORT: String(PORT) },
stdio: "ignore",
});
const violations = [];
const errors = [];
const browser = await chromium.launch({
headless: true,
args: ["--disable-features=LocalNetworkAccessChecks", "--disable-dev-shm-usage"],
});
try {
await waitFor(() => isUp(`${BASE}/connect`), 20_000, "static server");
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
await context.route("**/*", async (route) => {
if (route.request().resourceType() !== "document") return route.continue();
const response = await route.fetch();
const body = (await response.text()).replace(
"connect-src 'self' wss: https:",
"connect-src 'self' wss: https: ws://localhost:5280",
);
await route.fulfill({
response,
body,
headers: { ...response.headers(), "content-type": "text/html; charset=utf-8" },
});
});
const page = await context.newPage();
await page.addInitScript(() => {
window.__cspViolations = [];
document.addEventListener("securitypolicyviolation", (event) => {
window.__cspViolations.push({
directive: event.violatedDirective,
blocked: event.blockedURI,
});
});
});
collect(page, violations, errors);

for (const path of ["/", "/connect"]) {
await page.goto(`${BASE}${path}`, { waitUntil: "networkidle" });
violations.push(...(await page.evaluate(() => window.__cspViolations ?? [])));
await page.waitForTimeout(300);
}

await login(page);
await page.locator('nav[aria-label="Primary"] button[aria-label="Settings"]').click({ force: true });
await page.getByRole("dialog").waitFor();
await page.getByRole("button", { name: /^Security$/ }).click();
await page.waitForTimeout(700);
violations.push(...(await page.evaluate(() => window.__cspViolations ?? [])));

const state = await page.evaluate(() => ({
cspMeta: document.querySelectorAll('meta[http-equiv="Content-Security-Policy"]').length,
styleSheets: document.styleSheets.length,
hasSecurity: (document.body.innerText || "").includes("Security"),
violations: window.__cspViolations ?? [],
}));

const unique = [...new Map(violations.map((item) => [`${item.directive}|${item.blocked}`, item])).values()];
if (state.cspMeta !== 1) throw new Error("CSP meta tag is missing");
if (state.styleSheets === 0) throw new Error("Stylesheets did not load");
if (!state.hasSecurity) throw new Error("Settings > Security did not render");
if (unique.length > 0) throw new Error(`CSP violations: ${JSON.stringify(unique)}`);
if (errors.length > 0) throw new Error(`Console/page errors: ${errors.join(" | ")}`);

console.log(JSON.stringify({ ok: true, cspMeta: state.cspMeta, styleSheets: state.styleSheets }, null, 2));
} finally {
await browser.close().catch(() => undefined);
server.kill("SIGTERM");
}
}

run().catch((error) => {
console.error("CSP E2E FAILED:", error.message);
process.exitCode = 1;
});
Loading
Loading