Skip to content

fix: harden Miranda per security review (keychain, relay DoS, LAN, supply chain)#53

Merged
frahlg merged 2 commits into
mainfrom
harden/security-review-fixes
Jul 25, 2026
Merged

fix: harden Miranda per security review (keychain, relay DoS, LAN, supply chain)#53
frahlg merged 2 commits into
mainfrom
harden/security-review-fixes

Conversation

@frahlg

@frahlg frahlg commented Jul 16, 2026

Copy link
Copy Markdown
Member

Full security + E2E review of the v0.7.0 restructure. Core architecture and invariants held (relay never sees plaintext, prf-rooted identity, byte-identical Go/JS crypto) — no critical findings. This PR fixes every actionable one, each with a regression test.

Fixes

macOS keychain — functional, high. commandSecretStore.Put drove security add-generic-password -w reading the secret from piped stdin, but that flag prompts via readpassphrase and silently stored an empty secret — breaking every mir list/up/attach on macOS with "owner secret is empty". The unit tests never caught it because they use the file-backed store, never the real keychain. Now drives security -i so the whole command (secret included) travels on stdin — never argv (no ps exposure), never a tty prompt. Found via E2E, not tests.

Relay registration/registry DoS — high. The agents map was unbounded and the non-32-byte owner_id path is unauthenticated, so an attacker could open a registration per (owner, machine) and hold each socket with a retained 256 KiB registry blob → OOM on the 512 MB relay box. Cap concurrent registrations (defaultMaxAgents + SetMaxAgents), always allow replacing an existing slot, and cap the retained blob to 16 KiB.

Revocation replay stall + fsync under the broker lock — medium. Every /revocations POST — including an exact replay of a validly-signed record — marshalled + fsync'd the whole file under s.mu, the single lock every signaling request takes. Now: an exact replay is a no-op (skips persist), and persistence runs on its own mutex with a versioned snapshot (a lock-free persister writes off the broker lock; stale snapshots are safely skipped because a higher version is always a superset).

LAN-direct head-of-line DoS — medium. The accept loop did AcceptStream inline, so a peer that completed the QUIC handshake but never opened a stream blocked all LAN-direct attach; the frame-0 read had no pre-auth deadline. Split quicmsg.Accept (connection only) from Establish (stream + nudge, off the accept path) and bound the pre-auth phase with lanPreAuthTimeout.

Reproducible-release recipe — medium. verify-reproducible.sh baked different ldflags than GoReleaser (8-char commit, local-tz date), so it could never reproduce an honest release — the reproducibility claim in SECURITY.md was structurally broken. Now matches .ShortCommit (7 chars) and .CommitDate (UTC RFC3339); verified it reproduces the released binary's embedded Commit/Date exactly.

Browser-shipped crypto now gated by CI — medium. The Go↔JS interop vectors only validated the @noble code in node_modules, not the vendored bundles the SPA imports via the importmap. web/tools/verify-vendor.sh re-bundles each @noble entry point with a pinned esbuild and byte-compares against web/vendor/, wired into the web CI job. Scoped to the six @noble crypto bundles (which reproduce byte-identically); xterm/jsqr are UI and carry a non-byte-stable esbuild wrapper, so they are out of scope.

STUN client-IP privacy — low. The web client hardcoded Google STUN and sent it on every attach, leaking the client IP to a third party. It now prefers the relay's own TURN (which already yields a server-reflexive candidate) and only falls back to a default STUN when no TURN is offered — and that default is overridable via window.MIRANDA_STUN (set '' to disable).

Lower-severity hardening. Cache-Control: no-store on /registry + /revocations (a stale empty revocation list could let a client attach to a revoked machine); reject | in owner_id/machine_id (registry-listing injection); HSTS at the origin; corrected the cosign fail-closed comment that described the opposite (soft-fallback) behavior.

Verification

  • go test ./... + -race: green
  • 111 web tests + byte-identical crypto vectors: green
  • verify-reproducible.sh (matches the released binary's embedded fields) and verify-vendor.sh: green
  • Full local E2E: pair → mir list → encrypted mir run over both the relay path and LAN-direct; plus a revocation E2E (local enforcement + relay publish + durable persist through the lock-free persister): green

🤖 Generated with Claude Code

…pply chain)

Fixes surfaced by a full security + E2E review of the v0.7.0 restructure.

macOS keychain (functional, high) — commandSecretStore.Put drove
`security add-generic-password -w` reading the secret from piped stdin, but
that flag prompts via readpassphrase and silently stored an EMPTY secret,
breaking every `mir list/up/attach` on macOS with "owner secret is empty".
The unit tests never caught it because they use the file-backed store. Drive
`security -i` so the whole command (secret included) travels on stdin — never
argv, never a tty prompt. Extracted darwinPutInput + regression tests.

Relay registration/registry DoS (high) — the agents map was unbounded and the
non-32-byte owner_id path is unauthenticated, so an attacker could open a
registration per (owner, machine) and hold each socket with a retained 256 KiB
registry blob until OOM. Cap concurrent registrations (defaultMaxAgents,
SetMaxAgents), always allow replacing an existing slot, and cap the retained
registry blob to 16 KiB.

Revocation replay stall (medium) — every /revocations POST, including an exact
replay of a validly-signed record, marshalled + fsync'd the whole file under
the global broker lock. Skip the persist entirely when the record is
byte-identical to the stored one.

