Skip to content

fix(peers): close 50+ audit findings across P2P sync system#133

Merged
ParkerM2 merged 23 commits intodevelopfrom
feature/peers-audit-fixes
Apr 27, 2026
Merged

fix(peers): close 50+ audit findings across P2P sync system#133
ParkerM2 merged 23 commits intodevelopfrom
feature/peers-audit-fixes

Conversation

@ParkerM2
Copy link
Copy Markdown
Owner

Summary

Multi-agent audit + fix sprint on the new device-to-device (peers / P2P) sync system. Closes every Critical and High finding across security, transport, replication, service/IPC, and renderer layers.

  • 5-stream audit cataloged in tmp/audit/0[1-5]-*.md + tmp/audit/SUMMARY.md
  • 17 implementation tasks (T1-T17) executed via subagent-driven TDD
  • Plan: docs/superpowers/plans/2026-04-26-peers-audit-fixes.md

Key Fixes

Securitycrypto.randomInt PIN, timingSafeEqual on decoded HMAC, per-IP rate-limit on /pair/*, slowloris timeouts, safeStorage-required identity with 0o600 mode, checkServerIdentity for TLS pinning (no more post-handshake fingerprint hacks).

TransportOutboundDialer state machine (backoff + jitter + cap + permanently_failed), wss/socket 'error' listeners, signed Ed25519 HELLO inbound peer auth, bound incomingSockets, Zod-validated wire frames.

Replicationpeer_state.last_seen_hlc actually written (op-log GC was a no-op before), lastHlc seeded from op_log on engine restart, GC frontier strips peerIdShort suffix, WALL_PAD=13, dedup-first in applyRemoteOp, op_log(hlc) index, per-table column allowlist via SYNC_TABLE_DEFS.

Service / IPC — Awaitable peers bootstrap (kills the IIFE race + dispose race), validatedHandle enforcing input AND output schemas, HostnameSchema regex (closes SSRF surface), single PairedPeer source-of-truth, postJsonPinned / safeFanOut / runGcTick extracted.

RendereruseIpcEvent (no more window.api.on), peerKeys factory used everywhere (relocated to @shared/ipc/peers to remove FSD boundary disable), IncomingPinDialog hoisted to RootLayout (was scoped to Settings only), MVP presentation hooks useOutgoingPair + usePeerListPanel, peerLabel/sanitizePin helpers, PIN_LENGTH constant.

Cleanuppeer-constants.ts module, Phase1PeerConfig deprecation removed, migration-tags relocated to @main/db, peer-state-schema merged into schema.ts, dev-default peer-a peerId fallbacks dropped, cross-device-query accepts peerStore via DI, schema-hash uses node:crypto.

Test plan

  • npx tsc --noEmit — PASS
  • npx eslint on all touched dirs — PASS (0 warnings)
  • Unit tests: 161/164 in scope (2 failures are pre-existing better-sqlite3 ABI on local Windows; CI is the gate; 1 platform-skip)
  • Integration tests run on CI (blocked locally by better-sqlite3 ABI rebuild)
  • E2E pair + TLS transport + sync smoke

🤖 Generated with Claude Code

ParkerES and others added 23 commits April 25, 2026 16:13
… privkey from PeerIdentity surface

Audit refs: tmp/audit/01-security.md
- peer-identity.ts:39-43 — plaintext private key fallback no 0o600
- peer-identity.ts:73 — PeerIdentity.privkey exported but unused

Changes:
- Add IdentityOpts { allowPlaintext?: boolean } and require explicit
  opt-in when safeStorage.isEncryptionAvailable() is false. Throw
  before generating the keypair so failure path doesn't leak entropy.
  Error message instructs users to set
  ADC_PEERS_ALLOW_PLAINTEXT_IDENTITY=1 to opt in.
- Log a serviceLogger warning when writing in plaintext mode.
- Persist identity file with mode 0o600 (was default 0o644 minus umask).
  Mirrors peer-tls.ts pattern.
- Drop privkey from the exported PeerIdentity interface — only sign()
  is used by callers (verified via git grep: no consumers of
  PeerIdentity.privkey in src/main/features/peers or assistant). The
  on-disk JSON format is unchanged (still pubkey/privkey/useSafeStorage),
  only the in-memory surface is minimized.
- peers-service.ts: pass { allowPlaintext: process.env
  .ADC_PEERS_ALLOW_PLAINTEXT_IDENTITY === '1' } at the single call site.

Tests:
- New tests/unit/peers/peer-identity.test.ts (8 cases, 1 skipped on
  win32) — covers throw-without-opt-in, no-leak-on-failure-path,
  0o600 mode (POSIX), reload symmetry, sign() signature shape, and
  asserts 'privkey' not in identity.
- Update tests/integration/peers/peer-identity.test.ts to pass
  allowPlaintext: true (mock keeps safeStorage unavailable) and
  remove .privkey assertions; add a throw-without-opt-in case.

peer-tls.ts ripple: none. peer-tls.ts derives its own X.509
keypair via @peculiar/x509 and never reads PeerIdentity.privkey.
…rip peerIdShort from GC frontier, WALL_PAD=13
Audit 04 C1+C2: PeersService bootstrap was a fire-and-forget IIFE that
left handlers throwing "peers-service not yet initialized" if any IPC
call landed during the boot window, and disposePeerTransport was a no-op
if shutdown raced ahead of the IIFE — leaking the TLS listener + mDNS.

- Add wrapAsyncPeersService(): a Proxy that wraps Promise<PeersService>
  and returns Promise-shaped methods that await the inner promise before
  delegating. Source-compatible cast to PeersService at the wrap-site.
- Replace the IIFE in service-registry.ts with a single
  peersServicePromise + wrapper. disposePeerTransport now awaits the
  same promise so half-constructed resources still get cleaned up.
- Add a peersDisposed guard so a second dispose during the same window
  is a no-op.
- Update peers-handlers.ts comments — handlers continue to use
  Promise.resolve(...) which transparently chains the wrapper Promise
  for sync-typed methods (listPaired/listDiscovered/getIdentity/revoke)
  and keeps the existing await-thenable lint clean.
- Tests: 6 unit (wrapAsyncPeersService) + 3 integration (bootstrap-race)
  covering forward/race-safe/dispose-before-resolve/rejected-bootstrap.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…edPeer source-of-truth

Closes audit H1-H5 from tmp/audit/04-service-ipc.md:
- H1: validatedHandle helper parses both input and output schemas
  (output validated only in non-production for zero prod overhead).
- H2: peers-handlers reaches through peersInvoke[channel] map instead
  of importing individual schemas.
- H3: HostnameSchema regex (DNS / IPv4 / IPv6 / zone-id / brackets)
  replaces arbitrary z.string() on host fields, closing SSRF surface.
- H4: displayName normalized to z.string().nullable() across
  DiscoveredPeerSchema, PairInitInputSchema, PairConfirmInputSchema,
  PinIssuedEventSchema. Drops .optional() permutations.
- H5: PairedPeer is now exported from @shared/ipc/peers/contract.ts;
  peer-store re-exports it. Single source of truth.

Also normalize DiscoveredPeerWithPaired.displayName to string|null
and coalesce ad.displayName ?? null in enrichDiscovered so output
schema validation passes in dev.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… visibility

Closes audit H4. Previously <IncomingPinDialog /> was mounted inside
SettingsPage, so a PIN issued by a remote initiator while the user was
on any non-Settings route was silently lost (the useIncomingPin hook
only listens while mounted). Hoisted the dialog to RootLayout alongside
other always-mounted overlays (AssistantWidget, WorkflowPermissionModal,
notifications). Mounted in the post-onboarding shell only — incoming
pair invites have no meaning before the workspace is initialized.

Removed the corresponding TODO(p2p-phase4) comment and the now-unused
IncomingPinDialog import from SettingsPage.

Backstop test added at tests/unit/renderer/peers/incoming-pin-global.test.ts.
The repo's vitest config uses environment: 'node' (no jsdom), so the test
falls back to static-analysis assertions that verify the dialog is imported
and rendered in RootLayout and absent from SettingsPage. To be replaced
with a render test if/when a jsdom-enabled vitest project is added.
…ePeerListPanel) + format helpers

Closes audit C3, C4, M2, M5 (and L3 partially) from tmp/audit/05-renderer.md.

- Extract useOutgoingPair hook owning Stage state machine, session token,
  PIN value (sanitized via sanitizePin), and pair-init/pair-confirm mutations.
  OutgoingPairDialog becomes render-only.
- Extract usePeerListPanel hook owning inviteTarget state and the four
  query/mutation hooks. PeerListPanel becomes render-only and the
  renderSelfBody / renderPairedBody / renderDiscoveredBody helpers are now
  typed sub-components <SelfBody/>, <PairedList/>, <DiscoveredList/>.
- Replace lib/truncate.ts with lib/format.ts adding peerLabel() and
  sanitizePin(); re-export truncate. Removes inline (peer.displayName ??
  truncate(peer.peerId)) duplication across all three dialogs.
- Add src/shared/ipc/peers/constants.ts with PIN_LENGTH=6 (M5) and
  PEER_ID_DISPLAY_MAX=16. format.ts and the hook import the shared
  constant; main + renderer agree on PIN size from one source.
- IncomingPinDialog (L3): replace Heading as="h1" for the PIN value with
  Text size="lg" + className="text-3xl font-mono tracking-widest"
  (Text size tokens are sm/md/lg only — visual scale comes from the
  Tailwind utility). Uses peerLabel() helper.
- index.ts: re-export the hooks, peerLabel, sanitizePin, truncate; drop
  truncate.ts barrel entry.

Tests (44 added/touched, all passing):
- format.test.ts (15 real unit tests on truncate/peerLabel/sanitizePin)
- useOutgoingPair.test.ts (10 static-analysis assertions, T13/T14 style)
- usePeerListPanel.test.ts (10 static-analysis assertions)

Verification: vitest peers/ green (44/44), npx tsc --noEmit clean,
npx eslint clean on all touched files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ase1 deprecations

Adds src/main/features/peers/peer-constants.ts as the single source of truth
for protocol-level + runtime constants shared across the peers module. Updates
consumers to import from there:

- peer-pairing.ts: SESSION_TTL_MS, SESSION_MAX_ATTEMPTS, SESSION_SOFT_LIMIT
  (drops local DEFAULT_TTL_MS / DEFAULT_MAX_ATTEMPTS / DEFAULT_MAX_ACTIVE_SESSIONS)
- pair-server.ts: PAIR_BODY_MAX_BYTES, PAIR_REQUEST_TIMEOUT_MS,
  PAIR_HEADERS_TIMEOUT_MS, PAIR_KEEPALIVE_TIMEOUT_MS, PAIR_BODY_READ_TIMEOUT_MS,
  LOOPBACK_HOST
- ws-transport.ts: WS_CLOSE_CODES (SCHEMA_MISMATCH, FINGERPRINT_MISMATCH,
  MALFORMED_FRAME, UNTRUSTED), MAX_INBOUND_SOCKETS, LOOPBACK_HOST
- peer-mdns.ts: MDNS_SERVICE_TYPE, MDNS_PROTOCOL, PEER_ID_SHORT_LEN
- peers-service.ts: GC_INTERVAL_MS, LOOPBACK_HOST
- outbound-dialer.ts: WS_RECONNECT_BASE_MS / WS_RECONNECT_MAX_MS /
  WS_RECONNECT_JITTER as defaults (still overridable via opts)

Removes the Phase1PeerConfig type alias and loadPhase1PeerConfig() function
from peer-config.ts. Grep across src/ + tests/ found zero non-self callers.

Pure mechanical refactor — no value changes, no behavior changes. Audit refs:
02-transport.md H2/H3/L4/L7, 01-security.md "magic numbers" section.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…peer-state schema, drop peerId fallbacks, schema-hash uses node:crypto, cross-device-query DI, relocate peerKeys

- Move migration-tags.ts from src/main/features/peers/ to src/main/db/
  (audit 01/Low — generic Drizzle journal logic, not peer-specific).
- Clear revokedAt on re-pair in peerStore.upsert (audit 01/Medium —
  silent re-pair failure on previously-revoked peers).
- Merge peer-state-schema.ts into peers/schema.ts (audit 03/Low —
  one schema file per domain).
- Drop dev-default 'aaaaaaaa' / 'peer-a' peerId fallbacks in
  service-registry; resolve identity once via getOrCreatePeerIdentity
  and pass through to peers-service + replicationEngine. Throws if
  identity is unresolved (audit 04/L3).
- cross-device-query receives PeerStore via DI rather than
  constructing its own; reuses the registry-owned singleton
  (audit 04/M6).
- schema-hash.ts switches to node:crypto and is now sync — drops
  unnecessary async API at the only call site (audit 03/M8).
- Relocate peerKeys to @shared/ipc/peers/queryKeys.ts; renderer
  feature re-exports for source compat. EventBridge imports from
  @shared/ipc/peers, removing the boundaries/dependencies disable
  (audit 05/T13).

Tests: new tests/unit/peers/peer-store-revoked-reset.test.ts;
schema-hash + migration-tags + cross-device-query tests updated.

typecheck + lint pass on touched files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@ParkerM2 ParkerM2 merged commit 1d3871a into develop Apr 27, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants