diff --git a/ts/packages/core/src/domain/mesh-session.ts b/ts/packages/core/src/domain/mesh-session.ts index e45bd986..9a8fc689 100644 --- a/ts/packages/core/src/domain/mesh-session.ts +++ b/ts/packages/core/src/domain/mesh-session.ts @@ -114,7 +114,17 @@ export interface MeshSession { readonly coordinatorFrames: AsyncIterable; connect: (address: string, localDomains: readonly string[]) => Promise; sendPing: () => Promise; - /** Sends a ping-frame and resolves with the round-trip time in milliseconds once the correlated pong-frame arrives -- FIFO-paired against this call's own ping, since ping-frame carries no correlation id of its own (spec/transport.cddl): the Nth call's own promise resolves against the Nth pong received after it, never matched by any other means. Rejects if the connection closes, or (when timeoutMs is given) if no pong arrives within timeoutMs, rather than resolving a sentinel value the way sendManageRequest's own timeout does -- there is no natural "no answer" value for a bare millisecond count to double as. Unlike sendPing (fire-and-forget, answered by nothing on an ordinary peer connection), this is answered only by a peer that replies to ping with pong -- today, relay-hub's own frame handling (wire-mesh#181) -- so calling this against a connection to a plain peer that never sends pong hangs until timeoutMs (if given) or forever. Exists to isolate the sender-to-hub leg of a relayed path.trace round trip: time this over the same connection a relayed manage-request travelled, then subtract it from path.trace's own end-to-end RTT to recover the hub-to-target leg. */ + /** + * Sends a ping-frame and resolves with the round-trip time in milliseconds once the correlated pong-frame arrives. + * + * The pairing is FIFO because ping-frame carries no correlation id of its own (spec/transport.cddl): the Nth call's promise resolves against the Nth pong received after it, never matched by any other means. + * + * Rejects with a plain Error if the connection closes, and with a PingTimeoutError (from ping-round-trips.ts) when `timeoutMs` is given and no pong arrives within it, so a caller can tell an unanswered ping from a dropped connection. It never resolves a sentinel the way sendManageRequest's own timeout does, because there is no natural "no answer" value for a bare millisecond count to double as. + * + * Unlike sendPing (fire-and-forget, answered by nothing on an ordinary peer connection), this is answered only by a peer that replies to ping with pong, which today means relay-hub's own frame handling (wire-mesh#181). Called against a plain peer that never sends pong it hangs until `timeoutMs` if given, or forever. + * + * It exists to isolate the sender-to-hub leg of a relayed path.trace round trip: time it over the same connection a relayed manage-request travelled, then subtract it from path.trace's own end-to-end RTT to recover the hub-to-target leg. + */ sendPingMeasureRtt: (timeoutMs?: number) => Promise; /** Attaches this token to every `manage-request` sent from now on. */ setToken: (token: CapabilityToken) => void; diff --git a/ts/packages/core/src/domain/ping-round-trips.ts b/ts/packages/core/src/domain/ping-round-trips.ts index fd68c0db..ba2635c5 100644 --- a/ts/packages/core/src/domain/ping-round-trips.ts +++ b/ts/packages/core/src/domain/ping-round-trips.ts @@ -1,4 +1,12 @@ -// FIFO pairing between sendPingMeasureRtt calls and the pong-frames that answer them (wire-mesh#181) -- ping-frame/pong-frame carry no correlation id of their own (spec/transport.cddl), so the Nth outstanding call is paired against the Nth pong a connection receives. Extracted out of mesh-session.ts the same way relay-pairing.ts already extracts its own relay-pairing bookkeeping, so mesh-session.ts's own applyFrame/sendPingMeasureRtt stay thin wiring rather than owning this queue's mechanics directly. +// FIFO pairing between sendPingMeasureRtt calls and the pong-frames that answer them (wire-mesh#181). ping-frame and pong-frame carry no correlation id of their own (spec/transport.cddl), so the Nth outstanding call is paired against the Nth pong a connection receives. Extracted out of mesh-session.ts the same way relay-pairing.ts already extracts its own relay-pairing bookkeeping, so mesh-session.ts's own applyFrame/sendPingMeasureRtt stay thin wiring rather than owning this queue's mechanics directly. + +/** Why a ping was given up on: no pong arrived within the caller's `timeoutMs`. A call rejected for any other reason (the connection dropped or closed first) rejects with a plain Error, so a caller counting unanswered pings tells the two apart by this class rather than by message text. */ +export class PingTimeoutError extends Error { + constructor() { + super("timed out waiting for pong"); + this.name = "PingTimeoutError"; + } +} interface PendingPingRoundTrip { sentAt: number; @@ -58,7 +66,7 @@ export function createPingRoundTrips(): PingRoundTrips { new Promise((_resolve, reject) => { setTimeout(() => { if (cancel(sentAt)) { - reject(new Error("timed out waiting for pong")); + reject(new PingTimeoutError()); } }, timeoutMs); }), diff --git a/ts/packages/core/test/mesh-session-ping-pong.unit.test.ts b/ts/packages/core/test/mesh-session-ping-pong.unit.test.ts index 7fd5c364..00979fbb 100644 --- a/ts/packages/core/test/mesh-session-ping-pong.unit.test.ts +++ b/ts/packages/core/test/mesh-session-ping-pong.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { Clock } from "../src/ports/clock.js"; import { createMeshSession } from "../src/domain/mesh-session.js"; +import { PingTimeoutError } from "../src/domain/ping-round-trips.js"; import { fakeTransport, testIdentity, @@ -82,6 +83,7 @@ describe("sendPingMeasureRtt", () => { const pending = session.sendPingMeasureRtt(); connection.fail(new Error("dropped")); await expect(pending).rejects.toThrow("disconnected before a pong arrived"); + await expect(pending).rejects.not.toBeInstanceOf(PingTimeoutError); }); it("times out rather than hanging forever when no pong ever arrives", async () => { @@ -97,7 +99,7 @@ describe("sendPingMeasureRtt", () => { .catch((error: unknown) => error); await vi.advanceTimersByTimeAsync(PING_ROUND_TRIP_TIMEOUT_MS); const outcome = await pending; - expect(outcome).toBeInstanceOf(Error); + expect(outcome).toBeInstanceOf(PingTimeoutError); expect((outcome as Error).message).toBe("timed out waiting for pong"); await session.close(); } finally { diff --git a/ts/packages/web-console/README.md b/ts/packages/web-console/README.md index 7936bc03..ddde530e 100644 --- a/ts/packages/web-console/README.md +++ b/ts/packages/web-console/README.md @@ -8,8 +8,12 @@ The browser client for wire-mesh: a client of *any* node, not just the hub. It c - **Identity** — the console holds a persisted Web Crypto (P-256) identity (`src/adapters/web-crypto-identity.ts`'s `createPersistedWebCryptoIdentity`, backed by `src/adapters/indexeddb-storage.ts`), stable across reloads and shared across every connection the console makes. - **Capability tokens** — `MeshSession.setToken` accepts a capability token to present on subsequent `manage-request`s (`sendManageRequest`), gating anything the connected node or a relayed peer requires authorization for. - **Reconnect** — a dropped connection retries with backoff (opt-in `ReconnectPolicy`; `main.ts` wires a default exponential backoff, capped, for every session it creates), surfaced as an explicit `"reconnecting"` status rather than silently going dark. -- **Peer directory** — every `gossip` frame the node sends is folded into a directory table (device, addresses, snapshot time), latest advert per device winning. -- **Frame log** — a live, ordered feed of every frame that crossed the connection in either direction, plus a *Send ping* button. +- **Peer directory**: every `gossip` frame the node sends is folded into a directory table (device, addresses, and when the peer advertised itself by its own clock), latest advert per device winning. On a phone-width screen each row stacks into a labelled card (`stackedTable` in `src/App.css.ts`). +- **Naming**: a peer is named wherever it appears by one convention (`src/peer-names.ts`): a petname this viewer gave the device, else the display name the device signed about itself (always shown with the short id beside it, since a stranger chooses the name and two peers can claim the same one), else a short id with a button that copies the full device-id. Petnames live in this console's own storage (`src/name-store.ts`) and are never gossiped. A self display name travels in the `agent/self` extension of a verified peer-advert, the key agent-comms publishes its agent name under and the one the public hub's advert policy carries, and this console publishes its own the same way. +- **Trust**: an address that carries certificate pins is assessed against `src/certificate-memory.ts` before it is dialled (`src/certificate-trust.ts`): nothing remembered asks for a first-use confirmation showing the certificate fingerprint, a remembered set sharing nothing with the presented one raises a change warning that must be accepted, and an overlapping set (a rotation) connects silently, dialled with only the presented certificates that are already remembered. A connected node that announces certificates unlike the remembered ones is reported and then pinned: the announcement arrived on a session this console had already pinned, so it is applied, with a dismissable notice. Each discovered-peer row names the connection whose directory gossiped it, and the device the gossip claimed for that connection's address is labelled as a claim, since nothing on the connection authenticates it. +- **Activity and health**: the connection panel narrates the session as sentences (`src/activity.ts`) with the raw frame log behind a toggle. Round-trip time is probed without a button press and drawn as a sparkline, a dropped connection shows when the next reconnect attempt is due, and directory rows show when a peer was last seen. +- **Identity and grants**: the identity panel shows this device's id and offers a deliberate backup of its key material: a file the person saves through the browser (`src/adapters/identity-backup.ts`), behind a warning and an explicit acknowledgement, never shown on the page or written to a log, and restorable from the same file after both identities are named. The grants panel lists the capability grants this device holds and has issued (`src/grant-store.ts`), each judged afresh by core's verifier, and mints grants with core's `mintCapabilityToken` (a delegation may only narrow the grant it is made from), adds a grant from its copied grant code, and revokes one it issued with `mintRevocationEntry`. +- **First run and search**: a device that has not dismissed it sees a short walkthrough (connect, who is here, why discovered peers wait for an explicit connect, what works offline), reopenable from the header. Search covers the messages and readable notices of every conversation (`src/search.ts`) and opens the conversation a result belongs to. ## How it maps onto core's ports @@ -44,7 +48,7 @@ Messages travel over `core/room`. A conversation is identified by its room path: ## What is deliberately deferred - **Group rooms** — direct messages between two peers are supported, but creating, inviting to and joining named multi-member rooms has client logic in `src/room-client.ts` and no UI yet. -- **Revocation-gossip ingestion** — the console has no way to learn a capability token was revoked after it was issued, so `webrtc-negotiation.ts`'s token check treats every otherwise-valid token as unrevoked (see the `noRevocationCheck` comment there) — an explicit, documented limitation, not a silent gap. +- **Relaying revocations** — the console records and honours the revocations it mints and the ones a connected node announces (`src/revocation-store.ts`), but a revocation reaches a peer only if a node relays it, and the hub forwards nothing but gossip, relay frames and their kin. A revoked grant is therefore refused here at once and elsewhere only where a revocation was heard; relaying revocation announcements is tracked in ExaDev/wire-mesh#314. - **TURN**: the console is configured with a STUN server (`src/ice-servers.ts`) so a direct connection can form across NATs, and with no TURN server. Where no direct path exists a conversation is meant to fall back to the hub relay rather than a second relay service. - **TLS in dev** — `ws://` against localhost is fine; production deployments serve the console over HTTPS and dial `wss://`, which the adapter already handles. diff --git a/ts/packages/web-console/live-check/harness.ts b/ts/packages/web-console/live-check/harness.ts index 1990fb5d..a171e85f 100644 --- a/ts/packages/web-console/live-check/harness.ts +++ b/ts/packages/web-console/live-check/harness.ts @@ -141,6 +141,7 @@ window.harness = { const negotiator = createWebrtcNegotiator(session, { identity, clock, + revocation: { entriesFor: async () => Promise.resolve([]) }, // Both browsers of the check run on one machine, where host candidates are all that is needed, and the check must not depend on reaching an outside server. iceServers: [], onIncomingConnection: (connection) => { diff --git a/ts/packages/web-console/src/App.css.ts b/ts/packages/web-console/src/App.css.ts index eed59363..94646cf9 100644 --- a/ts/packages/web-console/src/App.css.ts +++ b/ts/packages/web-console/src/App.css.ts @@ -1,8 +1,58 @@ -// The one piece of layout Mantine's own style props don't cover cleanly: capping the console's own content width and centring it on a wide viewport, so the peer directory and frame log tables don't stretch edge-to-edge on a desktop monitor. +// The layout Mantine's own style props don't cover cleanly: capping the console's own content width and centring it on a wide viewport, so the peer tables don't stretch edge-to-edge on a desktop monitor, and turning a peer table into a stack of labelled cards on a phone. -import { style } from "@vanilla-extract/css"; +import { globalStyle, style } from "@vanilla-extract/css"; export const appShell = style({ maxWidth: "64rem", marginInline: "auto", + // A long address or device-id with no break opportunity would otherwise widen the page past a phone's viewport. + overflowWrap: "anywhere", +}); + +/** Mantine's `xs` breakpoint (the same 36em vite.config.ts hands the PostCSS pipeline), below which a peer table no longer fits a row of columns. */ +const PHONE_MEDIA = "screen and (max-width: 36em)"; + +/** A table whose rows become cards on a phone: the header row is hidden and each cell shows its column's name (its `data-label`) above its value. Use it through `StackedTable`, which also states each element's ARIA role, because changing `display` on table elements makes Safari drop their table semantics. */ +export const stackedTable = style({}); + +globalStyle(`${stackedTable} thead`, { + "@media": { + [PHONE_MEDIA]: { + position: "absolute", + width: 1, + height: 1, + overflow: "hidden", + clip: "rect(0 0 0 0)", + }, + }, +}); + +globalStyle(`${stackedTable} tr`, { + "@media": { + [PHONE_MEDIA]: { + display: "block", + paddingBlock: "0.5rem", + }, + }, +}); + +globalStyle(`${stackedTable} td`, { + "@media": { + [PHONE_MEDIA]: { + display: "block", + border: "none", + paddingBlock: "0.125rem", + }, + }, +}); + +globalStyle(`${stackedTable} td::before`, { + "@media": { + [PHONE_MEDIA]: { + content: "attr(data-label)", + display: "block", + fontSize: "0.75rem", + opacity: 0.7, + }, + }, }); diff --git a/ts/packages/web-console/src/App.tsx b/ts/packages/web-console/src/App.tsx index 17156af3..64b27f88 100644 --- a/ts/packages/web-console/src/App.tsx +++ b/ts/packages/web-console/src/App.tsx @@ -1,6 +1,6 @@ // Top-level layout: the connect form, the list of currently open relay connection panels, and every peer-to-peer room-messaging panel. Owns only the set of live sessions and negotiators -- everything about rendering one relay session's own state lives in ConnectionPanel, and everything about a room session's own state lives in the useRoomMessaging hook plus RoomPanel. -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Alert, Button, @@ -11,10 +11,7 @@ import { Title, } from "@mantine/core"; import { createMeshSession } from "wire-mesh-core/domain/mesh-session"; -import type { - MeshSession, - ReconnectPolicy, -} from "wire-mesh-core/domain/mesh-session"; +import type { MeshSession } from "wire-mesh-core/domain/mesh-session"; import { createGossipExpansion } from "wire-mesh-core/domain/gossip-expansion"; import type { GossipExpansion, @@ -24,9 +21,13 @@ import type { IdentityPort } from "wire-mesh-core/ports/identity"; import type { Clock } from "wire-mesh-core/ports/clock"; import type { Connection } from "wire-mesh-core/ports/transport"; import type { KeyValueStorage } from "wire-mesh-core/ports/storage"; -import type { DeviceId } from "wire-mesh-core/generated/protocol"; +import type { + DeviceId, + RevocationEntry, +} from "wire-mesh-core/generated/protocol"; import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; import { dmRoomPath } from "wire-mesh-core/domain/room-path"; +import { reconnectPolicy } from "./reconnect-policy.js"; import { createDialTransport } from "./adapters/dial-transport.js"; import type { CertificateMemory } from "./certificate-memory.js"; import { DEFAULT_ICE_SERVERS } from "./ice-servers.js"; @@ -34,7 +35,26 @@ import { createWebrtcNegotiator } from "./webrtc-negotiation.js"; import type { WebrtcNegotiator } from "./webrtc-negotiation.js"; import { ConnectionPanel, deviceHex } from "./components/ConnectionPanel.js"; import { DiscoveredPeersPanel } from "./components/DiscoveredPeersPanel.js"; -import type { DiscoveredPeerRow } from "./components/DiscoveredPeersPanel.js"; +import type { + DiscoveredPeerRow, + DiscoveredVia, +} from "./components/DiscoveredPeersPanel.js"; +import { CertificateTrustPanel } from "./components/CertificateTrustPanel.js"; +import { useCertificateTrust } from "./hooks/use-certificate-trust.js"; +import { PeerNamesProvider } from "./components/PeerNamesProvider.js"; +import { PeerName } from "./components/PeerName.js"; +import { SelfNameField } from "./components/SelfNameField.js"; +import { OnboardingIntro } from "./components/OnboardingIntro.js"; +import { SearchPanel } from "./components/SearchPanel.js"; +import { IdentitySection } from "./components/IdentitySection.js"; +import { UnreadableRevocations } from "./components/UnreadableRevocations.js"; +import type { AnnounceResult } from "./hooks/use-grants.js"; +import type { IdentityBackupService } from "./adapters/identity-backup.js"; +import type { GrantStore } from "./grant-store.js"; +import type { RevocationStore } from "./revocation-store.js"; +import { useIntro } from "./hooks/use-intro.js"; +import type { PreferencesStore } from "./preferences-store.js"; +import type { NameStore } from "./name-store.js"; import { ConversationList } from "./components/ConversationList.js"; import { RoomPanel } from "./components/RoomPanel.js"; import { useRoomMessaging } from "./hooks/use-room-messaging.js"; @@ -54,6 +74,16 @@ export interface AppProps { roomStorage: KeyValueStorage; /** What each node last announced about the certificates it serves, so a node's address keeps working as its certificates change. */ certificateMemory: CertificateMemory; + /** The petnames this viewer has given peers and this console's own display name, kept in the console's own storage. */ + nameStore: NameStore; + /** Interface preferences kept in the console's own storage, such as whether the first-run intro has been dismissed. */ + preferences: PreferencesStore; + /** The grants this device holds and has issued, kept in the console's own storage. */ + grants: GrantStore; + /** The revocations this device has made or heard, which every token check in the console consults. */ + revocations: RevocationStore; + /** Backs up and restores this device's identity key material. */ + identityBackup: IdentityBackupService; /** Attempts same-device node auto-discovery once, on mount. Defaults to the real `discoverLocalNode` (a no-op when this console is served from a loopback origin, a real localhost probe otherwise); tests inject a fake to avoid depending on `location`/`fetch`. */ discoverLocalNode?: () => Promise; // Seeds the Node field. Left as a prop (rather than App reading location/import.meta.env itself) so App stays the plain, testable component its own header comment describes; main.tsx computes the real value via default-hub-address.ts. @@ -64,20 +94,6 @@ const DEFAULT_ADDRESS = "ws://localhost:8787"; const AVAILABLE_DOMAINS = ["core/management", "core/exec", "core/data"]; const DEFAULT_DOMAINS = ["core/management", "core/data"]; -// Exponential backoff, capped at 30s, giving up after 5 attempts -- reasonable defaults for a browser console reconnecting to a relay that may just be restarting, without retrying forever against one that is genuinely gone. -const RECONNECT_MAX_ATTEMPTS = 5; -const RECONNECT_BASE_DELAY_MS = 1000; -const RECONNECT_MAX_DELAY_MS = 30_000; -const RECONNECT_BACKOFF_BASE = 2; -const reconnectPolicy: ReconnectPolicy = { - maxAttempts: RECONNECT_MAX_ATTEMPTS, - delayMs: (attempt) => - Math.min( - RECONNECT_BASE_DELAY_MS * RECONNECT_BACKOFF_BASE ** (attempt - 1), - RECONNECT_MAX_DELAY_MS, - ), -}; - /** A direct connection attempt that did not open, kept on screen until dismissed so a refusal or a failure to negotiate is never silent. The conversation itself carries on through the hub. */ interface ConnectionFailure { key: string; @@ -105,6 +121,11 @@ export function App({ messageStore, roomStorage, certificateMemory, + nameStore, + preferences, + grants, + revocations, + identityBackup, discoverLocalNode = discoverLocalNodeDefault, defaultAddress = DEFAULT_ADDRESS, }: Readonly): React.JSX.Element { @@ -113,11 +134,21 @@ export function App({ const [connections, setConnections] = useState([]); const [discovered, setDiscovered] = useState([]); const [failures, setFailures] = useState([]); + const [revocationFailure, setRevocationFailure] = useState< + string | undefined + >(undefined); + const intro = useIntro(preferences); + const trust = useCertificateTrust(certificateMemory, clock); + const capabilities = useMemo( + () => ({ revocation: revocations.view, grants }), + [revocations, grants], + ); const roomMessaging = useRoomMessaging( identity, clock, messageStore, roomStorage, + capabilities, ); const [selectedPath, setSelectedPath] = useState(); // The conversation shown is the one the user picked, falling back to the first while nothing is picked or the picked one no longer exists. @@ -153,6 +184,23 @@ export function App({ connectionsRef.current = connections; }, [connections]); + // Addresses of this console's own connect attempts still waiting on a certificate decision. + const confirmingRef = useRef(new Set()); + // For each address this console has offered to dial and not yet connected or given up on, the devices gossip claimed live there. More than one device claiming an address leaves its claim ambiguous, so none is shown for it. + const claimedDevices = useRef(new Map>()); + // Set once the console is closed, so a certificate decision or dial that settles afterwards opens nothing. Closing the console also closes every open session: a session's own reconnect timer would otherwise keep dialling for a console that is gone. + const closedRef = useRef(false); + // A function rather than a bare read so that a check after an await is not narrowed to the answer it got before it. + const isClosed = (): boolean => closedRef.current; + useEffect(() => { + closedRef.current = false; + return () => { + closedRef.current = true; + for (const entry of connectionsRef.current) { + void entry.session.close(); + } + }; + }, []); // The current domains value, read from a ref rather than closed over directly -- dialExpanded/createExpandableSession are invoked from callbacks createGossipExpansion holds onto for as long as a given session's own gossip directory keeps discovering new peers, well outliving any single render, and must still dial with whatever domains the connect form currently offers, not whichever were selected when that session's own expansion was first wired up. const domainsRef = useRef(domains); useEffect(() => { @@ -171,12 +219,25 @@ export function App({ { identity, clock, + revocation: capabilities.revocation, iceServers: DEFAULT_ICE_SERVERS, onIncomingConnection: (connection: Readonly) => { void attachRef.current(connection); }, }, ); + // Revocations a node announces are verified and recorded by the store, so every token check in the console honours them. One that cannot be stored is reported and the next is still read, since one storage failure must not end ingestion for the rest of the connection. + void (async (): Promise => { + for await (const entry of session.revocationAnnouncements) { + try { + await revocations.ingest(entry); + } catch (error) { + setRevocationFailure( + error instanceof Error ? error.message : String(error), + ); + } + } + })(); watchHubRef.current({ sendManageRequest: async (...args) => session.sendManageRequest(...args), incomingManageRequests: demux.stream(ROOM_MEMBER_CAPABILITY), @@ -190,14 +251,24 @@ export function App({ /** Asks this console's own user whether to dial a gossiped candidate at all (wire-mesh#187) -- the explicit-confirmation half of the issue's own two named options, since this console holds no persistent device-trust store a gateway_trust-style allow-list could check instead. Resolves once the user clicks Connect or Dismiss in DiscoveredPeersPanel. */ async function confirmExpansion( candidate: Readonly, + via: Readonly, ): Promise { + const key = deviceHex(candidate.device); + // The connection made through a gossiped address authenticates nothing about which device answers, so the device the gossip named is kept only to show as a claim beside what that connection gossips in turn. + for (const candidateAddress of candidate.addresses) { + const claimants = + claimedDevices.current.get(candidateAddress) ?? new Set(); + claimants.add(key); + claimedDevices.current.set(candidateAddress, claimants); + } return new Promise((resolve) => { setDiscovered((current) => [ ...current, { - key: deviceHex(candidate.device), + key, device: candidate.device, addresses: candidate.addresses, + via, resolve, }, ]); @@ -212,12 +283,12 @@ export function App({ } /** Builds a session wired to attempt direct connections to its own gossip directory's newly-discovered peers (wire-mesh#187's "discover once connected, expand outward"), confirmed by this console's own user first. Every expansion-dialled session gets the identical wiring in turn, so discovery keeps expanding outward through however many hops of directly-reachable peers actually exist -- not just the one node dialled first, and not just its own immediate peers. */ - function createExpandableSession(): MeshSession { + function createExpandableSession(via: Readonly): MeshSession { const expansionRef: { current: GossipExpansion | null } = { current: null, }; const session = createMeshSession( - createDialTransport(certificateMemory), + createDialTransport(trust.memory), identity, clock, reconnectPolicy, @@ -228,21 +299,54 @@ export function App({ ); expansionRef.current = createGossipExpansion({ selfDeviceId: identity.deviceId, - shouldExpand: confirmExpansion, + shouldExpand: async (candidate) => confirmExpansion(candidate, via), dial: dialExpanded, - onExpanded: (_candidate, expandedAddress, expandedSession) => { + onExpanded: (candidate, expandedAddress, expandedSession) => { attachConnection(expandedAddress, expandedSession); + forgetClaims(candidate); }, - // A dial that never got user approval (onExpansionDeclined) or that failed against every one of its addresses (onExpansionFailed) simply never produces a session -- there is no panel to remove and nothing further for this console to do, matching how a manually-typed address that fails to connect leaves no panel behind either. + // A dial that never got user approval (onExpansionDeclined) or that failed against every one of its addresses (onExpansionFailed) simply never produces a session, so there is no panel to remove, matching how a manually-typed address that fails to connect leaves no panel behind either. Whichever way an expansion ends, the claims kept for its addresses are no longer needed. + onExpansionDeclined: forgetClaims, + onExpansionFailed: forgetClaims, }); return session; } + /** Withdraws only this candidate's own claims, so another candidate that shares an address keeps its own. */ + function forgetClaims(candidate: Readonly): void { + const key = deviceHex(candidate.device); + for (const candidateAddress of candidate.addresses) { + const claimants = claimedDevices.current.get(candidateAddress); + claimants?.delete(key); + if (claimants?.size === 0) { + claimedDevices.current.delete(candidateAddress); + } + } + } + async function dialExpanded(gossipedAddress: string): Promise { - const session = createExpandableSession(); - return session - .connect(toDialAddress(gossipedAddress), domainsRef.current) - .then(() => session); + const dialAddress = await trust.confirmAddress( + toDialAddress(gossipedAddress), + ); + if (dialAddress === undefined) { + throw new Error(`the certificate of ${gossipedAddress} was not trusted`); + } + if (isClosed()) { + throw new Error("the console was closed before the dial began"); + } + const claimants = claimedDevices.current.get(gossipedAddress); + const [soleClaimant] = claimants ?? []; + const claimedDevice = claimants?.size === 1 ? soleClaimant : undefined; + const session = createExpandableSession({ + address: gossipedAddress, + claimedDevice, + }); + await session.connect(dialAddress, domainsRef.current); + if (isClosed()) { + await session.close(); + throw new Error("the console was closed while the dial was connecting"); + } + return session; } function connectTo(targetAddress: string): void { @@ -252,11 +356,29 @@ export function App({ ) { return; } - const session = createExpandableSession(); - attachConnection(targetAddress, session); - void session.connect(toDialAddress(targetAddress), domains).catch(() => { - // ConnectionPanel's own render of the session's events already surfaces a connect failure via its status line; nothing further to do here beyond letting the entry remain (its own close button still works on a failed session). - }); + // The same address submitted again while its certificate is awaiting a decision is the same attempt, not a second one. + if (confirmingRef.current.has(targetAddress)) { + return; + } + confirmingRef.current.add(targetAddress); + void trust + .confirmAddress(toDialAddress(targetAddress)) + .then((dialAddress) => { + if (dialAddress === undefined || isClosed()) { + return; + } + const session = createExpandableSession({ + address: targetAddress, + claimedDevice: undefined, + }); + attachConnection(targetAddress, session); + void session.connect(dialAddress, domains).catch(() => { + // ConnectionPanel's own render of the session's events already surfaces a connect failure via its status line; nothing further to do here beyond letting the entry remain (its own close button still works on a failed session). + }); + }) + .finally(() => { + confirmingRef.current.delete(targetAddress); + }); } // discoverLocalNode is a fresh closure every render (or a caller-supplied fake in tests), so the mount-only effect below reads it through a ref (the same pattern attachRef already uses above) rather than listing it as an effect dependency, which would either re-run the probe every render or need a lint suppression. connectTo is a plain function, recreated every render like attachConnection/createExpandableSession above it -- its own ref-update effect below simply runs every render too, which is cheap and still gives the mount effect the latest version by the time it actually fires. @@ -286,6 +408,22 @@ export function App({ connectTo(address); } + /** Tells every connected node about a revocation. A connection that is down cannot be told, so it is skipped rather than failing the revocation, which has already been recorded here. */ + async function announceRevocation( + entry: RevocationEntry, + ): Promise { + const outcomes = await Promise.allSettled( + connectionsRef.current.map(async (connection) => + connection.session.sendRevocationAnnounce([entry]), + ), + ); + return { + attempted: outcomes.length, + reached: outcomes.filter((outcome) => outcome.status === "fulfilled") + .length, + }; + } + function handleClose(target: Readonly): void { roomMessaging.dropHub(target.session); void target.session.close(); @@ -319,93 +457,151 @@ export function App({ } return ( - - wire-mesh console -
- - { - setAddress(event.currentTarget.value); - }} - required - style={{ flex: 1, minWidth: "16rem" }} - /> - + + + + wire-mesh console + {intro.ready && !intro.visible && ( + + )} - - - {AVAILABLE_DOMAINS.map((domain) => ( - - ))} + {intro.error !== undefined && ( + + {intro.error} + + )} + {intro.visible && } + + + + { + setAddress(event.currentTarget.value); + }} + required + style={{ flex: 1, minWidth: "16rem" }} + /> + - - - { - resolveDiscovered(key, true); - }} - onDismiss={(key) => { - resolveDiscovered(key, false); - }} - /> - {connections.map((entry) => ( - { - handleClose(entry); - }} - onMessagePeer={(device) => { - handleMessagePeer(entry, device); - }} + + + {AVAILABLE_DOMAINS.map((domain) => ( + + ))} + + + + - ))} - {failures.map((failure) => ( - { - setFailures((current) => - current.filter((entry) => entry.key !== failure.key), - ); - }} - > - {failure.reason}. Messages go through the hub instead. - - ))} - - {selectedConversation !== undefined && ( - - roomMessaging.send(selectedConversation.roomPath, text) - } - onPostNotice={async (text) => - roomMessaging.postNotice(selectedConversation.roomPath, text) - } - onRetry={(localId) => { - void roomMessaging.retry(selectedConversation.roomPath, localId); + { + resolveDiscovered(key, true); }} - onDiscard={(localId) => { - roomMessaging.discard(selectedConversation.roomPath, localId); + onDismiss={(key) => { + resolveDiscovered(key, false); }} /> - )} - + {connections.map((entry) => ( + { + handleClose(entry); + }} + onMessagePeer={(device) => { + handleMessagePeer(entry, device); + }} + /> + ))} + {failures.map((failure) => ( + + No direct connection to + + } + withCloseButton + onClose={() => { + setFailures((current) => + current.filter((entry) => entry.key !== failure.key), + ); + }} + > + {failure.reason}. Messages go through the hub instead. + + ))} + {roomMessaging.persistenceFailure !== undefined && ( + + {roomMessaging.persistenceFailure}. The conversation carries on, but + what failed to save will be missing after a reload. + + )} + {revocationFailure !== undefined && ( + + {revocationFailure} + + )} + + + + + {selectedConversation !== undefined && ( + + roomMessaging.send(selectedConversation.roomPath, text) + } + onPostNotice={async (text) => + roomMessaging.postNotice(selectedConversation.roomPath, text) + } + onRetry={(localId) => { + void roomMessaging.retry(selectedConversation.roomPath, localId); + }} + onDiscard={(localId) => { + roomMessaging.discard(selectedConversation.roomPath, localId); + }} + /> + )} +
+ ); } diff --git a/ts/packages/web-console/src/activity.ts b/ts/packages/web-console/src/activity.ts new file mode 100644 index 00000000..71ab5968 --- /dev/null +++ b/ts/packages/web-console/src/activity.ts @@ -0,0 +1,220 @@ +// The console's human reading of a session: what a person wants to know happened ("connected", "pairing established", "peer seen", "certificate changed"), derived from the session's state changes and the frames that crossed it. The raw frames stay available beside it for debugging. Pure, so the wording and the change detection can be tested without a session. + +import type { SessionEvent } from "wire-mesh-core/domain/mesh-session"; +import type { DeviceId, Frame } from "wire-mesh-core/generated/protocol"; +import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; + +export type ActivityKind = + "connection" | "peer" | "pairing" | "relay" | "certificate"; + +export interface ActivityEntry { + key: string; + /** When the console observed it, from the injected clock. */ + at: number; + kind: ActivityKind; + /** The sentence, which ends where the peer's name goes when `peer` is set. */ + summary: string; + /** Hex device-id of the peer the entry is about, named by the console's naming convention when shown. */ + peer: string | undefined; +} + +/** What has already been turned into entries, so the next event yields only what is new. */ +export interface ActivityTracker { + /** How many frame-log entries have been read. The log only ever grows. */ + frames: number; + /** The connection stage last reported: the status, with the attempt number while reconnecting, so each failed retry is a change and a repeated event is not. */ + stage: string | undefined; + handshake: string | undefined; + /** Sequence for entry keys. */ + issued: number; + /** Hex device-ids already reported as seen. A directory is gossiped again and again with the same peers, so each is reported the first time only; otherwise a busy directory would push every other entry out of the capped activity view. */ + seen: ReadonlySet; +} + +export const initialTracker: ActivityTracker = { + frames: 0, + stage: undefined, + handshake: undefined, + issued: 0, + seen: new Set(), +}; + +type Described = Pick; + +function hex(device: DeviceId): string { + return deviceIdToHex(device); +} + +/** What one frame means to a person, or nothing for plumbing (pings, handshakes and the like have their own views or are only for the raw log). */ +export function describeFrame( + direction: "sent" | "received", + frame: Readonly, +): Described[] { + if (frame.type === "gossip") { + return direction === "received" + ? frame.peers.map((advert) => ({ + kind: "peer", + summary: "Peer seen:", + peer: hex(advert.device), + })) + : [ + { + kind: "peer", + summary: "Advertised this console to the node", + peer: undefined, + }, + ]; + } + if (frame.type === "relay-connect") { + return [ + { + kind: "pairing", + summary: "Asked the node to pair this console with", + peer: hex(frame["target-device"]), + }, + ]; + } + if (frame.type === "relay-inbound") { + return [ + { + kind: "pairing", + summary: "Pairing established with", + peer: hex(frame["source-device"]), + }, + ]; + } + if (frame.type === "relay-data") { + const other = + direction === "sent" ? frame["to-device"] : frame["from-device"]; + return [ + { + kind: "relay", + summary: + direction === "sent" + ? "Relayed a message to" + : "Relayed message received from", + peer: other === undefined ? undefined : hex(other), + }, + ]; + } + if (frame.type === "close") { + return [ + { + kind: "connection", + summary: "The node closed the connection", + peer: undefined, + }, + ]; + } + return []; +} + +function connectionStage(state: Readonly): string { + return state.status === "reconnecting" + ? `reconnecting:${String(state.attempt)}` + : state.status; +} + +function describeState( + previous: Readonly, + state: Readonly, + handshake: string | undefined, +): Described[] { + const entries: Described[] = []; + const connection = (summary: string): void => { + entries.push({ kind: "connection", summary, peer: undefined }); + }; + if (connectionStage(state) !== previous.stage) { + switch (state.status) { + case "connecting": + connection(`Connecting to ${state.address}`); + break; + case "connected": + connection(`Connected to ${state.address}`); + break; + case "reconnecting": + connection( + `${previous.stage?.startsWith("reconnecting") === true ? "Retry failed" : "Connection lost"} (${state.reason}); retrying, attempt ${String(state.attempt)}`, + ); + break; + case "closed": + connection(`Closed (${state.reason})`); + break; + case "idle": + break; + } + } + if (state.status === "connected" && handshake !== previous.handshake) { + if (state.handshake.status === "negotiated") { + connection( + `Handshake negotiated: ${state.handshake.sharedDomains.join(", ")}`, + ); + } else if (state.handshake.status === "unanswered") { + connection("The node does not answer handshakes (a relay-only node)"); + } else if (state.handshake.status === "rejected") { + connection(`Handshake rejected (${state.handshake.reason})`); + } + } + return entries; +} + +/** The handshake stage of a state, or undefined when not connected. */ +function handshakeStage( + state: Readonly, +): string | undefined { + return state.status === "connected" ? state.handshake.status : undefined; +} + +/** The entries an event adds beyond what `tracker` has already seen, and the tracker to use for the next event. */ +export function advanceActivity( + tracker: Readonly, + event: Readonly, + at: number, +): { tracker: ActivityTracker; entries: ActivityEntry[] } { + const handshake = handshakeStage(event.state); + const seen = new Set(tracker.seen); + const described: Described[] = [ + ...describeState(tracker, event.state, handshake), + ...event.frameLog + .slice(tracker.frames) + .flatMap((entry) => describeFrame(entry.direction, entry.frame)) + .filter((entry) => { + if (entry.kind !== "peer" || entry.peer === undefined) { + return true; + } + if (seen.has(entry.peer)) { + return false; + } + seen.add(entry.peer); + return true; + }), + ]; + const entries = described.map((entry, index): ActivityEntry => ({ + ...entry, + key: `activity-${String(tracker.issued + index)}`, + at, + })); + return { + tracker: { + frames: event.frameLog.length, + stage: connectionStage(event.state), + handshake, + issued: tracker.issued + entries.length, + seen, + }, + entries, + }; +} + +/** The activity entries for certificate changes announced by a node, for merging into that node's connection activity. */ +export function certificateActivity( + changes: readonly { key: string; at: number; node: string }[], +): ActivityEntry[] { + return changes.map((change) => ({ + key: `certificate-${change.key}`, + at: change.at, + kind: "certificate", + summary: `Certificate changed: ${change.node} announced certificates unlike the remembered ones`, + peer: undefined, + })); +} diff --git a/ts/packages/web-console/src/adapters/identity-backup.ts b/ts/packages/web-console/src/adapters/identity-backup.ts new file mode 100644 index 00000000..1db0d3b1 --- /dev/null +++ b/ts/packages/web-console/src/adapters/identity-backup.ts @@ -0,0 +1,210 @@ +// A deliberate, consented copy of this device's identity key material, and its restoration. The backup is a JSON file the person saves themselves: it holds the private key as a standard JWK, so anything that reads ES256 JWKs can use it, and it is never logged or shown on screen. Restoring replaces the stored identity, and takes effect the next time the console loads. + +import { cdeDecodeOptions, cdeEncodeOptions, decode, encode } from "cbor2"; +import { bytesToHex, deviceIdToHex } from "wire-mesh-core/domain/device-id"; +import type { KeyValueStorage } from "wire-mesh-core/ports/storage"; +import { + IDENTITY_STORAGE_KEY, + deriveDeviceId, + identityFromStoredEnvelope, + isStoredIdentityEnvelope, +} from "./web-crypto-identity.js"; + +export const IDENTITY_BACKUP_FORMAT = "wire-mesh-console-identity"; +export const IDENTITY_BACKUP_VERSION = 1; +const ES256_NAME = "ES256"; + +/** The contents of a backup file. */ +export interface IdentityBackup { + format: typeof IDENTITY_BACKUP_FORMAT; + version: typeof IDENTITY_BACKUP_VERSION; + alg: typeof ES256_NAME; + /** Hex device-id this key derives, so a person can tell which identity a file holds without opening it. */ + deviceId: string; + /** Hex of the raw (uncompressed point) P-256 public key. */ + publicKey: string; + /** The private key. Whoever holds it can act as this device. */ + privateJwk: JsonWebKey; +} + +export interface IdentityBackupService { + /** + * The backup of the stored identity. + * @throws Error when no identity is stored, or the stored one is malformed. + */ + export: () => Promise; + /** Replaces the stored identity with the backup's, which applies the next time the console loads. */ + restore: (backup: Readonly) => Promise; +} + +const HEX_RADIX = 16; +const HEX_DIGITS_PER_BYTE = 2; +const UNCOMPRESSED_POINT_PREFIX = 4; + +function bytesFromHex(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / HEX_DIGITS_PER_BYTE); + for (let index = 0; index < bytes.length; index++) { + bytes[index] = Number.parseInt( + hex.slice(index * HEX_DIGITS_PER_BYTE, (index + 1) * HEX_DIGITS_PER_BYTE), + HEX_RADIX, + ); + } + return bytes; +} + +function fromBase64Url(text: string): Uint8Array { + try { + const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/")); + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); + } catch { + throw new Error("the backup's key is not valid base64url"); + } +} + +/** A P-256 private key reduced to the five members that define it. Anything else a file carries (`key_ops`, `alg`, `ext`, unknown members) is dropped, so a hand-edited file cannot change how the key is imported once it is stored. */ +interface PrivateP256Jwk { + kty: "EC"; + crv: "P-256"; + x: string; + y: string; + d: string; +} + +/** Reads the defining members of a P-256 private JWK out of an untrusted record. */ +function privateP256From( + jwk: Readonly>, +): PrivateP256Jwk { + const { kty, crv, x, y, d } = jwk; + if ( + kty !== "EC" || + crv !== "P-256" || + typeof x !== "string" || + typeof y !== "string" || + typeof d !== "string" + ) { + throw new Error("the backup does not hold a P-256 private key"); + } + return { kty, crv, x, y, d }; +} + +/** The raw uncompressed public point a P-256 private JWK carries in its x and y. */ +function publicPointOf(jwk: Readonly): Uint8Array { + return Uint8Array.from([ + UNCOMPRESSED_POINT_PREFIX, + ...fromBase64Url(jwk.x), + ...fromBase64Url(jwk.y), + ]); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** What the private key must sign and its public key verify for a backup to count as holding a matching pair. */ +const KEY_PAIR_PROBE = new TextEncoder().encode( + "wire-mesh console identity backup key-pair check", +); + +/** Loads the backup's key the way the console does at startup, then proves by a signature that the private scalar belongs to the public key the backup names. */ +async function assertKeyPair( + privateJwk: Readonly, + publicKeyRaw: Uint8Array, +): Promise { + let loaded; + try { + loaded = await identityFromStoredEnvelope({ privateJwk, publicKeyRaw }); + } catch (error) { + throw new Error( + `the backup's private key cannot be loaded: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + const signature = await loaded.sign(KEY_PAIR_PROBE); + if (!(await loaded.verify(loaded.identityKey, KEY_PAIR_PROBE, signature))) { + throw new Error( + "the private key does not belong to the backup's public key", + ); + } +} + +/** + * Reads and checks the text of a backup file, including that its private key really is the one its public key and device-id name. + * @throws Error describing what is wrong with the file. + */ +export async function parseIdentityBackup( + text: string, +): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new Error("the file is not JSON"); + } + if ( + !isRecord(parsed) || + parsed.format !== IDENTITY_BACKUP_FORMAT || + parsed.version !== IDENTITY_BACKUP_VERSION || + parsed.alg !== ES256_NAME || + typeof parsed.deviceId !== "string" || + typeof parsed.publicKey !== "string" || + !isRecord(parsed.privateJwk) + ) { + throw new Error("the file is not a wire-mesh console identity backup"); + } + const privateJwk = privateP256From(parsed.privateJwk); + const point = publicPointOf(privateJwk); + if (bytesToHex(point) !== parsed.publicKey) { + throw new Error("the private key does not match the backup's public key"); + } + if (deviceIdToHex(await deriveDeviceId(point)) !== parsed.deviceId) { + throw new Error("the backup's device-id does not match its public key"); + } + await assertKeyPair(privateJwk, point); + return { + format: IDENTITY_BACKUP_FORMAT, + version: IDENTITY_BACKUP_VERSION, + alg: ES256_NAME, + deviceId: parsed.deviceId, + publicKey: parsed.publicKey, + privateJwk, + }; +} + +export function createIdentityBackupService( + storage: Readonly, +): IdentityBackupService { + return { + async export() { + const stored = await storage.get(IDENTITY_STORAGE_KEY); + if (stored === undefined) { + throw new Error("no identity is stored on this device"); + } + const envelope: unknown = decode(stored, cdeDecodeOptions); + if (!isStoredIdentityEnvelope(envelope)) { + throw new Error("stored identity envelope is malformed"); + } + return { + format: IDENTITY_BACKUP_FORMAT, + version: IDENTITY_BACKUP_VERSION, + alg: ES256_NAME, + deviceId: deviceIdToHex(await deriveDeviceId(envelope.publicKeyRaw)), + publicKey: bytesToHex(envelope.publicKeyRaw), + privateJwk: envelope.privateJwk, + }; + }, + async restore(backup) { + await storage.set( + IDENTITY_STORAGE_KEY, + new Uint8Array( + encode( + { + privateJwk: backup.privateJwk, + publicKeyRaw: bytesFromHex(backup.publicKey), + }, + cdeEncodeOptions, + ), + ), + ); + }, + }; +} diff --git a/ts/packages/web-console/src/adapters/web-crypto-identity.ts b/ts/packages/web-console/src/adapters/web-crypto-identity.ts index 5e5d382d..547c2314 100644 --- a/ts/packages/web-console/src/adapters/web-crypto-identity.ts +++ b/ts/packages/web-console/src/adapters/web-crypto-identity.ts @@ -7,7 +7,8 @@ import type { KeyValueStorage } from "wire-mesh-core/ports/storage"; const ES256 = -7; const EDDSA = -8; -const IDENTITY_STORAGE_KEY = "web-console/identity/es256"; +/** Where the persisted identity envelope lives, shared with the backup service so both read and write one record. */ +export const IDENTITY_STORAGE_KEY = "web-console/identity/es256"; function algParams(alg: number): EcdsaParams | { name: "Ed25519" } { if (alg === ES256) { @@ -98,12 +99,12 @@ export async function createWebCryptoIdentity(): Promise { }; } -interface StoredIdentityEnvelope { +export interface StoredIdentityEnvelope { privateJwk: JsonWebKey; publicKeyRaw: Uint8Array; } -function isStoredIdentityEnvelope( +export function isStoredIdentityEnvelope( value: unknown, ): value is StoredIdentityEnvelope { if (typeof value !== "object" || value === null) return false; @@ -129,7 +130,11 @@ async function generateStoredIdentityEnvelope(): Promise return { privateJwk, publicKeyRaw }; } -async function identityFromStoredEnvelope( +/** + * The IdentityPort a stored envelope loads as, through exactly the imports the console performs at startup, so anything that would stop the console starting fails here too. + * @throws Error (a DOMException from Web Crypto) when the private key cannot be imported for signing or for ECDH. + */ +export async function identityFromStoredEnvelope( envelope: StoredIdentityEnvelope, ): Promise { // Re-imported non-extractable: the one-time export in generateStoredIdentityEnvelope is the only point this key's raw material is ever needed outside the CryptoKey -- least privilege for the rest of its in-memory lifetime. diff --git a/ts/packages/web-console/src/capability-services.ts b/ts/packages/web-console/src/capability-services.ts new file mode 100644 index 00000000..c2c680e3 --- /dev/null +++ b/ts/packages/web-console/src/capability-services.ts @@ -0,0 +1,9 @@ +// The capability state the console's messaging shares: what token checks consult to refuse a revoked grant, and where the grants the console receives or issues are recorded. One of each for the whole console, so a revocation made in the grants panel is the same one a room verifies against. + +import type { RevocationCheck } from "wire-mesh-core/domain/tokens"; +import type { GrantStore } from "./grant-store.js"; + +export interface CapabilityServices { + revocation: RevocationCheck; + grants: GrantStore; +} diff --git a/ts/packages/web-console/src/certificate-trust.ts b/ts/packages/web-console/src/certificate-trust.ts new file mode 100644 index 00000000..19dd8b2f --- /dev/null +++ b/ts/packages/web-console/src/certificate-trust.ts @@ -0,0 +1,103 @@ +// The trust-on-first-use decision for a node served over WebTransport, kept pure so the rules can be tested without a session. An address that carries certificate pins is the first time this console is told what a node's certificate is; `certificate-memory.ts` holds what it has been told since. The decision compares the two. + +import { bytesToHex } from "wire-mesh-core/domain/device-id"; +import { + isPinnedAddress, + parsePinnedAddress, +} from "wire-mesh-core/domain/pinned-address"; +import type { CertificateMemory } from "./certificate-memory.js"; + +/** + * What an address's certificate pins say about the node, against what is remembered for it. + * - `first-use`: nothing is remembered, so the user decides whether to trust these certificates. + * - `known`: at least one presented certificate is one already remembered, which is how a rotation looks, since a node always announces the certificate it serves now beside the ones it will serve next. `trusted` is the presented certificates that are remembered: the only ones the address may be dialled with, since a presented certificate that is not remembered has not been approved by anyone. + * - `changed`: something is remembered and none of it is presented, which is what a different node behind the same address, or a replaced certificate, looks like. + */ +export type CertificateAssessment = + | { kind: "first-use"; node: string; presented: readonly string[] } + | { kind: "known"; node: string; trusted: readonly string[] } + | { + kind: "changed"; + node: string; + remembered: readonly string[]; + presented: readonly string[]; + }; + +export function assessCertificates( + node: string, + presented: readonly string[], + remembered: readonly string[], +): CertificateAssessment { + if (remembered.length === 0) { + return { kind: "first-use", node, presented }; + } + const known = new Set(remembered); + const trusted = presented.filter((hash) => known.has(hash)); + if (trusted.length > 0) { + return { kind: "known", node, trusted }; + } + return { kind: "changed", node, remembered, presented }; +} + +/** The certificate pins an address presents, with the node (`host:port`) they are for, or undefined for an address that has none to trust: one that is not pinned, or whose pins are malformed. */ +export function presentedCertificates( + address: string, +): { node: string; sha256: Uint8Array[] } | undefined { + if (!isPinnedAddress(address)) { + return undefined; + } + try { + const { url, sha256 } = parsePinnedAddress(address); + return { node: new URL(url).host, sha256: [...sha256] }; + } catch { + // Malformed pins are not something to trust or remember. The dial that follows rejects the address with the parser's own message, which the connection's status line reports. + return undefined; + } +} + +/** A node's announced certificates that share nothing with what was remembered for it. The session was authenticated by a certificate this console had pinned, so the node is reachable, but it has stopped serving anything this console had been told to expect. */ +export interface CertificateChange { + node: string; + remembered: readonly string[]; + presented: readonly string[]; +} + +/** + * A memory that reports every `remember` which replaces a non-empty set with a disjoint one. The write still happens: the announcement arrived on a session this console had already pinned, so it is authentic, but the change is not something to apply silently. + */ +export function observeCertificateChanges( + memory: Readonly, + onChange: (change: Readonly) => void, +): CertificateMemory { + return { + recall: memory.recall, + remember: async (node, hashes) => { + const assessment = assessCertificates( + node, + hashes.map((hash) => bytesToHex(hash)), + (await memory.recall(node)).map((hash) => bytesToHex(hash)), + ); + await memory.remember(node, hashes); + if (assessment.kind === "changed") { + onChange({ + node, + remembered: assessment.remembered, + presented: assessment.presented, + }); + } + }, + }; +} + +const HEX_DIGITS_PER_BYTE = 2; + +/** A certificate hash as browsers and `openssl x509 -fingerprint` show it, uppercase hex pairs separated by colons, so a person can compare it against what the node's operator reads out. */ +export function formatFingerprint(sha256Hex: string): string { + const pairs: string[] = []; + for (let index = 0; index < sha256Hex.length; index += HEX_DIGITS_PER_BYTE) { + pairs.push( + sha256Hex.slice(index, index + HEX_DIGITS_PER_BYTE).toUpperCase(), + ); + } + return pairs.join(":"); +} diff --git a/ts/packages/web-console/src/components/ActivityLog.tsx b/ts/packages/web-console/src/components/ActivityLog.tsx new file mode 100644 index 00000000..d931e6c1 --- /dev/null +++ b/ts/packages/web-console/src/components/ActivityLog.tsx @@ -0,0 +1,95 @@ +// The connection's activity as sentences with their times, and the raw frame log behind a toggle for debugging. + +import { useState } from "react"; +import { Switch, Table, Text } from "@mantine/core"; +import type { FrameLogEntry } from "wire-mesh-core/domain/mesh-session"; +import type { ActivityEntry } from "../activity.js"; +import { PeerName } from "./PeerName.js"; + +function describeRawFrame(frame: unknown): string { + return JSON.stringify(frame, (_key: string, value: unknown): unknown => + value instanceof Uint8Array ? `<${String(value.byteLength)} bytes>` : value, + ); +} + +export interface ActivityLogProps { + activity: readonly ActivityEntry[]; + frameLog: readonly FrameLogEntry[]; +} + +export function ActivityLog({ + activity, + frameLog, +}: Readonly): React.JSX.Element { + const [raw, setRaw] = useState(false); + return ( + <> + { + setRaw(event.currentTarget.checked); + }} + /> + {raw ? ( + + + + + direction + frame + + + + {frameLog.map((entry, index) => ( + + {entry.direction} + {describeRawFrame(entry.frame)} + + ))} + +
+
+ ) : ( + <> + + Activity + + {activity.length === 0 ? ( + + Nothing has happened yet. + + ) : ( + + + + {[...activity].reverse().map((entry) => ( + + + {new Date(entry.at).toLocaleTimeString()} + + + {entry.summary} + {entry.peer !== undefined && ( + <> + {" "} + + + )} + + + ))} + +
+
+ )} + + )} + + ); +} diff --git a/ts/packages/web-console/src/components/CertificateTrustPanel.tsx b/ts/packages/web-console/src/components/CertificateTrustPanel.tsx new file mode 100644 index 00000000..fcd0f9f4 --- /dev/null +++ b/ts/packages/web-console/src/components/CertificateTrustPanel.tsx @@ -0,0 +1,126 @@ +// What the user sees at the trust moments: a first-use confirmation naming the node and its certificate, a warning when an address presents a different certificate than the remembered one, and a notice when a connected node announces certificates unrelated to the remembered ones. Decisions are reported upward; nothing here touches storage or a session. + +import { Alert, Button, Code, Group, Stack, Text } from "@mantine/core"; +import { formatFingerprint } from "../certificate-trust.js"; +import type { + NodeCertificateChange, + TrustPrompt, +} from "../hooks/use-certificate-trust.js"; + +function Fingerprints({ + label, + hashes, +}: Readonly<{ label: string; hashes: readonly string[] }>): React.JSX.Element { + return ( +
+ + {label} + + {hashes.map((hash) => ( + + {formatFingerprint(hash)} + + ))} +
+ ); +} + +function PromptAlert({ + prompt, +}: Readonly<{ prompt: TrustPrompt }>): React.JSX.Element { + const { assessment, decide } = prompt; + const changed = assessment.kind === "changed"; + return ( + + + + {changed + ? "This address presents a certificate that shares nothing with the one remembered for this node. The node may have replaced its certificate, or something else may be answering at this address. Only continue if you expected the change." + : "This console has not connected to this node before. Connecting pins the certificate below as the one to expect from it from now on. Compare it with what the node's operator reports before you trust it."} + + {changed && ( + + )} + + + + + + + + ); +} + +export interface CertificateTrustPanelProps { + prompts: readonly TrustPrompt[]; + changes: readonly NodeCertificateChange[]; + onDismissChange: (key: string) => void; +} + +export function CertificateTrustPanel({ + prompts, + changes, + onDismissChange, +}: Readonly): React.JSX.Element | null { + const undismissed = changes.filter((change) => !change.dismissed); + if (prompts.length === 0 && undismissed.length === 0) { + return null; + } + return ( + + {prompts.map((prompt) => ( + + ))} + {undismissed.map((change) => ( + { + onDismissChange(change.key); + }} + > + + + The node is still reachable through the certificate you pinned, + but it now announces certificates that share nothing with the ones + remembered for it, and the announced set is what this console will + pin from now on. + + + + + + ))} + + ); +} diff --git a/ts/packages/web-console/src/components/ConnectionHealth.tsx b/ts/packages/web-console/src/components/ConnectionHealth.tsx new file mode 100644 index 00000000..dbbdd0a3 --- /dev/null +++ b/ts/packages/web-console/src/components/ConnectionHealth.tsx @@ -0,0 +1,76 @@ +// How healthy one connection is, without anyone pressing ping: the recent round-trip times, and for a dropped connection when the next reconnect attempt is due and how many remain. + +import { Group, Text } from "@mantine/core"; +import type { + ConnectionState, + ReconnectPolicy, +} from "wire-mesh-core/domain/mesh-session"; +import { formatRemaining } from "../format-duration.js"; +import type { ConnectionHealth as Health } from "../hooks/use-connection-health.js"; +import { RttSparkline } from "./RttSparkline.js"; + +export interface ConnectionHealthProps { + state: Readonly; + /** When the connection's status last changed, which is when a reconnect's backoff started. */ + statusSince: number | undefined; + now: number; + policy: Readonly; + health: Readonly; +} + +function ReconnectStatus({ + state, + statusSince, + now, + policy, +}: Readonly< + Pick & { + state: Extract; + } +>): React.JSX.Element { + const retryAt = + statusSince === undefined + ? undefined + : statusSince + policy.delayMs(state.attempt); + const due = retryAt === undefined ? 0 : retryAt - now; + return ( + + {due > 0 ? `Retrying in ${formatRemaining(due)}` : "Retrying now"}{" "} + (attempt {state.attempt} of {policy.maxAttempts}). Last failure:{" "} + {state.reason} + + ); +} + +export function ConnectionHealth({ + state, + statusSince, + now, + policy, + health, +}: Readonly): React.JSX.Element | null { + if (state.status === "reconnecting") { + return ( + + ); + } + if (state.status !== "connected") { + return null; + } + return ( + + + {health.unresponsive && ( + + This node does not answer pings, so round trips are not measured + automatically. + + )} + + ); +} diff --git a/ts/packages/web-console/src/components/ConnectionPanel.tsx b/ts/packages/web-console/src/components/ConnectionPanel.tsx index f7eb83ad..e1bc0163 100644 --- a/ts/packages/web-console/src/components/ConnectionPanel.tsx +++ b/ts/packages/web-console/src/components/ConnectionPanel.tsx @@ -1,20 +1,37 @@ // One open relay/hub connection's own UI: status line, ping/disconnect controls, peer directory, and frame log. All behaviour lives in wire-mesh-core's own mesh-session domain module; this component only renders whatever useMeshSessionEvents last reported and forwards clicks back onto the session. -import { Alert, Button, Card, Group, Table, Text } from "@mantine/core"; -import { useEffect, useState } from "react"; +import { Alert, Button, Card, Group, Text, Tooltip } from "@mantine/core"; import type { MeshSession, + ReconnectPolicy, SessionEvent, } from "wire-mesh-core/domain/mesh-session"; +import type { Clock } from "wire-mesh-core/ports/clock"; import type { DeviceId } from "wire-mesh-core/generated/protocol"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useMeshSessionEvents } from "../hooks/use-mesh-session-events.js"; import { browserPermissions, explainLocalNetworkBlock, type PermissionQuerier, } from "../local-network.js"; +import { usePeerNames } from "../hooks/use-peer-names.js"; +import { selfNameExtension } from "../peer-names.js"; +import { useConnectionHealth } from "../hooks/use-connection-health.js"; +import { useNow } from "../hooks/use-now.js"; +import { certificateActivity } from "../activity.js"; +import { formatAgo } from "../format-duration.js"; +import { presentedCertificates } from "../certificate-trust.js"; +import { toDialAddress } from "../dial-address.js"; +import type { NodeCertificateChange } from "../hooks/use-certificate-trust.js"; +import { ActivityLog } from "./ActivityLog.js"; +import { ConnectionHealth } from "./ConnectionHealth.js"; +import { PeerLabel } from "./PeerLabel.js"; +import { StackedTable } from "./StackedTable.js"; +import type { StackedColumn } from "./StackedTable.js"; const HEX_RADIX = 16; +const MS_PER_SECOND = 1000; export function deviceHex(device: Uint8Array): string { let hex = ""; @@ -58,15 +75,14 @@ function describeStatus(event: Readonly): string { return ""; } -function describeFrame(frame: unknown): string { - return JSON.stringify(frame, (_key: string, value: unknown): unknown => - value instanceof Uint8Array ? `<${String(value.byteLength)} bytes>` : value, - ); -} - export interface ConnectionPanelProps { address: string; session: Readonly; + clock: Readonly; + /** The backoff the session redials with, so the panel can say when the next attempt is due. */ + reconnectPolicy: Readonly; + /** Every certificate change seen for any node; the panel shows those for its own node in its activity. */ + certificateChanges: readonly NodeCertificateChange[]; onClose: () => void; onMessagePeer: (device: DeviceId) => void; /** The host of the page this panel is shown on, which decides whether reaching a local address needs the browser's permission. Defaults to the real page's. */ @@ -75,15 +91,32 @@ export interface ConnectionPanelProps { permissions?: PermissionQuerier | undefined; } +/** How often elapsed times and the reconnect countdown refresh. */ +const CLOCK_TICK_MS = 1000; + +const DIRECTORY_COLUMNS: readonly StackedColumn[] = [ + { label: "device" }, + { label: "addresses" }, + { label: "advertised" }, + { label: "actions", headerless: true }, +]; + export function ConnectionPanel({ address, session, + clock, + reconnectPolicy, + certificateChanges, onClose, onMessagePeer, pageHost = window.location.hostname, permissions = browserPermissions(), }: Readonly): React.JSX.Element { - const event = useMeshSessionEvents(session); + const { + event, + activity: sessionActivity, + statusSince, + } = useMeshSessionEvents(session, clock); const failing = event?.state.status === "reconnecting" || event?.state.status === "closed"; // What the browser's answer was for one address; ignored once the connection is working again or the address is another. @@ -108,22 +141,65 @@ export function ConnectionPanel({ }, [failing, address, pageHost, permissions]); const blocked = failing && explained?.address === address ? explained.message : undefined; + const { observeDirectory, selfName } = usePeerNames(); + const connected = event?.state.status === "connected"; + useEffect(() => { + if (event !== undefined) { + observeDirectory(event.directory); + } + }, [event, observeDirectory]); + // A session's initial self-advert carries no extensions, so this console's own display name is published by re-sending it once the link is up, and again whenever the name changes. Clearing the name re-sends the advert with no extensions, which is what retracts the earlier claim; that is only needed once a name has been published on this session. + const namePublished = useRef(false); + const [publishFailure, setPublishFailure] = useState( + undefined, + ); + useEffect(() => { + if (!connected) return; + if (selfName === undefined && !namePublished.current) return; + namePublished.current = selfName !== undefined; + session + .sendGossipUpdate( + selfName === undefined ? undefined : selfNameExtension(selfName), + ) + .then( + () => { + setPublishFailure(undefined); + }, + (error: unknown) => { + setPublishFailure( + error instanceof Error ? error.message : String(error), + ); + }, + ); + }, [connected, selfName, session]); const status = event === undefined ? "idle" : describeStatus(event); const directory = event?.directory ?? []; const frameLog = event?.frameLog ?? []; + const health = useConnectionHealth(session, connected); + const reconnecting = event?.state.status === "reconnecting"; + const now = useNow( + clock, + reconnecting || directory.length > 0, + CLOCK_TICK_MS, + ); + const node = presentedCertificates(toDialAddress(address))?.node; + const activity = useMemo( + () => + [ + ...sessionActivity, + ...certificateActivity( + certificateChanges.filter((change) => change.node === node), + ), + ].sort((a, b) => a.at - b.at), + [sessionActivity, certificateChanges, node], + ); return ( {address} - , + ], + }))} + /> )} - - Frame log - - - - - - direction - frame - - - - {frameLog.map((entry, index) => ( - - {entry.direction} - {describeFrame(entry.frame)} - - ))} - -
-
+
); } diff --git a/ts/packages/web-console/src/components/ConversationList.tsx b/ts/packages/web-console/src/components/ConversationList.tsx index e6ad658f..cbb6e3a9 100644 --- a/ts/packages/web-console/src/components/ConversationList.tsx +++ b/ts/packages/web-console/src/components/ConversationList.tsx @@ -2,6 +2,7 @@ import { Badge, Group, NavLink, Stack, Text } from "@mantine/core"; import { participantLabel, type ConversationView } from "../conversations.js"; +import { usePeerNames } from "../hooks/use-peer-names.js"; export interface ConversationListProps { conversations: readonly ConversationView[]; @@ -14,6 +15,7 @@ export function ConversationList({ selected, onSelect, }: Readonly): React.JSX.Element | null { + const names = usePeerNames(); if (conversations.length === 0) { return null; } @@ -26,7 +28,7 @@ export function ConversationList({ void; @@ -29,45 +49,54 @@ export function DiscoveredPeersPanel({ Discovered peers - - - - device - addresses - - - - - {peers.map((peer) => ( - - {deviceHex(peer.device)} - {peer.addresses.join(", ")} - - - - - - - - ))} - -
+ + Each node below was listed in the directory of a node you are connected + to, which is the only thing vouching for it: the address it lists may + not be a wire-mesh node at all. Nothing is dialled until you connect. + + ({ + key: peer.key, + cells: [ + , + peer.addresses.join(", "), + + {peer.via.claimedDevice !== undefined && ( + <> + + claimed to be + + + + )} + + over {peer.via.address} + + , + + + + , + ], + }))} + /> ); } diff --git a/ts/packages/web-console/src/components/GrantDetails.tsx b/ts/packages/web-console/src/components/GrantDetails.tsx new file mode 100644 index 00000000..7b4bf235 --- /dev/null +++ b/ts/packages/web-console/src/components/GrantDetails.tsx @@ -0,0 +1,78 @@ +// Everything one grant says about itself, for inspection: who issued it and to whom, what it allows and over what, its limits, its current status, and the grant code that carries it to another device. + +import { Code, CopyButton, Button, Stack, Text, Textarea } from "@mantine/core"; +import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; +import { encodeGrantCode, describeGrantStatus } from "../grants.js"; +import type { GrantRow } from "../hooks/use-grants.js"; +import { PeerName } from "./PeerName.js"; + +function Field({ + label, + children, +}: Readonly<{ label: string; children: React.ReactNode }>): React.JSX.Element { + return ( + + + {label}: + {" "} + {children} + + ); +} + +export function GrantDetails({ + row, +}: Readonly<{ row: GrantRow }>): React.JSX.Element { + const { claims } = row; + const code = encodeGrantCode(row.token); + return ( + + + {row.tokenId} + + + + + + + + + {claims.capability} over {claims.scope.kind} + {claims.scope.path === undefined ? "" : ` ${claims.scope.path}`} + + {new Date(claims.expires).toISOString()} + {claims["not-before"] !== undefined && ( + + {new Date(claims["not-before"]).toISOString()} + + )} + {claims["delegations-remaining"] !== undefined && ( + + {claims["delegations-remaining"]} + + )} + {claims.parent !== undefined && ( + + another grant (its parent rides inside this one) + + )} + {describeGrantStatus(row.status)} +