LAN-direct head-of-line DoS (medium) — the accept loop did AcceptStream inline,
so a peer that completed the QUIC handshake but never opened a stream blocked
all LAN-direct attach, and the frame-0 read had no pre-auth deadline. Split
quicmsg Accept (connection only) from Establish (stream + nudge, off the accept
path), and bound the pre-auth phase with lanPreAuthTimeout.

Reproducible-release recipe (medium) — verify-reproducible.sh baked different
ldflags than GoReleaser (8-char commit, local-tz date), so it could never
reproduce an honest release. Match .ShortCommit (7 chars) and .CommitDate (UTC
RFC3339); verified it now reproduces the released binary's embedded Commit/Date.

Lower-severity hardening — Cache-Control: no-store on /registry and
/revocations (stale revocation list could let a client attach to a revoked
machine); reject '|' in owner_id/machine_id (registry-listing injection); HSTS
at the origin; corrected the cosign fail-closed comment that described the
opposite (soft-fallback) behavior.

Verified: go test ./... (+ -race), 111 web tests, byte-identical crypto
vectors, reproducibility script, and full local E2E (pair → encrypted run over
relay and LAN-direct) all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cb1239c596

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +117 to +118
existing, exists := s.revoked[slot]
if exists && existing == record {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist duplicate revocations after write failures

When durable revocations are enabled and the first POST reaches persistRevocationsLocked() but the write fails, s.revoked[slot] has already been updated and is not rolled back. A client retrying the exact same signed tombstone will now hit this duplicate fast path, return 204, and never retry the disk write, so the revocation is lost on relay restart even though the retry appeared successful.

Useful? React with 👍 / 👎.

The three items called out as follow-ups in the hardening PR.

Revocation fsync off the broker lock (medium) — /revocations POST previously
marshalled + fsync'd the whole file while holding s.mu, the single lock every
signaling request takes, so a slow disk stalled all signaling. Persistence now
runs on its own mutex: each in-memory change (under s.mu) bumps a version and
snapshots the set, and a lock-free persister writes the snapshot, skipping any
snapshot whose version is already superseded (a higher version is always a
superset, so the skip is safe). Added a concurrent-persist test.

CI gate for browser-shipped crypto (medium) — the Go<->JS interop vectors only
validated the @noble code in node_modules, not the vendored bundles the SPA
actually imports via the importmap. web/tools/verify-vendor.sh re-bundles each
@noble entry point with a pinned esbuild and byte-compares against web/vendor/,
wired into the web CI job. Scoped to the six @noble crypto bundles (which
reproduce byte-identically); xterm/jsqr are UI and carry a non-byte-stable
esbuild wrapper, so they are intentionally out of scope.

STUN client-IP privacy (low) — the web client hardcoded Google STUN and sent it
on every attach, leaking the client IP to a third party. It now prefers the
relay's own TURN (which already yields a server-reflexive candidate, so no
separate STUN is needed) and only falls back to a default STUN when no TURN is
offered — and that default is overridable via window.MIRANDA_STUN (set '' to
disable).

Verified: go test ./... (+ -race on signal), 111 web tests, verify-vendor.sh,
and a live revocation E2E (local enforcement + relay publish + durable persist
through the refactored lock-free persister).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@miravoss26 miravoss26 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the whole diff. Tight, well-argued hardening pass, and every change carries its rationale in-comment, which made the review fast. My read: correct and safe to merge on the security and correctness axes. Notes below, none blocking.

What it does: closes real gaps: the macOS keychain silently storing an empty owner secret, two unbounded-memory / head-of-line DoS vectors on the relay, an unsigned-release fallback, and a vendored-crypto supply-chain blind spot.

Findings (confirmations / minor, none blocking):

  • client/keychain.go — the security -i fix is right: -w last really does read from the tty and store an empty secret. darwinPutInput keeps the secret off argv and fails closed on command-unsafe / non-hex input, and the three new tests pin exactly that.
  • signal/revocations.go + server.go — the off-lock versioned persister is the subtle one. Skipping version <= revPersisted is safe because every bump happens under s.mu with the map write, so a higher version is always a superset. TestConcurrentRevocationsAllPersist is the right tripwire; keep it if anyone later moves the version bump out from under s.mu.
  • quicmsg.go / agent/lan.go — splitting Accept/Establish to bound pre-auth under a 10s deadline while the authenticated session runs on the parent ctx is correct, and the comment on why serveAuthenticated uses ctx not authCtx is exactly the thing a future reader needs.
  • selfupdate/cosign.go — one item worth a human's explicit nod (not a bug): this flips missing-signature from fallback to hard refuse. Right posture, but it means any deployed miranda still on a pre-signing release can't self-update past this. If that population is empty or will re-install, ship it.
  • web/src/app.js — dropping third-party STUN when TURN is present removes an IP leak for no connectivity loss (TURN already yields a srflx candidate). Correct.
  • web/tools/verify-vendor.sh — good gate, and the scope note (only the six @noble bundles are byte-stable) is honest, so it shouldn't flake.

Security screen: no secrets in the diff, no new runtime deps (esbuild is pinned + CI-only), no new network destinations (app.js removes one), and the authz-relevant changes all tighten. CI is green (go + web) and it's mergeable.

Safe to merge from my read. The single thing I'd want a human to actively confirm is the cosign fail-closed change above. Not merging from here (miranda isn't on the steward allowlist), just flagging: 8 days is a while for a security PR to sit.

@frahlg
frahlg merged commit 33a5e8c into main Jul 25, 2026
2 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