From 1a63b21f7db54ea60c417e4a152533cd34662a81 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 15 Jul 2026 15:13:09 +0200 Subject: [PATCH] feat: harden Miranda for v0.7.0 release candidate --- .github/workflows/release.yml | 24 +- .github/workflows/test.yml | 26 +- .goreleaser.yaml | 18 +- Makefile | 7 +- README.md | 381 +++++++------- SECURITY.md | 476 +++++++++++------- deploy/lightsail/README.md | 74 ++- deploy/lightsail/mir-signal.service | 31 +- deploy/lightsail/redeploy.sh | 4 +- docs/audit-scope.md | 46 ++ docs/naming.md | 21 +- docs/operations.md | 70 +++ docs/product.md | 101 ++++ docs/release.md | 52 ++ go/cmd/mir-signal/main.go | 54 +- go/cmd/mir-signal/main_test.go | 26 + go/go.mod | 8 +- go/internal/agent/binding_test.go | 31 +- go/internal/agent/e2e_test.go | 15 +- go/internal/agent/lan.go | 8 +- go/internal/agent/registry_publish_test.go | 185 ++----- go/internal/agent/runtime.go | 142 +++--- go/internal/agent/runtime_reconnect_test.go | 26 + go/internal/agent/runtime_test.go | 20 +- go/internal/agent/store.go | 102 +++- go/internal/bip39/bip39.go | 18 +- go/internal/bip39/bip39_test.go | 7 +- go/internal/cli/agent_cmds.go | 47 +- go/internal/cli/agent_up_identity_test.go | 49 ++ go/internal/cli/agent_up_wallet_test.go | 59 --- go/internal/cli/cli.go | 43 +- go/internal/cli/cli_test.go | 53 +- go/internal/cli/client_cmds.go | 242 ++++++--- go/internal/cli/doctor.go | 264 ++++++++++ go/internal/cli/doctor_test.go | 59 +++ go/internal/cli/identity_cmds.go | 169 +++++++ go/internal/cli/identity_cmds_test.go | 80 +++ go/internal/cli/main_test.go | 17 + go/internal/cli/pair.go | 44 +- go/internal/cli/shared.go | 27 +- go/internal/cli/wallet_cmds.go | 128 ----- go/internal/cli/wallet_cmds_test.go | 100 ---- go/internal/client/attach.go | 4 +- go/internal/client/binding_test.go | 6 +- go/internal/client/e2e_mux_test.go | 2 +- go/internal/client/e2e_test.go | 2 +- go/internal/client/keychain.go | 184 +++++++ go/internal/client/lan_locator.go | 10 +- go/internal/client/lan_locator_test.go | 6 +- go/internal/client/main_test.go | 17 + go/internal/client/muxterm.go | 15 +- go/internal/client/registry.go | 56 ++- go/internal/client/registry_test.go | 6 +- go/internal/client/relay_locator.go | 22 +- go/internal/client/revocations.go | 201 ++++++++ go/internal/client/revocations_test.go | 121 +++++ go/internal/client/store.go | 334 ++++++++++-- go/internal/client/store_test.go | 86 +++- go/internal/identity/auth.go | 39 +- go/internal/identity/auth_test.go | 18 +- go/internal/identity/binding.go | 22 +- go/internal/identity/binding_test.go | 20 +- go/internal/identity/fuzz_test.go | 18 + go/internal/identity/registry.go | 6 +- go/internal/identity/revocation.go | 55 ++ go/internal/identity/revocation_test.go | 66 +++ go/internal/identity/signer.go | 54 ++ go/internal/identity/signer_test.go | 67 +++ go/internal/identity/wallet.go | 66 --- go/internal/identity/wallet_test.go | 100 ---- go/internal/pairing/code.go | 11 +- go/internal/pairing/e2e_test.go | 7 +- go/internal/pairing/fuzz_test.go | 12 + go/internal/pairing/interop_test.go | 2 +- go/internal/pairing/pairing.go | 140 ++++-- go/internal/pairing/pairing_test.go | 70 ++- go/internal/quicmsg/quicmsg.go | 6 +- go/internal/sas/sas.go | 12 +- go/internal/sas/sas_test.go | 6 +- go/internal/selfupdate/apply.go | 7 +- go/internal/selfupdate/apply_test.go | 13 +- go/internal/selfupdate/cosign.go | 25 +- go/internal/selfupdate/cosign_test.go | 26 +- go/internal/signal/fuzz_test.go | 12 + go/internal/signal/protocol.go | 3 +- go/internal/signal/ratelimit.go | 109 ++++ go/internal/signal/ratelimit_test.go | 88 ++++ go/internal/signal/registration_proof_test.go | 84 ++++ go/internal/signal/registry_e2e_test.go | 8 +- go/internal/signal/revocations.go | 182 +++++++ go/internal/signal/revocations_test.go | 157 ++++++ go/internal/signal/server.go | 232 +++++++-- go/internal/signal/server_recovery_test.go | 9 + go/internal/signal/server_test.go | 6 + go/internal/signal/spike_test.go | 8 + go/internal/slip10/slip10.go | 69 --- go/internal/slip10/slip10_test.go | 48 -- install.sh | 20 +- scripts/verify-reproducible.sh | 50 ++ test/install_test.sh | 9 + testdata/identity-binding.json | 10 + testdata/identity-derivation.json | 8 + testdata/pair-interop.json | 12 +- testdata/revocation.json | 8 + testdata/wallet-binding.json | 10 - testdata/wallet-derivation.json | 9 - web/index.html | 1 - web/manifest.json | 2 +- web/package-lock.json | 4 +- web/package.json | 2 +- web/src/app.js | 87 +++- web/src/identity.js | 26 +- web/src/identity/auth.js | 28 +- web/src/identity/binding.js | 20 +- web/src/identity/registry.js | 6 +- web/src/identity/signer.js | 23 + web/src/identity/wallet.js | 32 -- web/src/pair.js | 22 +- web/src/pairing/nnpsk0.js | 61 ++- web/src/pairing/sas.js | 8 +- web/src/registry.js | 10 +- web/src/revocations.js | 125 +++++ web/src/rp.js | 10 +- web/src/wallet/bip39.js | 14 +- web/src/wallet/slip10.js | 45 -- web/sw.js | 9 +- web/test/auth.test.js | 20 +- web/test/binding.test.js | 16 +- web/test/bip39.test.js | 7 +- web/test/pair.test.js | 8 +- web/test/pairing-interop.test.js | 22 +- web/test/pairing-sas.test.js | 4 +- web/test/revocations.test.js | 63 +++ web/test/rp.test.js | 12 +- web/test/signer.test.js | 27 + web/test/slip10.test.js | 34 -- web/test/sw.test.js | 4 +- web/test/wallet.test.js | 35 -- web/vendor/noble-hashes-pbkdf2.js | 6 - 139 files changed, 5250 insertions(+), 1988 deletions(-) create mode 100644 docs/audit-scope.md create mode 100644 docs/operations.md create mode 100644 docs/product.md create mode 100644 docs/release.md create mode 100644 go/internal/cli/agent_up_identity_test.go delete mode 100644 go/internal/cli/agent_up_wallet_test.go create mode 100644 go/internal/cli/doctor.go create mode 100644 go/internal/cli/doctor_test.go create mode 100644 go/internal/cli/identity_cmds.go create mode 100644 go/internal/cli/identity_cmds_test.go create mode 100644 go/internal/cli/main_test.go delete mode 100644 go/internal/cli/wallet_cmds.go delete mode 100644 go/internal/cli/wallet_cmds_test.go create mode 100644 go/internal/client/keychain.go create mode 100644 go/internal/client/main_test.go create mode 100644 go/internal/client/revocations.go create mode 100644 go/internal/client/revocations_test.go create mode 100644 go/internal/identity/fuzz_test.go create mode 100644 go/internal/identity/revocation.go create mode 100644 go/internal/identity/revocation_test.go create mode 100644 go/internal/identity/signer.go create mode 100644 go/internal/identity/signer_test.go delete mode 100644 go/internal/identity/wallet.go delete mode 100644 go/internal/identity/wallet_test.go create mode 100644 go/internal/pairing/fuzz_test.go create mode 100644 go/internal/signal/fuzz_test.go create mode 100644 go/internal/signal/ratelimit.go create mode 100644 go/internal/signal/ratelimit_test.go create mode 100644 go/internal/signal/revocations.go create mode 100644 go/internal/signal/revocations_test.go delete mode 100644 go/internal/slip10/slip10.go delete mode 100644 go/internal/slip10/slip10_test.go create mode 100755 scripts/verify-reproducible.sh create mode 100644 testdata/identity-binding.json create mode 100644 testdata/identity-derivation.json create mode 100644 testdata/revocation.json delete mode 100644 testdata/wallet-binding.json delete mode 100644 testdata/wallet-derivation.json create mode 100644 web/src/identity/signer.js delete mode 100644 web/src/identity/wallet.js create mode 100644 web/src/revocations.js delete mode 100644 web/src/wallet/slip10.js create mode 100644 web/test/revocations.test.js create mode 100644 web/test/signer.test.js delete mode 100644 web/test/slip10.test.js delete mode 100644 web/test/wallet.test.js delete mode 100644 web/vendor/noble-hashes-pbkdf2.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f13802..10bf05a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,21 +11,33 @@ jobs: goreleaser: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 # full history for changelog - - uses: actions/setup-go@v6 + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: # Pin an exact floor, not "1.26.x": go.mod's toolchain directive pins - # go1.26.4 for the stdlib CVE fixes, and the wildcard guarantees no + # go1.26.5 for the stdlib CVE fixes, and the wildcard guarantees no # minimum version (setup-go could resolve an older patch). - go-version: "1.26.4" + go-version: "1.26.5" cache-dependency-path: go/go.sum # module lives in go/, not repo root + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "24" + cache: npm + cache-dependency-path: web/package-lock.json + - name: release gate + run: | + cd go && go test ./... + go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... + cd ../web && npm ci && npm test + npm audit --omit=dev + cd .. && ./scripts/verify-reproducible.sh # cosign must be on PATH before goreleaser: the `signs:` block shells out to # it to keyless-sign checksums.txt. No key material is configured — signing # uses the job's OIDC identity (hence id-token: write above). - - uses: sigstore/cosign-installer@v3 - - uses: goreleaser/goreleaser-action@v7 + - uses: sigstore/cosign-installer@f713795cb21599bc4e5c4b58cbad1da852d7eeb9 # v3 + - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 with: version: "~> v2" args: release --clean diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0c73ed7..20a4f10 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,11 +11,11 @@ jobs: go: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: # Match release.yml: exact floor pinned for the stdlib CVE fixes. - go-version: "1.26.4" + go-version: "1.26.5" cache-dependency-path: go/go.sum # module lives in go/, not repo root - name: gofmt run: | @@ -24,15 +24,29 @@ jobs: - name: vet working-directory: go run: go vet ./... + - name: vulnerability scan + working-directory: go + run: go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... - name: test working-directory: go run: go test ./... + - name: race + working-directory: go + run: go test -race -count=1 ./... + - name: parser fuzz smoke + working-directory: go + run: | + go test ./internal/pairing -run=^$ -fuzz=FuzzDecodeCode -fuzztime=5s + go test ./internal/signal -run=^$ -fuzz=FuzzDecodeInboundSignal -fuzztime=5s + go test ./internal/identity -run=^$ -fuzz=FuzzRevocationJSON -fuzztime=5s + - name: reproducible binaries + run: ./scripts/verify-reproducible.sh web: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: "24" cache: npm @@ -41,6 +55,8 @@ jobs: run: npm ci - working-directory: web run: npm test + - working-directory: web + run: npm audit --omit=dev # The Go and web jobs together are the byte-identical crypto gate: go test # writes/asserts testdata/ vectors and npm test asserts the JS side matches the diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 58ff9c2..b80badc 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -17,11 +17,13 @@ builds: env: [CGO_ENABLED=0] goos: [linux, darwin] goarch: [amd64, arm64] + flags: [-trimpath, -buildvcs=false] + mod_timestamp: "{{ .CommitTimestamp }}" ldflags: - - -s -w + - -s -w -buildid= - -X github.com/srcful/terminal-relay/go/internal/version.Version={{ .Version }} - -X github.com/srcful/terminal-relay/go/internal/version.Commit={{ .ShortCommit }} - - -X github.com/srcful/terminal-relay/go/internal/version.Date={{ .Date }} + - -X github.com/srcful/terminal-relay/go/internal/version.Date={{ .CommitDate }} - id: mir-agent main: ./cmd/mir-agent binary: mir-agent @@ -29,11 +31,13 @@ builds: env: [CGO_ENABLED=0] goos: [linux, darwin] goarch: [amd64, arm64] + flags: [-trimpath, -buildvcs=false] + mod_timestamp: "{{ .CommitTimestamp }}" ldflags: - - -s -w + - -s -w -buildid= - -X github.com/srcful/terminal-relay/go/internal/version.Version={{ .Version }} - -X github.com/srcful/terminal-relay/go/internal/version.Commit={{ .ShortCommit }} - - -X github.com/srcful/terminal-relay/go/internal/version.Date={{ .Date }} + - -X github.com/srcful/terminal-relay/go/internal/version.Date={{ .CommitDate }} - id: mir-signal main: ./cmd/mir-signal binary: mir-signal @@ -41,11 +45,13 @@ builds: env: [CGO_ENABLED=0] goos: [linux, darwin] goarch: [amd64, arm64] + flags: [-trimpath, -buildvcs=false] + mod_timestamp: "{{ .CommitTimestamp }}" ldflags: - - -s -w + - -s -w -buildid= - -X github.com/srcful/terminal-relay/go/internal/version.Version={{ .Version }} - -X github.com/srcful/terminal-relay/go/internal/version.Commit={{ .ShortCommit }} - - -X github.com/srcful/terminal-relay/go/internal/version.Date={{ .Date }} + - -X github.com/srcful/terminal-relay/go/internal/version.Date={{ .CommitDate }} archives: # One archive entry PER build id. A single entry covering multiple builds would diff --git a/Makefile b/Makefile index feddc4d..6ca3e83 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ -# terminal-relay — local dev +# Miranda — local development PREFIX ?= $(HOME)/.local/bin -.PHONY: build test race install dev +.PHONY: build test race reproduce install dev build: cd go && go build -o ../bin/mir-signal ./cmd/mir-signal @@ -15,6 +15,9 @@ test: race: cd go && go test -race -count=1 ./... +reproduce: + ./scripts/verify-reproducible.sh + # install the CLIs onto your PATH (default ~/.local/bin; override with PREFIX=...) install: build mkdir -p "$(PREFIX)" diff --git a/README.md b/README.md index 5bbc5b0..54a4f0b 100644 --- a/README.md +++ b/README.md @@ -4,251 +4,232 @@ [![License: MIT](https://img.shields.io/badge/license-MIT-black.svg)](LICENSE) [![Platforms](https://img.shields.io/badge/macOS%20%C2%B7%20Linux-black)](#install) [![Go](https://img.shields.io/badge/Go-1.26-00ADD8?logo=go&logoColor=white)](go/go.mod) -[![Data plane](https://img.shields.io/badge/data%20plane-P2P%20%2B%20Noise%20KK-9cf)](#how-it-works) -**SSH-less, peer-to-peer, end-to-end-encrypted shells on every machine you own** — reach a -real terminal from your laptop or your phone's browser, authenticated by a passkey, brokered -by a relay you **never have to trust**. +**Leave your desk. Keep your terminal.** + +Miranda is passkey-native terminal continuity for long-running development and +AI sessions. Start work in `tmux` on one of your machines, walk away, and continue +the same live terminal from a laptop or phone. No inbound port forwarding, copied +SSH keys, or blanket network access. + +It is deliberately narrow: Miranda connects **you to persistent terminals on +machines you own**. It is not a general VPN, network overlay, remote desktop, or +multi-user access platform.

mir up serves a machine; from a laptop, mir list auto-discovers it by name and mir attach drops into its real shell — peer-to-peer, Noise-encrypted end-to-end, no SSH + alt="Miranda reconnecting to a persistent terminal session on another machine">

-

Serve a machine, then reach its real shell from anywhere — P2P + Noise, the relay never sees a keystroke.

- -Terminal traffic flows **directly peer-to-peer** over a WebRTC DataChannel, end-to-end -encrypted with the Noise `KK` handshake. A small relay only introduces the two ends and then -steps aside — it sees ciphertext and routing metadata, never your keystrokes or output. The -relay, in other words, has **the right to remain silent**. - -It's `tmux` for terminals that live on different machines: tmux owns the windows/panes and -persistence on each host; Miranda owns *which machine you're looking at* — and your machines -**find each other by wallet**, so a new one shows up everywhere the moment it comes online. - -## Features - -- **Peer-to-peer terminal** — direct WebRTC DataChannel, no server in the data path. -- **End-to-end encrypted** — Noise `KK` (X25519 / ChaCha20-Poly1305) runs *inside* - the channel, so even a malicious relay can't read or MITM your session. -- **Passkey authentication** — WebAuthn `prf` derives your identity. No SSH keys, no - passwords, no `authorized_keys`. Your passkey is the only thing you carry. -- **Persistent sessions** — `tmux` on each machine. Disconnect and reconnect resume - the exact same shell, scrollback intact. Long-running agents (Claude Code, Codex) - survive the browser sleeping or the network dropping. -- **Cross-machine multiplexer** — attach several machines at once and switch focus - with a hotkey. -- **Zero-config discovery** — your machines self-publish a wallet-signed, *encrypted* - record; every device of yours lists them by name automatically — no `add-machine`, no - pairing between your own machines. The relay holds only opaque blobs it can't read. -- **LAN-direct** — on the same network, `mir attach` finds the machine over mDNS and - connects straight over QUIC: no relay round-trip at all, automatic fallback if it's remote. -- **Browser or CLI** — the `mir` CLI in your terminal, or any browser including - iPhone Safari. -- **Self-hostable, blind relay** — run your own, or use the hosted one; either way it - never sees plaintext. -- **macOS + Linux**, single static Go binaries. MIT licensed. +## Is it “P2P tmux”? -## Install +Close, but the useful boundary is: -Prebuilt binaries for macOS and Linux are published on GitHub Releases. Install the -client with one line: +- `tmux` owns persistence, panes, windows, and processes **inside one machine**. +- Miranda owns passkey identity, machine pairing, discovery, reachability, and + encrypted continuity **between your devices and machines**. -```bash -# client (mir) -curl -fsSL https://raw.githubusercontent.com/srcfl/miranda/main/install.sh | sh +So the exact niche is a **private terminal-continuity mesh**. Miranda does not +replace tmux; it makes your tmux sessions securely follow you. -# agent on a machine you want to reach -curl -fsSL https://raw.githubusercontent.com/srcfl/miranda/main/install.sh | sh -s -- --agent -``` +| Tool | Its job | What Miranda adds | +|---|---|---| +| `tmux` | Keep a local terminal session alive | Reach and resume it from another device | +| SSH | Log in to a host | Passkey-first pairing, discovery, browser access, no exposed SSH service | +| VPN/overlay | Put devices on one private network | Expose only a terminal, not the rest of the network | +| Miranda | Continue your own live terminals | The focused product | -The installer verifies each download against the release `checksums.txt` before -installing to `~/.local/bin`. Pin a version with `MIR_VERSION=v0.6.0`, or change the -target with `INSTALL_DIR=/usr/local/bin`. Prefer building from source? See the -[Quickstart](#quickstart) below. +## The one-minute flow -## Quickstart +On the machine whose terminal should stay available: ```bash -# build the CLIs (Go 1.26+; tmux for persistence) -git clone https://github.com/srcfl/miranda && cd miranda -make install # -> ~/.local/bin: mir, mir-agent, mir-signal +mir pair # shows a QR/code and a six-group safety number +# scan with the Miranda web app, or run `mir pair ` on a client +# compare and confirm the safety number on both ends +mir up # keeps the machine's tmux session reachable +``` -# on a machine you want to reach (same `mir` binary — every node is symmetric): -mir pair # prints a pairing code + QR, then waits -mir up & # serve this machine (persistent tmux sessions) +Then, from another terminal: -# on your client (laptop, another machine): -mir pair # ...the code from above; compare the safety numbers -mir attach # you're in. a real shell, over P2P. +```bash +mir list +mir attach workstation ``` -Several machines at once — the cross-machine multiplexer: +Or open the Miranda web app on a passkey-capable phone or laptop. Machines paired +to the same owner appear by name from an end-to-end-encrypted registry. + +Attach several machines at once with `mir attach a b c`; press `Ctrl-O`, then +`1`–`9` or `n`, to switch focus. + +## Why it feels simpler + +- **One terminal-shaped capability.** Miranda does not grant subnet access or + expose unrelated services. +- **Passkey-first browser identity.** WebAuthn PRF derives the owner identity for + the current ceremony. The owner private key is not stored by the agent or relay. +- **One visual trust step.** Pair once, compare one safety number, then reconnect + without managing `authorized_keys`. +- **Persistent by default.** Network changes, browser sleep, or closing the lid do + not kill the work running inside tmux. +- **Direct when possible.** WebRTC connects peer-to-peer across networks; LAN + clients use direct QUIC. An optional TURN server can forward ciphertext where + direct NAT traversal fails. +- **Blind discovery.** The relay stores only owner-encrypted machine records while + agents are online. + +## Security in one screen + +Miranda assumes the rendezvous relay is untrusted. + +1. Pairing uses a random 128-bit token as a Noise `NNpsk0` PSK. Both sides show a + 96-bit safety number before trust is persisted. +2. The passkey-holding client signs the agent's registration commitment during + pairing. A third party cannot squat an unused `owner_id` + `machine_id` slot. +3. Every remote attach must carry a fresh owner signature bound to the relay-issued + session, machine ID, and exact SDP offer. The agent verifies it before allocating + ICE, TURN, or a peer connection. +4. Terminal bytes run through mutually authenticated Noise `KK` inside WebRTC or + QUIC. Transport encryption is not the identity boundary. +5. The target agent stores its host key, a registration secret, pinned owner + **public** IDs, and owner-encrypted registry blobs. It never stores the owner's + root or private key. + +The relay can observe routing metadata, candidate IP addresses, machine IDs, and +timing. It can deny service. It cannot decrypt terminal data or complete the pinned +Noise handshake as either endpoint. The browser application origin is a client-code +trust root, just like an installed binary. + +Read the exact guarantees, non-goals, and residual risks in +[SECURITY.md](SECURITY.md). + +## Install + +Miranda currently targets macOS and Linux. `tmux` is required on machines serving +persistent sessions. Release installation requires `cosign` and fails +closed unless the checksum manifest has a valid keyless signature from this +repository's tagged release workflow. Native owner clients store their root in +macOS Keychain or Linux Secret Service; on Linux, install the package that provides +`secret-tool` (`libsecret-tools` on Debian/Ubuntu). Target-only agents do not need +keychain access. ```bash -mir attach laptop macmini linux -# Ctrl-O then 1-9 / n / q to switch machines (change the key with --prefix) +curl -fsSL https://raw.githubusercontent.com/srcfl/miranda/main/install.sh | sh ``` -Everything defaults to the hosted relay + STUN, so no flags are needed. Point at your -own infrastructure with `--signal` / `MIR_SIGNAL` and `--stun` / `MIR_STUN`. +The binary is installed to `~/.local/bin` by default. Set `MIR_VERSION` to pin a +release or `INSTALL_DIR` to change the destination. + +After installation, run `mir doctor`. It checks state separation and permissions, +the keychain reference, tmux, signed revocations, and relay health without printing +private material. + +To build the current source instead: -**Your machines appear automatically.** Once they share your wallet (passkey-sync, or -`mir wallet import-phrase` on a new machine), `mir up` publishes an **encrypted** record to -the relay and your machines show up by name in `mir list` and the browser — no -`mir add-machine`, no pairing between your own devices. The relay only ever holds opaque -blobs it can't read; only your wallet decrypts them, and a forged record simply fails to -open. A new machine prints a one-line "new device joined" notice. It's online-discovery: -a powered-off machine reappears when it comes back; to retire one, turn it off (or, if a -device is compromised, rotate with `mir keygen --wallet`). +```bash +git clone https://github.com/srcfl/miranda +cd miranda +make install +``` -**LAN-direct (no relay on the same network).** When the client and the machine are on -the same LAN, `mir attach` finds it over mDNS and connects straight over QUIC — no relay -round-trip. It's automatic and falls back to the relay within ~0.6 s if there's no local -answer. Same trust as ever: Noise-KK + the wallet binding run inside, so the LAN only -supplies an address. Turn it off with `mir up --no-lan` (agent) or -`mir attach --relay-only` (client). +The same `mir` binary is used on clients and target machines. `mir-agent` remains +only as a deprecated compatibility shim. -## Updating +## Self-hosting and local development -`mir` checks GitHub for a newer release at most once a day and prints a one-line -notice — never blocking your command. Apply it when you choose: +The defaults point at the project's hosted web and signaling endpoints. They are a +convenience, not part of the cryptographic trust boundary or an availability SLA. +Override them with: ```bash -mir self-update +MIR_SIGNAL=https://relay.example mir up +MIR_SIGNAL=https://relay.example MIR_WEB=https://term.example mir pair +MIR_STUN=stun:stun.example:3478 mir attach workstation ``` -Disable the check with `MIR_NO_UPDATE_CHECK=1`. For unattended machines, opt into -automatic updates — applied only when no session is active, then the agent re-execs -in place so its PID (and any systemd wrapper) survives: +Build and run a local relay with: ```bash -mir up --auto-update # or MIR_AUTO_UPDATE=1 +make build +./bin/mir-signal --addr :8443 --webroot ./web ``` -> `mir-agent` is a deprecated alias for `mir` and forwards to it (with a notice). Use -> `mir` everywhere; the shim will be removed in a future release. - -## Don't trust the relay — that's the whole point +Deployment examples live under [`deploy/`](deploy/). -There's a relay, because two machines behind two NATs need an introduction. **You -don't have to trust it.** +## What Miranda intentionally does not do -Your terminal traffic flows **peer-to-peer**, end-to-end encrypted (Noise `KK`). The -relay only brokers the WebRTC offer/answer + ICE candidates that let the two ends -find each other, then gets out of the way. It learns only `owner_id`, `machine_id`, -the SDP/ICE blobs, and liveness metadata — never your keystrokes, never your output, -and it *cannot* impersonate you or your machines. At pairing time both ends show a -**safety number**; if they match, you've seen with your own eyes that no one is in -the middle. +- route IP packets, subnets, databases, or arbitrary TCP services; +- act as an SSH server or support the SSH wire protocol; +- transfer or synchronize files; +- provide organization roles, shared accounts, session recording, or approval + workflows; +- protect a compromised endpoint, browser origin, or passkey account; +- claim independent security validation yet. -The exact, falsifiable model — what the relay can and can't do, what you *do* have to -trust, and how to verify all of it — lives in **[`SECURITY.md`](SECURITY.md)**. That -document is not an afterthought. It is the project. +Those constraints are the product strategy. A small capability is easier to +understand, safer to grant, and easier to make feel magical. +The longer positioning decision is in [`docs/product.md`](docs/product.md). -## How it works +## Status -``` - your client relay (blind matchmaker) your machine - ┌───────────┐ WSS: who/where (SDP) ┌────────┐ WSS ┌──────────────┐ - │ mir │ ─────────────────────▶│ signal │◀────── │ mir-agent │ - │ + Noise │ ◀─────────────────────│no data │ ─────▶ │ + Noise + tmux│ - └─────┬──────┘ └────────┘ └──────┬───────┘ - └════════ WebRTC DataChannel (direct P2P) ═══════════════┘ - Noise KK runs INSIDE it — the relay only sees ciphertext +Miranda is a working **v0.7 security release candidate**. The CLI and browser can pair, +discover, attach, reconnect, multiplex machines, and resume real tmux sessions over +the P2P/LAN data plane. Machine revocation, native OS-keychain storage, bounded +relay rate limits, diagnostics, signed releases, and reproducible binary checks are +implemented. Go and browser crypto are gated by shared interop vectors. + +It is not independently audited or production-certified. The remaining gates are +external audit, measured hostile public-relay/NAT testing, and validation of the +actual hosted configuration against the runbooks. See [SECURITY.md](SECURITY.md) +for the honest boundary. + +### v0.7 migration + +This security reset is intentionally not a silent upgrade from the old +wallet-shaped model: + +- state is split into `~/.miranda/client` and `~/.miranda/agent`; +- agents no longer receive or retain an owner root; +- the neutral Miranda signer produces a new owner ID; +- agent registration now requires an owner authorization created during pairing; +- native owner roots migrate from `owner.json` into macOS Keychain/Linux Secret + Service, with no plaintext fallback; +- revoked machines are permanent owner-signed tombstones; +- `mir identity` replaces the old wallet commands. + +Keep a backup of old state, install the new version on both ends, and pair each +machine again. Do not copy an old combined state directory into either new path. + +## Architecture + +```text + passkey / mir client blind rendezvous relay target machine + ┌──────────────────┐ ┌──────────────────────┐ ┌─────────────────┐ + │ owner identity │ WSS │ SDP/ICE + metadata │ WSS │ host identity │ + │ registry key │─────▶│ no terminal plaintext│◀──────│ tmux + PTY │ + └────────┬─────────┘ └──────────────────────┘ └────────┬────────┘ + └════ WebRTC DataChannel or LAN QUIC, Noise KK E2E ══════┘ ``` -- **Identity** is a passkey-derived key (browser) or a local key (CLI). The relay - never holds your private key. -- **Pairing** uses a one-time token (the QR/code) as the PSK of a Noise `NNpsk0` - handshake; the relay only ever sees `hash(token)`. -- **Per machine:** `tmux` — persistence, windows, panes; the proven engine. -- **Across machines:** the `mir` client switches focus. tmux owns a machine's - windows; `mir` owns *which machine*. No tmux-in-tmux. +The cryptographic wire domains and Go module path retain their historical +`terminal-relay` strings for compatibility; the product and binaries are Miranda. -The components, in *The Tempest*'s cast: the agent on each machine is **Prospero** -(the magician who conjures the shell), and the hosted relay is **Ariel** (the -invisible spirit that carries messages and can't speak of what it carries). +## Repository -## Status - -- ✅ **Works today:** the `mir` CLI — pair a machine with one code/QR, attach to a - real shell over P2P, multiplex across all your machines, persistent `tmux` - sessions. A hosted relay is live at `relay.sourceful-labs.net`. -- 🚧 **Coming:** the browser client (passkeys + WebRTC + xterm.js → from your phone), - signed releases (cosign), a third-party audit, and (one day) a decentralized relay. - -> **Ops note:** agents auto-add a local `registration_secret` to `config.json` on the -> next `mir enroll`, `mir pair`, or `mir up`. Restart long-running -> agents after updating. Relays accept older no-secret agents until a proof has been -> learned for that `owner_id` + `machine_id`; after that, replacements must present -> the same proof. - -## The story (or: a tale about juggling) - -This was born out of frustration, which — as a certain Guide notes — is how most -useful things come to be, the rest being born of either boredom or a profound -misunderstanding of the laws of thermodynamics. - -The specific frustration: on any given day there is **a small herd of Claude Code -sessions** scattered across a laptop, an office Mac mini, and a Linux box that exists -mainly to be warm. One of them is doing the interesting thing. The others are also -doing the interesting thing, somewhere, probably, and getting to the right one -involved an amount of `ssh`-ing, tunnel-poking, and quiet swearing that is not, -strictly speaking, *magic*. - -A few stubborn convictions fell out of that: - -- **I love my terminal and I am not leaving.** The terminal isn't a fallback for when - the GUI breaks. It's the best window we've got. I want to stay in it. -- **The tool must not belong to one robot.** Claude Code, Codex, whatever comes next — - they're all just things that run *in a terminal*. So don't build for the robot. - Build for the window. The terminal has been the universal interface since before - graphical anything, and will be long after. -- **Don't reinvent the engine.** `tmux` is good, has been good since approximately the - dawn of time, and will be good when we are all dust. We keep the proven engine and - build *around* it. -- **It should feel like magic** — open a thing on any device, and there are your - machines, alive, exactly as you left them. The reaction we're after is the one - where you tilt your head and go *"…wait, why has nobody done this already?"* -- **Modern, not antique.** No password files, no copied keys. **Passkeys.** Real - end-to-end crypto. The newest safe web tech, used properly. -- **As serverless as physics allows.** There's a relay, because two NATs need an - introduction. But it's a *blind matchmaker* — it never sees your traffic, and you - never have to trust it. One day the relay itself could be decentralized. - -The name is **Miranda** — for the Miranda warning ("you have the right to remain -silent"), which is exactly what a blind relay does, and for Shakespeare's Miranda on -the magician's island, who looks out at a connected world and says *"O brave new -world, that has such people in't!"* (See [`docs/naming.md`](docs/naming.md) for the -full naming rationale.) - -So: **SSH, without thinking about SSH.** A terminal that exists on every device. -Peer-to-peer, end-to-end encrypted, passkey-shaped, tmux-powered. - -## Repo layout - -| Path | What | +| Path | Purpose | |---|---| -| `go/internal/noise` | Noise `KK` (Go + JS interop vectors) | -| `go/internal/pairing` | NNpsk0 one-tap pairing + safety number | -| `go/internal/signal` | the blind signaling/matchmaking server | -| `go/internal/peer` | WebRTC P2P DataChannel glue | -| `go/internal/agent`, `go/cmd/mir-agent` | the machine-side agent | -| `go/internal/client`, `go/cmd/mir` | the `mir` CLI multiplexer | -| `go/cmd/mir-signal` | the signaling/relay server | -| `web/` | browser client (vanilla JS + xterm.js) | -| `deploy/lightsail` | how the hosted relay is deployed | -| `deploy/netsim` | Docker harness that reproduces real NAT traversal | -| `docs/superpowers/` | design spec + implementation plans | - -## Build & test +| [`go/internal/noise`](go/internal/noise) | Noise `KK` transport and Go interop | +| [`go/internal/pairing`](go/internal/pairing) | Noise `NNpsk0` pairing and provisioning | +| [`go/internal/identity`](go/internal/identity) | domain-separated owner identity and proofs | +| [`go/internal/signal`](go/internal/signal) | blind rendezvous and encrypted registry | +| [`go/internal/agent`](go/internal/agent) | attach authorization, PTY, tmux, runtime | +| [`web`](web) | passkey browser client and xterm.js UI | +| [`testdata`](testdata) | stable cross-language cryptographic vectors | ```bash -cd go && go test ./... # all green; -race clean -cd deploy/netsim && ./run.sh # NAT traversal, locally (TURN=1 for the relay path) +cd go && go test ./... +cd ../web && npm test ``` -## License - -MIT — see [`LICENSE`](LICENSE). +MIT — see [LICENSE](LICENSE). Security reports: security@sourceful-labs.net. diff --git a/SECURITY.md b/SECURITY.md index 616315a..679cf27 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,195 +1,287 @@ -# Security model - -Miranda lets you reach a real shell on your machines from anywhere. The -whole point is that **you do not have to trust the relay** (our hosted signaling -server, the Cloudflare proxy in front of it, an optional TURN relay, or the -network in between). This document states precisely what that means, what you -_do_ have to trust, and how to verify the claims. - -We do not say "100% secure" — no system is. We make a **precise, falsifiable -guarantee** and are honest about the residual exposure. The honesty is the point: -if you can read this and audit the code, you can decide for yourself. - -## The guarantee - -> The relay **cannot read or modify your terminal traffic**, and **cannot -> impersonate you or your machines**. Terminal data flows peer-to-peer and is -> end-to-end encrypted; the relay only ever sees ciphertext and routing metadata. - -This holds even if the relay operator is malicious, the relay is compromised, or -the network is hostile — provided the trust roots below are intact. - -## How it works (the basis for the guarantee) - -- **Peer-to-peer data plane.** Terminal bytes travel over a direct WebRTC - DataChannel between your client and the agent (hole-punched via STUN). They do - **not** pass through the relay. The relay only brokers the WebRTC handshake - (SDP/ICE) and then steps out. (See `go/internal/peer`, `go/internal/signal`.) -- **End-to-end encryption: Noise `KK`.** Inside the DataChannel we run - `Noise_KK_25519_ChaChaPoly_SHA256` — mutual authentication with **pinned static - keys** plus forward secrecy. Because both ends already hold each other's static - public key (pinned at pairing), a relay that tampers with the WebRTC DTLS - fingerprints (a classic proxy MITM) **cannot** complete the handshake. DTLS is - just transport; Noise is what authenticates the peers. (See `go/internal/noise`, - certified byte-for-byte against the reference implementation in `testdata/`.) -- **Identity.** Your **owner key** is derived from your passkey (WebAuthn `prf`) - in the browser, or a local key file for the CLI. The relay never sees the - private key, so it can never authenticate as you to an agent. Each agent has a - **host key** pinned by you at pairing (trust-on-first-use). -- **Pairing without trusting the relay.** Adding a machine uses a one-time, - 128-bit **token** shown out-of-band (a QR/code you scan or paste). The token is - the pre-shared key of a `Noise_NNpsk0` handshake; the relay only ever sees - `roomID = H(token)` (domain-separated) and opaque ciphertext — never the token, - never the exchanged keys. So the relay cannot MITM pairing either. The token is - the **trust anchor**: it is the one secret that bootstraps everything, and it - travels out of band, not through the relay. (See `go/internal/pairing`.) -- **Agent registration proof.** Each agent persists a random - `registration_secret` in its local `config.json` and sends it only on - `/agent/signal` as `X-TR-Agent-Registration-Secret`. The relay learns that - proof for the `owner_id` + `machine_id` slot and rejects later replacement - attempts that do not present the same value. This protects live registrations - from clients that only know routing metadata while keeping the relay blind to - terminal bytes, host private keys, owner secrets, and pairing tokens. Existing - configs are auto-migrated on the next agent load; older no-secret agents keep - legacy behavior until a relay has learned a proof for that slot. - -## What you have to trust (and it is never the relay) - -1. **The pairing token, at the one moment it is shown.** Treat it as a bearer - secret: scan the QR in person or paste it over a channel you trust. It is - single-use and short-lived. If an attacker obtains a live token, they could - pair in your place — so do not post it publicly. -2. **Your passkey / iCloud Keychain** (browser) or your **owner key file** (CLI). - Whoever holds these is you. -3. **The target machine.** The agent runs a real shell as you; a compromised host - is game over (that is true of SSH too). **Run the agent as a dedicated, - low-privilege user — never as root.** The shell it spawns inherits the agent's - privileges, so anyone who reaches that shell gets exactly that user's rights; - running as root turns a single agent compromise (or a leaked pairing token) - into full control of the box. Give it its own unprivileged account, no - passwordless sudo, and only the access that account genuinely needs. -4. **The code you run.** This is why open source + verifiable builds matter (see - roadmap). Do **not** install binaries fetched blindly from the relay. -5. **The browser JavaScript served by `term.sourceful-labs.net`.** When the SPA is - served from `mir-signal`, that JavaScript is a trust root just like an installed - binary. A compromised Cloudflare zone, origin host, deploy key, or build output - could ship client code that reads terminal data before encryption or derives - the owner key after a valid WebAuthn ceremony. - -## Residual exposure (honest, by design) - -- **Metadata.** The relay sees: your `owner_id` (a pseudonymous public key, not - your name), your `machine_id`s, the SDP/ICE blobs (**which include your - machines' candidate IP addresses** — inherent to establishing a direct path), - and connection timing. It does **not** see terminal content, display names, or - what you run. If hiding IPs from the relay matters to you, run over a VPN/overlay. -- **Availability.** The relay is the rendezvous point; a malicious or down relay - can deny new connections. It cannot read or alter existing P2P sessions. - Registration proofs prevent unauthenticated third-party clients from replacing - an already protected live agent registration, but the relay can still deny - service by policy or outage. -- **TURN fallback (opt-in).** For symmetric NATs that cannot hole-punch, an - operator may enable a TURN relay. Even then the relay forwards only ciphertext — - Noise keeps it blind — but it does carry (encrypted) bytes and learns more - timing/volume. It is **off by default**. -- **LAN-direct (mDNS + QUIC).** `mir up` advertises itself on the local network - (mDNS `_miranda._udp`, instance = your opaque `machine_id`) and listens for direct - QUIC connections, so a `mir attach` on the same LAN reaches it **without the relay**. - This changes nothing about trust: the QUIC layer uses a throwaway self-signed cert - (the client skips TLS verification), and the **real** authentication is the unchanged - Noise-KK handshake + the wallet binding that runs *inside* the QUIC stream — exactly - as over the relay. A rogue LAN host that spoofs the mDNS record or connects to the - listener can at worst cause a **failed handshake (DoS)**: it cannot impersonate your - agent (Noise-KK pins `host_pub`), cannot attach as you (the agent rejects any - unpinned wallet *before* Noise, and a binding requires your wallet key), and cannot - read traffic (Noise). The new exposure is (a) the agent now accepts inbound LAN - connections — bounded by the same pre-auth handshake limiter as relay attaches — and - (b) the mDNS advertisement reveals that a Miranda node with a given `machine_id` - exists on the LAN. Disable both with `mir up --no-lan`; skip LAN discovery on a - client with `mir attach --relay-only`. -- **Device registry (auto-discovery).** `mir up` publishes a device record so your other - devices find this machine by name with no manual pairing. The record (`name`, `host_pub`, - `signal_url`) is **encrypted** with a key derived from your wallet (ChaCha20-Poly1305, - HKDF of the wallet secret) before it leaves the machine, and the relay holds it **only - in-memory**, tied to the live registration — **no persistence, no database**. The relay - sees an opaque blob and which `machine_id`s are live under a wallet (the same linkability - it already has at attach); it **cannot read the record, and cannot forge one** — a record - sealed by anyone without your wallet fails to decrypt and is dropped by your devices, so - the AEAD is the authenticity check (no relay verification needed). The blob is bound to - its `machine_id` (AEAD associated data), so the relay can't even shuffle records between - slots. Discovery is online-only; "revocation" is powering a device off (it stops - registering) or, for a leaked phrase, rotating the wallet. -- **Compromised endpoints / Keychain.** Out of scope — the same trust you already - place in your own devices. - -## Live deployment operations - -The live browser deployment is `https://term.sourceful-labs.net`; signaling may -also be exposed on `https://relay.sourceful-labs.net` for CLIs and agents. Apply -these controls to every Cloudflare hostname that routes to `mir-signal`. - -- **Rate-limit public rendezvous endpoints.** `/turn-credentials`, `/pair`, - `/attach`, and `/agent/signal` are intentionally public and unauthenticated at - the HTTP layer. Protect them in Cloudflare WAF rate limiting rules before broad - use. Treat `/pair`, `/attach`, and `/agent/signal` as WebSocket endpoints: the - edge can rate-limit the initial HTTP upgrade request, but it will not inspect - frames after the socket is established. -- **Monitor TURN as a paid abuse surface.** `/turn-credentials` issues coturn REST - credentials when `MIR_TURN_SECRET` and `--turn-url` are configured. The current - credential TTL is 12 hours, so an exposed credential can consume relay bandwidth - until expiry. Watch both Cloudflare request volume and coturn allocation logs; - rotate the shared secret and close TURN firewall ports if abuse appears. -- **Serve the SPA with defensive headers.** If `mir-signal --webroot` serves the - browser app, set CSP and security headers at Cloudflare or in the origin before - relying on the browser flow for real machines. The CSP must still allow module - scripts/assets from the same origin and `connect-src` to the live signaling - hostnames. -- **Keep the WebAuthn RP ID boundary small and intentional.** The browser code - uses `term.sourceful-labs.net` — the exact app host — as the RP ID, so the - owner passkey is bound to that single origin. Sibling `*.sourceful-labs.net` - subdomains (including `relay`) are therefore **outside** the owner-key trust - boundary: a passkey scoped to `term.sourceful-labs.net` cannot be exercised by - another subdomain, and the registrable parent domain is **not** the RP ID. - Keep it that way: do not widen the RP ID to the parent domain to "share" - passkeys across subdomains without a deliberate re-enrollment plan, since that - would pull every such subdomain into the trust boundary. -- **Require pairing safety-number confirmation.** A scanned/pasted pairing code is - not enough for high-assurance pairing. The operator and browser/CLI user must - compare the printed `safety number: xxxx-xxxx-xxxx-xxxx` out of band and abort - on any mismatch. - -## How to verify (don't take our word for it) - -- **Read the code.** The crypto is small, standard, and isolated: - `go/internal/noise` (Noise KK), `go/internal/pairing` (NNpsk0), the `prf`→owner - derivation. Cross-language interop is pinned by `testdata/` vectors. -- **Run the tests.** `cd go && go test ./...` (and `-race`). The relay's - blindness is structural: `go/internal/signal` only ever marshals SDP/`roomID`, - never terminal bytes. -- **Watch the wire.** The data plane is a direct DataChannel; the relay never - receives it. `deploy/netsim` reproduces real NAT scenarios locally. -- **Compare the safety number at pairing.** Both ends of `mir pair` each print a - `safety number: xxxx-xxxx-xxxx-xxxx`. If both ends show the same value, - you have visibly confirmed there is no MITM — even if the pairing token leaked. - -## Roadmap to full, independent trust - -These are the steps that let _anyone_ — not just us — trust the relay-free model: - -- [ ] **Open source** the client, agent, relay, and crypto (so it is auditable). -- [ ] **Signed, checksummed releases** (and an installer that verifies them — never - trust binaries from the relay). -- [ ] **Reproducible builds** (the binary you run matches the audited source). -- [x] **Verifiable pairing authenticity (safety number).** Both ends of - `mir pair` print a 64-bit **safety number** derived from the Noise - handshake transcript hash. With no MITM both ends show the same number; - a man-in-the-middle (e.g. with a leaked token) produces two different - handshakes → mismatched numbers, which you catch by eye. (Session-time SAS - on `attach` is a planned extension.) -- [ ] **Independent third-party security audit** of the crypto and protocol. -- [ ] **Metadata minimization** (e.g. rotating/blinded `owner_id`s) where feasible. +# Miranda security model + +Miranda exposes a real terminal. Its security boundary must therefore be smaller +and clearer than its marketing. + +The core guarantee is: + +> With uncompromised endpoints and correctly confirmed pairing, the rendezvous +> relay cannot read or forge authenticated terminal plaintext, even when it carries +> signaling or optional TURN ciphertext. + +The relay can observe metadata and deny service. The target machine, client code, +owner identity, and initial pairing confirmation remain trusted components. + +Miranda is an alpha and has not had an independent security audit. + +## Security invariants + +- The relay never receives terminal plaintext. +- The agent never receives or persists an owner root/private key. +- Browser owner identity is derived from WebAuthn PRF output for the exact app RP + ID. Derived key material lives in page memory for the active ceremony/session. +- Go and JavaScript cryptography must remain byte-identical. Stable vectors under + [`testdata/`](testdata/) gate identity, binding, registry, Noise, and pairing + behavior. +- A target shell runs with the agent process's OS privileges. `mir up` refuses root + by default. + +## Components and trust + +### Owner client + +The owner client is the authority that can pair machines and authorize attaches. + +- In the browser, a passkey's WebAuthn PRF output is the root. The RP ID is the + exact hostname serving Miranda, not its parent domain. +- In the native CLI, the recovery root is stored in macOS Keychain or the Linux + Secret Service. `~/.miranda/client/owner.json` (mode `0600`) contains only an + opaque keychain reference and public metadata. Existing plaintext-root files + migrate atomically on first load; there is no plaintext fallback. +- Domain-separated derivations produce the Ed25519 owner signer, X25519 Noise + static key, and registry AEAD key. + +Compromise of the passkey account, unlocked OS keychain/user account, browser +process, or client binary is compromise of the owner identity. + +### Target agent + +The target stores only machine-scoped authority in +`~/.miranda/agent/config.json`: + +- its X25519 host private key; +- a random machine ID; +- a random relay-registration secret; +- pinned owner public IDs; +- owner signatures authorizing registration; +- opaque owner-encrypted discovery records. + +It cannot decrypt or forge those discovery records and cannot authorize another +machine. Client and agent state directories are separate; `mir up` refuses a state +directory containing `owner.json`. + +A compromised target still exposes everything available to the Unix account running +the agent. Use a dedicated unprivileged account, no passwordless sudo, and minimal +filesystem credentials. The `--allow-root` override is for isolated development, +not normal deployment. + +### Rendezvous relay + +The relay matches owner/machine IDs, forwards one-shot SDP messages, bridges pairing +rooms, serves temporary TURN credentials when configured, retains encrypted +registry blobs only with live agent registrations, and persists owner-signed +machine revocation tombstones. + +It is not trusted for confidentiality, integrity, identity, or availability. It can: + +- see `owner_id`, `machine_id`, source addresses, connection times, SDP/ICE data, + candidate IP addresses, and traffic size/timing; +- correlate machines belonging to the same stable owner ID; +- drop, delay, replay, or alter control-plane messages; +- claim a machine is offline, withhold registry records, or deny all new sessions; +- observe an agent registration secret and its public owner authorization. +- suppress a real revocation record or lie about revocation availability. + +It cannot use those capabilities to complete the pinned Noise handshake as the +owner or host. Tampering becomes a failed connection, not authenticated plaintext. +A malicious relay can always cause denial of service. + +### Browser application origin + +The JavaScript served by the Miranda web origin is a trust root equivalent to an +installed client binary. Compromise of the origin, Cloudflare account, deploy key, +service worker, or build output can read data before encryption or after decryption. + +Self-hosting the relay does not remove this trust unless the browser app is also +self-hosted from an origin you control. Custom deployments use their exact hostname +as the WebAuthn RP ID and require a separate passkey enrollment. + +## Protocol flows + +### 1. Pairing and provisioning + +1. The agent generates a 128-bit random token and displays it only in the QR/code. +2. The relay sees a domain-separated hash of that token as the room ID, not the + token itself. +3. Both endpoints run `Noise_NNpsk0_25519_ChaChaPoly_SHA256`, with a PSK derived + from the token. +4. The client claims an owner public ID, then signs the completed Noise transcript. + The agent pins the owner only after this verifies. +5. Both endpoints display a 96-bit, six-group safety number derived from the + transcript. Neither CLI endpoint persists trust until the human confirms it. +6. Inside the authenticated pairing channel, the owner sends: + - an encrypted discovery record; and + - a signature over the machine ID plus the SHA-256 commitment of the agent's + registration secret. + +The token is a temporary bearer secret. Scan it in person or send it over a trusted +channel, compare every safety-number group, and cancel on any mismatch. Possession +of a live token enables an active pairing attempt; the safety-number comparison is +what detects a substituted endpoint. + +### 2. Agent registration + +For every paired owner, the agent registers its `owner_id` + `machine_id` slot with: + +- the 256-bit random local registration secret; and +- the owner's Ed25519 signature over that machine ID and the secret commitment. + +The relay validates the owner signature before accepting the WebSocket. It then +uses a constant-time comparison against the first learned secret for replacement +defense. This closes first-registration squatting for real Miranda owner IDs while +keeping the owner key out of the agent. + +The relay learns the registration credential over TLS. This credential controls +only relay availability for one slot; it is not a terminal or Noise credential. +Relay restarts lose the learned-secret cache, but the owner signature remains +independently verifiable. + +### 3. Attach authorization + +Before an agent allocates WebRTC, ICE, TURN, or Pion resources: + +1. the relay creates a fresh 128-bit session ID and sends it to the client; +2. the client signs a domain-separated challenge containing that session ID, + machine ID, and SHA-256 hash of the exact SDP offer; +3. the offer also carries the owner's signed binding to its X25519 Noise key; +4. the agent verifies that the owner is pinned, verifies both signatures, and + rejects session replays. + +Idle attach sockets have a short first-offer deadline. The relay and agent bound +message size, pair rooms, per-agent browser sessions, proof storage, and concurrent +pre-auth attaches. These are resource controls, not a substitute for edge rate +limits. + +### 4. Terminal data plane + +After authorization, the peers establish WebRTC or LAN QUIC and run +`Noise_KK_25519_ChaChaPoly_SHA256` inside it. Both static keys were authenticated +through pairing/binding, so transport DTLS/TLS certificates are not the identity +boundary. + +Noise provides mutual authentication, forward secrecy for session traffic, and +authenticated encryption. A relay or network attacker may corrupt or suppress +ciphertext, causing disconnect, but cannot turn modified bytes into accepted +terminal plaintext. + +### 5. Encrypted discovery + +The passkey/owner client creates each machine record during pairing and seals it +with ChaCha20-Poly1305 under a domain-separated registry key. `machine_id` is AEAD +associated data. The target stores and republishes the opaque base64 record but +cannot open or alter it. + +The relay holds records only in memory while the corresponding agent is live. +Clients discard records that fail AEAD authentication. The relay still sees the +stable owner/machine routing relationship. + +### 6. Machine revocation + +`mir machine revoke --yes` and the browser revoke action create a permanent, +domain-separated Ed25519 tombstone over `owner_id`, `machine_id`, and timestamp. +The client stores it locally before publication. Honest relays verify the owner +signature, persist the record atomically, close the target's signaling registration +and pending attaches, and reject future registration/attach attempts for that slot. +An already-established P2P/LAN Noise session no longer traverses the relay and +cannot be remotely killed by it; the client performing revocation closes its own +session, while other already-connected clients must disconnect or sync the record. + +Every client independently verifies fetched tombstones before filtering discovery +and attach. A relay cannot forge a revocation, but it can suppress one. Previously +cached local records remain enforced while offline or during relay failure. A fully +compromised owner root can authorize attaches and cannot be repaired by per-machine +revocation; rotate the entire identity and re-pair instead. + +## Network paths + +- **WebRTC direct:** preferred across networks; STUN exposes candidate addresses as + required for NAT traversal. +- **TURN:** available only when the deployment configures it. TURN forwards Noise + ciphertext and learns traffic metadata/volume. +- **LAN direct:** mDNS advertises `_miranda._udp` and the opaque machine ID; QUIC + connects directly. The self-signed QUIC certificate is transport-only. Noise is + still mandatory. Disable both advertisement and listener with `mir up --no-lan`; + clients can use `mir attach --relay-only`. + +Miranda does not hide IP addresses or provide traffic anonymity. Use a separate +privacy network if that is a requirement. + +## Recovery and rotation + +- `mir identity export-recovery --yes` intentionally prints a 24-word recovery + phrase. Anyone who sees it controls the owner identity. +- Import never accepts a phrase in process arguments. Interactive input is hidden; + controlled automation must opt into `--stdin`. +- `mir identity rotate --yes` creates a new owner identity and requires pairing + every target again. +- `mir machine revoke --yes` permanently blocks a lost/compromised target + for the current owner identity. The browser exposes the same action from the + terminal toolbar. + +## Supply chain + +- CI actions are pinned to commit SHAs. +- Releases sign `checksums.txt` through GitHub Actions OIDC and cosign keyless + signing. +- The installer and self-updater require cosign verification and fail closed if + cosign or signature assets are missing. +- The browser vendors cryptographic dependencies locally and does not load them + from a runtime CDN. +- Release binaries use `-trimpath`, omit VCS/build IDs, use the commit timestamp, + and pass a two-clean-cache byte comparison in CI. Independent verification is + documented in [`docs/release.md`](docs/release.md). Reproducibility and release + provenance do not replace a source/security audit. + +## Operational requirements + +Before exposing a public deployment: + +- keep the bounded in-process per-IP limits enabled and add edge/global limits to + `/pair`, `/attach`, `/agent/signal`, `/registry`, `/revocations`, and + `/turn-credentials`; +- monitor agent replacement/flap events, capacity events, TURN allocations, and + unusual owner/machine cardinality; +- keep TURN credential TTL and bandwidth limits conservative; +- serve the SPA with the emitted CSP nonce and defensive security headers; +- protect the web origin and release workflow as production trust roots; +- require Full (strict) TLS to the origin, restrict the origin firewall to trusted + proxy ranges, and back up the signed revocation file; +- run the agent under a dedicated non-root service account; +- test direct, symmetric-NAT, cellular, reconnect, and relay-outage paths. + +## Known gaps before production + +- independent third-party protocol and implementation audit; +- privacy improvements for stable owner/machine metadata; +- measured, abuse-tested limits on the actual public relay and TURN service; +- sustained fuzz campaigns beyond the CI smoke targets, plus hostile-network and + real NAT/cellular interoperability testing; +- recovery testing across the supported macOS Keychain/Linux Secret Service + environments and passkey browser matrix. + +## Verification + +Run both language suites after any cryptographic or protocol change: + +```bash +cd go && go test ./... +cd ../web && npm test +cd .. && ./scripts/verify-reproducible.sh +``` + +If a legitimate handshake/derivation change updates a stable vector: + +```bash +cd go +UPDATE_VECTORS=1 go test ./internal/noise/ -run TestInteropVectorsStable +go test ./... +cd ../web && npm test +``` + +The relay's lack of terminal plaintext is also structural: terminal frames exist in +the peer/Noise data plane, not in `go/internal/signal`. ## Reporting -Found a problem? Please report privately to security@sourceful-labs.net before -public disclosure. +Report vulnerabilities privately to **security@sourceful-labs.net** before public +disclosure. Include affected version/commit, reproduction steps, and expected impact. diff --git a/deploy/lightsail/README.md b/deploy/lightsail/README.md index f5cb399..b987d5f 100644 --- a/deploy/lightsail/README.md +++ b/deploy/lightsail/README.md @@ -1,6 +1,7 @@ # Deploying `mir-signal` (AWS Lightsail + Cloudflare) -The signaling server is a tiny stateless Go binary. It runs on a small Lightsail +The signaling server is a tiny Go binary with only one durable data set: verified, +owner-signed machine revocation tombstones. It runs on a small Lightsail instance behind Cloudflare, which provides TLS for the browser SPA at `https://term.sourceful-labs.net` and signaling at `wss://relay.sourceful-labs.net` (or any other proxied hostname routed to this @@ -18,24 +19,27 @@ below). After a successful `redeploy.sh` the box matches this table. | ------------------ | -------------------------------------------------------------------------------------------------------------------------- | | Lightsail instance | `tr-signal` (legacy AWS name; renamed only on recreate), region `eu-north-1` (Stockholm), `ubuntu_24_04`, `nano_3_0` (~$5/mo) | | Static IP | `16.171.89.172` (Lightsail `tr-signal-ip`) | -| Service | systemd `mir-signal.service`, listens on `:80`, user `mirsignal` + `CAP_NET_BIND_SERVICE`, serves `/opt/mir-web` when present | -| Open ports | 22 (SSH), 80 (HTTP — Cloudflare origin); TURN ports only when enabled | -| Health | `curl http://16.171.89.172/healthz` → 200 | +| Service | systemd `mir-signal.service`, HTTPS `:443`, local health `:80`, user `mirsignal`, durable revocations in `/var/lib/mir-signal` | +| Open ports | restricted SSH, HTTPS 443 from Cloudflare ranges; TURN ports only when enabled | +| Health | `curl https://relay.sourceful-labs.net/healthz` → 200 and local `curl http://localhost/healthz` → 200 | Architecture: browser `https://term.sourceful-labs.net` and client/agent `wss://relay.sourceful-labs.net` -> **Cloudflare** (TLS termination, WebSocket -proxy) -> origin `http://16.171.89.172:80` -> `mir-signal`. +proxy) -> origin TLS `https://16.171.89.172:443` -> `mir-signal`. Port 80 is not +publicly exposed; it exists for localhost health checks during deploy/rollback. ## Cloudflare setup (manual — do this in the `sourceful-labs.net` dashboard) 1. **DNS** → add a record: - Type `A`, Name `term`, IPv4 `16.171.89.172`, **Proxied** (orange cloud). - Type `A`, Name `relay`, IPv4 `16.171.89.172`, **Proxied** (orange cloud). -2. **SSL/TLS → Overview** → encryption mode **Flexible** (Cloudflare ⇄ origin over - HTTP:80; the origin has no cert). Cloudflare presents a valid public cert to - clients, so `https://term.sourceful-labs.net` and - `wss://relay.sourceful-labs.net` just work. +2. Create a Cloudflare Origin CA certificate for the hostnames, install it as + `/etc/ssl/mir-signal/{cert,key}.pem`, owned/readable by `mirsignal`, then set + **SSL/TLS → Overview → Full (strict)**. The production unit uses + `--require-tls` and refuses to start without both files. 3. **Network** → ensure **WebSockets** is **On** (default on). +4. Restrict the origin firewall so 443 accepts only Cloudflare's published IPv4 + and IPv6 ranges. Restrict SSH to the deployment source. Do not expose port 80. Then verify: @@ -44,10 +48,9 @@ curl https://term.sourceful-labs.net/healthz curl https://relay.sourceful-labs.net/healthz ``` -> Hardening (optional, later): switch to **Full (strict)** by generating a -> Cloudflare **Origin CA** cert in the dashboard and terminating TLS on the origin -> (e.g. Caddy in front of `mir-signal`). Flexible is fine to start because the data -> plane is already E2E (Noise); the origin only sees signaling SDP. +Never use Flexible mode for production. Noise protects terminal bytes, but +signaling metadata, registration credentials, and the served browser trust root +still require authenticated encryption on the Cloudflare-to-origin leg. ## Live security hardening checklist @@ -81,6 +84,8 @@ Starting rules: | Pair bridge | `and http.request.uri.path eq "/pair"` | 20 upgrades / minute / IP | Block for 10 minutes | | Browser attach | `and http.request.uri.path eq "/attach"` | 120 upgrades / minute / IP | Block for 10 minutes | | Agent signal | `and http.request.uri.path eq "/agent/signal"` | 60 upgrades / minute / IP | Block for 10 minutes | +| Registry | `and http.request.uri.path eq "/registry"` | 120 requests / minute / IP | Block for 10 minutes | +| Revocations | `and http.request.uri.path eq "/revocations"` | 30 requests / minute / IP | Block for 10 minutes | Rollout steps: @@ -94,6 +99,31 @@ Rollout steps: 4. Review the first 24 hours of events, then either lower noisy limits or document the observed baseline here. +The binary enforces the same starting limits with a bounded in-process table as a +second layer. `CF-Connecting-IP` is accepted only when the immediate source belongs +to the explicit `--trusted-proxy-cidr` allow-list. Keep the unit's ranges synchronized +with Cloudflare's definitive IP-range list; never enable proxy-header trust without +that allow-list: . + +### Durable revocations + +The production unit stores verified tombstones at +`/var/lib/mir-signal/revocations.json`, created through systemd's +`StateDirectory=mir-signal`. Startup verifies every owner signature and fails on +corruption rather than silently re-enabling a machine. + +Checks and backup: + +```bash +sudo -u mirsignal test -r /var/lib/mir-signal/revocations.json +sudo journalctl -u mir-signal | grep -E 'machine_revoked|revocation_.*error' +sudo install -m 0600 -o mirsignal -g mirsignal \ + /var/lib/mir-signal/revocations.json /var/backups/mir-signal-revocations.json +``` + +Restore the file only while the service is stopped. Never edit records by hand; +invalid signatures intentionally make the next startup fail. + ### TURN TTL and abuse monitoring `/turn-credentials` returns coturn REST credentials only when both @@ -147,7 +177,9 @@ Use the **repeatable `--csp-connect-src` flag** in the systemd unit, one origin per occurrence (this is what `deploy/lightsail/mir-signal.service` ships): ```ini -ExecStart=/usr/local/bin/mir-signal --addr :80 --webroot /opt/mir-web \ +ExecStart=/usr/local/bin/mir-signal --addr :80 --require-tls \ + --tls-addr :443 --tls-cert /etc/ssl/mir-signal/cert.pem \ + --tls-key /etc/ssl/mir-signal/key.pem --webroot /opt/mir-web \ --csp-connect-src "'self'" \ --csp-connect-src https://relay.sourceful-labs.net \ --csp-connect-src wss://relay.sourceful-labs.net \ @@ -299,15 +331,11 @@ assumes that build is deployed. headers" above and the shipped `mir-signal.service`). The binary joins those tokens verbatim, so keep `'self'`'s quotes — and wrap it as `"'self'"` so systemd's *own* ExecStart quoting delivers the literal `'self'` token. -- **A missing TLS cert used to crash-loop the relay.** The unit passes - `--tls-addr :443 --tls-cert … --tls-key …` for an eventual Cloudflare Full - (strict) cutover, but the box currently runs Cloudflare **Flexible** with no - origin cert. The binary now `os.Stat`-gates the TLS branch: if the cert/key - files are absent it logs a warning and serves HTTP-only instead of - `log.Fatal`-ing the process. So the `--tls-*` flags are safe to leave in; to - actually enable HTTPS later, just drop the PEMs into `/etc/ssl/mir-signal/` and - restart. (Before the gate, a stray `--tls-*` on a no-cert box meant - `Restart=always` + instant fatal = a tight crash loop.) +- **TLS is now fail-closed.** The production unit includes `--require-tls`; a + missing/unreadable origin certificate makes startup fail and the health-gated + deploy rolls back. Install the certificate and select Full (strict) before the + first deploy of this unit. Plain HTTP remains available only for localhost + health checks and must stay closed in the public firewall. - **`TR_TURN_SECRET` → `MIR_TURN_SECRET` rename.** The TURN shared secret env var was renamed with the `tr-` → `mir-` cutover. `redeploy.sh` migrates an existing `/etc/tr-signal.env` automatically (see above); `deploy/turn/setup-coturn.sh` diff --git a/deploy/lightsail/mir-signal.service b/deploy/lightsail/mir-signal.service index 66c50c2..2d8876f 100644 --- a/deploy/lightsail/mir-signal.service +++ b/deploy/lightsail/mir-signal.service @@ -22,12 +22,24 @@ EnvironmentFile=-/etc/mir-signal.env # as CSP requires) the 'self' token is wrapped in DOUBLE quotes -> "'self'". # The URL tokens have no special characters and are passed bare. # -# TLS: the --tls-* flags below are os.Stat-gated by the binary — it only enters -# the HTTPS branch when BOTH cert and key files exist. On a Cloudflare-Flexible -# (HTTP-only, no cert) box it logs a warning and serves HTTP-only instead of -# crash-looping, so it is safe to leave these flags in for an eventual Full -# (strict) cutover (just drop the PEM files into /etc/ssl/mir-signal/). +# TLS is mandatory in this production unit. Install the origin certificate and +# select Cloudflare Full (strict) before enabling the service. ExecStart=/usr/local/bin/mir-signal --addr :80 \ + --trust-proxy-headers \ + --trusted-proxy-cidr 103.21.244.0/22 --trusted-proxy-cidr 103.22.200.0/22 \ + --trusted-proxy-cidr 103.31.4.0/22 --trusted-proxy-cidr 104.16.0.0/13 \ + --trusted-proxy-cidr 104.24.0.0/14 --trusted-proxy-cidr 108.162.192.0/18 \ + --trusted-proxy-cidr 131.0.72.0/22 --trusted-proxy-cidr 141.101.64.0/18 \ + --trusted-proxy-cidr 162.158.0.0/15 --trusted-proxy-cidr 172.64.0.0/13 \ + --trusted-proxy-cidr 173.245.48.0/20 --trusted-proxy-cidr 188.114.96.0/20 \ + --trusted-proxy-cidr 190.93.240.0/20 --trusted-proxy-cidr 197.234.240.0/22 \ + --trusted-proxy-cidr 198.41.128.0/17 \ + --trusted-proxy-cidr 2400:cb00::/32 --trusted-proxy-cidr 2606:4700::/32 \ + --trusted-proxy-cidr 2803:f800::/32 --trusted-proxy-cidr 2405:b500::/32 \ + --trusted-proxy-cidr 2405:8100::/32 --trusted-proxy-cidr 2a06:98c0::/29 \ + --trusted-proxy-cidr 2c0f:f248::/32 \ + --revocations-file /var/lib/mir-signal/revocations.json \ + --require-tls \ --tls-addr :443 --tls-cert /etc/ssl/mir-signal/cert.pem --tls-key /etc/ssl/mir-signal/key.pem \ --webroot /opt/mir-web --turn-url turn:16.171.89.172:3478 \ --csp-connect-src "'self'" \ @@ -38,11 +50,20 @@ ExecStart=/usr/local/bin/mir-signal --addr :80 \ Restart=always RestartSec=2 User=mirsignal +StateDirectory=mir-signal +StateDirectoryMode=0700 AmbientCapabilities=CAP_NET_BIND_SERVICE NoNewPrivileges=true ProtectSystem=strict ProtectHome=true PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +LockPersonality=true +MemoryDenyWriteExecute=true [Install] WantedBy=multi-user.target diff --git a/deploy/lightsail/redeploy.sh b/deploy/lightsail/redeploy.sh index 1d58396..33694ff 100755 --- a/deploy/lightsail/redeploy.sh +++ b/deploy/lightsail/redeploy.sh @@ -23,7 +23,9 @@ else fi echo "== build mir-signal (linux/amd64, static) ==" -( cd "$REPO/go" && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o /tmp/mir-signal-linux ./cmd/mir-signal ) +( cd "$REPO/go" && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build \ + -trimpath -buildvcs=false -ldflags "-buildid=" \ + -o /tmp/mir-signal-linux ./cmd/mir-signal ) # Pin the exact bytes we just built. /tmp on the relay is world-writable, so # without an integrity check a local user on the box (or a TOCTOU between scp and diff --git a/docs/audit-scope.md b/docs/audit-scope.md new file mode 100644 index 0000000..1b68bdc --- /dev/null +++ b/docs/audit-scope.md @@ -0,0 +1,46 @@ +# Miranda v0.7 audit scope + +This document is the hand-off boundary for an independent security review. The +claim to test is narrow: with uncompromised endpoints and confirmed pairing, the +relay cannot read or forge authenticated terminal plaintext. + +## Highest-priority surfaces + +1. Cross-language identity and crypto under `go/internal/{identity,noise,pairing}` + and `web/src/{identity,noise,pairing}`; compare every domain separator, + canonical encoding, transcript, nonce, and test vector. +2. Pairing/provisioning: bearer-token entropy and lifetime, NNpsk0 transcript + authentication, 96-bit safety number, confirmation-before-persist, agent + registration commitment. +3. Attach authorization in `go/internal/{agent,client,signal}` and `web/src/app.js`: + session/SDP binding, replay behavior, allocation order, replacement races, LAN + parity, and Noise KK peer pinning. +4. Machine revocation in Go and JavaScript: signature canonicalization, local-first + persistence, relay durability, suppression behavior, pending-vs-established + session semantics, and post-restart enforcement. +5. Secret handling and supply chain: OS-keychain invocation, migration/crash + consistency, recovery import/export, agent/client state separation, cosign + identity pinning, installer/self-update, deterministic builds, SPA/service-worker + trust. +6. Relay abuse: all capacity bounds, fixed-window limits, trusted-proxy parsing, + WebSocket deadlines, TURN issuance, persistent-store exhaustion, malformed + bodies, and concurrent shutdown/re-registration. + +## Explicit non-claims + +- A compromised target Unix account, browser origin, client binary, passkey account, + or unlocked owner keychain is outside the confidentiality guarantee. +- The relay can observe metadata, suppress revocations/discovery, and deny service. +- Miranda is not a privacy network, VPN, SSH implementation, multi-user access + system, or sandbox for commands on the target. +- tmux, WebRTC/Pion, browser WebAuthn implementations, OS keychains, coturn, and the + operating systems are dependencies, not reimplemented security boundaries. + +## Evidence expected before sign-off + +- `go test ./...`, full race suite, `npm test`, fuzz targets, and reproducibility + check pass at the reviewed commit. +- No owner root/private key exists in agent state, relay state, logs, argv, or + `owner.json`; the relay has no terminal-frame parser or plaintext data path. +- Findings include exploitability, affected invariant, reproduction, recommended + fix, and a regression test. Retest every high/critical fix before release. diff --git a/docs/naming.md b/docs/naming.md index 3420f0b..f5172a4 100644 --- a/docs/naming.md +++ b/docs/naming.md @@ -70,12 +70,11 @@ backbone. | Role | Binary | Thematic name (story / docs / service brand) | |---|---|---| | Client you type all day (`mir attach macbook`) | **`mir`** | Miranda | -| Machine-side agent | **`mir-agent`** | **Prospero** — the magician who conjures the shell | +| Machine-side mode (`mir up`) | **`mir`** | **Prospero** — the magician who conjures the shell | | Blind signaling / relay server | **`mir-signal`** | **Ariel** — the invisible spirit that carries messages and is bound to obey | -Binaries stay functional (`mir` / `mir-agent` / `mir-signal`) for discoverability; -the Tempest names (Prospero, Ariel) are used in the README, marketing, and as the -brand for the hosted relay service — not as binary names. +`mir-agent` remains only as a deprecated compatibility shim. The Tempest names +(Prospero, Ariel) are story names, not extra binaries users need to learn. ### Verification at decision time @@ -89,11 +88,11 @@ brand for the hosted relay service — not as binary names. Both dev-adjacent → some name recognition + weaker SEO. Accepted for the strength of the story; distinct enough in context. -### Rollout (mechanical, do as its own change / PR) +### Implementation notes -- Rename binaries: `trm` → `mir`, `tr-agent` → `mir-agent`, `tr-signal` → - `mir-signal` (`go/cmd/*`, Makefile, `web/dev-serve.sh`, deploy scripts, systemd unit). -- Rewrite README (SEO + what-it-does first, then story) and update CLAUDE.md + deploy docs. -- Rename repo `srcfl/terminal-relay` → `srcfl/miranda` (GitHub keeps a redirect). -- Re-deploy the relay after the binary rename (`deploy/lightsail/redeploy.sh`). -- Optional: buy a marketing domain (`getmiranda.com` free) or keep `*.sourceful-labs.net`. +- The repository and product are Miranda; `mir` and `mir-signal` are the primary + binaries. +- The Go module path and historical cryptographic wire-domain strings remain + `github.com/srcful/terminal-relay/go` / `terminal-relay/...` for compatibility. +- Product positioning now lives in [`product.md`](product.md): passkey-native + terminal continuity, not a general VPN or SSH reimplementation. diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..bdf9475 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,70 @@ +# Miranda production operations + +This is the short incident and readiness runbook. Deployment-specific commands and +Cloudflare settings live in `deploy/lightsail/README.md`. + +## Release-candidate acceptance + +- `go test ./...`, `go test -race -count=1 ./...`, `npm test`, fuzz smoke, install + tests, and `scripts/verify-reproducible.sh` are green on the release commit. +- `mir doctor` reports no blocking failures on every owner client and target. +- Relay origin uses Full (strict), the origin firewall accepts only the trusted + proxy ranges, proxy headers are CIDR-gated, and `/revocations` is durable. +- Direct WebRTC, LAN QUIC, TURN fallback, Wi-Fi/cellular transition, browser sleep, + agent restart, relay restart, and relay outage are exercised with real devices. +- A current revocation backup exists and restore has been rehearsed. + +## Signals to monitor + +`mir-signal` emits structured events suitable for journald aggregation: + +- `agent_reject`, `agent_replaced`, `agent_flap`, `agent_gone`; +- `attach`, `attach_offline`, `attach_capacity`, `attach_revoked`; +- `rate_limit`, `machine_revoked`, `revocation_reject`, + `revocation_persist_error`; +- periodic `stats` with live agents, proof entries, and revocation count. + +Alert on any persistence error, repeated flap for one slot, sustained capacity/rate +events, unexplained revocation growth, origin requests outside the proxy network, +or TURN bandwidth that does not match active sessions. Do not log full owner IDs, +registration secrets, signed attach payloads, recovery phrases, SDP, or terminal +data. + +## Compromised or lost target + +1. From an uncompromised owner client, run `mir machine revoke NAME --yes` or use + the browser revoke control. +2. Confirm local blocking and successful publication to every configured relay. +3. Confirm `machine_revoked` on the relay and that re-registration returns 410. +4. Disconnect any other already-established clients; the blind relay cannot kill + a P2P session after signaling has completed. Isolate/reimage the target and + rotate credentials available to its Unix account. +5. Re-pair only after the host is trusted again. Revocation itself is permanent for + that owner/machine ID; a reinstalled target receives a new machine identity. + +## Compromised owner root, passkey account, or web origin + +Per-machine revocation is insufficient because the attacker can sign as the owner. +Take the web origin offline if implicated, preserve evidence, run +`mir identity rotate --yes` from a clean client, re-pair every target in person, +and revoke/remove the old passkey credential at its provider. Rotate deploy/release +credentials if the browser origin or CI was in scope. Publish an incident notice +only after a private vulnerability report and user remediation path exist. + +## Relay incident + +The relay is not a confidentiality authority, so fail closed without rotating +owner roots solely for relay compromise. Preserve logs and the signed revocation +file, disable TURN if it is being abused, rebuild from a known release, restore the +verified tombstones, and re-enable traffic behind edge limits. Rotate TURN and +deployment credentials. Registration secrets are machine-slot availability +credentials; re-pair/reinstall a target if its slot is actively being disrupted. + +## Backup and restore + +Back up only the relay's signed revocation file and deployment configuration. +Encrypted discovery and live registrations are soft state and republish after +restart. Stop `mir-signal`, restore `revocations.json` as mode `0600` owned by the +service user, then start it; startup signature verification is the restore gate. +Never “repair” a corrupt tombstone file by deleting entries. Restore a known-good +backup and investigate the corruption. diff --git a/docs/product.md b/docs/product.md new file mode 100644 index 0000000..e9c1f3c --- /dev/null +++ b/docs/product.md @@ -0,0 +1,101 @@ +# Product thesis: terminal continuity + +## One sentence + +**Miranda lets one person leave a device and continue the same live terminal on +another, using a passkey and an end-to-end-encrypted connection to machines they +own.** + +## The niche + +Miranda is not “SSH with different crypto” and not “a smaller VPN.” Its category is +**private terminal continuity**. + +The initial user is a developer who has long-running terminal work — especially AI +coding agents, builds, data jobs, or debugging sessions — distributed across a +laptop, workstation, home server, or cloud box. The pain is not merely logging in. +It is finding the right live session and resuming it safely from the device currently +in hand. + +`tmux` solves process and terminal persistence on one host. Miranda supplies the +missing cross-device layer: + +```text +passkey identity → paired machines → encrypted discovery → safe reachability → tmux continuity +``` + +That makes “P2P tmux” a useful shorthand but an incomplete product definition. +Miranda is the secure continuity fabric around tmux. + +## The product promise + +> Leave your desk. Keep your terminal. + +The ideal moment is: a developer starts an agent or build on a workstation, walks +away, opens a phone, and sees the same terminal exactly where it was — without +remembering a hostname, moving a key, joining a subnet, or exposing other services. + +## Scope discipline + +The first excellent product does five things: + +1. pairs one owner to one machine with an understandable visual trust ceremony; +2. keeps a persistent tmux terminal reachable without inbound port forwarding; +3. discovers the owner's online machines without revealing their records to the + relay; +4. reconnects cleanly across sleep and network changes; +5. makes switching among several live machines fast on desktop and mobile. + +Everything else must justify the security and UX cost. + +## Explicit non-goals + +- general network or subnet access; +- arbitrary TCP forwarding; +- remote desktop; +- file synchronization; +- shared organizational bastions, RBAC, or session recording; +- SSH protocol compatibility; +- building a new terminal multiplexer instead of using tmux. + +These may be adjacent markets, but absorbing them would destroy the narrow +capability users can understand and safely grant. + +## Why it can spread + +The shareable story is a visible before/after, not a cryptography diagram: + +- before: “Which box was that agent running on? Is the VPN on? Where is my key?” +- after: open Miranda, tap the machine, continue typing. + +The security story supports that magic: one passkey, one pairing comparison, no +network-wide access, and a relay that cannot read terminal content. The product +should lead with continuity and prove security immediately behind it. + +## Product principles + +- **Terminal, not network.** Grant the smallest capability that solves the job. +- **Pair deliberately; reconnect effortlessly.** Friction belongs at the trust + decision, not every session. +- **Targets are expendable.** Compromising one agent must not reveal the owner root + or authorize other machines. +- **The relay is coordination, never trust.** It may know where; it must never know + what was typed. +- **Use proven engines.** tmux owns persistence; standard Noise patterns and mature + primitives own cryptography. +- **Honest failure beats invisible downgrade.** Missing signatures, entropy, + capabilities, or release verification fail closed with a useful explanation. + +## Near-term bar for “awesome” + +- a first-run path that reaches a terminal in under a minute; +- reliable browser passkeys on the supported browser matrix; +- reconnect that feels instant after phone sleep or Wi-Fi/cellular changes; +- clear online/offline/retrying states and actionable diagnostics; +- machine rename and a clearer retirement/re-pair flow around signed revocation; +- an independent protocol/implementation audit of the published threat model; +- measured success across home NAT, office NAT, cellular, and TURN fallback. + +The north-star metric is not registrations. It is **successful continuation**: the +percentage of attempts where a user reaches the intended existing session quickly +enough that Miranda feels like one terminal spanning all their devices. diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..7314326 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,52 @@ +# Releasing and independently verifying Miranda + +Miranda's release boundary is deliberately fail-closed: CI tests both language +implementations, builds deterministic binaries, signs the checksum manifest with +GitHub OIDC/cosign, and only then publishes through GoReleaser. + +## Maintainer release gate + +1. Start from a clean commit on the intended release branch. +2. Run: + + ```bash + cd go && go test ./... && go test -race -count=1 ./... + cd ../web && npm ci && npm test + cd .. && ./scripts/verify-reproducible.sh + goreleaser check + goreleaser release --snapshot --clean --skip=sign + ``` + +3. Review `SECURITY.md`, migration notes, and the diff since the previous tag. +4. Tag `vX.Y.Z`. The release workflow repeats the Go/web/reproducibility gates, + builds macOS/Linux amd64/arm64 archives, writes `checksums.txt`, and keyless-signs + that manifest. Do not upload replacement assets by hand. + +## Independent binary reproduction + +Use the exact Go toolchain from `go/go.mod` (currently Go 1.26.5), a clean clone, +and the tagged commit: + +```bash +git clone https://github.com/srcfl/miranda +cd miranda +git checkout vX.Y.Z +git status --porcelain # must print nothing +./scripts/verify-reproducible.sh +``` + +The script builds `mir`, `mir-agent`, and `mir-signal` twice with separate Go build +caches, `CGO_ENABLED=0`, `-trimpath`, no embedded VCS/build ID, and the commit's +timestamp. It fails unless both copies are byte-identical and prints SHA-256 for +the current OS/architecture. + +To compare with a release, first verify `checksums.txt` using the identity pinned +in `install.sh`, verify the archive digest, extract the matching binary, and compare +its SHA-256 with the local result. A mismatch is a release blocker. + +## What this proves + +Reproduction detects hidden or nondeterministic changes between tagged source and +binary output on the same toolchain/platform. Cosign ties the published manifest +to this repository's tag workflow. Neither mechanism proves protocol correctness, +endpoint safety, or absence of vulnerabilities; those require review and audit. diff --git a/go/cmd/mir-signal/main.go b/go/cmd/mir-signal/main.go index 6843670..e7842ea 100644 --- a/go/cmd/mir-signal/main.go +++ b/go/cmd/mir-signal/main.go @@ -43,6 +43,11 @@ func main() { tlsKey := flag.String("tls-key", "", "TLS private key file (PEM)") webroot := flag.String("webroot", "", "if set, serve the static SPA from this directory on non-signaling paths") turnURL := flag.String("turn-url", "", "TURN url to hand out (e.g. turn:relay.example:3478); secret via MIR_TURN_SECRET env") + trustProxyHeaders := flag.Bool("trust-proxy-headers", false, "trust CF-Connecting-IP/X-Forwarded-For for rate limits (only behind a trusted proxy)") + var trustedProxyCIDRs stringSlice + flag.Var(&trustedProxyCIDRs, "trusted-proxy-cidr", "CIDR allowed to set client-IP headers (repeatable; required with --trust-proxy-headers)") + revocationsFile := flag.String("revocations-file", "", "durable signed machine-revocation store (recommended in production)") + requireTLS := flag.Bool("require-tls", false, "fail startup unless --tls-addr, certificate, and key are present") var cspConnect stringSlice flag.Var(&cspConnect, "csp-connect-src", "CSP connect-src token (repeatable); systemd-safe alternative to MIR_CSP_CONNECT_SRC. e.g. --csp-connect-src 'self' --csp-connect-src https://relay.example.net") flag.Parse() @@ -55,10 +60,27 @@ func main() { s := signal.New() s.Logf = log.Printf // structured per-event relay lines (register/replace/reject/gone/attach/flap/stats) s.TURNURL = *turnURL + s.TrustProxyHeaders = *trustProxyHeaders + if *trustProxyHeaders { + if err := s.SetTrustedProxyCIDRs(trustedProxyCIDRs); err != nil { + log.Fatalf("mir-signal: trusted proxy configuration: %v", err) + } + } s.TURNSecret = os.Getenv("MIR_TURN_SECRET") // shared with coturn; never logged/shipped + if *revocationsFile != "" { + if err := s.LoadRevocations(*revocationsFile); err != nil { + log.Fatalf("mir-signal: load revocations: %v", err) + } + log.Printf("mir-signal: durable revocations enabled at %s", *revocationsFile) + } else { + log.Printf("mir-signal WARNING: revocations are memory-only; set --revocations-file in production") + } if s.TURNSecret != "" && s.TURNURL != "" { log.Printf("mir-signal: issuing ephemeral TURN credentials for %s", s.TURNURL) } + if err := validateTLSConfig(*requireTLS, *tlsAddr, *tlsCert, *tlsKey); err != nil { + log.Fatalf("mir-signal: %v", err) + } // Stash any --csp-connect-src flag tokens so the static handler's per-request // CSP picks them up (flag takes precedence over MIR_CSP_CONNECT_SRC). This is @@ -83,13 +105,13 @@ func main() { go s.RunStats(context.Background()) // Serve HTTPS directly when a cert is provided (Cloudflare "Full (strict)": - // the CF->origin leg is then encrypted). Runs alongside the plain listener so - // the cutover from "Flexible" (CF->origin :80) has no downtime. + // the CF->origin leg is then encrypted). The plain listener remains useful for + // localhost health checks and local development; production firewalls keep it + // unreachable from the public Internet. // - // Only enter the TLS branch when the cert AND key files actually exist: - // ListenAndServeTLS log.Fatals the whole process if they are missing, which on - // a Cloudflare-Flexible (HTTP-only) box turns a stray --tls-* into a crash - // loop. If they are absent we warn and serve HTTP-only instead. + // Only enter the TLS branch when the cert AND key files actually exist. A + // production unit uses --require-tls and has already failed above if they are + // absent; optional mode warns and serves HTTP-only for local development. if *tlsAddr != "" && *tlsCert != "" && *tlsKey != "" { if fileExists(*tlsCert) && fileExists(*tlsKey) { go func() { @@ -122,6 +144,19 @@ func fileExists(path string) bool { return err == nil && !info.IsDir() } +func validateTLSConfig(required bool, addr, cert, key string) error { + if !required { + return nil + } + if addr == "" || cert == "" || key == "" { + return fmt.Errorf("TLS is required but --tls-addr/--tls-cert/--tls-key are incomplete") + } + if !fileExists(cert) || !fileExists(key) { + return fmt.Errorf("TLS is required but certificate or key is unavailable") + } + return nil +} + func newHTTPServer(addr string, handler http.Handler) *http.Server { return &http.Server{ Addr: addr, @@ -149,7 +184,7 @@ func withStatic(sig http.Handler, dir string) http.Handler { // routes registered in signal.Server.Handler(); main_test.go's // TestWithStaticForwardsSignalingPaths guards against drift (it caught /registry // going missing in production). - signalPaths := map[string]bool{"/agent/signal": true, "/attach": true, "/pair": true, "/turn-credentials": true, "/healthz": true, "/registry": true} + signalPaths := map[string]bool{"/agent/signal": true, "/attach": true, "/pair": true, "/turn-credentials": true, "/healthz": true, "/registry": true, "/revocations": true} return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if signalPaths[r.URL.Path] { sig.ServeHTTP(w, r) @@ -177,7 +212,10 @@ func serveIndex(w http.ResponseWriter, path string) { return } b := make([]byte, 16) - _, _ = rand.Read(b) + if _, err := rand.Read(b); err != nil { + http.Error(w, "server entropy unavailable", http.StatusInternalServerError) + return + } nonce := base64.StdEncoding.EncodeToString(b) setStaticSecurityHeaders(w, nonce) w.Header().Set("Content-Type", "text/html; charset=utf-8") diff --git a/go/cmd/mir-signal/main_test.go b/go/cmd/mir-signal/main_test.go index e26abeb..140b7cd 100644 --- a/go/cmd/mir-signal/main_test.go +++ b/go/cmd/mir-signal/main_test.go @@ -43,6 +43,11 @@ func TestWithStaticForwardsSignalingPaths(t *testing.T) { t.Fatalf("/healthz via withStatic not forwarded (err=%v)", err) } + // Signed machine tombstones must never fall through to the SPA either. + if r, err := http.Get(ts.URL + "/revocations?owner_id=test"); err != nil || r.StatusCode != http.StatusOK { + t.Fatalf("/revocations via withStatic not forwarded (err=%v)", err) + } + // A missing static asset still 404s via the FS — proves we aren't trivially // forwarding everything to the signal server. if r, err := http.Get(ts.URL + "/vendor/missing-asset.js"); err != nil || r.StatusCode != http.StatusNotFound { @@ -76,6 +81,27 @@ func TestNewHTTPServerSetsTimeouts(t *testing.T) { } } +func TestValidateTLSConfigFailsClosedWhenRequired(t *testing.T) { + dir := t.TempDir() + cert := filepath.Join(dir, "cert.pem") + key := filepath.Join(dir, "key.pem") + if err := validateTLSConfig(true, ":443", cert, key); err == nil { + t.Fatal("missing required TLS files accepted") + } + if err := os.WriteFile(cert, []byte("test"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(key, []byte("test"), 0o600); err != nil { + t.Fatal(err) + } + if err := validateTLSConfig(true, ":443", cert, key); err != nil { + t.Fatalf("present TLS config rejected: %v", err) + } + if err := validateTLSConfig(false, "", "", ""); err != nil { + t.Fatalf("optional TLS config rejected: %v", err) + } +} + func TestWithStaticAppliesBrowserSecurityHeaders(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("tr"), 0o600); err != nil { diff --git a/go/go.mod b/go/go.mod index 9c82fd2..a488b34 100644 --- a/go/go.mod +++ b/go/go.mod @@ -1,12 +1,6 @@ module github.com/srcful/terminal-relay/go -go 1.26.3 - -// Build with >= go1.26.4: it ships the fix for two reachable stdlib advisories -// (GO-2026-5039 net/textproto, GO-2026-5037 crypto/x509) that mir-signal pulls -// in via net/http. The compiled binary's stdlib version is the toolchain's, so -// this is what actually clears them. Language floor stays at 1.26.3. -toolchain go1.26.4 +go 1.26.5 require ( github.com/coder/websocket v1.8.14 diff --git a/go/internal/agent/binding_test.go b/go/internal/agent/binding_test.go index 7149324..eed7f31 100644 --- a/go/internal/agent/binding_test.go +++ b/go/internal/agent/binding_test.go @@ -3,6 +3,7 @@ package agent import ( "bytes" + "encoding/base64" "encoding/hex" "strings" "testing" @@ -17,7 +18,7 @@ import ( // machine_id, so it is not checked. func TestOwnerPubFromBinding(t *testing.T) { secret := bytes.Repeat([]byte{0x42}, 32) - w, err := identity.DeriveWallet(secret) + w, err := identity.DeriveSigner(secret) if err != nil { t.Fatalf("DeriveWallet: %v", err) } @@ -40,9 +41,9 @@ func TestOwnerPubFromBinding(t *testing.T) { // A second, unrelated wallet — its address is a valid base58 owner_id that // does not match the binding's wallet. - other, err := identity.DeriveWallet(bytes.Repeat([]byte{0x07}, 32)) + other, err := identity.DeriveSigner(bytes.Repeat([]byte{0x07}, 32)) if err != nil { - t.Fatalf("DeriveWallet(other): %v", err) + t.Fatalf("DeriveSigner(other): %v", err) } // Tamper one character of the base58 signature inside the rendered JSON. @@ -81,6 +82,28 @@ func TestOwnerPubFromBinding(t *testing.T) { } } +func ownerAttachAuth(t *testing.T, secret []byte, session, machineID, sdp string) string { + t.Helper() + signer, err := identity.DeriveSigner(secret) + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(signer.SignAuth(identity.AttachChallenge(session, machineID, sdp))) +} + +func ownerRegistrationAuth(t *testing.T, secret []byte, cfg *Config) string { + t.Helper() + signer, err := identity.DeriveSigner(secret) + if err != nil { + t.Fatal(err) + } + commitment, err := cfg.RegistrationCommitment() + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(signer.SignAuth(identity.RegistrationChallenge(cfg.MachineID, commitment))) +} + // ownerBinding mints a wallet-rooted owner identity for handleOffer tests: it // derives the wallet (owner_id) and the X25519 transport keypair from a shared // secret, then signs a binding authorizing that x25519 under the wallet for the @@ -88,7 +111,7 @@ func TestOwnerPubFromBinding(t *testing.T) { // to register/pin, and the signed-binding JSON to attach to the offer. func ownerBinding(t *testing.T, secret []byte, device string) (priv, pub []byte, ownerID, bindingJSON string) { t.Helper() - w, err := identity.DeriveWallet(secret) + w, err := identity.DeriveSigner(secret) if err != nil { t.Fatalf("DeriveWallet: %v", err) } diff --git a/go/internal/agent/e2e_test.go b/go/internal/agent/e2e_test.go index 2e09965..49086f5 100644 --- a/go/internal/agent/e2e_test.go +++ b/go/internal/agent/e2e_test.go @@ -24,13 +24,14 @@ func TestEndToEndRealShellOverP2P(t *testing.T) { // Owner (browser) identity + agent keystore with that owner pinned. The owner // is a real wallet: owner_id is the base58 wallet address, and the Noise pin is // recovered from a wallet-signed binding carried on the offer (B1.4.1). - ownerPriv, _, ownerID, bindingJSON := ownerBinding(t, bytes.Repeat([]byte{0x11}, 32), "owner-device-e2e") + ownerSecret := bytes.Repeat([]byte{0x11}, 32) + ownerPriv, _, ownerID, bindingJSON := ownerBinding(t, ownerSecret, "owner-device-e2e") dir := t.TempDir() cfg, err := LoadOrInit(dir, "e2e-machine", srv.URL) if err != nil { t.Fatal(err) } - if err := PinOwner(dir, ownerID); err != nil { + if err := ProvisionOwner(dir, ownerID, "", ownerRegistrationAuth(t, ownerSecret, cfg)); err != nil { t.Fatal(err) } cfg, _ = LoadOrInit(dir, "e2e-machine", srv.URL) // reload with the pinned owner @@ -50,6 +51,14 @@ func TestEndToEndRealShellOverP2P(t *testing.T) { if err != nil { t.Fatal(err) } + _, readyData, err := bc.Read(ctx) + if err != nil { + t.Fatal(err) + } + var ready signal.SignalMsg + if json.Unmarshal(readyData, &ready) != nil || ready.Type != signal.TypeReady || ready.Session == "" { + t.Fatalf("expected attach ready, got %s", readyData) + } off, opened, err := peer.NewOfferer(nil) if err != nil { t.Fatal(err) @@ -60,7 +69,7 @@ func TestEndToEndRealShellOverP2P(t *testing.T) { if err != nil { t.Fatal(err) } - offerMsg, _ := json.Marshal(signal.SignalMsg{Type: signal.TypeOffer, SDP: offerSDP, Binding: bindingJSON}) + offerMsg, _ := json.Marshal(signal.SignalMsg{Type: signal.TypeOffer, SDP: offerSDP, Binding: bindingJSON, Auth: ownerAttachAuth(t, ownerSecret, ready.Session, cfg.MachineID, offerSDP)}) if err := bc.Write(ctx, websocket.MessageText, offerMsg); err != nil { t.Fatal(err) } diff --git a/go/internal/agent/lan.go b/go/internal/agent/lan.go index cb4017b..633589e 100644 --- a/go/internal/agent/lan.go +++ b/go/internal/agent/lan.go @@ -20,7 +20,7 @@ const lanService = "_miranda._udp" // Returns the bound address (for callers/tests) and a stop func. Each connection runs // the same binding-gated authenticated session as the relay path. // -// The QUIC TLS identity carries no trust (see quicmsg): authentication is the wallet +// The QUIC TLS identity carries no trust (see quicmsg): authentication is the owner // binding (frame 0) plus the Noise-KK handshake that run inside the stream. func (rt *Runtime) startLAN(ctx context.Context) (addr string, stop func(), err error) { ln, err := quicmsg.Listen("0.0.0.0:0") // ephemeral; advertised via mDNS @@ -74,8 +74,8 @@ func (rt *Runtime) acceptLAN(ctx context.Context, ln *quicmsg.Listener) { } } -// lanAccept gates a single LAN-direct connection: it reads the wallet binding as -// frame 0, refuses any unpinned wallet *before* the Noise handshake, recovers the +// lanAccept gates a single LAN-direct connection: it reads the owner binding as +// frame 0, refuses any unpinned owner *before* the Noise handshake, recovers the // X25519 pin from the binding, then runs the same authenticated PTY session as the // relay path. admit() bounds concurrent pre-auth handshakes (a DoS bound shared with // the relay path). @@ -95,7 +95,7 @@ func (rt *Runtime) lanAccept(ctx context.Context, conn *quicmsg.Conn) { return } if !rt.cfg.IsOwnerPinned(sb.Wallet) { - return // unpinned wallet: refuse pre-Noise, no session starts + return // unpinned owner: refuse pre-Noise, no session starts } ownerPub, err := ownerPubFromBinding(string(bindingJSON), sb.Wallet) if err != nil { diff --git a/go/internal/agent/registry_publish_test.go b/go/internal/agent/registry_publish_test.go index bca0414..b3fe0f0 100644 --- a/go/internal/agent/registry_publish_test.go +++ b/go/internal/agent/registry_publish_test.go @@ -1,97 +1,60 @@ -// go/internal/agent/registry_publish_test.go package agent import ( - "bytes" "context" - "encoding/base64" "encoding/json" "net/http" "net/http/httptest" + "os" + "strings" "testing" "time" "github.com/coder/websocket" - "github.com/srcful/terminal-relay/go/internal/identity" "github.com/srcful/terminal-relay/go/internal/signal" ) -// TestRegistryBlobOpens proves the agent seals a device record under K_reg that a -// wallet-holder can open: registryBlob() returns base64(nonce||ct) which, decoded -// and run through OpenRecord with the wallet's K_reg and the machine_id AAD, yields -// the {name,host_pub,...} JSON. A wrong machine_id (AAD) must fail to open. -func TestRegistryBlobOpens(t *testing.T) { - secret := bytes.Repeat([]byte{0x42}, 32) - cfg := &Config{ - MachineID: "machine-xyz", - MachineName: "fredde-laptop", - HostPubHex: "deadbeefcafef00d", - SignalURL: "https://relay.example", +func TestProvisionOwnerStoresOpaqueRegistryRecord(t *testing.T) { + dir := t.TempDir() + if _, err := LoadOrInit(dir, "workstation", "https://relay.example"); err != nil { + t.Fatal(err) } - rt := NewRuntime(cfg, []string{"sh"}, nil) - rt.WalletSecret = secret - rt.WalletAddress = "WalletAddrBase58" - - b64, err := rt.registryBlob() - if err != nil { - t.Fatalf("registryBlob: %v", err) - } - blob, err := base64.StdEncoding.DecodeString(b64) - if err != nil { - t.Fatalf("base64 decode: %v", err) - } - - key, err := identity.RegistryKey(secret) - if err != nil { - t.Fatalf("RegistryKey: %v", err) + const owner = "owner-id" + const blob = "opaque-owner-encrypted-record" + const registrationAuth = "public-owner-authorization" + if err := ProvisionOwner(dir, owner, blob, registrationAuth); err != nil { + t.Fatal(err) } - pt, err := identity.OpenRecord(key, blob, cfg.MachineID) + owners, err := ReloadOwners(dir) if err != nil { - t.Fatalf("OpenRecord (right machine_id): %v", err) + t.Fatal(err) } - var rec map[string]any - if err := json.Unmarshal(pt, &rec); err != nil { - t.Fatalf("record JSON: %v", err) + if len(owners) != 1 || owners[0] != owner { + t.Fatalf("owners = %v", owners) } - if rec["name"] != cfg.MachineName { - t.Fatalf("record name = %v, want %q", rec["name"], cfg.MachineName) + if got := RegistryForOwner(dir, owner); got != blob { + t.Fatalf("registry = %q, want %q", got, blob) } - if rec["host_pub"] != cfg.HostPubHex { - t.Fatalf("record host_pub = %v, want %q", rec["host_pub"], cfg.HostPubHex) + if got := RegistrationAuthForOwner(dir, owner); got != registrationAuth { + t.Fatalf("registration auth = %q, want %q", got, registrationAuth) } - if rec["signal_url"] != cfg.SignalURL { - t.Fatalf("record signal_url = %v, want %q", rec["signal_url"], cfg.SignalURL) - } - - // AAD is machine_id: opening under a different machine_id must fail. - if _, err := identity.OpenRecord(key, blob, "other-machine"); err == nil { - t.Fatal("OpenRecord with wrong machine_id (AAD) should fail, but succeeded") + data, err := os.ReadFile(configPath(dir)) + if err != nil { + t.Fatal(err) } -} - -// TestRegistryBlobLegacyNoWallet proves a wallet-less Runtime never produces a -// blob (legacy mir up publishes nothing). -func TestRegistryBlobLegacyNoWallet(t *testing.T) { - cfg := &Config{MachineID: "m1", MachineName: "legacy"} - rt := NewRuntime(cfg, []string{"sh"}, nil) - if _, err := rt.registryBlob(); err == nil { - t.Fatal("registryBlob with no WalletSecret should error, but succeeded") + if strings.Contains(string(data), "owner_priv") || strings.Contains(string(data), "wallet_address") { + t.Fatal("agent config unexpectedly contains owner identity material") } } -// TestServeOncePublishesRegistry proves that when serving the self-wallet owner, -// the agent's FIRST message on the live registration is a TypeRegistry whose blob -// opens to the device record. A fake relay captures the first frame. -func TestServeOncePublishesRegistry(t *testing.T) { - secret := bytes.Repeat([]byte{0x55}, 32) - wallet := "SelfWalletBase58" - +func TestServeOncePublishesProvisionedOpaqueRecord(t *testing.T) { first := make(chan signal.SignalMsg, 1) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { c, err := websocket.Accept(w, r, nil) if err != nil { return } + defer c.CloseNow() _, data, err := c.Read(r.Context()) if err != nil { return @@ -100,101 +63,63 @@ func TestServeOncePublishesRegistry(t *testing.T) { if json.Unmarshal(data, &m) == nil { first <- m } - // hold the registration open until the test ends - _, _, _ = c.Read(r.Context()) + <-r.Context().Done() })) defer srv.Close() - cfg := &Config{ - SignalURL: srv.URL, - MachineID: "machine-pub-1", - MachineName: "publisher", - HostPubHex: "0011223344556677", - PairedOwners: []string{wallet}, + dir := t.TempDir() + cfg, err := LoadOrInit(dir, "publisher", srv.URL) + if err != nil { + t.Fatal(err) + } + const owner = "owner-id" + const blob = "opaque-owner-encrypted-record" + if err := ProvisionOwner(dir, owner, blob, ""); err != nil { + t.Fatal(err) } rt := NewRuntime(cfg, []string{"sh"}, nil) - rt.WalletSecret = secret - rt.WalletAddress = wallet - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - go func() { _, _, _ = rt.serveOnce(ctx, wallet) }() + go func() { _, _, _ = rt.serveOnce(ctx, owner) }() select { case m := <-first: - if m.Type != signal.TypeRegistry { - t.Fatalf("first message type = %q, want %q", m.Type, signal.TypeRegistry) - } - blob, err := base64.StdEncoding.DecodeString(m.Registry) - if err != nil { - t.Fatalf("registry base64: %v", err) - } - key, err := identity.RegistryKey(secret) - if err != nil { - t.Fatalf("RegistryKey: %v", err) - } - pt, err := identity.OpenRecord(key, blob, cfg.MachineID) - if err != nil { - t.Fatalf("OpenRecord: %v", err) - } - var rec map[string]any - if err := json.Unmarshal(pt, &rec); err != nil { - t.Fatalf("record JSON: %v", err) - } - if rec["name"] != cfg.MachineName || rec["host_pub"] != cfg.HostPubHex { - t.Fatalf("record = %v, want name=%q host_pub=%q", rec, cfg.MachineName, cfg.HostPubHex) + if m.Type != signal.TypeRegistry || m.Registry != blob { + t.Fatalf("first message = %+v", m) } case <-ctx.Done(): - t.Fatal("relay never received the first registry message") + t.Fatal("relay never received provisioned registry record") } } -// TestServeOnceNoPublishForOtherOwner proves the agent does NOT publish a registry -// blob when serving an owner that is not its own wallet (it lacks that wallet's -// K_reg). For a non-self owner the first frame must not be a registry message. -func TestServeOnceNoPublishForOtherOwner(t *testing.T) { - secret := bytes.Repeat([]byte{0x55}, 32) - - got := make(chan string, 1) // first message type, or "" if the conn closed without one +func TestServeOnceWithoutProvisionDoesNotPublishRegistry(t *testing.T) { + got := make(chan struct{}, 1) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { c, err := websocket.Accept(w, r, nil) if err != nil { return } - typ := "" - _, data, err := c.Read(r.Context()) - if err == nil { - var m signal.SignalMsg - if json.Unmarshal(data, &m) == nil { - typ = m.Type - } + defer c.CloseNow() + readCtx, cancel := context.WithTimeout(r.Context(), 150*time.Millisecond) + defer cancel() + if _, _, err := c.Read(readCtx); err == nil { + got <- struct{}{} } - got <- typ - _, _, _ = c.Read(r.Context()) })) defer srv.Close() - cfg := &Config{ - SignalURL: srv.URL, - MachineID: "machine-pub-1", - MachineName: "publisher", - HostPubHex: "0011223344556677", - PairedOwners: []string{"OtherOwner", "SelfWallet"}, + dir := t.TempDir() + cfg, err := LoadOrInit(dir, "publisher", srv.URL) + if err != nil { + t.Fatal(err) } rt := NewRuntime(cfg, []string{"sh"}, nil) - rt.WalletSecret = secret - rt.WalletAddress = "SelfWallet" - - ctx, cancel := context.WithTimeout(context.Background(), 700*time.Millisecond) + ctx, cancel := context.WithTimeout(context.Background(), 350*time.Millisecond) defer cancel() - go func() { _, _, _ = rt.serveOnce(ctx, "OtherOwner") }() - + go func() { _, _, _ = rt.serveOnce(ctx, "owner-without-record") }() select { - case typ := <-got: - if typ == signal.TypeRegistry { - t.Fatal("agent published a registry blob for a non-self owner") - } + case <-got: + t.Fatal("agent published an unprovisioned registry record") case <-ctx.Done(): - // no message at all is also correct (the agent only sends on offers). } } diff --git a/go/internal/agent/runtime.go b/go/internal/agent/runtime.go index c7b7aa5..77e34b7 100644 --- a/go/internal/agent/runtime.go +++ b/go/internal/agent/runtime.go @@ -3,12 +3,12 @@ package agent import ( "context" - cryptorand "crypto/rand" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" + "io" "math/rand" "net/http" "net/url" @@ -24,19 +24,19 @@ import ( "github.com/srcful/terminal-relay/go/internal/signal" ) -// ownerPubFromBinding verifies the offer's wallet binding and returns the X25519 -// transport key to pin for Noise-KK. owner is the routing wallet (owner_id). There -// is no legacy hex path: a valid binding whose wallet == owner_id is required. +// ownerPubFromBinding verifies the offer's owner binding and returns the X25519 +// transport key to pin for Noise-KK. The signed record retains the historical +// Wallet field on the wire, but it contains the Miranda owner id. func ownerPubFromBinding(bindingJSON, owner string) ([]byte, error) { if bindingJSON == "" { - return nil, fmt.Errorf("attach: missing wallet binding") + return nil, fmt.Errorf("attach: missing owner binding") } sb, err := identity.ParseSignedBinding([]byte(bindingJSON)) if err != nil { return nil, err } if sb.Wallet != owner { - return nil, fmt.Errorf("attach: binding wallet %q != owner_id %q", sb.Wallet, owner) + return nil, fmt.Errorf("attach: binding owner %q != owner_id %q", sb.Wallet, owner) } if err := identity.VerifyBinding(sb); err != nil { return nil, err @@ -59,7 +59,7 @@ const minHealthyUptime = 10 * time.Second // layer, so without this cap anyone who knows an owner_id+machine_id could pump // offers and exhaust the agent's FDs/memory/goroutines (a pre-auth DoS) — without // ever getting the shell. 64 comfortably covers a person's real devices. -const defaultMaxConcurrentAttaches = 64 +const defaultMaxConcurrentAttaches = 16 // Runtime runs the agent: it holds the signaling channel and, per attach, // answers the WebRTC offer, runs the Noise responder, and bridges to a shell. @@ -70,6 +70,9 @@ type Runtime struct { sem chan struct{} // bounds concurrent in-flight attach handshakes (pre-auth DoS guard) + seenMu sync.Mutex + seenAttaches map[string]time.Time // valid signed session ids; replay guard + active int64 // count of authenticated, serving sessions (atomic); gates auto-update baseBackoff time.Duration // first reconnect delay (grows on repeated dial failures) @@ -79,14 +82,6 @@ type Runtime struct { DisableLAN bool // when set, mir up serves the relay only (no QUIC listener / mDNS advertise) - // WalletSecret is the 32-byte prf secret of THIS machine's wallet (nil = - // legacy/no wallet). It derives K_reg, the key under which the agent seals its - // encrypted device registry record. Never sent to the relay. - WalletSecret []byte - // WalletAddress is this machine's base58 wallet. The agent publishes a registry - // record on the live registration for this owner so your other devices discover - // it; it publishes only for this self-wallet (it has no other wallet's K_reg). - WalletAddress string } // admit reserves a slot for a new attach handshake, returning false immediately @@ -110,7 +105,24 @@ func (rt *Runtime) sessionEnded() { atomic.AddInt64(&rt.active, -1) } func (rt *Runtime) ActiveSessions() int { return int(atomic.LoadInt64(&rt.active)) } func NewRuntime(cfg *Config, launch []string, ice []peer.ICEServer) *Runtime { - return &Runtime{cfg: cfg, launch: launch, ice: ice, sem: make(chan struct{}, defaultMaxConcurrentAttaches), baseBackoff: time.Second, maxBackoff: 30 * time.Second, reloadInterval: 3 * time.Second} + return &Runtime{cfg: cfg, launch: launch, ice: ice, sem: make(chan struct{}, defaultMaxConcurrentAttaches), seenAttaches: make(map[string]time.Time), baseBackoff: time.Second, maxBackoff: 30 * time.Second, reloadInterval: 3 * time.Second} +} + +func (rt *Runtime) acceptAttachSession(owner, session string) bool { + now := time.Now() + key := owner + "|" + session + rt.seenMu.Lock() + defer rt.seenMu.Unlock() + for k, seen := range rt.seenAttaches { + if now.Sub(seen) > 5*time.Minute { + delete(rt.seenAttaches, k) + } + } + if _, replay := rt.seenAttaches[key]; replay { + return false + } + rt.seenAttaches[key] = now + return true } // Up keeps the agent registered on the signaling channel for EVERY paired owner @@ -219,6 +231,12 @@ func (rt *Runtime) serveOwner(ctx context.Context, owner string) { if ctx.Err() != nil { return } + if errors.Is(err, errMachineRevoked) { + if rt.Logf != nil { + rt.Logf("event=revoked owner=%s; stopping registration for this owner", short(owner)) + } + return + } backoff = nextBackoff(backoff, rt.baseBackoff, rt.maxBackoff, dialed, uptime) sleep := rt.jitter(backoff) code, reason := closeCodeReason(err) @@ -234,43 +252,6 @@ func (rt *Runtime) serveOwner(ctx context.Context, owner string) { } } -// registryBlob builds and AEAD-seals this machine's device registry record under -// K_reg (derived from the wallet secret), returning base64(nonce||ciphertext||tag). -// The record — {v, name, host_pub, signal_url, ts} — lets your other devices -// discover this machine by name with no pairing. The relay never parses it (it's -// encrypted and opaque); only a wallet-holder can open it, so plain json.Marshal -// of the map is fine. A fresh random nonce per call keeps reconnect re-publishes -// safe. Errors when there is no wallet (legacy mir up publishes nothing). -func (rt *Runtime) registryBlob() (string, error) { - if len(rt.WalletSecret) == 0 { - return "", fmt.Errorf("registry: no wallet secret") - } - rec := map[string]any{ - "v": 1, - "name": rt.cfg.MachineName, - "host_pub": rt.cfg.HostPubHex, - "signal_url": rt.cfg.SignalURL, - "ts": time.Now().Unix(), - } - pt, err := json.Marshal(rec) - if err != nil { - return "", err - } - key, err := identity.RegistryKey(rt.WalletSecret) - if err != nil { - return "", err - } - nonce := make([]byte, 12) - if _, err := cryptorand.Read(nonce); err != nil { - return "", err - } - blob, err := identity.SealRecord(key, nonce, pt, rt.cfg.MachineID) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(blob), nil -} - // serveOnce dials the signaling channel for one owner and serves offers until // the connection drops. It returns: // - dialed: whether the dial itself succeeded (vs. relay down). @@ -280,8 +261,17 @@ func (rt *Runtime) registryBlob() (string, error) { // - err: the read error, with any websocket.CloseError code+reason preserved // so a deliberate relay rejection isn't misread as a network blip. func (rt *Runtime) serveOnce(ctx context.Context, owner string) (dialed bool, uptime time.Duration, err error) { - c, _, err := websocket.Dial(ctx, agentSignalURL(rt.cfg.SignalURL, owner, rt.cfg.MachineID), agentDialOptions(rt.cfg.RegistrationSecret)) + c, response, err := websocket.Dial(ctx, agentSignalURL(rt.cfg.SignalURL, owner, rt.cfg.MachineID), agentDialOptions(rt.cfg.RegistrationSecret, RegistrationAuthForOwner(rt.cfg.Dir, owner))) if err != nil { + if response != nil { + if response.Body != nil { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) + response.Body.Close() + } + if response.StatusCode == http.StatusGone { + return false, 0, fmt.Errorf("%w: relay returned %s", errMachineRevoked, response.Status) + } + } return false, 0, err } defer c.CloseNow() @@ -294,15 +284,11 @@ func (rt *Runtime) serveOnce(ctx context.Context, owner string) (dialed bool, up rt.Logf("event=connected owner=%s", short(owner)) } - // Publish our encrypted device registry record as the first message, but ONLY - // for our own wallet (we hold no other wallet's K_reg). It rides this live - // registration; the relay holds it opaquely and serves it to your other - // devices. Re-publishing on every reconnect is correct (fresh nonce + ts). - if owner == rt.WalletAddress && len(rt.WalletSecret) > 0 { - if blob, err := rt.registryBlob(); err == nil { - if msg, err := json.Marshal(signal.SignalMsg{Type: signal.TypeRegistry, Registry: blob}); err == nil { - _ = c.Write(ctx, websocket.MessageText, msg) - } + // Publish the opaque record the owner provisioned during pairing. The agent + // has no key capable of opening or forging it. + if blob := RegistryForOwner(rt.cfg.Dir, owner); blob != "" { + if msg, err := json.Marshal(signal.SignalMsg{Type: signal.TypeRegistry, Registry: blob}); err == nil { + _ = c.Write(ctx, websocket.MessageText, msg) } } @@ -380,6 +366,24 @@ func (rt *Runtime) iceFor(ctx context.Context) []peer.ICEServer { } func (rt *Runtime) handleOffer(ctx context.Context, c *websocket.Conn, m signal.SignalMsg, owner string) { + // Verify owner control before allocating ICE, TURN, file descriptors, or a + // Pion PeerConnection. The stable binding selects the Noise key; the fresh + // signature binds this exact SDP to the relay-issued one-shot session. + if !rt.cfg.IsOwnerPinned(owner) || m.Session == "" || m.Auth == "" { + return + } + ownerPub, err := ownerPubFromBinding(m.Binding, owner) + if err != nil { + return + } + auth, err := base64.StdEncoding.DecodeString(m.Auth) + if err != nil || identity.VerifyAuth(owner, identity.AttachChallenge(m.Session, rt.cfg.MachineID, m.SDP), auth) != nil { + return + } + if !rt.acceptAttachSession(owner, m.Session) { + return + } + ans, opened, err := peer.NewAnswerer(rt.iceFor(ctx)) if err != nil { return @@ -427,10 +431,6 @@ func (rt *Runtime) handleOffer(ctx context.Context, c *websocket.Conn, m signal. return // no P2P path (strict P2P) — give up this attach } - ownerPub, err := ownerPubFromBinding(m.Binding, owner) - if err != nil { - return - } _ = rt.serveAuthenticated(attachCtx, dc, ownerPub) } @@ -473,13 +473,14 @@ func agentSignalURL(base, owner, machine string) string { return ws + "/agent/signal?owner_id=" + url.QueryEscape(owner) + "&machine_id=" + url.QueryEscape(machine) } -func agentDialOptions(registrationSecret string) *websocket.DialOptions { - if registrationSecret == "" { +func agentDialOptions(registrationSecret, registrationAuth string) *websocket.DialOptions { + if registrationSecret == "" && registrationAuth == "" { return nil } return &websocket.DialOptions{ HTTPHeader: http.Header{ signal.AgentRegistrationSecretHeader: []string{registrationSecret}, + signal.AgentRegistrationAuthHeader: []string{registrationAuth}, }, } } @@ -488,4 +489,5 @@ type runtimeError string func (e runtimeError) Error() string { return string(e) } -const errNoOwner = runtimeError("no paired owner; run `mir pair-dev --owner-pub ` first") +const errNoOwner = runtimeError("no paired owner; run `mir pair` first") +const errMachineRevoked = runtimeError("machine revoked by owner") diff --git a/go/internal/agent/runtime_reconnect_test.go b/go/internal/agent/runtime_reconnect_test.go index be863d6..5d8812e 100644 --- a/go/internal/agent/runtime_reconnect_test.go +++ b/go/internal/agent/runtime_reconnect_test.go @@ -124,6 +124,32 @@ func TestUpReconnectsAfterDrop(t *testing.T) { } } +func TestRevokedAgentStopsRegistrationLoop(t *testing.T) { + var attempts int32 + seen := make(chan struct{}, 4) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&attempts, 1) + seen <- struct{}{} + http.Error(w, "machine revoked", http.StatusGone) + })) + defer srv.Close() + cfg := &Config{SignalURL: srv.URL, MachineID: "m1", PairedOwners: []string{"owner"}} + rt := NewRuntime(cfg, []string{"sh"}, nil) + rt.baseBackoff, rt.maxBackoff = time.Millisecond, time.Millisecond + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + go rt.serveOwner(ctx, "owner") + select { + case <-seen: + case <-ctx.Done(): + t.Fatal("agent never attempted registration") + } + time.Sleep(20 * time.Millisecond) + if got := atomic.LoadInt32(&attempts); got != 1 { + t.Fatalf("revoked registration attempts = %d, want exactly 1", got) + } +} + // Pairing a new device/identity must take effect WITHOUT restarting the agent: // `mir up` should pick up an owner added to config.json at runtime. func TestUpHotReloadsNewlyPairedOwner(t *testing.T) { diff --git a/go/internal/agent/runtime_test.go b/go/internal/agent/runtime_test.go index 46d1175..dd90fcc 100644 --- a/go/internal/agent/runtime_test.go +++ b/go/internal/agent/runtime_test.go @@ -29,15 +29,17 @@ func TestRuntimeReclaimsAttachOnDisconnect(t *testing.T) { // Wallet-rooted owner: owner_id is the base58 wallet address and the Noise pin // is recovered from a wallet-signed binding carried on the offer (B1.4.1). - ownerPriv, _, ownerID, bindingJSON := ownerBinding(t, bytes.Repeat([]byte{0x22}, 32), "owner-device-leak") + ownerSecret := bytes.Repeat([]byte{0x22}, 32) + ownerPriv, _, ownerID, bindingJSON := ownerBinding(t, ownerSecret, "owner-device-leak") dir := t.TempDir() - if _, err := LoadOrInit(dir, "leak-machine", srv.URL); err != nil { + cfg, err := LoadOrInit(dir, "leak-machine", srv.URL) + if err != nil { t.Fatal(err) } - if err := PinOwner(dir, ownerID); err != nil { + if err := ProvisionOwner(dir, ownerID, "", ownerRegistrationAuth(t, ownerSecret, cfg)); err != nil { t.Fatal(err) } - cfg, _ := LoadOrInit(dir, "leak-machine", srv.URL) + cfg, _ = LoadOrInit(dir, "leak-machine", srv.URL) // Long-lived agent ctx — stays alive across the whole test (it must NOT be // what frees the attach). @@ -59,6 +61,14 @@ func TestRuntimeReclaimsAttachOnDisconnect(t *testing.T) { if err != nil { t.Fatal(err) } + _, readyData, err := bc.Read(dialCtx) + if err != nil { + t.Fatal(err) + } + var ready signal.SignalMsg + if json.Unmarshal(readyData, &ready) != nil || ready.Type != signal.TypeReady || ready.Session == "" { + t.Fatalf("expected attach ready, got %s", readyData) + } off, opened, err := peer.NewOfferer(nil) if err != nil { t.Fatal(err) @@ -68,7 +78,7 @@ func TestRuntimeReclaimsAttachOnDisconnect(t *testing.T) { if err != nil { t.Fatal(err) } - offerMsg, _ := json.Marshal(signal.SignalMsg{Type: signal.TypeOffer, SDP: offerSDP, Binding: bindingJSON}) + offerMsg, _ := json.Marshal(signal.SignalMsg{Type: signal.TypeOffer, SDP: offerSDP, Binding: bindingJSON, Auth: ownerAttachAuth(t, ownerSecret, ready.Session, cfg.MachineID, offerSDP)}) if err := bc.Write(dialCtx, websocket.MessageText, offerMsg); err != nil { t.Fatal(err) } diff --git a/go/internal/agent/store.go b/go/internal/agent/store.go index e3785fd..ded4978 100644 --- a/go/internal/agent/store.go +++ b/go/internal/agent/store.go @@ -3,8 +3,10 @@ package agent import ( "crypto/rand" + "crypto/sha256" "encoding/hex" "encoding/json" + "fmt" "os" "path/filepath" @@ -19,8 +21,15 @@ type Config struct { RegistrationSecret string `json:"registration_secret,omitempty"` // relay-side registration proof MachineName string `json:"machine_name"` // human label (travels E2E only) SignalURL string `json:"signal_url"` // e.g. http://localhost:8443 - PairedOwners []string `json:"paired_owners"` // hex owner pubkeys - Dir string `json:"-"` // source directory (for hot-reloading owners) + PairedOwners []string `json:"paired_owners"` // base58 Miranda owner ids + // OwnerRegistry contains opaque, owner-encrypted discovery records keyed by + // owner id. They are created by the passkey-holding client during pairing. + // The agent can republish them but cannot decrypt them. + OwnerRegistry map[string]string `json:"owner_registry,omitempty"` + // OwnerRegistrationAuth contains owner signatures authorizing this agent's + // registration-secret commitment. They are public proofs, not owner secrets. + OwnerRegistrationAuth map[string]string `json:"owner_registration_auth,omitempty"` + Dir string `json:"-"` // source directory (for hot-reloading owners) } // ReloadOwners reads the current paired-owner set from dir's config.json. Used by @@ -68,7 +77,9 @@ func LoadOrInit(dir, machineName, signalURL string) (*Config, error) { } if cfg.MachineID == "" { b := make([]byte, 8) - _, _ = rand.Read(b) + if _, err := rand.Read(b); err != nil { + return nil, err + } cfg.MachineID = hex.EncodeToString(b) } if cfg.RegistrationSecret == "" { @@ -92,17 +103,37 @@ func save(dir string, cfg *Config) error { if err != nil { return err } - path := configPath(dir) - if err := os.WriteFile(path, data, 0o600); err != nil { + // Atomic replacement avoids truncating the machine key or owner pin set if + // the process crashes during a write. + tmp, err := os.CreateTemp(dir, "config-*.json.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() return err } - // WriteFile preserves the mode of a pre-existing file (it only truncates), so - // explicitly tighten to 0600 to protect the host private key. - return os.Chmod(path, 0o600) + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, configPath(dir)) } // PinOwner adds an owner pubkey (hex) to the trusted set and persists it. func PinOwner(dir, ownerPubHex string) error { + return ProvisionOwner(dir, ownerPubHex, "", "") +} + +// ProvisionOwner pins an owner and optionally stores the opaque registry record +// that owner created during pairing. The record is safe for an untrusted agent +// host to retain: only the owner can open or forge it. +func ProvisionOwner(dir, ownerID, registryBlob, registrationAuth string) error { cfg := &Config{} data, err := os.ReadFile(configPath(dir)) if err != nil { @@ -111,12 +142,63 @@ func PinOwner(dir, ownerPubHex string) error { if err := json.Unmarshal(data, cfg); err != nil { return err } - if !cfg.IsOwnerPinned(ownerPubHex) { - cfg.PairedOwners = append(cfg.PairedOwners, ownerPubHex) + if !cfg.IsOwnerPinned(ownerID) { + cfg.PairedOwners = append(cfg.PairedOwners, ownerID) + } + if registryBlob != "" { + if cfg.OwnerRegistry == nil { + cfg.OwnerRegistry = make(map[string]string) + } + cfg.OwnerRegistry[ownerID] = registryBlob + } + if registrationAuth != "" { + if cfg.OwnerRegistrationAuth == nil { + cfg.OwnerRegistrationAuth = make(map[string]string) + } + cfg.OwnerRegistrationAuth[ownerID] = registrationAuth } return save(dir, cfg) } +// RegistryForOwner reloads an owner's opaque discovery record so a running +// agent sees newly completed pairings without receiving any owner secret. +func RegistryForOwner(dir, ownerID string) string { + data, err := os.ReadFile(configPath(dir)) + if err != nil { + return "" + } + var cfg Config + if json.Unmarshal(data, &cfg) != nil || cfg.OwnerRegistry == nil { + return "" + } + return cfg.OwnerRegistry[ownerID] +} + +// RegistrationAuthForOwner reloads the public owner authorization used when +// this agent registers its owner|machine slot with the relay. +func RegistrationAuthForOwner(dir, ownerID string) string { + data, err := os.ReadFile(configPath(dir)) + if err != nil { + return "" + } + var cfg Config + if json.Unmarshal(data, &cfg) != nil || cfg.OwnerRegistrationAuth == nil { + return "" + } + return cfg.OwnerRegistrationAuth[ownerID] +} + +// RegistrationCommitment is the value an owner signs during pairing. It is +// safe to reveal and does not permit recovery of the 256-bit random secret. +func (c *Config) RegistrationCommitment() (string, error) { + secret, err := hex.DecodeString(c.RegistrationSecret) + if err != nil || len(secret) != 32 { + return "", fmt.Errorf("invalid agent registration secret") + } + h := sha256.Sum256(secret) + return hex.EncodeToString(h[:]), nil +} + func (c *Config) IsOwnerPinned(ownerPubHex string) bool { for _, o := range c.PairedOwners { if o == ownerPubHex { diff --git a/go/internal/bip39/bip39.go b/go/internal/bip39/bip39.go index d7d2c67..1aea9f3 100644 --- a/go/internal/bip39/bip39.go +++ b/go/internal/bip39/bip39.go @@ -1,12 +1,9 @@ -// Package bip39 implements the BIP39 entropy<->mnemonic and mnemonic->seed -// steps used to render the passkey prf as a 24-word phrase and a wallet seed. +// Package bip39 implements BIP39 entropy<->mnemonic for recovery rendering. // Tiny and dependency-free for byte-identical parity with web/src/wallet/bip39.js. package bip39 import ( - "crypto/pbkdf2" "crypto/sha256" - "crypto/sha512" "fmt" "strings" ) @@ -41,19 +38,6 @@ func EntropyToMnemonic(entropy []byte) (string, error) { return strings.Join(words, " "), nil } -// MnemonicToSeed derives the 64-byte BIP39 seed via PBKDF2-HMAC-SHA512 with 2048 -// iterations and salt "mnemonic"+passphrase. Inputs must already be NFKD- -// normalized; the English wordlist and Miranda's empty passphrase are ASCII, so -// this matches the JS side (which applies NFKD) byte-for-byte. -func MnemonicToSeed(mnemonic, passphrase string) []byte { - seed, err := pbkdf2.Key(sha512.New, mnemonic, []byte("mnemonic"+passphrase), 2048, 64) - if err != nil { - // pbkdf2.Key only errors on absurd key lengths; 64 is always valid. - panic(err) - } - return seed -} - var wordIndexMap = func() map[string]int { m := make(map[string]int, len(wordlist)) for i, w := range wordlist { diff --git a/go/internal/bip39/bip39_test.go b/go/internal/bip39/bip39_test.go index 0a056a6..fb2f681 100644 --- a/go/internal/bip39/bip39_test.go +++ b/go/internal/bip39/bip39_test.go @@ -29,11 +29,9 @@ func TestEntropyToMnemonicZero(t *testing.T) { } } -func TestPrfToMnemonicAndSeed(t *testing.T) { - // External anchor (bip-utils) for the B1 fixed prf. +func TestPrfToMnemonic(t *testing.T) { prf, _ := hex.DecodeString("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff") wantMnemonic := "abandon math mimic master filter design carbon crystal rookie group knife wrap absurd much snack melt grid rough chapter fever rubber humble room trophy" - wantSeed := "559da5e7655dd1fbe657c100870512afb2b654b0acfd32f2c549344407e555bc16c2e71219eefc24acc7ed2cfaeac8a1808d543a5de4890bb2d95a7bb58af5b7" m, err := EntropyToMnemonic(prf) if err != nil { @@ -42,9 +40,6 @@ func TestPrfToMnemonicAndSeed(t *testing.T) { if m != wantMnemonic { t.Fatalf("mnemonic = %q", m) } - if got := hex.EncodeToString(MnemonicToSeed(m, "")); got != wantSeed { - t.Fatalf("seed = %s", got) - } } func TestEntropyBounds(t *testing.T) { diff --git a/go/internal/cli/agent_cmds.go b/go/internal/cli/agent_cmds.go index f4b9921..3db83b4 100644 --- a/go/internal/cli/agent_cmds.go +++ b/go/internal/cli/agent_cmds.go @@ -20,10 +20,13 @@ import ( func (a *app) cmdEnroll(args []string) error { fs := flag.NewFlagSet("enroll", flag.ExitOnError) - dir := fs.String("dir", defaultDir(), "config directory") + dir := fs.String("dir", defaultAgentDir(), "agent state directory") name := fs.String("name", hostname(), "machine display name") signalURL := fs.String("signal", defaults.SignalURL(), "signaling server base URL") _ = fs.Parse(args) + if err := ensureAgentOnlyDir(*dir); err != nil { + return err + } cfg, err := agent.LoadOrInit(*dir, *name, *signalURL) if err != nil { @@ -41,9 +44,12 @@ func (a *app) cmdEnroll(args []string) error { func (a *app) cmdPairDev(args []string) error { fs := flag.NewFlagSet("pair-dev", flag.ExitOnError) - dir := fs.String("dir", defaultDir(), "config directory") + dir := fs.String("dir", defaultAgentDir(), "agent state directory") ownerPub := fs.String("owner-pub", "", "owner X25519 public key (hex) to trust") _ = fs.Parse(args) + if err := ensureAgentOnlyDir(*dir); err != nil { + return err + } if *ownerPub == "" { return fmt.Errorf("--owner-pub is required") } @@ -56,14 +62,21 @@ func (a *app) cmdPairDev(args []string) error { func (a *app) cmdUp(args []string) error { fs := flag.NewFlagSet("up", flag.ExitOnError) - dir := fs.String("dir", defaultDir(), "config directory") + dir := fs.String("dir", defaultAgentDir(), "agent state directory") name := fs.String("name", hostname(), "machine display name") signalURL := fs.String("signal", defaults.SignalURL(), "signaling server base URL") shell := fs.String("shell", "tmux:new:-A:-s:main", "launch command, ':'-separated") ice := iceFlags(fs) autoUpdate := fs.Bool("auto-update", os.Getenv("MIR_AUTO_UPDATE") == "1", "opt-in: automatically self-update when idle") noLAN := fs.Bool("no-lan", false, "disable LAN-direct (no QUIC listener, no mDNS advertise); serve the relay only") + allowRoot := fs.Bool("allow-root", false, "unsafe override: allow the terminal agent to run as root") _ = fs.Parse(args) + if err := ensureAgentOnlyDir(*dir); err != nil { + return err + } + if os.Geteuid() == 0 && !*allowRoot { + return fmt.Errorf("refusing to expose a root shell; run mir as the target user (or pass --allow-root only in an isolated environment)") + } cfg, err := agent.LoadOrInit(*dir, *name, *signalURL) if err != nil { @@ -79,12 +92,6 @@ func (a *app) cmdUp(args []string) error { rt := agent.NewRuntime(cfg, launch, ice()) rt.DisableLAN = *noLAN - // Wallet-rooted machines auto-serve their own wallet (no pairing for your own - // devices) and publish an encrypted registry record. Legacy (wallet-less) mir - // up is unchanged: it serves PairedOwners and publishes nothing. - if err := a.applyWalletToUp(*dir, rt); err != nil { - return err - } // Structured, timestamped agent log. RFC3339-ish date+time in UTC plus the // binary prefix turns a bare "owner … disconnected" line into something you // can correlate against relay logs and tell a flap (low uptime) from a normal @@ -103,28 +110,6 @@ func (a *app) cmdUp(args []string) error { return nil } -// applyWalletToUp wires this machine's wallet into the serving Runtime. On a -// wallet-rooted identity it auto-pins the machine's OWN wallet as a served owner -// (so your own devices attach with no SAS/pairing — B1.4 bindings) and hands the -// wallet secret + address to the Runtime so it can seal + publish its encrypted -// registry record on the live registration. A wallet-less (legacy) identity is a -// no-op: `mir up` keeps today's behavior (serve PairedOwners, publish nothing). -// PinOwner writes config.json's PairedOwners (the agent hot-reloads owners; pinning -// before Up() puts it in the initial set). Any pin failure aborts so we never serve -// in a half-configured state. -func (a *app) applyWalletToUp(dir string, rt *agent.Runtime) error { - idn, err := a.identity(dir) - if err != nil || !idn.HasWallet() { - return nil // legacy / no wallet: unchanged behavior - } - if err := agent.PinOwner(dir, idn.WalletAddress); err != nil { - return err - } - rt.WalletSecret = idn.Secret() - rt.WalletAddress = idn.WalletAddress - return nil -} - // autoUpdateLoop checks for a newer release every 12h and applies it only when no // owner session is active, then re-execs into the new binary (preserving PID/FDs // so a systemd/supervisor wrapper survives). Opt-in via --auto-update / MIR_AUTO_UPDATE. diff --git a/go/internal/cli/agent_up_identity_test.go b/go/internal/cli/agent_up_identity_test.go new file mode 100644 index 0000000..0ffe49e --- /dev/null +++ b/go/internal/cli/agent_up_identity_test.go @@ -0,0 +1,49 @@ +package cli + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/srcful/terminal-relay/go/internal/agent" + "github.com/srcful/terminal-relay/go/internal/client" +) + +func TestDefaultStateSeparatesOwnerFromTarget(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clientDir := defaultClientDir() + agentDir := defaultAgentDir() + if clientDir == agentDir { + t.Fatal("client and agent state directories must be separate") + } + if !strings.HasSuffix(clientDir, filepath.Join(".miranda", "client")) || !strings.HasSuffix(agentDir, filepath.Join(".miranda", "agent")) { + t.Fatalf("unexpected state layout: client=%q agent=%q", clientDir, agentDir) + } + if _, err := client.LoadOrCreateIdentity(clientDir); err != nil { + t.Fatal(err) + } + if err := ensureAgentOnlyDir(clientDir); err == nil { + t.Fatal("agent must refuse a directory containing the owner identity") + } + if err := ensureAgentOnlyDir(agentDir); err != nil { + t.Fatalf("fresh agent directory rejected: %v", err) + } +} + +// Agent startup depends only on its machine key, owner pins, and opaque +// records. It must not create or load owner.json as a side effect. +func TestAgentConfigContainsNoOwnerIdentity(t *testing.T) { + dir := t.TempDir() + if _, err := agent.LoadOrInit(dir, "machine", "https://relay.example"); err != nil { + t.Fatal(err) + } + if client.IdentityExists(dir) { + t.Fatal("agent initialization created owner.json") + } + if err := agent.ProvisionOwner(dir, "owner-id", "opaque-record", "public-registration-auth"); err != nil { + t.Fatal(err) + } + if client.IdentityExists(dir) { + t.Fatal("owner provisioning created owner.json") + } +} diff --git a/go/internal/cli/agent_up_wallet_test.go b/go/internal/cli/agent_up_wallet_test.go deleted file mode 100644 index 78f182d..0000000 --- a/go/internal/cli/agent_up_wallet_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package cli - -import ( - "bytes" - "testing" - - "github.com/srcful/terminal-relay/go/internal/agent" -) - -// TestUpAutoPinsOwnWallet proves the wallet block in `mir up` auto-pins the -// machine's own wallet as a served owner (so your own devices need no pairing) -// and hands the wallet secret to the Runtime so it can publish the registry -// record. A wallet-rooted identity must end up pinned; the Runtime must carry the -// wallet. -func TestUpAutoPinsOwnWallet(t *testing.T) { - dir := t.TempDir() - var out, errb bytes.Buffer - a := &app{out: &out, errOut: &errb, binary: "mir"} - - // Enroll the agent so config.json exists (PinOwner reads/writes it). - cfg, err := agent.LoadOrInit(dir, "test-machine", "https://relay.example") - if err != nil { - t.Fatalf("LoadOrInit: %v", err) - } - rt := agent.NewRuntime(cfg, []string{"sh"}, nil) - - // The unit under test: load the wallet, auto-pin it, wire it into the Runtime. - if err := a.applyWalletToUp(dir, rt); err != nil { - t.Fatalf("applyWalletToUp: %v", err) - } - - idn, err := a.identity(dir) - if err != nil { - t.Fatalf("identity: %v", err) - } - if !idn.HasWallet() { - t.Fatal("fresh identity should be wallet-rooted") - } - if pinned, err := agent.ReloadOwners(dir); err != nil { - t.Fatalf("ReloadOwners: %v", err) - } else if !contains(pinned, idn.WalletAddress) { - t.Fatalf("own wallet %s not pinned; owners = %v", idn.WalletAddress, pinned) - } - if rt.WalletAddress != idn.WalletAddress { - t.Fatalf("rt.WalletAddress = %q, want %q", rt.WalletAddress, idn.WalletAddress) - } - if len(rt.WalletSecret) == 0 { - t.Fatal("rt.WalletSecret not set; Runtime cannot publish the registry record") - } -} - -func contains(ss []string, want string) bool { - for _, s := range ss { - if s == want { - return true - } - } - return false -} diff --git a/go/internal/cli/cli.go b/go/internal/cli/cli.go index 5d0eb5e..d5eebb0 100644 --- a/go/internal/cli/cli.go +++ b/go/internal/cli/cli.go @@ -7,6 +7,7 @@ package cli import ( "fmt" "io" + "os" "github.com/srcful/terminal-relay/go/internal/version" ) @@ -15,6 +16,7 @@ import ( // handler. binary is "mir" normally and "mir-agent" via the shim; it selects the // self-update release asset and labels update notices. type app struct { + in io.Reader out io.Writer // user-facing stdout errOut io.Writer // diagnostics, usage, update/deprecation notices binary string @@ -23,17 +25,23 @@ type app struct { // Run dispatches a `mir` invocation. argv is os.Args[1:] (no program name). // Returns a process exit code. func Run(argv []string, stdout, stderr io.Writer) int { - return (&app{out: stdout, errOut: stderr, binary: "mir"}).run(argv) + return (&app{in: os.Stdin, out: stdout, errOut: stderr, binary: "mir"}).run(argv) } -const agentDeprecationNotice = "note: `mir-agent` is deprecated and now an alias for `mir` — use `mir up` / `mir pair` / `mir enroll`. This shim will be removed in a future release." +// runWithInput is the testable form used by secret-input commands. Production +// callers use Run, which wires stdin to os.Stdin. +func runWithInput(argv []string, stdin io.Reader, stdout, stderr io.Writer) int { + return (&app{in: stdin, out: stdout, errOut: stderr, binary: "mir"}).run(argv) +} + +const agentDeprecationNotice = "note: `mir-agent` is deprecated and now an alias for `mir` — use `mir up` / `mir pair`. This shim will be removed in a future release." // RunAgentCompat is the deprecated mir-agent entry point: it prints a one-line // deprecation notice to stderr, then dispatches exactly like Run but labelled // "mir-agent" (so self-update fetches the mir-agent asset and notices read right). func RunAgentCompat(argv []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, agentDeprecationNotice) - return (&app{out: stdout, errOut: stderr, binary: "mir-agent"}).run(argv) + return (&app{in: os.Stdin, out: stdout, errOut: stderr, binary: "mir-agent"}).run(argv) } func (a *app) run(argv []string) int { @@ -65,8 +73,14 @@ func (a *app) run(argv []string) int { return a.exit(a.cmdUp(argv[1:])) case "pair": return a.exit(a.cmdPair(argv[1:])) + case "identity": + return a.exit(a.cmdIdentity(argv[1:])) + case "machine": + return a.exit(a.cmdMachine(argv[1:])) + case "doctor": + return a.exit(a.cmdDoctor(argv[1:])) case "wallet": - return a.exit(a.cmdWallet(argv[1:])) + return a.exit(a.cmdLegacyWallet(argv[1:])) default: a.usage() return 2 @@ -83,7 +97,7 @@ func (a *app) exit(err error) int { } func (a *app) usage() { - fmt.Fprintln(a.errOut, "usage: "+a.binary+" [flags]") + fmt.Fprintln(a.errOut, "usage: "+a.binary+" [flags]") } // guide is the no-argument landing: a friendly walkthrough of the core flow, with a @@ -95,17 +109,16 @@ func (a *app) guide() { if freshSetup() { p("👋 Welcome to " + b + ". Looks like a fresh setup.") p("") - p(b + " opens a real shell on your own machines from anywhere — no SSH, fully") - p("end-to-end encrypted. Your identity is a wallet created locally the first time") - p("you run a command; keep its 24-word phrase safe and you can restore it anywhere.") + p(b + " keeps your live terminal and AI sessions available on every device.") + p("No inbound ports or SSH keys. Your passkey identity and terminal data stay") + p("end-to-end encrypted; targets hold only their own machine keys.") p("") } - p(b + " — a real shell on your machines, from anywhere. Every node is symmetric: it") - p("can serve and it can attach.") + p(b + " — terminal continuity for long-running development and AI sessions.") p("") p(" Serve a machine (on the box you want to reach):") - p(" " + b + " up keep it reachable (persistent tmux sessions)") - p(" " + b + " pair make it pairable — prints a code + QR, then waits") + p(" " + b + " pair authorize your passkey — prints a QR + safety number") + p(" " + b + " up keep its tmux sessions reachable") p("") p(" Reach your machines (where you are):") p(" " + b + " pair pair to a machine (compare the safety numbers)") @@ -113,9 +126,11 @@ func (a *app) guide() { p(" " + b + " attach a b c several at once — Ctrl-O then 1–9 to switch") p("") p(" Identity & machines:") - p(" " + b + " wallet address your wallet — this is your owner id") - p(" " + b + " wallet export-phrase back it up (24 words; restores everything)") + p(" " + b + " identity show your Miranda owner id") + p(" " + b + " identity export-recovery emergency recovery phrase") p(" " + b + " list machines you've paired") + p(" " + b + " machine revoke --yes permanently block a lost target") + p(" " + b + " doctor verify local state, keychain, tmux and relay") p("") p("On the same network, " + b + " attach connects directly over the LAN (no relay) and") p("falls back to the relay automatically. Full help for any command: " + b + " -h") diff --git a/go/internal/cli/cli_test.go b/go/internal/cli/cli_test.go index 956c59d..a54ac32 100644 --- a/go/internal/cli/cli_test.go +++ b/go/internal/cli/cli_test.go @@ -5,11 +5,14 @@ import ( "errors" "fmt" "io" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" "testing" + "github.com/srcful/terminal-relay/go/internal/client" "github.com/srcful/terminal-relay/go/internal/peer" "github.com/srcful/terminal-relay/go/internal/version" ) @@ -35,6 +38,52 @@ func TestIsCleanDetach(t *testing.T) { } } +func TestMachineRevokePublishesAndHidesMachine(t *testing.T) { + t.Setenv("MIR_NO_UPDATE_CHECK", "1") + posts := 0 + relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/registry": + _, _ = io.WriteString(w, "[]") + case r.URL.Path == "/revocations" && r.Method == http.MethodGet: + _, _ = io.WriteString(w, "[]") + case r.URL.Path == "/revocations" && r.Method == http.MethodPost: + posts++ + w.WriteHeader(http.StatusNoContent) + default: + http.NotFound(w, r) + } + })) + defer relay.Close() + t.Setenv("MIR_SIGNAL", relay.URL) + dir := t.TempDir() + if _, err := client.LoadOrCreateIdentity(dir); err != nil { + t.Fatal(err) + } + if err := client.AddMachine(dir, client.Machine{Name: "box", MachineID: "machine-1", HostPubHex: "aabb", SignalURL: relay.URL}); err != nil { + t.Fatal(err) + } + var out, errb bytes.Buffer + if code := Run([]string{"machine", "revoke", "box", "--dir", dir, "--yes"}, &out, &errb); code != 0 { + t.Fatalf("exit = %d, stdout=%q stderr=%q", code, out.String(), errb.String()) + } + if posts != 1 { + t.Fatalf("POST count = %d, want 1", posts) + } + records, err := client.ListRevocations(dir) + if err != nil || len(records) != 1 || records[0].MachineID != "machine-1" { + t.Fatalf("records=%+v err=%v", records, err) + } + out.Reset() + errb.Reset() + if code := Run([]string{"list", "--dir", dir}, &out, &errb); code != 0 { + t.Fatalf("list exit = %d, stderr=%q", code, errb.String()) + } + if strings.Contains(out.String(), "machine-1") { + t.Fatalf("revoked machine still listed: %q", out.String()) + } +} + func TestRunVersion(t *testing.T) { var out, errb bytes.Buffer if code := Run([]string{"--version"}, &out, &errb); code != 0 { @@ -67,7 +116,7 @@ func TestNoArgsShowsGuide(t *testing.T) { var out, errb bytes.Buffer Run(nil, &out, &errb) g := out.String() - for _, want := range []string{"mir attach", "mir pair", "wallet", "LAN"} { + for _, want := range []string{"mir attach", "mir pair", "identity", "LAN"} { if !strings.Contains(g, want) { t.Fatalf("guide missing %q:\n%s", want, g) } @@ -87,7 +136,7 @@ func TestAttachLegacyIdentityGuidesToKeygen(t *testing.T) { if code := Run([]string{"attach", "--dir", dir, "box"}, &out, &errb); code == 0 { t.Fatal("attach with a wallet-less identity should fail") } - if !strings.Contains(errb.String(), "keygen --wallet") || !strings.Contains(errb.String(), "re-paired") { + if !strings.Contains(errb.String(), "identity rotate --yes") || !strings.Contains(errb.String(), "re-paired") { t.Fatalf("expected a keygen + re-pair migration hint, got:\n%s", errb.String()) } } diff --git a/go/internal/cli/client_cmds.go b/go/internal/cli/client_cmds.go index b023829..128ae8b 100644 --- a/go/internal/cli/client_cmds.go +++ b/go/internal/cli/client_cmds.go @@ -21,7 +21,7 @@ import ( ) // identity loads the client owner identity (creating it on first use), printing a -// one-time intro when it was just created so a new user learns they have a wallet +// one-time intro when it was just created so a new user learns they have an owner // identity and how to back it up. The intro goes to stderr, keeping command stdout // clean for scripts. func (a *app) identity(dir string) (*client.Identity, error) { @@ -30,29 +30,29 @@ func (a *app) identity(dir string) (*client.Identity, error) { if err != nil { return nil, err } - if fresh && id.HasWallet() { - fmt.Fprintf(a.errOut, "✓ created your %s identity — wallet %s\n", a.binary, id.WalletAddress) - fmt.Fprintf(a.errOut, " back it up anytime: %s wallet export-phrase (24 words restore your whole identity)\n\n", a.binary) + if fresh && id.HasRootedIdentity() { + fmt.Fprintf(a.errOut, "✓ created your %s identity — %s\n", a.binary, id.OwnerID) + fmt.Fprintf(a.errOut, " recovery: %s identity export-recovery\n\n", a.binary) } return id, nil } -// requireWallet returns a guided error when a legacy (pre-wallet) identity tries to -// attach, spelling out the one-time keygen + re-pair migration so the user isn't +// requireRootedIdentity returns a guided error when a legacy identity tries to +// attach, spelling out the one-time identity + re-pair migration so the user isn't // surprised when re-pairing turns out to be necessary. -func (a *app) requireWallet(id *client.Identity) error { - if id.HasWallet() { +func (a *app) requireRootedIdentity(id *client.Identity) error { + if id.HasRootedIdentity() { return nil } b := a.binary - fmt.Fprintln(a.errOut, "This identity predates wallets, so it can't attach on this version of "+b+".") + fmt.Fprintln(a.errOut, "This identity predates Miranda identity v2.") fmt.Fprintln(a.errOut) fmt.Fprintln(a.errOut, "Upgrade it (one-time):") - fmt.Fprintln(a.errOut, " "+b+" keygen --wallet") + fmt.Fprintln(a.errOut, " "+b+" identity rotate --yes") fmt.Fprintln(a.errOut) - fmt.Fprintln(a.errOut, "That mints a NEW identity (new owner id + wallet), so each machine you paired") + fmt.Fprintln(a.errOut, "That creates a NEW owner id, so each machine you paired") fmt.Fprintln(a.errOut, "before must be re-paired: run `"+b+" pair` on the machine and `"+b+" pair ` here.") - return fmt.Errorf("no wallet identity — run `%s keygen --wallet`", b) + return fmt.Errorf("legacy identity — run `%s identity rotate --yes`", b) } // cmdSelfUpdate replaces the running binary with the latest GitHub Release @@ -87,7 +87,7 @@ func (a *app) cmdSelfUpdate(args []string) error { // short window. Useful for scripts and the NAT-sim smoke test (no TTY needed). func (a *app) cmdRun(args []string) error { fs := flag.NewFlagSet("run", flag.ExitOnError) - dir := fs.String("dir", defaultDir(), "config directory") + dir := fs.String("dir", defaultClientDir(), "client state directory") ice := iceFlags(fs) window := fs.Duration("window", 3*time.Second, "how long to stream output before exiting") _ = fs.Parse(args) @@ -102,13 +102,14 @@ func (a *app) cmdRun(args []string) error { if err != nil { return err } - if err := a.requireWallet(idn); err != nil { + if err := a.requireRootedIdentity(idn); err != nil { return err } - m, err := client.GetMachine(*dir, name) + machines, err := a.resolveMachines(context.Background(), *dir, []string{name}, idn) if err != nil { return err } + m := &machines[0] ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() @@ -126,29 +127,29 @@ func (a *app) cmdRun(args []string) error { func (a *app) cmdKeygen(args []string) error { fs := flag.NewFlagSet("keygen", flag.ExitOnError) - dir := fs.String("dir", defaultDir(), "config directory") - wallet := fs.Bool("wallet", false, "re-key a legacy identity into a prf-rooted wallet identity (changes owner_id; re-pair needed)") + dir := fs.String("dir", defaultClientDir(), "client state directory") + legacyRotate := fs.Bool("wallet", false, "deprecated alias for identity rotation") _ = fs.Parse(args) id, err := a.identity(*dir) if err != nil { return err } - if *wallet && !id.HasWallet() { + if *legacyRotate && !id.HasRootedIdentity() { if id, err = client.Rekey(*dir); err != nil { return err } - fmt.Fprintln(a.errOut, "re-keyed to a prf-rooted wallet identity — owner_id changed, re-pair your machines") + fmt.Fprintln(a.errOut, "identity rotated — owner_id changed; re-pair your machines") } fmt.Fprintf(a.out, "owner public key:\n %s\n\nPin it on each machine:\n mir pair-dev --owner-pub %s\n", id.OwnerPubHex, id.OwnerPubHex) - if id.HasWallet() { - fmt.Fprintf(a.out, "\nwallet address:\n %s\n", id.WalletAddress) + if id.HasRootedIdentity() { + fmt.Fprintf(a.out, "\nMiranda owner id:\n %s\n", id.OwnerID) } return nil } func (a *app) cmdAddMachine(args []string) error { fs := flag.NewFlagSet("add-machine", flag.ExitOnError) - dir := fs.String("dir", defaultDir(), "config directory") + dir := fs.String("dir", defaultClientDir(), "client state directory") name := fs.String("name", "", "machine name") id := fs.String("id", "", "machine id (from `mir enroll`)") hostPub := fs.String("host-pub", "", "machine host public key (hex, from `mir enroll`)") @@ -167,7 +168,7 @@ func (a *app) cmdAddMachine(args []string) error { func (a *app) cmdList(args []string) error { fs := flag.NewFlagSet("list", flag.ExitOnError) - dir := fs.String("dir", defaultDir(), "config directory") + dir := fs.String("dir", defaultClientDir(), "client state directory") _ = fs.Parse(args) // Cheap, non-blocking update notice (cache-only display; refresh in background). selfupdate.New(repoSlug, a.binary).MaybeNotify(a.errOut, updateCachePath(*dir), version.Version, 24*time.Hour) @@ -177,8 +178,8 @@ func (a *app) cmdList(args []string) error { } // Discover your own machines from the relay's encrypted registry. Best-effort: - // a wallet-less identity or a relay hiccup just falls back to the local list. - // The registry is keyed by wallet on the default relay (the one agents register + // a legacy identity or a relay hiccup just falls back to the local list. + // The registry is keyed by owner id on the default relay (the one agents register // with), so fetch there regardless of any per-machine SignalURL. idn, err := a.identity(*dir) if err != nil { @@ -186,8 +187,9 @@ func (a *app) cmdList(args []string) error { } var discovered []client.Machine discoveredID := map[string]bool{} - if idn.HasWallet() { + if idn.HasRootedIdentity() { ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + a.syncRevocations(ctx, *dir, idn, []string{defaults.SignalURL()}) disc, _ := client.FetchRegistry(ctx, nil, defaults.SignalURL(), idn) cancel() discovered = disc @@ -198,9 +200,13 @@ func (a *app) cmdList(args []string) error { _ = client.NotifyNewDevices(a.errOut, *dir, disc) } - merged := client.MergeMachines(local, discovered) + revocations, err := client.ListRevocations(*dir) + if err != nil { + return err + } + merged := client.FilterRevoked(client.MergeMachines(local, discovered), idn.OwnerID, revocations) if len(merged) == 0 { - fmt.Fprintln(a.out, "no machines yet — add one with `mir add-machine`") + fmt.Fprintln(a.out, "no machines yet — run `mir pair` on a target, then scan its QR or pair with its code") return nil } for _, m := range merged { @@ -222,29 +228,157 @@ func isCleanDetach(err error) bool { return errors.Is(err, peer.ErrDataChannelClosed) || errors.Is(err, io.EOF) } -// resolveFromRegistry looks up a machine by name in the wallet's encrypted relay -// registry when it isn't pinned locally. The returned Machine is trusted: its -// host_pub was sealed under your wallet, so attaching needs no add-machine. If the -// machine isn't in the registry either, it returns an "unknown machine" error that -// hints it may simply be offline (the registry only lists live agents). -func (a *app) resolveFromRegistry(ctx context.Context, dir, name string, idn *client.Identity) (*client.Machine, error) { - if idn.HasWallet() { +// resolveMachines resolves every name against the local pin set and the owner's +// encrypted live registry in one fetch. This gives single attach, multi-attach, +// and non-interactive run identical zero-config discovery behavior. +func (a *app) resolveMachines(ctx context.Context, dir string, names []string, idn *client.Identity) ([]client.Machine, error) { + local, err := client.ListMachines(dir) + if err != nil { + return nil, err + } + var discovered []client.Machine + if idn.HasRootedIdentity() { fctx, cancel := context.WithTimeout(ctx, 8*time.Second) - disc, _ := client.FetchRegistry(fctx, nil, defaults.SignalURL(), idn) + a.syncRevocations(fctx, dir, idn, []string{defaults.SignalURL()}) + discovered, _ = client.FetchRegistry(fctx, nil, defaults.SignalURL(), idn) cancel() - _ = client.NotifyNewDevices(a.errOut, dir, disc) - for i := range disc { - if disc[i].Name == name { - return &disc[i], nil + _ = client.NotifyNewDevices(a.errOut, dir, discovered) + } + revocations, err := client.ListRevocations(dir) + if err != nil { + return nil, err + } + local = client.FilterRevoked(local, idn.OwnerID, revocations) + discovered = client.FilterRevoked(discovered, idn.OwnerID, revocations) + resolved := make([]client.Machine, 0, len(names)) + for _, name := range names { + m, ok, _ := client.ResolveMachine(local, discovered, name) + if !ok { + return nil, fmt.Errorf("unknown machine %q — it is neither paired locally nor online in your encrypted registry", name) + } + resolved = append(resolved, m) + } + return resolved, nil +} + +// syncRevocations best-effort gossips signed tombstones from each relay into the +// fail-closed local store. Relay availability is not authority: already-cached +// records are always enforced, and every newly fetched record is owner-verified. +func (a *app) syncRevocations(ctx context.Context, dir string, idn *client.Identity, relays []string) { + seen := map[string]bool{} + for _, relay := range relays { + relay = strings.TrimRight(strings.TrimSpace(relay), "/") + if relay == "" || seen[relay] { + continue + } + seen[relay] = true + records, err := client.FetchRevocations(ctx, nil, relay, idn.OwnerID) + if err != nil { + continue + } + for _, record := range records { + if err := client.RecordRevocation(dir, record); err != nil { + fmt.Fprintf(a.errOut, "warning: could not cache signed revocation: %v\n", err) + return } } } - return nil, fmt.Errorf("unknown machine %q — not paired locally and no live device by that name on your wallet (it may be offline)", name) +} + +func (a *app) cmdMachine(args []string) error { + if len(args) == 0 { + return fmt.Errorf("usage: mir machine revoke --yes") + } + switch args[0] { + case "revoke": + return a.cmdMachineRevoke(args[1:]) + default: + return fmt.Errorf("unknown machine subcommand %q", args[0]) + } +} + +func (a *app) cmdMachineRevoke(args []string) error { + fs := flag.NewFlagSet("machine revoke", flag.ExitOnError) + dir := fs.String("dir", defaultClientDir(), "client state directory") + yes := fs.Bool("yes", false, "confirm permanent machine revocation") + // Accept the human-friendly documented form `revoke box --yes` as well as + // Go flag's native `revoke --yes box` ordering. + name := "" + if len(args) > 0 && !strings.HasPrefix(args[0], "-") { + name, args = args[0], args[1:] + } + _ = fs.Parse(args) + if name == "" { + if len(fs.Args()) == 1 { + name = fs.Args()[0] + } + } else if len(fs.Args()) != 0 { + return fmt.Errorf("usage: mir machine revoke --yes") + } + if name == "" || len(fs.Args()) > 1 { + return fmt.Errorf("usage: mir machine revoke --yes") + } + if !*yes { + return fmt.Errorf("machine revocation is permanent for this owner id; re-run with --yes") + } + idn, err := a.identity(*dir) + if err != nil { + return err + } + if err := a.requireRootedIdentity(idn); err != nil { + return err + } + local, err := client.ListMachines(*dir) + if err != nil { + return err + } + var discovered []client.Machine + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + discovered, _ = client.FetchRegistry(ctx, nil, defaults.SignalURL(), idn) + machine, ok, _ := client.ResolveMachine(local, discovered, name) + if !ok { + return fmt.Errorf("unknown machine %q", name) + } + signer, err := idn.Signer() + if err != nil { + return err + } + record, err := signer.SignRevocation(machine.MachineID, time.Now()) + if err != nil { + return err + } + // Local-first is intentional: even when every relay is unavailable, this + // client must stop trusting the target immediately. + if err := client.RecordRevocation(*dir, *record); err != nil { + return err + } + fmt.Fprintf(a.out, "revoked %q locally (%s)\n", machine.Name, machine.MachineID) + + relays := []string{machine.SignalURL, defaults.SignalURL()} + seen := map[string]bool{} + var publishErrors []string + for _, relay := range relays { + relay = strings.TrimRight(strings.TrimSpace(relay), "/") + if relay == "" || seen[relay] { + continue + } + seen[relay] = true + if err := client.PostRevocation(ctx, nil, relay, *record); err != nil { + publishErrors = append(publishErrors, relay+": "+err.Error()) + continue + } + fmt.Fprintf(a.out, "published signed revocation to %s\n", relay) + } + if len(publishErrors) > 0 { + return fmt.Errorf("machine is blocked locally, but relay publication failed (%s); retry the command when online", strings.Join(publishErrors, "; ")) + } + return nil } func (a *app) cmdAttach(args []string) error { fs := flag.NewFlagSet("attach", flag.ExitOnError) - dir := fs.String("dir", defaultDir(), "config directory") + dir := fs.String("dir", defaultClientDir(), "client state directory") prefixFlag := fs.String("prefix", "ctrl-o", "multiplexer switch key (e.g. ctrl-o, ctrl-a, ctrl-space)") relayOnly := fs.Bool("relay-only", false, "skip LAN-direct discovery; use the relay") ice := iceFlags(fs) @@ -262,7 +396,7 @@ func (a *app) cmdAttach(args []string) error { if err != nil { return err } - if err := a.requireWallet(idn); err != nil { + if err := a.requireRootedIdentity(idn); err != nil { return err } // attach is long-lived, so the backgrounded refresh has time to land for the @@ -272,19 +406,13 @@ func (a *app) cmdAttach(args []string) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - if len(names) == 1 { - m, err := client.GetMachine(*dir, names[0]) - if err != nil { - // Not pinned locally — fall back to the wallet registry. A registry hit - // is wallet-authenticated (its host_pub came sealed under your wallet), - // so it's trusted: no add-machine needed for your own devices. - rm, rerr := a.resolveFromRegistry(ctx, *dir, names[0], idn) - if rerr != nil { - return rerr - } - m = rm - } - mc, sess, cleanup, err := client.Attach(ctx, *m, idn, servers, *relayOnly) + resolved, err := a.resolveMachines(ctx, *dir, names, idn) + if err != nil { + return err + } + if len(resolved) == 1 { + m := resolved[0] + mc, sess, cleanup, err := client.Attach(ctx, m, idn, servers, *relayOnly) if err != nil { return err } @@ -295,7 +423,7 @@ func (a *app) cmdAttach(args []string) error { return nil } - sessions, cleanup, err := client.AttachAll(ctx, *dir, names, idn, servers, *relayOnly) + sessions, cleanup, err := client.AttachAll(ctx, resolved, idn, servers, *relayOnly) if err != nil { return err } diff --git a/go/internal/cli/doctor.go b/go/internal/cli/doctor.go new file mode 100644 index 0000000..c66e577 --- /dev/null +++ b/go/internal/cli/doctor.go @@ -0,0 +1,264 @@ +package cli + +import ( + "bytes" + "encoding/base64" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/srcful/terminal-relay/go/internal/agent" + "github.com/srcful/terminal-relay/go/internal/client" + "github.com/srcful/terminal-relay/go/internal/defaults" + "github.com/srcful/terminal-relay/go/internal/identity" + "github.com/srcful/terminal-relay/go/internal/noise" +) + +type doctorReport struct { + w func(string, ...any) + fails int + warns int +} + +func (d *doctorReport) ok(format string, args ...any) { d.w("ok "+format+"\n", args...) } +func (d *doctorReport) warn(format string, args ...any) { + d.warns++ + d.w("warn "+format+"\n", args...) +} +func (d *doctorReport) fail(format string, args ...any) { + d.fails++ + d.w("FAIL "+format+"\n", args...) +} + +func (a *app) cmdDoctor(args []string) error { + fs := flag.NewFlagSet("doctor", flag.ExitOnError) + clientDir := fs.String("client-dir", defaultClientDir(), "client state directory") + agentDir := fs.String("agent-dir", defaultAgentDir(), "agent state directory") + signalURL := fs.String("signal", defaults.SignalURL(), "signaling relay base URL") + offline := fs.Bool("offline", false, "skip relay health check") + _ = fs.Parse(args) + d := &doctorReport{w: func(format string, values ...any) { fmt.Fprintf(a.out, format, values...) }} + + d.checkClient(*clientDir) + d.checkAgent(*agentDir) + if _, err := exec.LookPath("tmux"); err != nil { + d.warn("tmux is not installed; Miranda can serve a shell but cannot preserve terminal sessions") + } else { + d.ok("tmux is available") + } + if *offline { + d.warn("relay health check skipped (--offline)") + } else { + d.checkRelay(*signalURL) + } + if d.fails > 0 { + return fmt.Errorf("doctor found %d security/availability failure(s) and %d warning(s)", d.fails, d.warns) + } + fmt.Fprintf(a.out, "ready: %d warning(s), no blocking failures\n", d.warns) + return nil +} + +func (d *doctorReport) checkClient(dir string) { + info, err := os.Stat(dir) + if os.IsNotExist(err) { + d.warn("client identity is not initialized (%s)", dir) + return + } + if err != nil { + d.fail("cannot inspect client state: %v", err) + return + } + if info.Mode().Perm()&0o077 != 0 { + d.fail("client state directory permissions are %o, want 700 or stricter", info.Mode().Perm()) + } else { + d.ok("client state directory permissions are private") + } + ownerPath := filepath.Join(dir, "owner.json") + if !checkPrivateFile(d, ownerPath, "owner metadata") { + return + } + data, err := os.ReadFile(ownerPath) + if err != nil { + d.fail("cannot read owner metadata: %v", err) + return + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + d.fail("owner metadata is invalid JSON: %v", err) + return + } + if _, exposed := raw["secret"]; exposed { + d.fail("owner.json still contains a plaintext root; run any identity command once to migrate it") + } + if _, exposed := raw["owner_priv"]; exposed { + d.fail("owner.json contains a derived private key; rotate the legacy identity") + } + storage, err := client.InspectIdentityStorage(dir) + if err != nil { + d.fail("owner identity/keychain verification failed: %v", err) + } else if storage.LegacyPlaintext { + d.fail("owner identity uses legacy plaintext private material") + } else { + d.ok("owner root is available from %s and matches %s", storage.Backend, shortDoctorID(storage.OwnerID)) + } + if _, err := client.ListRevocations(dir); err != nil { + d.fail("local revocation store failed signature verification: %v", err) + } else { + d.ok("local signed revocation store verifies") + } +} + +func (d *doctorReport) checkAgent(dir string) { + info, err := os.Stat(dir) + if os.IsNotExist(err) { + d.warn("agent is not initialized (%s)", dir) + return + } + if err != nil { + d.fail("cannot inspect agent state: %v", err) + return + } + if info.Mode().Perm()&0o077 != 0 { + d.fail("agent state directory permissions are %o, want 700 or stricter", info.Mode().Perm()) + } else { + d.ok("agent state directory permissions are private") + } + if client.IdentityExists(dir) { + d.fail("agent directory contains owner.json; targets must never hold the owner root") + } + configPath := filepath.Join(dir, "config.json") + if !checkPrivateFile(d, configPath, "agent config") { + return + } + data, err := os.ReadFile(configPath) + if err != nil { + d.fail("cannot read agent config: %v", err) + return + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + d.fail("agent config is invalid JSON: %v", err) + return + } + if _, bad := raw["secret"]; bad { + d.fail("agent config contains an owner-root-shaped secret field") + } + if _, bad := raw["owner_priv"]; bad { + d.fail("agent config contains an owner private key") + } + var cfg agent.Config + if err := json.Unmarshal(data, &cfg); err != nil { + d.fail("agent config cannot be decoded: %v", err) + return + } + private, privateErr := hex.DecodeString(cfg.HostPrivHex) + if privateErr != nil || len(private) != 32 { + d.fail("agent host private key is missing or malformed") + } else if derived, err := noise.PublicFromPrivate(private); err != nil || !bytes.Equal(derived, cfg.HostPub()) { + d.fail("agent host public key does not match its private key") + } + if secret, err := hex.DecodeString(cfg.RegistrationSecret); err != nil || len(secret) != 32 { + d.fail("agent registration secret is missing or malformed") + } + if !validDoctorMachineID(cfg.MachineID) { + d.fail("agent machine id is missing or malformed") + } else { + d.ok("agent machine identity is structurally valid (%s)", shortDoctorID(cfg.MachineID)) + } + commitment, commitmentErr := cfg.RegistrationCommitment() + authFailed := false + for _, owner := range cfg.PairedOwners { + auth := cfg.OwnerRegistrationAuth[owner] + signature, err := base64.StdEncoding.DecodeString(auth) + if commitmentErr != nil || err != nil || identity.VerifyAuth(owner, identity.RegistrationChallenge(cfg.MachineID, commitment), signature) != nil { + d.fail("agent owner %s lacks a valid registration authorization", shortDoctorID(owner)) + authFailed = true + } + } + if len(cfg.PairedOwners) > 0 && !authFailed { + d.ok("agent owner registration authorizations verify") + } + if cfg.SignalURL != "" { + u, err := url.Parse(cfg.SignalURL) + if err != nil || u.Hostname() == "" || (u.Scheme != "https" && !(u.Scheme == "http" && isLoopbackHost(u.Hostname()))) { + d.fail("agent relay URL is unsafe: %q", cfg.SignalURL) + } + } +} + +func checkPrivateFile(d *doctorReport, path, label string) bool { + info, err := os.Stat(path) + if os.IsNotExist(err) { + d.warn("%s is absent (%s)", label, path) + return false + } + if err != nil { + d.fail("cannot stat %s: %v", label, err) + return false + } + if info.Mode().Perm()&0o077 != 0 { + d.fail("%s permissions are %o, want 600 or stricter", label, info.Mode().Perm()) + } else { + d.ok("%s permissions are private", label) + } + return true +} + +func (d *doctorReport) checkRelay(rawURL string) { + u, err := url.Parse(rawURL) + if err != nil || u.Hostname() == "" { + d.fail("relay URL is invalid: %q", rawURL) + return + } + if u.Scheme != "https" && !isLoopbackHost(u.Hostname()) { + d.fail("relay URL must use HTTPS outside localhost: %s", rawURL) + return + } + ctxClient := &http.Client{ + Timeout: 5 * time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, + } + response, err := ctxClient.Get(strings.TrimRight(rawURL, "/") + "/healthz") + if err != nil { + d.fail("relay health check failed: %v", err) + return + } + response.Body.Close() + if response.StatusCode != http.StatusOK { + d.fail("relay health returned %s", response.Status) + return + } + d.ok("relay is healthy over %s", strings.ToUpper(u.Scheme)) +} + +func isLoopbackHost(host string) bool { + return strings.EqualFold(host, "localhost") || (net.ParseIP(host) != nil && net.ParseIP(host).IsLoopback()) +} + +func shortDoctorID(value string) string { + if len(value) > 12 { + return value[:12] + "…" + } + return value +} + +func validDoctorMachineID(value string) bool { + if len(value) < 1 || len(value) > 128 { + return false + } + for _, r := range value { + if !((r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || strings.ContainsRune("._-", r)) { + return false + } + } + return true +} diff --git a/go/internal/cli/doctor_test.go b/go/internal/cli/doctor_test.go new file mode 100644 index 0000000..fa31221 --- /dev/null +++ b/go/internal/cli/doctor_test.go @@ -0,0 +1,59 @@ +package cli + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/srcful/terminal-relay/go/internal/agent" + "github.com/srcful/terminal-relay/go/internal/client" +) + +func TestDoctorValidatesHealthyLocalStateAndRelay(t *testing.T) { + clientDir := t.TempDir() + agentDir := t.TempDir() + if _, err := client.LoadOrCreateIdentity(clientDir); err != nil { + t.Fatal(err) + } + if _, err := agent.LoadOrInit(agentDir, "box", "http://localhost"); err != nil { + t.Fatal(err) + } + relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/healthz" { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusOK) + })) + defer relay.Close() + var out, errOut bytes.Buffer + code := Run([]string{"doctor", "--client-dir", clientDir, "--agent-dir", agentDir, "--signal", relay.URL}, &out, &errOut) + if code != 0 { + t.Fatalf("exit=%d stdout=%q stderr=%q", code, out.String(), errOut.String()) + } + for _, want := range []string{"owner root is available", "agent machine identity", "relay is healthy", "no blocking failures"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("doctor output missing %q:\n%s", want, out.String()) + } + } +} + +func TestDoctorRejectsLegacyPlaintextOwnerRoot(t *testing.T) { + clientDir := t.TempDir() + legacy := `{"secret":"` + strings.Repeat("ab", 32) + `","owner_pub":"` + strings.Repeat("cd", 32) + `"}` + if err := os.WriteFile(filepath.Join(clientDir, "owner.json"), []byte(legacy), 0o600); err != nil { + t.Fatal(err) + } + var out, errOut bytes.Buffer + code := Run([]string{"doctor", "--client-dir", clientDir, "--agent-dir", filepath.Join(t.TempDir(), "absent"), "--offline"}, &out, &errOut) + if code == 0 { + t.Fatalf("plaintext identity passed doctor:\n%s", out.String()) + } + if !strings.Contains(out.String(), "plaintext root") || !strings.Contains(errOut.String(), "doctor found") { + t.Fatalf("unexpected output stdout=%q stderr=%q", out.String(), errOut.String()) + } +} diff --git a/go/internal/cli/identity_cmds.go b/go/internal/cli/identity_cmds.go new file mode 100644 index 0000000..326fc9c --- /dev/null +++ b/go/internal/cli/identity_cmds.go @@ -0,0 +1,169 @@ +package cli + +import ( + "flag" + "fmt" + "io" + "os" + "strings" + + "golang.org/x/term" + + "github.com/srcful/terminal-relay/go/internal/bip39" + "github.com/srcful/terminal-relay/go/internal/client" +) + +func (a *app) cmdIdentity(args []string) error { + if len(args) == 0 { + return fmt.Errorf("usage: mir identity ") + } + switch args[0] { + case "show": + return a.cmdIdentityShow(args[1:]) + case "export-recovery": + return a.cmdIdentityExport(args[1:]) + case "import-recovery": + return a.cmdIdentityImport(args[1:]) + case "rotate": + return a.cmdIdentityRotate(args[1:]) + default: + return fmt.Errorf("unknown identity subcommand %q", args[0]) + } +} + +// cmdLegacyWallet keeps the v0.6 recovery surface long enough to give users a +// clear migration path; financial wallet/account functionality is removed. +func (a *app) cmdLegacyWallet(args []string) error { + fmt.Fprintln(a.errOut, "note: `mir wallet` was replaced by `mir identity`; Miranda identities are not cryptocurrency wallets") + if len(args) == 0 { + return fmt.Errorf("use `mir identity show|export-recovery|import-recovery`") + } + switch args[0] { + case "address": + return a.cmdIdentityShow(args[1:]) + case "export-phrase": + return a.cmdIdentityExport(args[1:]) + case "import-phrase": + return a.cmdIdentityImport(args[1:]) + default: + return fmt.Errorf("wallet subcommand %q was removed; use `mir identity`", args[0]) + } +} + +func (a *app) cmdIdentityShow(args []string) error { + fs := flag.NewFlagSet("identity show", flag.ExitOnError) + dir := fs.String("dir", defaultClientDir(), "client state directory") + _ = fs.Parse(args) + id, err := client.LoadOrCreateIdentity(*dir) + if err != nil { + return err + } + fmt.Fprintln(a.out, id.OwnerID) + return nil +} + +func (a *app) cmdIdentityExport(args []string) error { + fs := flag.NewFlagSet("identity export-recovery", flag.ExitOnError) + dir := fs.String("dir", defaultClientDir(), "client state directory") + yes := fs.Bool("yes", false, "confirm you want to reveal the secret recovery phrase") + _ = fs.Parse(args) + id, err := client.LoadOrCreateIdentity(*dir) + if err != nil { + return err + } + signer, err := id.Signer() + if err != nil { + return err + } + if !*yes { + fmt.Fprintln(a.errOut, "This prints the 24-word Miranda recovery phrase. Anyone who sees it controls all machines paired to this identity.") + return fmt.Errorf("refused: re-run with --yes in a private terminal") + } + fmt.Fprintln(a.out, signer.Mnemonic) + return nil +} + +func (a *app) cmdIdentityImport(args []string) error { + fs := flag.NewFlagSet("identity import-recovery", flag.ExitOnError) + dir := fs.String("dir", defaultClientDir(), "client state directory") + fromStdin := fs.Bool("stdin", false, "read recovery phrase from stdin (controlled automation only)") + yes := fs.Bool("yes", false, "confirm replacing the current identity") + _ = fs.Parse(args) + if !*yes { + return fmt.Errorf("this replaces the current identity and requires re-pairing; re-run with --yes") + } + phrase, err := a.readRecoveryPhrase(*fromStdin) + if err != nil { + return err + } + root, err := bip39.MnemonicToEntropy(phrase) + if err != nil { + return err + } + defer func() { + for i := range root { + root[i] = 0 + } + }() + id, err := client.LoadOrCreateIdentity(*dir) + if err != nil { + return err + } + if err := id.SetFromSecret(root); err != nil { + return err + } + if err := client.SaveIdentity(*dir, id); err != nil { + return err + } + fmt.Fprintf(a.out, "identity restored\n owner_id: %s\n", id.OwnerID) + return nil +} + +func (a *app) cmdIdentityRotate(args []string) error { + fs := flag.NewFlagSet("identity rotate", flag.ExitOnError) + dir := fs.String("dir", defaultClientDir(), "client state directory") + yes := fs.Bool("yes", false, "confirm rotating the identity and re-pairing every machine") + _ = fs.Parse(args) + if !*yes { + return fmt.Errorf("identity rotation requires re-pairing every machine; re-run with --yes") + } + id, err := client.Rekey(*dir) + if err != nil { + return err + } + fmt.Fprintf(a.out, "identity rotated\n owner_id: %s\n", id.OwnerID) + return nil +} + +func (a *app) readRecoveryPhrase(fromStdin bool) (string, error) { + if a.in == nil { + a.in = os.Stdin + } + if fromStdin { + data, err := io.ReadAll(io.LimitReader(a.in, 4097)) + if err != nil { + return "", err + } + if len(data) > 4096 { + return "", fmt.Errorf("recovery phrase input is too large") + } + if phrase := strings.TrimSpace(string(data)); phrase != "" { + return phrase, nil + } + return "", fmt.Errorf("stdin contained no recovery phrase") + } + f, ok := a.in.(*os.File) + if !ok || !term.IsTerminal(int(f.Fd())) { + return "", fmt.Errorf("recovery phrase must be entered in a TTY, or piped with explicit --stdin; it is never accepted as a command-line argument") + } + fmt.Fprint(a.errOut, "Recovery phrase (input hidden): ") + data, err := term.ReadPassword(int(f.Fd())) + fmt.Fprintln(a.errOut) + if err != nil { + return "", err + } + if phrase := strings.TrimSpace(string(data)); phrase != "" { + return phrase, nil + } + return "", fmt.Errorf("empty recovery phrase") +} diff --git a/go/internal/cli/identity_cmds_test.go b/go/internal/cli/identity_cmds_test.go new file mode 100644 index 0000000..8901e8b --- /dev/null +++ b/go/internal/cli/identity_cmds_test.go @@ -0,0 +1,80 @@ +package cli + +import ( + "bytes" + "strings" + "testing" +) + +const knownMnemonic = "abandon math mimic master filter design carbon crystal rookie group knife wrap absurd much snack melt grid rough chapter fever rubber humble room trophy" + +func run(t *testing.T, args ...string) (int, string, string) { + t.Helper() + t.Setenv("MIR_NO_UPDATE_CHECK", "1") + var out, errb bytes.Buffer + code := Run(args, &out, &errb) + return code, out.String(), errb.String() +} + +func runInput(t *testing.T, input string, args ...string) (int, string, string) { + t.Helper() + t.Setenv("MIR_NO_UPDATE_CHECK", "1") + var out, errb bytes.Buffer + code := runWithInput(args, strings.NewReader(input), &out, &errb) + return code, out.String(), errb.String() +} + +func TestIdentityShowCreatesRootedIdentity(t *testing.T) { + dir := t.TempDir() + code, out, errb := run(t, "identity", "show", "--dir", dir) + if code != 0 || len(strings.TrimSpace(out)) < 32 { + t.Fatalf("exit=%d out=%q stderr=%q", code, out, errb) + } +} + +func TestIdentityImportThenShow(t *testing.T) { + dir := t.TempDir() + code, out, errb := runInput(t, knownMnemonic, "identity", "import-recovery", "--dir", dir, "--stdin", "--yes") + if code != 0 || !strings.Contains(out, "owner_id:") { + t.Fatalf("import exit=%d out=%q stderr=%q", code, out, errb) + } + want := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(out), "identity restored\n owner_id:")) + code, out, errb = run(t, "identity", "show", "--dir", dir) + if code != 0 || strings.TrimSpace(out) != want { + t.Fatalf("show=%q want=%q stderr=%q", out, want, errb) + } +} + +func TestIdentityRecoveryExportGate(t *testing.T) { + dir := t.TempDir() + if code, _, _ := runInput(t, knownMnemonic, "identity", "import-recovery", "--dir", dir, "--stdin", "--yes"); code != 0 { + t.Fatal("import failed") + } + if code, _, errb := run(t, "identity", "export-recovery", "--dir", dir); code == 0 || !strings.Contains(errb, "--yes") { + t.Fatalf("expected guarded export, exit=%d stderr=%q", code, errb) + } + code, out, _ := run(t, "identity", "export-recovery", "--dir", dir, "--yes") + if code != 0 || strings.TrimSpace(out) != knownMnemonic { + t.Fatalf("export=%q", out) + } +} + +func TestIdentityImportRequiresExplicitStdinAndConfirmation(t *testing.T) { + dir := t.TempDir() + if code, _, errb := runInput(t, knownMnemonic, "identity", "import-recovery", "--dir", dir, "--stdin"); code == 0 || !strings.Contains(errb, "--yes") { + t.Fatalf("expected confirmation failure, exit=%d stderr=%q", code, errb) + } + if code, _, errb := runWithCapturedInput(t, knownMnemonic, "identity", "import-recovery", "--dir", dir, "--yes"); code == 0 || !strings.Contains(errb, "explicit --stdin") { + t.Fatalf("expected non-TTY input refusal, exit=%d stderr=%q", code, errb) + } +} + +func runWithCapturedInput(t *testing.T, input string, args ...string) (int, string, string) { + return runInput(t, input, args...) +} + +func TestLegacyWalletAliasHasNoFinancialAccounts(t *testing.T) { + if code, _, errb := run(t, "wallet", "accounts"); code == 0 || !strings.Contains(errb, "removed") { + t.Fatalf("exit=%d stderr=%q", code, errb) + } +} diff --git a/go/internal/cli/main_test.go b/go/internal/cli/main_test.go new file mode 100644 index 0000000..2bbd201 --- /dev/null +++ b/go/internal/cli/main_test.go @@ -0,0 +1,17 @@ +package cli + +import ( + "os" + "testing" +) + +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "miranda-cli-test-keychain-") + if err != nil { + panic(err) + } + _ = os.Setenv("MIR_TEST_KEYCHAIN_DIR", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/go/internal/cli/pair.go b/go/internal/cli/pair.go index dc983cd..66309c1 100644 --- a/go/internal/cli/pair.go +++ b/go/internal/cli/pair.go @@ -105,7 +105,7 @@ func classifyPair(positionals []string) (pairMode, string, error) { func (a *app) cmdPair(args []string) error { fs := flag.NewFlagSet("pair", flag.ExitOnError) - dir := fs.String("dir", defaultDir(), "config directory") + dir := fs.String("dir", "", "state directory (defaults to client or agent state by pairing role)") name := fs.String("name", hostname(), "machine display name (responder)") signalURL := fs.String("signal", defaults.SignalURL(), "signaling server base URL (responder)") webURL := fs.String("web", defaults.WebURL(), "browser SPA base URL the QR opens (responder)") @@ -117,6 +117,13 @@ func (a *app) cmdPair(args []string) error { if err != nil { return err } + if *dir == "" { + if mode == pairInitiator { + *dir = defaultClientDir() + } else { + *dir = defaultAgentDir() + } + } gate := sasGate{ confirmSAS: *confirmSAS, skip: *yes, @@ -126,6 +133,9 @@ func (a *app) cmdPair(args []string) error { if mode == pairInitiator { return a.pairInitiate(*dir, code, gate) } + if err := ensureAgentOnlyDir(*dir); err != nil { + return err + } return a.pairRespond(*dir, *name, *signalURL, *webURL, gate) } @@ -142,10 +152,10 @@ func (a *app) pairInitiate(dir, codeStr string, gate sasGate) error { if err != nil { return err } - if err := a.requireWallet(idn); err != nil { + if err := a.requireRootedIdentity(idn); err != nil { return err } - w, err := idn.Wallet() + w, err := idn.Signer() if err != nil { return err } @@ -156,11 +166,18 @@ func (a *app) pairInitiate(dir, codeStr string, gate sasGate) error { return err } defer closeConn() - info, binding, err := pairing.RunInitiator(ctx, mc, token, w) + var provisioned client.Machine + info, binding, err := pairing.RunInitiatorProvisioned(ctx, mc, token, w, func(info *pairing.AgentInfo) (string, error) { + provisioned = client.Machine{Name: info.Name, MachineID: info.MachineID, HostPubHex: info.HostPubHex, SignalURL: signalURL} + return client.SealRegistryMachine(idn, provisioned) + }) if err != nil { return err } - m := client.Machine{Name: info.Name, MachineID: info.MachineID, HostPubHex: info.HostPubHex, SignalURL: signalURL} + m := provisioned + if m.MachineID == "" { // defensive: provisioner must have run after msg2 + m = client.Machine{Name: info.Name, MachineID: info.MachineID, HostPubHex: info.HostPubHex, SignalURL: signalURL} + } // Show the safety number FIRST, then require confirmation BEFORE persisting — // otherwise the printed number is advisory and a MITM is never caught. @@ -185,7 +202,10 @@ func (a *app) pairRespond(dir, name, signalURL, webURL string, gate sasGate) err if err != nil { return err } - token := pairing.NewToken() + token, err := pairing.NewToken() + if err != nil { + return fmt.Errorf("generate pairing token: %w", err) + } code := pairing.EncodeCode(signalURL, token) pairURL := strings.TrimRight(webURL, "/") + "/#" + code @@ -204,8 +224,12 @@ func (a *app) pairRespond(dir, name, signalURL, webURL string, gate sasGate) err } defer closeConn() - info := pairing.AgentInfo{HostPubHex: cfg.HostPubHex, MachineID: cfg.MachineID, Name: cfg.MachineName} - wallet, binding, err := pairing.RunResponder(ctx, mc, token, info) + commitment, err := cfg.RegistrationCommitment() + if err != nil { + return err + } + info := pairing.AgentInfo{HostPubHex: cfg.HostPubHex, MachineID: cfg.MachineID, Name: cfg.MachineName, RegistrationCommitment: commitment} + ownerID, provision, binding, err := pairing.RunResponderProvisioned(ctx, mc, token, info) if err != nil { return err } @@ -217,9 +241,9 @@ func (a *app) pairRespond(dir, name, signalURL, webURL string, gate sasGate) err if ok, reason := gate.confirm(safety, a.out); !ok { return fmt.Errorf("pairing cancelled: %s", reason) } - if err := agent.PinOwner(dir, wallet); err != nil { + if err := agent.ProvisionOwner(dir, ownerID, provision.Registry, provision.RegistrationAuth); err != nil { return err } - fmt.Fprintf(a.out, "✓ paired — trusting wallet %s…\n", wallet[:16]) + fmt.Fprintf(a.out, "✓ paired — trusting Miranda identity %s…\n", ownerID[:16]) return nil } diff --git a/go/internal/cli/shared.go b/go/internal/cli/shared.go index 1c260d7..0f1d961 100644 --- a/go/internal/cli/shared.go +++ b/go/internal/cli/shared.go @@ -7,15 +7,29 @@ import ( "path/filepath" "strings" + "github.com/srcful/terminal-relay/go/internal/client" "github.com/srcful/terminal-relay/go/internal/defaults" "github.com/srcful/terminal-relay/go/internal/peer" ) const repoSlug = "srcfl/miranda" -func defaultDir() string { +func stateRoot() string { home, _ := os.UserHomeDir() - return filepath.Join(home, ".terminal-relay") + return filepath.Join(home, ".miranda") +} + +func defaultClientDir() string { return filepath.Join(stateRoot(), "client") } +func defaultAgentDir() string { return filepath.Join(stateRoot(), "agent") } + +// ensureAgentOnlyDir prevents a target machine from accidentally sharing the +// directory that contains an owner's mesh-wide client identity. A compromised +// target must yield only that target's device key. +func ensureAgentOnlyDir(dir string) error { + if client.IdentityExists(dir) { + return fmt.Errorf("refusing agent state directory %q: it contains owner.json; use a separate --dir (default: %s)", dir, defaultAgentDir()) + } + return nil } func updateCachePath(dir string) string { return filepath.Join(dir, "update-check.json") } @@ -23,9 +37,12 @@ func updateCachePath(dir string) string { return filepath.Join(dir, "update-chec // freshSetup reports whether the default config dir holds no mir state yet, so the // no-argument guide can lead with a one-time welcome. func freshSetup() bool { - dir := defaultDir() - for _, f := range []string{"owner.json", "config.json", "machines.json"} { - if _, err := os.Stat(filepath.Join(dir, f)); err == nil { + for _, path := range []string{ + filepath.Join(defaultClientDir(), "owner.json"), + filepath.Join(defaultClientDir(), "machines.json"), + filepath.Join(defaultAgentDir(), "config.json"), + } { + if _, err := os.Stat(path); err == nil { return false } } diff --git a/go/internal/cli/wallet_cmds.go b/go/internal/cli/wallet_cmds.go deleted file mode 100644 index c323e89..0000000 --- a/go/internal/cli/wallet_cmds.go +++ /dev/null @@ -1,128 +0,0 @@ -package cli - -import ( - "flag" - "fmt" - "strings" - - "github.com/srcful/terminal-relay/go/internal/bip39" - "github.com/srcful/terminal-relay/go/internal/client" - "github.com/srcful/terminal-relay/go/internal/identity" -) - -// cmdWallet dispatches `mir wallet `: the Solana wallet derived from the -// identity's prf root. Reveal/restore subcommands gate on --yes. -func (a *app) cmdWallet(args []string) error { - if len(args) == 0 { - return fmt.Errorf("usage: mir wallet ") - } - switch args[0] { - case "address": - return a.cmdWalletAddress(args[1:]) - case "accounts": - return a.cmdWalletAccounts(args[1:]) - case "export-phrase": - return a.cmdWalletExportPhrase(args[1:]) - case "import-phrase": - return a.cmdWalletImportPhrase(args[1:]) - default: - return fmt.Errorf("unknown wallet subcommand %q (want address|accounts|export-phrase|import-phrase)", args[0]) - } -} - -func (a *app) cmdWalletAddress(args []string) error { - fs := flag.NewFlagSet("wallet address", flag.ExitOnError) - dir := fs.String("dir", defaultDir(), "config directory") - _ = fs.Parse(args) - id, err := client.LoadOrCreateIdentity(*dir) - if err != nil { - return err - } - w, err := id.Wallet() - if err != nil { - return err - } - fmt.Fprintln(a.out, w.Address) - return nil -} - -func (a *app) cmdWalletAccounts(args []string) error { - fs := flag.NewFlagSet("wallet accounts", flag.ExitOnError) - dir := fs.String("dir", defaultDir(), "config directory") - count := fs.Int("count", 5, "number of HD sub-accounts to list") - _ = fs.Parse(args) - id, err := client.LoadOrCreateIdentity(*dir) - if err != nil { - return err - } - if _, err := id.Wallet(); err != nil { // legacy identities have no wallet - return err - } - if *count < 1 { - return fmt.Errorf("--count must be >= 1") - } - secret := id.Secret() - for i := 0; i < *count; i++ { - w, err := identity.DeriveWalletAccount(secret, uint32(i)) - if err != nil { - return err - } - fmt.Fprintf(a.out, "m/44'/501'/%d'/0' %s\n", i, w.Address) - } - return nil -} - -func (a *app) cmdWalletExportPhrase(args []string) error { - fs := flag.NewFlagSet("wallet export-phrase", flag.ExitOnError) - dir := fs.String("dir", defaultDir(), "config directory") - yes := fs.Bool("yes", false, "confirm you want to reveal the secret recovery phrase") - _ = fs.Parse(args) - id, err := client.LoadOrCreateIdentity(*dir) - if err != nil { - return err - } - w, err := id.Wallet() - if err != nil { - return err - } - if !*yes { - fmt.Fprintln(a.errOut, "This prints your 24-word secret recovery phrase. Anyone who sees it controls your identity and wallet.") - fmt.Fprintln(a.errOut, "Re-run with --yes in a private terminal to reveal it.") - return fmt.Errorf("refused: re-run with --yes") - } - fmt.Fprintln(a.out, w.Mnemonic) - return nil -} - -func (a *app) cmdWalletImportPhrase(args []string) error { - fs := flag.NewFlagSet("wallet import-phrase", flag.ExitOnError) - dir := fs.String("dir", defaultDir(), "config directory") - phrase := fs.String("phrase", "", "the BIP39 recovery phrase (quoted)") - yes := fs.Bool("yes", false, "confirm replacing the current identity") - _ = fs.Parse(args) - if strings.TrimSpace(*phrase) == "" { - return fmt.Errorf("--phrase is required (quote the words)") - } - secret, err := bip39.MnemonicToEntropy(*phrase) - if err != nil { - return err - } - if !*yes { - fmt.Fprintln(a.errOut, "This REPLACES your current owner identity (owner_id and wallet). Machines pinned to the old owner_id must be re-paired.") - fmt.Fprintln(a.errOut, "Re-run with --yes to proceed.") - return fmt.Errorf("refused: re-run with --yes") - } - id, err := client.LoadOrCreateIdentity(*dir) - if err != nil { - return err - } - if err := id.SetFromSecret(secret); err != nil { - return err - } - if err := client.SaveIdentity(*dir, id); err != nil { - return err - } - w, _ := id.Wallet() - fmt.Fprintf(a.out, "identity restored from phrase\n wallet: %s\n owner_id: %s\n", w.Address, id.OwnerPubHex) - return nil -} diff --git a/go/internal/cli/wallet_cmds_test.go b/go/internal/cli/wallet_cmds_test.go deleted file mode 100644 index 0afd6ee..0000000 --- a/go/internal/cli/wallet_cmds_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package cli - -import ( - "bytes" - "strings" - "testing" -) - -const ( - knownMnemonic = "abandon math mimic master filter design carbon crystal rookie group knife wrap absurd much snack melt grid rough chapter fever rubber humble room trophy" - knownAddress = "C2XYPfExbj6azVqYLWeUphzsdKK2dQ53dm83Brd3THmS" -) - -func run(t *testing.T, args ...string) (int, string, string) { - t.Helper() - t.Setenv("MIR_NO_UPDATE_CHECK", "1") - var out, errb bytes.Buffer - code := Run(args, &out, &errb) - return code, out.String(), errb.String() -} - -func TestWalletAddressCreatesPrfRooted(t *testing.T) { - dir := t.TempDir() - code, out, errb := run(t, "wallet", "address", "--dir", dir) - if code != 0 { - t.Fatalf("exit = %d, stderr = %q", code, errb) - } - if len(strings.TrimSpace(out)) < 32 { - t.Fatalf("address output = %q", out) - } -} - -func TestWalletImportThenAddressAndAccounts(t *testing.T) { - dir := t.TempDir() - // import the known phrase -> deterministic external-anchor address. - code, out, errb := run(t, "wallet", "import-phrase", "--dir", dir, "--phrase", knownMnemonic, "--yes") - if code != 0 { - t.Fatalf("import exit = %d, stderr = %q", code, errb) - } - if !strings.Contains(out, knownAddress) { - t.Fatalf("import output = %q, want %s", out, knownAddress) - } - // address reflects the imported identity. - code, out, _ = run(t, "wallet", "address", "--dir", dir) - if code != 0 || strings.TrimSpace(out) != knownAddress { - t.Fatalf("address = %q, want %s", out, knownAddress) - } - // accounts: first line is account 0 = the same address. - code, out, errb = run(t, "wallet", "accounts", "--dir", dir, "--count", "3") - if code != 0 { - t.Fatalf("accounts exit = %d, stderr = %q", code, errb) - } - lines := strings.Split(strings.TrimSpace(out), "\n") - if len(lines) != 3 { - t.Fatalf("accounts lines = %d: %q", len(lines), out) - } - if !strings.Contains(lines[0], "m/44'/501'/0'/0'") || !strings.Contains(lines[0], knownAddress) { - t.Fatalf("account 0 = %q", lines[0]) - } -} - -func TestWalletExportPhraseGate(t *testing.T) { - dir := t.TempDir() - if code, _, _ := run(t, "wallet", "import-phrase", "--dir", dir, "--phrase", knownMnemonic, "--yes"); code != 0 { - t.Fatal("import failed") - } - // refused without --yes. - code, out, errb := run(t, "wallet", "export-phrase", "--dir", dir) - if code == 0 { - t.Fatalf("export-phrase without --yes should fail; out=%q", out) - } - if !strings.Contains(errb, "--yes") { - t.Fatalf("expected --yes hint, got %q", errb) - } - // reveals with --yes. - code, out, _ = run(t, "wallet", "export-phrase", "--dir", dir, "--yes") - if code != 0 || strings.TrimSpace(out) != knownMnemonic { - t.Fatalf("export-phrase --yes = %q", out) - } -} - -func TestWalletImportPhraseGuards(t *testing.T) { - dir := t.TempDir() - // refused without --yes (identity not replaced). - if code, _, errb := run(t, "wallet", "import-phrase", "--dir", dir, "--phrase", knownMnemonic); code == 0 { - t.Fatal("import without --yes should fail") - } else if !strings.Contains(errb, "--yes") { - t.Fatalf("expected --yes hint, got %q", errb) - } - // invalid phrase errors. - if code, _, _ := run(t, "wallet", "import-phrase", "--dir", dir, "--phrase", "not a real bip39 phrase at all", "--yes"); code == 0 { - t.Fatal("invalid phrase should fail") - } -} - -func TestWalletUnknownSubcommand(t *testing.T) { - if code, _, errb := run(t, "wallet", "frobnicate"); code != 1 || !strings.Contains(errb, "unknown wallet subcommand") { - t.Fatalf("exit=%d stderr=%q", code, errb) - } -} diff --git a/go/internal/client/attach.go b/go/internal/client/attach.go index 1b8b3f7..d880ecf 100644 --- a/go/internal/client/attach.go +++ b/go/internal/client/attach.go @@ -28,8 +28,8 @@ const relayHeadStart = 200 * time.Millisecond // relay; the LAN attempt is bounded (see lanLocator.Dial) so a remote attach // drops to the relay fast. relayOnly skips LAN entirely. func Attach(ctx context.Context, m Machine, id *Identity, ice []peer.ICEServer, relayOnly bool) (mc peer.MsgConn, sess *noise.Session, cleanup func(), err error) { - if !id.HasWallet() { - return nil, nil, nil, fmt.Errorf("this identity has no wallet; run `mir keygen --wallet`") + if !id.HasRootedIdentity() { + return nil, nil, nil, fmt.Errorf("this identity predates Miranda identity v2; run `mir identity rotate --yes` and re-pair") } mc, cleanup, err = dialStaggered(ctx, attachLocators(relayOnly), relayHeadStart, m, id, ice) diff --git a/go/internal/client/binding_test.go b/go/internal/client/binding_test.go index d43c18d..e335351 100644 --- a/go/internal/client/binding_test.go +++ b/go/internal/client/binding_test.go @@ -37,8 +37,8 @@ func TestSetFromSecretCachesBinding(t *testing.T) { if err := identity.VerifyBinding(sb); err != nil { t.Fatalf("verify binding: %v", err) } - if sb.Wallet != id.WalletAddress { - t.Fatalf("binding wallet %q != identity wallet %q", sb.Wallet, id.WalletAddress) + if sb.Wallet != id.OwnerID { + t.Fatalf("binding wallet %q != identity wallet %q", sb.Wallet, id.OwnerID) } if sb.X25519 != id.OwnerPubHex { t.Fatalf("binding x25519 %q != owner pub %q", sb.X25519, id.OwnerPubHex) @@ -84,7 +84,7 @@ func TestRekeyRotatesDeviceAndBinding(t *testing.T) { if err := identity.VerifyBinding(sb); err != nil { t.Fatalf("verify rekeyed binding: %v", err) } - if sb.Wallet != rekeyed.WalletAddress || sb.X25519 != rekeyed.OwnerPubHex || sb.Device != rekeyed.DeviceID { + if sb.Wallet != rekeyed.OwnerID || sb.X25519 != rekeyed.OwnerPubHex || sb.Device != rekeyed.DeviceID { t.Fatalf("rekeyed binding fields mismatch: %+v", sb) } } diff --git a/go/internal/client/e2e_mux_test.go b/go/internal/client/e2e_mux_test.go index b954b25..e2e5a83 100644 --- a/go/internal/client/e2e_mux_test.go +++ b/go/internal/client/e2e_mux_test.go @@ -21,7 +21,7 @@ func startAgent(t *testing.T, ctx context.Context, srvURL, name string, id *Iden if err != nil { t.Fatal(err) } - if err := agent.PinOwner(dir, id.WalletAddress); err != nil { + if err := agent.PinOwner(dir, id.OwnerID); err != nil { t.Fatal(err) } cfg, _ = agent.LoadOrInit(dir, name, srvURL) diff --git a/go/internal/client/e2e_test.go b/go/internal/client/e2e_test.go index 97e4ef6..7bf0588 100644 --- a/go/internal/client/e2e_test.go +++ b/go/internal/client/e2e_test.go @@ -29,7 +29,7 @@ func TestEndToEndTrClientDrivesRealShell(t *testing.T) { if err != nil { t.Fatal(err) } - if err := agent.PinOwner(agentDir, id.WalletAddress); err != nil { + if err := agent.PinOwner(agentDir, id.OwnerID); err != nil { t.Fatal(err) } acfg, _ = agent.LoadOrInit(agentDir, "e2e-box", srv.URL) diff --git a/go/internal/client/keychain.go b/go/internal/client/keychain.go new file mode 100644 index 0000000..5ff6efb --- /dev/null +++ b/go/internal/client/keychain.go @@ -0,0 +1,184 @@ +package client + +import ( + "bytes" + "encoding/hex" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" +) + +const keychainService = "net.miranda.cli.owner" + +var secretRefRE = regexp.MustCompile(`^[0-9A-Za-z._-]{1,128}$`) + +type secretStore interface { + Get(ref string) ([]byte, error) + Put(ref string, secret []byte) error + Delete(ref string) error + Name() string +} + +func platformSecretStore() (secretStore, error) { + // Hermetic tests must never touch a developer's real login keychain. The + // override is deliberately accepted only by a Go test binary. + if dir := os.Getenv("MIR_TEST_KEYCHAIN_DIR"); dir != "" && strings.HasSuffix(os.Args[0], ".test") { + return fileSecretStore{dir: dir}, nil + } + switch runtime.GOOS { + case "darwin": + const path = "/usr/bin/security" + if _, err := os.Stat(path); err != nil { + return nil, fmt.Errorf("macOS Keychain is unavailable: %w", err) + } + return commandSecretStore{kind: "macOS Keychain", path: path}, nil + case "linux": + // Do not resolve a secret-consuming executable through caller-controlled + // PATH. Only known system locations are eligible. + for _, path := range []string{"/usr/bin/secret-tool", "/bin/secret-tool", "/usr/local/bin/secret-tool"} { + if info, err := os.Stat(path); err == nil && !info.IsDir() { + return commandSecretStore{kind: "Secret Service", path: path}, nil + } + } + return nil, fmt.Errorf("Secret Service is unavailable: install secret-tool/libsecret-tools (plaintext fallback is disabled)") + default: + return nil, fmt.Errorf("secure owner-secret storage is not supported on %s", runtime.GOOS) + } +} + +type commandSecretStore struct { + kind string + path string +} + +func (s commandSecretStore) Name() string { return s.kind } + +func (s commandSecretStore) Get(ref string) ([]byte, error) { + var cmd *exec.Cmd + if runtime.GOOS == "darwin" { + cmd = exec.Command(s.path, "find-generic-password", "-a", ref, "-s", keychainService, "-w") + } else { + cmd = exec.Command(s.path, "lookup", "service", keychainService, "owner", ref) + } + cmd.Stderr = io.Discard + out, err := cmd.Output() + if err != nil { + // Never include command output: some keychain tools can echo secret data. + return nil, fmt.Errorf("%s: owner secret %q is unavailable", s.kind, ref) + } + secret := bytes.TrimSpace(out) + if len(secret) == 0 { + return nil, fmt.Errorf("%s: owner secret %q is empty", s.kind, ref) + } + return secret, nil +} + +func (s commandSecretStore) Put(ref string, secret []byte) error { + var cmd *exec.Cmd + if runtime.GOOS == "darwin" { + // With -w last, security reads the password from stdin. It never appears in + // argv (and therefore not in ps output, shell history, or diagnostics). + cmd = exec.Command(s.path, "add-generic-password", "-a", ref, "-s", keychainService, "-U", "-w") + } else { + cmd = exec.Command(s.path, "store", "--label=Miranda owner identity", "service", keychainService, "owner", ref) + } + input := append(append([]byte(nil), secret...), '\n') + defer zeroBytes(input) + cmd.Stdin = bytes.NewReader(input) + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s: could not store owner secret (plaintext fallback is disabled)", s.kind) + } + return nil +} + +func (s commandSecretStore) Delete(ref string) error { + var cmd *exec.Cmd + if runtime.GOOS == "darwin" { + cmd = exec.Command(s.path, "delete-generic-password", "-a", ref, "-s", keychainService) + } else { + cmd = exec.Command(s.path, "clear", "service", keychainService, "owner", ref) + } + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s: could not delete retired owner secret", s.kind) + } + return nil +} + +// fileSecretStore is test-only; platformSecretStore refuses to select it from a +// production binary even if the environment variable is present. +type fileSecretStore struct{ dir string } + +func (s fileSecretStore) Name() string { return "test keychain" } +func (s fileSecretStore) path(ref string) (string, error) { + if !secretRefRE.MatchString(ref) { + return "", fmt.Errorf("invalid secret reference") + } + return filepath.Join(s.dir, ref), nil +} +func (s fileSecretStore) Get(ref string) ([]byte, error) { + path, err := s.path(ref) + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("test keychain: owner secret unavailable") + } + return bytes.TrimSpace(data), nil +} +func (s fileSecretStore) Put(ref string, secret []byte) error { + path, err := s.path(ref) + if err != nil { + return err + } + if err := os.MkdirAll(s.dir, 0o700); err != nil { + return err + } + data := append(append([]byte(nil), secret...), '\n') + defer zeroBytes(data) + return os.WriteFile(path, data, 0o600) +} + +func encodeSecretHex(secret []byte) []byte { + encoded := make([]byte, hex.EncodedLen(len(secret))) + hex.Encode(encoded, secret) + return encoded +} + +func decodeSecretHex(encoded []byte) ([]byte, error) { + encoded = bytes.TrimSpace(encoded) + if len(encoded) != 64 { + return nil, fmt.Errorf("owner identity: keychain secret must be 32 bytes") + } + secret := make([]byte, 32) + if _, err := hex.Decode(secret, encoded); err != nil { + zeroBytes(secret) + return nil, fmt.Errorf("owner identity: keychain secret must be 32 bytes") + } + return secret, nil +} + +func zeroBytes(value []byte) { + for i := range value { + value[i] = 0 + } +} +func (s fileSecretStore) Delete(ref string) error { + path, err := s.path(ref) + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} diff --git a/go/internal/client/lan_locator.go b/go/internal/client/lan_locator.go index b89ae82..c7cfe53 100644 --- a/go/internal/client/lan_locator.go +++ b/go/internal/client/lan_locator.go @@ -23,14 +23,14 @@ type resolver interface { // lanLocator reaches an agent on the local network: it resolves the machine_id // to a host:port (mDNS in prod, injectable in tests), QUIC-dials it, and sends -// the wallet binding as the first application frame before Noise-KK runs inside -// the MsgConn. It returns ErrUnreachable on any miss (no wallet, resolve miss, +// the owner binding as the first application frame before Noise-KK runs inside +// the MsgConn. It returns ErrUnreachable on any miss (legacy identity, resolve miss, // dial/send failure) so Attach falls through to the relay path. type lanLocator struct{ res resolver } func (l lanLocator) Dial(ctx context.Context, m Machine, id *Identity, _ []peer.ICEServer) (peer.MsgConn, func(), error) { - if !id.HasWallet() { - return nil, nil, ErrUnreachable // LAN attach requires a wallet binding + if !id.HasRootedIdentity() { + return nil, nil, ErrUnreachable // LAN attach requires an owner binding } // Bound the whole LAN attempt (resolve + dial) so a remote attach — where no // LAN peer answers — falls through to the relay fast instead of waiting out @@ -45,7 +45,7 @@ func (l lanLocator) Dial(ctx context.Context, m Machine, id *Identity, _ []peer. if err != nil { return nil, nil, ErrUnreachable } - if err := conn.Send([]byte(id.BindingJSON)); err != nil { // frame 0: the wallet binding + if err := conn.Send([]byte(id.BindingJSON)); err != nil { // frame 0: the owner binding _ = conn.Close() return nil, nil, ErrUnreachable } diff --git a/go/internal/client/lan_locator_test.go b/go/internal/client/lan_locator_test.go index a7b439d..71b6bdf 100644 --- a/go/internal/client/lan_locator_test.go +++ b/go/internal/client/lan_locator_test.go @@ -60,7 +60,7 @@ func TestLANLocatorDialSendsBinding(t *testing.T) { if err := id.SetFromSecret(secret); err != nil { t.Fatalf("set from secret: %v", err) } - if !id.HasWallet() { + if !id.HasRootedIdentity() { t.Fatal("expected identity to have a wallet") } @@ -123,8 +123,8 @@ func TestLANLocatorUnreachableOnResolveMiss(t *testing.T) { // LAN attach (which requires a binding), so Dial returns ErrUnreachable before // even touching the resolver. func TestLANLocatorUnreachableWithoutWallet(t *testing.T) { - id := &Identity{} // no SecretHex/BindingJSON => HasWallet()==false - if id.HasWallet() { + id := &Identity{} // no SecretHex/BindingJSON => HasRootedIdentity()==false + if id.HasRootedIdentity() { t.Fatal("expected identity to have no wallet") } diff --git a/go/internal/client/main_test.go b/go/internal/client/main_test.go new file mode 100644 index 0000000..2e26f42 --- /dev/null +++ b/go/internal/client/main_test.go @@ -0,0 +1,17 @@ +package client + +import ( + "os" + "testing" +) + +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "miranda-client-test-keychain-") + if err != nil { + panic(err) + } + _ = os.Setenv("MIR_TEST_KEYCHAIN_DIR", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/go/internal/client/muxterm.go b/go/internal/client/muxterm.go index 3d0d520..2d5f10e 100644 --- a/go/internal/client/muxterm.go +++ b/go/internal/client/muxterm.go @@ -13,10 +13,10 @@ import ( "github.com/srcful/terminal-relay/go/internal/peer" ) -// AttachAll attaches every named machine and returns their sessions + a cleanup. +// AttachAll attaches every resolved machine and returns their sessions + a cleanup. // On any failure it cleans up the ones already attached. relayOnly is threaded to // each Attach to skip LAN-direct discovery. -func AttachAll(ctx context.Context, dir string, names []string, id *Identity, ice []peer.ICEServer, relayOnly bool) ([]*MuxSession, func(), error) { +func AttachAll(ctx context.Context, machines []Machine, id *Identity, ice []peer.ICEServer, relayOnly bool) ([]*MuxSession, func(), error) { var sessions []*MuxSession var cleanups []func() cleanupAll := func() { @@ -24,16 +24,11 @@ func AttachAll(ctx context.Context, dir string, names []string, id *Identity, ic c() } } - for _, name := range names { - m, err := GetMachine(dir, name) + for _, m := range machines { + mc, sess, cleanup, err := Attach(ctx, m, id, ice, relayOnly) if err != nil { cleanupAll() - return nil, nil, err - } - mc, sess, cleanup, err := Attach(ctx, *m, id, ice, relayOnly) - if err != nil { - cleanupAll() - return nil, nil, fmt.Errorf("attach %s: %w", name, err) + return nil, nil, fmt.Errorf("attach %s: %w", m.Name, err) } sessions = append(sessions, &MuxSession{Name: m.Name, MC: mc, Sess: sess}) cleanups = append(cleanups, cleanup) diff --git a/go/internal/client/registry.go b/go/internal/client/registry.go index 8d8abdf..af4f8e9 100644 --- a/go/internal/client/registry.go +++ b/go/internal/client/registry.go @@ -3,6 +3,7 @@ package client import ( "context" + "crypto/rand" "encoding/base64" "encoding/json" "fmt" @@ -17,38 +18,71 @@ import ( "github.com/srcful/terminal-relay/go/internal/identity" ) -// registryEntry is the relay's blind wire shape: GET /registry?wallet=W -> -// [{machine_id, blob}]. blob is base64(nonce||ciphertext||tag), sealed by a -// wallet-holding agent (AAD = machine_id). The relay never opens it. +// registryEntry is the relay's blind wire shape: GET /registry?owner_id=W -> +// [{machine_id, blob}]. blob is base64(nonce||ciphertext||tag), sealed by the +// owner client (AAD = machine_id). The relay never opens it. type registryEntry struct { MachineID string `json:"machine_id"` Blob string `json:"blob"` } // registryRecord is the sealed plaintext an agent publishes: {v, name, host_pub, -// signal_url, ts}. Only the wallet-holder can open the blob to recover it. +// signal_url, ts}. Only an owner-root holder can open the blob to recover it. type registryRecord struct { + V int `json:"v"` Name string `json:"name"` HostPub string `json:"host_pub"` SignalURL string `json:"signal_url"` + TS int64 `json:"ts"` } -// FetchRegistry asks the relay for this wallet's live device records and decrypts -// them. Forged/garbage blobs (sealed by a non-wallet-holder) fail to open and are -// silently dropped. Best-effort: a relay error or a wallet-less identity returns +// SealRegistryMachine creates the opaque discovery record provisioned to an +// agent during pairing. Encryption happens on the owner client; the agent only +// stores and republishes the blob and never receives the registry key or root. +func SealRegistryMachine(id *Identity, m Machine) (string, error) { + if !id.HasRootedIdentity() { + return "", fmt.Errorf("registry: identity has no secret root") + } + rec := registryRecord{V: 1, Name: m.Name, HostPub: m.HostPubHex, SignalURL: m.SignalURL, TS: time.Now().Unix()} + pt, err := json.Marshal(rec) + if err != nil { + return "", err + } + secret := id.Secret() + defer zeroBytes(secret) + key, err := identity.RegistryKey(secret) + if err != nil { + return "", err + } + nonce := make([]byte, 12) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + blob, err := identity.SealRecord(key, nonce, pt, m.MachineID) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(blob), nil +} + +// FetchRegistry asks the relay for this owner's live device records and decrypts +// them. Forged/garbage blobs (sealed without the owner root) fail to open and are +// silently dropped. Best-effort: a relay error or a legacy identity returns // nil so callers can fall back to the local machines.json without surfacing noise. func FetchRegistry(ctx context.Context, hc *http.Client, signalURL string, id *Identity) ([]Machine, error) { - if !id.HasWallet() { + if !id.HasRootedIdentity() { return nil, nil } - key, err := identity.RegistryKey(id.Secret()) + secret := id.Secret() + defer zeroBytes(secret) + key, err := identity.RegistryKey(secret) if err != nil { return nil, err } if hc == nil { hc = &http.Client{Timeout: 8 * time.Second} } - url := strings.TrimRight(signalURL, "/") + "/registry?wallet=" + neturl.QueryEscape(id.WalletAddress) + url := strings.TrimRight(signalURL, "/") + "/registry?owner_id=" + neturl.QueryEscape(id.OwnerID) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err @@ -155,7 +189,7 @@ func NotifyNewDevices(w io.Writer, dir string, machines []Machine) error { known[m.MachineID] = true seen.MachineIDs = append(seen.MachineIDs, m.MachineID) changed = true - fmt.Fprintf(w, "📣 new device %q joined your wallet\n", m.Name) + fmt.Fprintf(w, "📣 new machine %q joined your Miranda identity\n", m.Name) } if !changed { return nil diff --git a/go/internal/client/registry_test.go b/go/internal/client/registry_test.go index a93bbb1..d996c0a 100644 --- a/go/internal/client/registry_test.go +++ b/go/internal/client/registry_test.go @@ -84,8 +84,8 @@ func TestFetchRegistryDecryptsAndDropsForgeries(t *testing.T) { http.NotFound(w, r) return } - if got := r.URL.Query().Get("wallet"); got != id.WalletAddress { - t.Errorf("wallet query = %q, want %q", got, id.WalletAddress) + if got := r.URL.Query().Get("owner_id"); got != id.OwnerID { + t.Errorf("owner_id query = %q, want %q", got, id.OwnerID) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode([]any{good, forged}) @@ -215,7 +215,7 @@ func TestNotifyNewDevices(t *testing.T) { if !strings.Contains(first, `"box"`) || !strings.Contains(first, `"kitchen"`) { t.Fatalf("first notify should name both new devices, got:\n%s", first) } - if !strings.Contains(first, "new device") || !strings.Contains(first, "joined your wallet") { + if !strings.Contains(first, "new machine") || !strings.Contains(first, "joined your Miranda identity") { t.Fatalf("first notify wording = %q", first) } diff --git a/go/internal/client/relay_locator.go b/go/internal/client/relay_locator.go index 479fbc0..463f7f6 100644 --- a/go/internal/client/relay_locator.go +++ b/go/internal/client/relay_locator.go @@ -3,6 +3,7 @@ package client import ( "context" + "encoding/base64" "encoding/json" "fmt" "net/url" @@ -11,6 +12,7 @@ import ( "github.com/coder/websocket" + "github.com/srcful/terminal-relay/go/internal/identity" "github.com/srcful/terminal-relay/go/internal/peer" "github.com/srcful/terminal-relay/go/internal/signal" ) @@ -21,7 +23,7 @@ import ( type relayLocator struct{} func (relayLocator) Dial(ctx context.Context, m Machine, id *Identity, ice []peer.ICEServer) (peer.MsgConn, func(), error) { - ownerID := id.WalletAddress + ownerID := id.OwnerID wsURL := "ws" + strings.TrimPrefix(m.SignalURL, "http") + "/attach?owner_id=" + url.QueryEscape(ownerID) + "&machine_id=" + url.QueryEscape(m.MachineID) @@ -31,6 +33,16 @@ func (relayLocator) Dial(ctx context.Context, m Machine, id *Identity, ice []pee return nil, nil, fmt.Errorf("dial signaling: %w", err) } closeWS := func() { _ = c.CloseNow() } + _, readyData, err := c.Read(ctx) + if err != nil { + closeWS() + return nil, nil, fmt.Errorf("signaling session: %w", err) + } + var ready signal.SignalMsg + if json.Unmarshal(readyData, &ready) != nil || ready.Type != signal.TypeReady || ready.Session == "" { + closeWS() + return nil, nil, fmt.Errorf("signaling: missing attach session") + } off, opened, err := peer.NewOfferer(ice) if err != nil { @@ -44,7 +56,13 @@ func (relayLocator) Dial(ctx context.Context, m Machine, id *Identity, ice []pee cleanup() return nil, nil, err } - offerMsg, _ := json.Marshal(signal.SignalMsg{Type: signal.TypeOffer, SDP: offerSDP, Binding: id.BindingJSON}) + signer, err := id.Signer() + if err != nil { + cleanup() + return nil, nil, err + } + auth := signer.SignAuth(identity.AttachChallenge(ready.Session, m.MachineID, offerSDP)) + offerMsg, _ := json.Marshal(signal.SignalMsg{Type: signal.TypeOffer, SDP: offerSDP, Binding: id.BindingJSON, Auth: base64.StdEncoding.EncodeToString(auth)}) if err := c.Write(ctx, websocket.MessageText, offerMsg); err != nil { cleanup() return nil, nil, err diff --git a/go/internal/client/revocations.go b/go/internal/client/revocations.go new file mode 100644 index 0000000..f53f144 --- /dev/null +++ b/go/internal/client/revocations.go @@ -0,0 +1,201 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + neturl "net/url" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/srcful/terminal-relay/go/internal/identity" +) + +const maxRevocationResponseBytes = 4 << 20 + +type localRevocations struct { + V int `json:"v"` + Revocations []identity.Revocation `json:"revocations"` +} + +func revocationsPath(dir string) string { return filepath.Join(dir, "revocations.json") } + +// ListRevocations loads and verifies the local owner-signed tombstones. A +// corrupt store fails closed: callers must not silently attach while the local +// deny-list cannot be trusted. +func ListRevocations(dir string) ([]identity.Revocation, error) { + data, err := os.ReadFile(revocationsPath(dir)) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + var file localRevocations + if err := json.Unmarshal(data, &file); err != nil { + return nil, fmt.Errorf("revocations: decode local store: %w", err) + } + if file.V != 1 { + return nil, fmt.Errorf("revocations: unsupported local store version %d", file.V) + } + for i := range file.Revocations { + if err := identity.VerifyRevocation(&file.Revocations[i]); err != nil { + return nil, fmt.Errorf("revocations: invalid local record: %w", err) + } + } + return file.Revocations, nil +} + +// RecordRevocation verifies and atomically stores a permanent tombstone. +func RecordRevocation(dir string, record identity.Revocation) error { + if err := identity.VerifyRevocation(&record); err != nil { + return err + } + records, err := ListRevocations(dir) + if err != nil { + return err + } + replaced := false + for i := range records { + if records[i].OwnerID == record.OwnerID && records[i].MachineID == record.MachineID { + records[i] = record + replaced = true + break + } + } + if !replaced { + records = append(records, record) + } + sort.Slice(records, func(i, j int) bool { + if records[i].OwnerID == records[j].OwnerID { + return records[i].MachineID < records[j].MachineID + } + return records[i].OwnerID < records[j].OwnerID + }) + data, err := json.MarshalIndent(localRevocations{V: 1, Revocations: records}, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + if err := os.Chmod(dir, 0o700); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, "revocations-*.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, revocationsPath(dir)) +} + +// FilterRevoked removes machines covered by a verified tombstone for ownerID. +func FilterRevoked(machines []Machine, ownerID string, records []identity.Revocation) []Machine { + revoked := make(map[string]bool, len(records)) + for _, record := range records { + if record.OwnerID == ownerID { + revoked[record.MachineID] = true + } + } + out := make([]Machine, 0, len(machines)) + for _, machine := range machines { + if !revoked[machine.MachineID] { + out = append(out, machine) + } + } + return out +} + +func IsMachineRevoked(machineID, ownerID string, records []identity.Revocation) bool { + for _, record := range records { + if record.OwnerID == ownerID && record.MachineID == machineID { + return true + } + } + return false +} + +// FetchRevocations obtains owner-signed records from a relay and independently +// verifies every signature. The relay may suppress records but cannot forge one. +func FetchRevocations(ctx context.Context, hc *http.Client, signalURL, ownerID string) ([]identity.Revocation, error) { + if hc == nil { + hc = &http.Client{Timeout: 8 * time.Second} + } + url := strings.TrimRight(signalURL, "/") + "/revocations?owner_id=" + neturl.QueryEscape(ownerID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := hc.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("revocations: relay returned %s", resp.Status) + } + dec := json.NewDecoder(io.LimitReader(resp.Body, maxRevocationResponseBytes)) + var records []identity.Revocation + if err := dec.Decode(&records); err != nil { + return nil, fmt.Errorf("revocations: decode relay response: %w", err) + } + for i := range records { + if records[i].OwnerID != ownerID { + return nil, fmt.Errorf("revocations: relay returned a record for another owner") + } + if err := identity.VerifyRevocation(&records[i]); err != nil { + return nil, fmt.Errorf("revocations: relay returned an invalid record: %w", err) + } + } + return records, nil +} + +// PostRevocation publishes a signed tombstone. The caller should persist it +// locally first, so a relay outage never re-enables the machine on this client. +func PostRevocation(ctx context.Context, hc *http.Client, signalURL string, record identity.Revocation) error { + if err := identity.VerifyRevocation(&record); err != nil { + return err + } + if hc == nil { + hc = &http.Client{Timeout: 8 * time.Second} + } + body, err := json.Marshal(record) + if err != nil { + return err + } + url := strings.TrimRight(signalURL, "/") + "/revocations" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := hc.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("revocations: relay returned %s: %s", resp.Status, strings.TrimSpace(string(msg))) + } + return nil +} diff --git a/go/internal/client/revocations_test.go b/go/internal/client/revocations_test.go new file mode 100644 index 0000000..f131a6a --- /dev/null +++ b/go/internal/client/revocations_test.go @@ -0,0 +1,121 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/srcful/terminal-relay/go/internal/identity" +) + +func testRevocation(t *testing.T, machine string) identity.Revocation { + t.Helper() + signer, err := identity.DeriveSigner(bytes.Repeat([]byte{0x42}, 32)) + if err != nil { + t.Fatal(err) + } + record, err := signer.SignRevocation(machine, time.Unix(1_700_000_000, 0)) + if err != nil { + t.Fatal(err) + } + return *record +} + +func TestRecordAndFilterRevocations(t *testing.T) { + dir := t.TempDir() + record := testRevocation(t, "machine-1") + if err := RecordRevocation(dir, record); err != nil { + t.Fatal(err) + } + // Duplicate recording is idempotent. + if err := RecordRevocation(dir, record); err != nil { + t.Fatal(err) + } + records, err := ListRevocations(dir) + if err != nil { + t.Fatal(err) + } + if len(records) != 1 || !IsMachineRevoked("machine-1", record.OwnerID, records) { + t.Fatalf("records = %+v", records) + } + filtered := FilterRevoked([]Machine{{MachineID: "machine-1"}, {MachineID: "machine-2"}}, record.OwnerID, records) + if len(filtered) != 1 || filtered[0].MachineID != "machine-2" { + t.Fatalf("filtered = %+v", filtered) + } + info, err := os.Stat(filepath.Join(dir, "revocations.json")) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("mode = %o, want 600", info.Mode().Perm()) + } +} + +func TestListRevocationsFailsClosedOnTampering(t *testing.T) { + dir := t.TempDir() + record := testRevocation(t, "machine-1") + if err := RecordRevocation(dir, record); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "revocations.json") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + data = bytes.Replace(data, []byte("machine-1"), []byte("machine-2"), 1) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + if _, err := ListRevocations(dir); err == nil { + t.Fatal("tampered local store unexpectedly accepted") + } +} + +func TestFetchAndPostRevocationsVerifyRelayData(t *testing.T) { + record := testRevocation(t, "machine-1") + var posted identity.Revocation + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + if r.URL.Query().Get("owner_id") != record.OwnerID { + t.Fatalf("owner query = %q", r.URL.Query().Get("owner_id")) + } + json.NewEncoder(w).Encode([]identity.Revocation{record}) + case http.MethodPost: + if err := json.NewDecoder(r.Body).Decode(&posted); err != nil { + t.Fatal(err) + } + w.WriteHeader(http.StatusNoContent) + } + })) + defer server.Close() + records, err := FetchRevocations(context.Background(), nil, server.URL, record.OwnerID) + if err != nil || len(records) != 1 { + t.Fatalf("FetchRevocations = %+v, %v", records, err) + } + if err := PostRevocation(context.Background(), nil, server.URL, record); err != nil { + t.Fatal(err) + } + if posted.Signature != record.Signature { + t.Fatalf("posted record changed: %+v", posted) + } +} + +func TestFetchRevocationsRejectsRelayForgery(t *testing.T) { + record := testRevocation(t, "machine-1") + forged := record + forged.MachineID = "machine-2" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode([]identity.Revocation{forged}) + })) + defer server.Close() + if _, err := FetchRevocations(context.Background(), nil, server.URL, record.OwnerID); err == nil { + t.Fatal("forged relay record unexpectedly accepted") + } +} diff --git a/go/internal/client/store.go b/go/internal/client/store.go index 4898f10..1d4dde2 100644 --- a/go/internal/client/store.go +++ b/go/internal/client/store.go @@ -3,6 +3,7 @@ package client import ( "crypto/rand" + "crypto/subtle" "encoding/hex" "encoding/json" "fmt" @@ -13,18 +14,21 @@ import ( "github.com/srcful/terminal-relay/go/internal/identity" ) -// Identity is the client's owner identity (owner.json). New identities are -// prf-rooted: a 32-byte secret derives BOTH the X25519 transport key (owner_priv) -// and the Solana wallet (wallet_address). Legacy identities created before B1 -// have only owner_priv (a directly-generated X25519 key) and no secret — they -// keep working for transport but have no wallet until re-keyed. +// Identity is trusted-client state. Modern identities keep their root in the OS +// keychain and persist only SecretRef plus public metadata in owner.json. Both +// private keys are re-derived in memory. SecretHex and OwnerPrivHex are read-only +// migration fields and are scrubbed on the first successful secure save. type Identity struct { - SecretHex string `json:"secret,omitempty"` // 32-byte prf root (hex); absent on legacy identities - OwnerPrivHex string `json:"owner_priv"` // X25519 transport private key (hex) - OwnerPubHex string `json:"owner_pub"` // X25519 transport public key (hex) — the legacy owner_id - WalletAddress string `json:"wallet_address,omitempty"` // base58 Solana address — the wallet owner_id - DeviceID string `json:"device_id,omitempty"` // stable per-identity device id (binding.device) - BindingJSON string `json:"binding,omitempty"` // cached wallet->x25519 binding (signed at re-key) + SecretHex string `json:"secret,omitempty"` // legacy plaintext root; migration only + SecretRef string `json:"secret_ref,omitempty"` // opaque OS-keychain account + OwnerPrivHex string `json:"owner_priv,omitempty"` // legacy only; rooted identities re-derive in memory + OwnerPubHex string `json:"owner_pub"` // X25519 transport public key + OwnerID string `json:"owner_id,omitempty"` // neutral Ed25519 Miranda identity + DeviceID string `json:"device_id,omitempty"` // stable per-identity device id (binding.device) + BindingJSON string `json:"binding,omitempty"` // cached signer->x25519 binding + + secret []byte `json:"-"` + obsoleteSecretRef string `json:"-"` } // Machine is a known agent (machines.json), pinned by host pubkey. @@ -35,6 +39,13 @@ type Machine struct { SignalURL string `json:"signal_url"` } +type IdentityStorageInfo struct { + Exists bool + OwnerID string + Backend string + LegacyPlaintext bool +} + func identityPath(dir string) string { return filepath.Join(dir, "owner.json") } func machinesPath(dir string) string { return filepath.Join(dir, "machines.json") } @@ -45,6 +56,60 @@ func IdentityExists(dir string) bool { return err == nil } +// InspectIdentityStorage validates owner.json and its keychain entry without +// creating, migrating, chmodding, or otherwise mutating state. +func InspectIdentityStorage(dir string) (IdentityStorageInfo, error) { + data, err := os.ReadFile(identityPath(dir)) + if os.IsNotExist(err) { + return IdentityStorageInfo{}, nil + } + if err != nil { + return IdentityStorageInfo{}, err + } + var id Identity + if err := json.Unmarshal(data, &id); err != nil { + return IdentityStorageInfo{}, fmt.Errorf("owner identity: invalid metadata: %w", err) + } + info := IdentityStorageInfo{Exists: true, OwnerID: id.OwnerID, LegacyPlaintext: id.SecretHex != "" || id.OwnerPrivHex != ""} + if id.SecretRef == "" { + if info.LegacyPlaintext { + return info, nil + } + return info, fmt.Errorf("owner identity: no keychain reference or legacy identity material") + } + store, err := platformSecretStore() + if err != nil { + return info, err + } + encoded, err := store.Get(id.SecretRef) + if err != nil { + return info, err + } + defer zeroBytes(encoded) + secret, err := decodeSecretHex(encoded) + if err != nil { + return info, err + } + defer zeroBytes(secret) + _, pub, err := identity.DeriveOwnerKey(secret) + if err != nil { + return info, err + } + signer, err := identity.DeriveSigner(secret) + if err != nil { + return info, err + } + if id.OwnerID != "" && id.OwnerID != signer.Address { + return info, fmt.Errorf("owner identity: public id does not match keychain root") + } + if id.OwnerPubHex != "" && id.OwnerPubHex != hex.EncodeToString(pub) { + return info, fmt.Errorf("owner identity: transport public key does not match keychain root") + } + info.OwnerID = signer.Address + info.Backend = store.Name() + return info, nil +} + // LoadOrCreateIdentity reads owner.json, creating a fresh owner keypair on first use. func LoadOrCreateIdentity(dir string) (*Identity, error) { if err := os.MkdirAll(dir, 0o700); err != nil { @@ -57,9 +122,43 @@ func LoadOrCreateIdentity(dir string) (*Identity, error) { return nil, err } } - if id.OwnerPrivHex == "" { - // Fresh identity: prf-rooted. One secret derives transport + wallet. + if id.SecretRef != "" { + store, err := platformSecretStore() + if err != nil { + return nil, err + } + encoded, err := store.Get(id.SecretRef) + if err != nil { + return nil, err + } + defer zeroBytes(encoded) + secret, err := decodeSecretHex(encoded) + if err != nil { + return nil, err + } + defer zeroBytes(secret) + if err := id.setFromSecret(secret, true); err != nil { + return nil, err + } + } else if id.SecretHex != "" { + secret, err := hex.DecodeString(id.SecretHex) + if err != nil || len(secret) != 32 { + return nil, fmt.Errorf("owner identity: secret must be 32 bytes") + } + defer zeroBytes(secret) + if err := id.setFromSecret(secret, false); err != nil { + return nil, err + } + // One-way migration: do not remove plaintext until the keychain write and + // atomic owner.json replacement have both succeeded. + if err := SaveIdentity(dir, id); err != nil { + return nil, err + } + } + if id.OwnerPrivHex == "" && len(id.secret) == 0 { + // Fresh identity: one root derives independent transport and signing keys. secret := make([]byte, 32) + defer zeroBytes(secret) if _, err := rand.Read(secret); err != nil { return nil, err } @@ -74,21 +173,32 @@ func LoadOrCreateIdentity(dir string) (*Identity, error) { return id, nil } -// SetFromSecret roots the identity in a 32-byte prf secret, deriving both the -// X25519 transport key and the Solana wallet from it. +// SetFromSecret derives independent X25519 transport and Ed25519 signing keys. func (i *Identity) SetFromSecret(secret []byte) error { - priv, pub, err := identity.DeriveOwnerKey(secret) + return i.setFromSecret(secret, false) +} + +func (i *Identity) setFromSecret(secret []byte, preserveRef bool) error { + if len(secret) != 32 { + return fmt.Errorf("owner identity: secret must be 32 bytes") + } + if !preserveRef && i.SecretRef != "" { + i.obsoleteSecretRef = i.SecretRef + i.SecretRef = "" + } + _, pub, err := identity.DeriveOwnerKey(secret) if err != nil { return err } - w, err := identity.DeriveWallet(secret) + w, err := identity.DeriveSigner(secret) if err != nil { return err } - i.SecretHex = hex.EncodeToString(secret) - i.OwnerPrivHex = hex.EncodeToString(priv) + i.secret = append(i.secret[:0], secret...) + i.SecretHex = "" + i.OwnerPrivHex = "" // derived on demand; never persist this redundant private key i.OwnerPubHex = hex.EncodeToString(pub) - i.WalletAddress = w.Address + i.OwnerID = w.Address if i.DeviceID == "" { d := make([]byte, 8) if _, err := rand.Read(d); err != nil { @@ -108,18 +218,27 @@ func (i *Identity) SetFromSecret(secret []byte) error { return nil } -// Rekey replaces the identity with a fresh prf-rooted one (new secret -> new -// owner_id + wallet). Used to migrate a legacy identity or rotate keys. Machines +// Rekey replaces the identity with a fresh root. Machines // pinned to the old owner_id must be re-paired. func Rekey(dir string) (*Identity, error) { if err := os.MkdirAll(dir, 0o700); err != nil { return nil, err } secret := make([]byte, 32) + defer zeroBytes(secret) if _, err := rand.Read(secret); err != nil { return nil, err } id := &Identity{} + if data, readErr := os.ReadFile(identityPath(dir)); readErr == nil { + var old Identity + if err := json.Unmarshal(data, &old); err != nil { + return nil, fmt.Errorf("owner identity: cannot rotate corrupt metadata: %w", err) + } + id.obsoleteSecretRef = old.SecretRef + } else if !os.IsNotExist(readErr) { + return nil, readErr + } if err := id.SetFromSecret(secret); err != nil { return nil, err } @@ -129,33 +248,174 @@ func Rekey(dir string) (*Identity, error) { return id, nil } -// SaveIdentity writes owner.json with 0600 perms (it holds the root secret). +// SaveIdentity stores the root in the OS keychain, then atomically writes only +// public metadata plus an opaque reference to owner.json. func SaveIdentity(dir string, id *Identity) error { - data, err := json.MarshalIndent(id, "", " ") + secret := id.Secret() + if len(secret) != 32 { + return fmt.Errorf("owner identity: no 32-byte root is loaded") + } + defer zeroBytes(secret) + store, err := platformSecretStore() if err != nil { return err } - if err := os.WriteFile(identityPath(dir), data, 0o600); err != nil { + targetRef := id.SecretRef + createdRef := false + if targetRef == "" { + refBytes := make([]byte, 16) + if _, err := rand.Read(refBytes); err != nil { + return err + } + targetRef = "owner-" + hex.EncodeToString(refBytes) + createdRef = true + } + encodedSecret := encodeSecretHex(secret) + defer zeroBytes(encodedSecret) + if err := store.Put(targetRef, encodedSecret); err != nil { + return err + } + persisted := *id + persisted.SecretRef = targetRef + persisted.SecretHex = "" + persisted.OwnerPrivHex = "" + persisted.secret = nil + persisted.obsoleteSecretRef = "" + data, err := json.MarshalIndent(&persisted, "", " ") + if err != nil { + if createdRef { + _ = store.Delete(targetRef) + } return err } - return os.Chmod(identityPath(dir), 0o600) + if err := os.MkdirAll(dir, 0o700); err != nil { + if createdRef { + _ = store.Delete(targetRef) + } + return err + } + if err := os.Chmod(dir, 0o700); err != nil { + if createdRef { + _ = store.Delete(targetRef) + } + return err + } + tmp, err := os.CreateTemp(dir, "owner-*.json.tmp") + if err != nil { + if createdRef { + _ = store.Delete(targetRef) + } + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if _, err := tmp.Write(data); err != nil { + tmp.Close() + if createdRef { + _ = store.Delete(targetRef) + } + return err + } + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + if createdRef { + _ = store.Delete(targetRef) + } + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + if createdRef { + _ = store.Delete(targetRef) + } + return err + } + if err := tmp.Close(); err != nil { + if createdRef { + _ = store.Delete(targetRef) + } + return err + } + if err := os.Rename(tmpName, identityPath(dir)); err != nil { + if createdRef { + _ = store.Delete(targetRef) + } + return err + } + oldRef := id.obsoleteSecretRef + id.SecretRef = targetRef + id.SecretHex = "" + id.OwnerPrivHex = "" + id.obsoleteSecretRef = "" + if oldRef != "" && oldRef != targetRef { + if err := store.Delete(oldRef); err != nil { + return fmt.Errorf("identity saved, but retired keychain entry cleanup failed: %w", err) + } + } + return nil } -func (i *Identity) OwnerPriv() []byte { b, _ := hex.DecodeString(i.OwnerPrivHex); return b } -func (i *Identity) OwnerPub() []byte { b, _ := hex.DecodeString(i.OwnerPubHex); return b } +func (i *Identity) OwnerPriv() []byte { + if secret := i.Secret(); len(secret) == 32 { + defer zeroBytes(secret) + priv, _, err := identity.DeriveOwnerKey(secret) + if err == nil { + return priv + } + } + b, _ := hex.DecodeString(i.OwnerPrivHex) + return b +} +func (i *Identity) OwnerPub() []byte { b, _ := hex.DecodeString(i.OwnerPubHex); return b } // Secret returns the 32-byte prf root, or nil for a legacy identity. -func (i *Identity) Secret() []byte { b, _ := hex.DecodeString(i.SecretHex); return b } +func (i *Identity) Secret() []byte { + if len(i.secret) > 0 { + return append([]byte(nil), i.secret...) + } + b, _ := hex.DecodeString(i.SecretHex) // legacy migration before first load + return b +} + +// HasRootedIdentity reports whether the modern Miranda identity root exists. +func (i *Identity) HasRootedIdentity() bool { return len(i.Secret()) == 32 } -// HasWallet reports whether this identity is prf-rooted (and thus has a wallet). -func (i *Identity) HasWallet() bool { return i.SecretHex != "" } +// CheckSecretStorage verifies that the loaded root matches its OS-keychain +// entry. It is read-only and intended for `mir doctor`. +func (i *Identity) CheckSecretStorage() (string, error) { + if i.SecretRef == "" || len(i.Secret()) != 32 { + return "", fmt.Errorf("owner identity is not backed by a keychain reference") + } + store, err := platformSecretStore() + if err != nil { + return "", err + } + encoded, err := store.Get(i.SecretRef) + if err != nil { + return "", err + } + defer zeroBytes(encoded) + stored, err := decodeSecretHex(encoded) + if err != nil { + return "", err + } + defer zeroBytes(stored) + loaded := i.Secret() + defer zeroBytes(loaded) + if subtle.ConstantTimeCompare(stored, loaded) != 1 { + return "", fmt.Errorf("keychain owner secret does not match the loaded identity") + } + return store.Name(), nil +} -// Wallet derives the account-0 Solana wallet, or errors for a legacy identity. -func (i *Identity) Wallet() (*identity.Wallet, error) { - if !i.HasWallet() { - return nil, fmt.Errorf("this identity predates wallets (no secret root); re-key with `mir keygen --wallet` to create one") +// Signer re-derives the neutral Ed25519 owner signer in memory. +func (i *Identity) Signer() (*identity.Signer, error) { + if !i.HasRootedIdentity() { + return nil, fmt.Errorf("this identity predates Miranda identity v2; rotate with `mir identity rotate --yes` and re-pair") } - return identity.DeriveWallet(i.Secret()) + secret := i.Secret() + defer zeroBytes(secret) + return identity.DeriveSigner(secret) } // AddMachine inserts or updates a known machine by name. @@ -236,5 +496,5 @@ func GetMachine(dir, name string) (*Machine, error) { return &list[i], nil } } - return nil, fmt.Errorf("unknown machine %q (add it with `mir add-machine`)", name) + return nil, fmt.Errorf("unknown machine %q (pair it with `mir pair`)", name) } diff --git a/go/internal/client/store_test.go b/go/internal/client/store_test.go index bdc48c9..467f0d4 100644 --- a/go/internal/client/store_test.go +++ b/go/internal/client/store_test.go @@ -2,7 +2,12 @@ package client import ( + "bytes" + "encoding/json" + "fmt" "os" + "path/filepath" + "strings" "testing" ) @@ -19,8 +24,85 @@ func TestIdentityIsCreatedOnceAndStable(t *testing.T) { if err != nil { t.Fatal(err) } - if id2.OwnerPrivHex != id.OwnerPrivHex { - t.Fatal("owner identity not stable across loads") + if id2.OwnerPrivHex != "" { + t.Fatalf("derived owner private key was persisted: %q", id2.OwnerPrivHex) + } + if id2.SecretHex != "" || id2.SecretRef == "" { + t.Fatalf("root was not migrated to keychain metadata: secret=%q ref=%q", id2.SecretHex, id2.SecretRef) + } + backend, err := id2.CheckSecretStorage() + if err != nil || backend != "test keychain" { + t.Fatalf("CheckSecretStorage = %q, %v", backend, err) + } + data, err := os.ReadFile(identityPath(dir)) + if err != nil { + t.Fatal(err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + if _, ok := raw["secret"]; ok { + t.Fatal("owner.json contains the root secret") + } + if _, ok := raw["owner_priv"]; ok { + t.Fatal("owner.json contains a derived private key") + } + if !bytes.Equal(id2.OwnerPriv(), id.OwnerPriv()) { + t.Fatal("owner identity not stable across re-derivation") + } +} + +func TestLegacyPlaintextRootMigratesAtomicallyToKeychain(t *testing.T) { + dir := t.TempDir() + root := bytes.Repeat([]byte{0x31}, 32) + legacy := map[string]string{"secret": fmt.Sprintf("%x", root), "owner_priv": strings.Repeat("aa", 32)} + data, _ := json.Marshal(legacy) + if err := os.WriteFile(identityPath(dir), data, 0o600); err != nil { + t.Fatal(err) + } + id, err := LoadOrCreateIdentity(dir) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(id.Secret(), root) || id.SecretRef == "" { + t.Fatal("migrated identity lost its root") + } + after, err := os.ReadFile(identityPath(dir)) + if err != nil { + t.Fatal(err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(after, &raw); err != nil { + t.Fatal(err) + } + if _, ok := raw["secret"]; ok { + t.Fatal("plaintext secret survived migration") + } + if _, ok := raw["owner_priv"]; ok { + t.Fatal("derived private key survived migration") + } +} + +func TestMissingKeychainEntryFailsClosed(t *testing.T) { + dir := t.TempDir() + id, err := LoadOrCreateIdentity(dir) + if err != nil { + t.Fatal(err) + } + store, err := platformSecretStore() + if err != nil { + t.Fatal(err) + } + if err := store.Delete(id.SecretRef); err != nil { + t.Fatal(err) + } + if _, err := LoadOrCreateIdentity(dir); err == nil { + t.Fatal("missing keychain entry unexpectedly created a new identity") + } + // The metadata remains present; fail-closed must not rewrite it. + if _, err := os.Stat(filepath.Join(dir, "owner.json")); err != nil { + t.Fatal(err) } } diff --git a/go/internal/identity/auth.go b/go/internal/identity/auth.go index 141179b..2d3b2e9 100644 --- a/go/internal/identity/auth.go +++ b/go/internal/identity/auth.go @@ -1,34 +1,55 @@ -// go/internal/identity/auth.go — wallet control proof over a fresh challenge +// go/internal/identity/auth.go — owner control proof over a fresh challenge // (e.g. a pairing channel binding). Mirrors web/src/identity/auth.js. package identity import ( "crypto/ed25519" + "crypto/sha256" "fmt" "github.com/srcful/terminal-relay/go/internal/base58" ) // AuthDomain separates auth signatures from binding signatures and any other use -// of the wallet key. Signed bytes = AuthDomain || challenge. +// of the owner signing key. Signed bytes = AuthDomain || challenge. const AuthDomain = "miranda/auth/v1" +const attachDomain = "miranda/attach/v1" + +const registrationDomain = "miranda/agent-registration/v1" + func authMessage(challenge []byte) []byte { return append([]byte(AuthDomain), challenge...) } -// SignAuth proves control of the wallet over a fresh challenge. Returns the raw +// SignAuth proves control of the owner identity over a fresh challenge. Returns the raw // 64-byte Ed25519 signature. -func (w *Wallet) SignAuth(challenge []byte) []byte { - return ed25519.Sign(w.Priv, authMessage(challenge)) +func (s *Signer) SignAuth(challenge []byte) []byte { + return ed25519.Sign(s.Priv, authMessage(challenge)) } -// VerifyAuth checks a SignAuth signature against a base58 wallet address. -func VerifyAuth(walletBase58 string, challenge, sig []byte) error { - pub, err := base58.Decode(walletBase58) +// VerifyAuth checks a SignAuth signature against a base58 Miranda owner id. +func VerifyAuth(ownerID string, challenge, sig []byte) error { + pub, err := base58.Decode(ownerID) if err != nil || len(pub) != ed25519.PublicKeySize { - return fmt.Errorf("auth: bad wallet key") + return fmt.Errorf("auth: bad owner key") } if len(sig) != ed25519.SignatureSize || !ed25519.Verify(ed25519.PublicKey(pub), authMessage(challenge), sig) { return fmt.Errorf("auth: signature does not verify") } return nil } + +// AttachChallenge binds an owner signature to one relay-issued session, one +// machine, and the exact SDP offer. An observed signature cannot authorize a +// fresh session or a modified offer. Mirrors web/src/identity/auth.js. +func AttachChallenge(session, machineID, sdp string) []byte { + h := sha256.Sum256([]byte(sdp)) + return []byte(fmt.Sprintf("%s\n%s\n%s\n%x", attachDomain, session, machineID, h)) +} + +// RegistrationChallenge lets an owner authorize one agent registration secret +// for one machine. The secret itself is never sent through pairing: only its +// SHA-256 commitment is signed. The relay later verifies the owner signature +// and checks that the presented secret opens the commitment. +func RegistrationChallenge(machineID, commitment string) []byte { + return []byte(fmt.Sprintf("%s\n%s\n%s", registrationDomain, machineID, commitment)) +} diff --git a/go/internal/identity/auth_test.go b/go/internal/identity/auth_test.go index 29bdfb4..cba3297 100644 --- a/go/internal/identity/auth_test.go +++ b/go/internal/identity/auth_test.go @@ -3,22 +3,34 @@ package identity import ( "bytes" + "strings" "testing" ) -func mustWallet(t *testing.T) *Wallet { +func mustWallet(t *testing.T) *Signer { t.Helper() prf := make([]byte, 32) for i := range prf { prf[i] = byte(i) } - w, err := DeriveWallet(prf) + w, err := DeriveSigner(prf) if err != nil { t.Fatalf("DeriveWallet: %v", err) } return w } +func TestRegistrationChallengeIsUnambiguous(t *testing.T) { + got := string(RegistrationChallenge("machine-1", strings.Repeat("ab", 32))) + want := "miranda/agent-registration/v1\nmachine-1\n" + strings.Repeat("ab", 32) + if got != want { + t.Fatalf("RegistrationChallenge = %q, want %q", got, want) + } + if bytes.Equal(RegistrationChallenge("machine-1", "a"), RegistrationChallenge("machine-2", "a")) { + t.Fatal("registration challenge must bind the machine id") + } +} + func TestSignAuthVerifies(t *testing.T) { w := mustWallet(t) challenge := []byte("a fresh channel binding") @@ -61,7 +73,7 @@ func TestVerifyAuthRejectsWrongWallet(t *testing.T) { for i := range prf2 { prf2[i] = byte(255 - i) } - other, err := DeriveWallet(prf2) + other, err := DeriveSigner(prf2) if err != nil { t.Fatalf("DeriveWallet: %v", err) } diff --git a/go/internal/identity/binding.go b/go/internal/identity/binding.go index 9c8f9be..6754d78 100644 --- a/go/internal/identity/binding.go +++ b/go/internal/identity/binding.go @@ -12,7 +12,7 @@ import ( "github.com/srcful/terminal-relay/go/internal/base58" ) -// bindingDomain separates binding signatures from any other use of the wallet +// bindingDomain separates binding signatures from any other use of the owner // key (auth, future schemes). Signed bytes = bindingDomain || canonical(binding). const bindingDomain = "miranda/binding/v1" @@ -21,18 +21,18 @@ var ( hex64Re = regexp.MustCompile(`^[0-9a-f]{64}$`) ) -// Binding authorizes a device's X25519 transport key under the wallet identity. -// A wallet-addressed peer presents this so others can accept its Noise-KK static -// key. Mirrors web/src/identity/binding.js exactly. +// Binding authorizes a device's X25519 transport key under the Miranda owner +// identity. The Wallet field name is retained only for wire compatibility. +// Mirrors web/src/identity/binding.js exactly. type Binding struct { V int // version, always 1 - Wallet string // base58 Ed25519 wallet address (the signer) + Wallet string // base58 Ed25519 Miranda owner id (legacy wire field name) Device string // machine_id X25519 string // hex of the 32-byte transport public key it authorizes Ts int64 // unix seconds } -// SignedBinding is a Binding plus the wallet's base58 signature. +// SignedBinding is a Binding plus the owner's base58 signature. type SignedBinding struct { Binding Sig string @@ -43,7 +43,7 @@ func (b Binding) validate() error { return fmt.Errorf("binding: unsupported version %d", b.V) } if pk, err := base58.Decode(b.Wallet); err != nil || len(pk) != ed25519.PublicKeySize { - return fmt.Errorf("binding: wallet is not a 32-byte base58 key") + return fmt.Errorf("binding: owner is not a 32-byte base58 key") } if !deviceRe.MatchString(b.Device) { return fmt.Errorf("binding: device has unsafe characters") @@ -85,8 +85,8 @@ func bindingMessage(canonical string) []byte { } // SignBinding builds and signs a binding authorizing device + x25519 (the 32-byte -// transport pub, hex) under this wallet. -func (w *Wallet) SignBinding(device, x25519 string, ts int64) (*SignedBinding, error) { +// transport pub, hex) under this owner identity. +func (w *Signer) SignBinding(device, x25519 string, ts int64) (*SignedBinding, error) { b := Binding{V: 1, Wallet: w.Address, Device: device, X25519: x25519, Ts: ts} canon, err := b.Canonical() if err != nil { @@ -96,7 +96,7 @@ func (w *Wallet) SignBinding(device, x25519 string, ts int64) (*SignedBinding, e return &SignedBinding{Binding: b, Sig: base58.Encode(sig)}, nil } -// VerifyBinding checks the signature against the wallet public key embedded in +// VerifyBinding checks the signature against the owner public key embedded in // the binding. Returns nil iff valid. func VerifyBinding(sb *SignedBinding) error { canon, err := sb.Binding.Canonical() @@ -105,7 +105,7 @@ func VerifyBinding(sb *SignedBinding) error { } pub, err := base58.Decode(sb.Wallet) if err != nil || len(pub) != ed25519.PublicKeySize { - return fmt.Errorf("binding: bad wallet key") + return fmt.Errorf("binding: bad owner key") } sig, err := base58.Decode(sb.Sig) if err != nil || len(sig) != ed25519.SignatureSize { diff --git a/go/internal/identity/binding_test.go b/go/internal/identity/binding_test.go index 383e23a..9caba7f 100644 --- a/go/internal/identity/binding_test.go +++ b/go/internal/identity/binding_test.go @@ -8,7 +8,7 @@ import ( "testing" ) -// Fixed binding inputs for the vector: the B1.1 test wallet binds the X25519 +// Fixed binding inputs: the test signer binds the X25519 // transport key derived from the same prf. ts is a fixed constant (no clock). const ( bindDevice = "a1b2c3d4e5f60718" @@ -16,10 +16,10 @@ const ( bindTs = int64(1749600000) ) -func testWallet(t *testing.T) *Wallet { +func testSigner(t *testing.T) *Signer { t.Helper() - prf, _ := hex.DecodeString(walletPrfHex) - w, err := DeriveWallet(prf) + prf, _ := hex.DecodeString(signerRootHex) + w, err := DeriveSigner(prf) if err != nil { t.Fatal(err) } @@ -27,7 +27,7 @@ func testWallet(t *testing.T) *Wallet { } func TestSignVerifyRoundTrip(t *testing.T) { - w := testWallet(t) + w := testSigner(t) sb, err := w.SignBinding(bindDevice, bindX25519, bindTs) if err != nil { t.Fatal(err) @@ -50,7 +50,7 @@ func TestSignVerifyRoundTrip(t *testing.T) { } func TestTamperFailsVerify(t *testing.T) { - w := testWallet(t) + w := testSigner(t) sb, _ := w.SignBinding(bindDevice, bindX25519, bindTs) bad := *sb bad.Device = "b1b2c3d4e5f60718" // flip one field; sig no longer matches @@ -65,7 +65,7 @@ func TestTamperFailsVerify(t *testing.T) { } func TestRejectsUnsafeFields(t *testing.T) { - w := testWallet(t) + w := testSigner(t) for _, dev := range []string{`a"b`, `a\b`, "a b", "a,b", ""} { if _, err := w.SignBinding(dev, bindX25519, bindTs); err == nil { t.Errorf("device %q should be rejected", dev) @@ -77,7 +77,7 @@ func TestRejectsUnsafeFields(t *testing.T) { } func bindingVectorPath() string { - return filepath.Join("..", "..", "..", "testdata", "wallet-binding.json") + return filepath.Join("..", "..", "..", "testdata", "identity-binding.json") } type bindingVector struct { @@ -92,7 +92,7 @@ type bindingVector struct { } func TestBindingVector(t *testing.T) { - w := testWallet(t) + w := testSigner(t) sb, err := w.SignBinding(bindDevice, bindX25519, bindTs) if err != nil { t.Fatal(err) @@ -116,7 +116,7 @@ func TestBindingVector(t *testing.T) { if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { t.Fatal(err) } - t.Log("wallet-binding.json written") + t.Log("identity-binding.json written") return } diff --git a/go/internal/identity/fuzz_test.go b/go/internal/identity/fuzz_test.go new file mode 100644 index 0000000..94eb3d5 --- /dev/null +++ b/go/internal/identity/fuzz_test.go @@ -0,0 +1,18 @@ +package identity + +import ( + "encoding/json" + "testing" +) + +func FuzzRevocationJSON(f *testing.F) { + f.Add([]byte(`{"v":1,"owner_id":"invalid","machine_id":"machine-1","ts":1,"signature":"AA=="}`)) + f.Add([]byte(`{}`)) + f.Add([]byte(`not-json`)) + f.Fuzz(func(t *testing.T, data []byte) { + var record Revocation + if json.Unmarshal(data, &record) == nil { + _ = VerifyRevocation(&record) + } + }) +} diff --git a/go/internal/identity/registry.go b/go/internal/identity/registry.go index ffdb685..96b8c3a 100644 --- a/go/internal/identity/registry.go +++ b/go/internal/identity/registry.go @@ -11,11 +11,11 @@ import ( ) // registrySalt domain-separates the registry AEAD key from any other use of the -// wallet secret. K_reg = HKDF-SHA256(wallet_secret, registrySalt, "aead-key"). +// owner root. K_reg = HKDF-SHA256(owner_root, registrySalt, "aead-key"). const registrySalt = "miranda/registry/v1" -// RegistryKey derives the symmetric registry-encryption key from the wallet's -// 32-byte prf secret. Only wallet-holders can derive it; the relay never sees it. +// RegistryKey derives the symmetric registry-encryption key from the 32-byte +// owner root. Only owner clients can derive it; the relay never sees it. // Mirrors web/src/identity/registry.js exactly. func RegistryKey(secret []byte) ([]byte, error) { r := hkdf.New(sha256.New, secret, []byte(registrySalt), []byte("aead-key")) diff --git a/go/internal/identity/revocation.go b/go/internal/identity/revocation.go new file mode 100644 index 0000000..8e1edb3 --- /dev/null +++ b/go/internal/identity/revocation.go @@ -0,0 +1,55 @@ +package identity + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "regexp" + "time" +) + +const revocationDomain = "miranda/revocation/v1" + +var machineIDRe = regexp.MustCompile(`^[0-9A-Za-z._-]{1,128}$`) + +// Revocation is a permanent owner-signed tombstone for one machine id. A relay +// can suppress it (availability is never trusted), but cannot forge one. +type Revocation struct { + V int `json:"v"` + OwnerID string `json:"owner_id"` + MachineID string `json:"machine_id"` + TS int64 `json:"ts"` + Signature string `json:"signature"` +} + +func RevocationChallenge(machineID string, ts int64) []byte { + return []byte(fmt.Sprintf("%s\n%s\n%d", revocationDomain, machineID, ts)) +} + +func (s *Signer) SignRevocation(machineID string, ts time.Time) (*Revocation, error) { + if !machineIDRe.MatchString(machineID) { + return nil, fmt.Errorf("revocation: invalid machine id") + } + unix := ts.Unix() + if unix <= 0 { + return nil, fmt.Errorf("revocation: invalid timestamp") + } + sig := s.SignAuth(RevocationChallenge(machineID, unix)) + return &Revocation{V: 1, OwnerID: s.Address, MachineID: machineID, TS: unix, Signature: base64.StdEncoding.EncodeToString(sig)}, nil +} + +func VerifyRevocation(r *Revocation) error { + if r == nil || r.V != 1 || !machineIDRe.MatchString(r.MachineID) || r.TS <= 0 { + return fmt.Errorf("revocation: invalid record") + } + sig, err := base64.StdEncoding.DecodeString(r.Signature) + if err != nil { + return fmt.Errorf("revocation: invalid signature encoding") + } + if err := VerifyAuth(r.OwnerID, RevocationChallenge(r.MachineID, r.TS), sig); err != nil { + return fmt.Errorf("revocation: owner signature failed: %w", err) + } + return nil +} + +func (r Revocation) JSON() ([]byte, error) { return json.Marshal(r) } diff --git a/go/internal/identity/revocation_test.go b/go/internal/identity/revocation_test.go new file mode 100644 index 0000000..83af184 --- /dev/null +++ b/go/internal/identity/revocation_test.go @@ -0,0 +1,66 @@ +package identity + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + "time" +) + +func TestRevocationRoundTripAndTamper(t *testing.T) { + signer, err := DeriveSigner(bytes.Repeat([]byte{0x61}, 32)) + if err != nil { + t.Fatal(err) + } + r, err := signer.SignRevocation("machine-1", time.Unix(1_700_000_000, 0)) + if err != nil { + t.Fatal(err) + } + if err := VerifyRevocation(r); err != nil { + t.Fatalf("valid revocation: %v", err) + } + tampered := *r + tampered.MachineID = "machine-2" + if err := VerifyRevocation(&tampered); err == nil { + t.Fatal("tampered machine id verified") + } +} + +func TestRevocationInteropVectorStable(t *testing.T) { + type vector struct { + Root string `json:"root"` + V int `json:"v"` + OwnerID string `json:"owner_id"` + MachineID string `json:"machine_id"` + TS int64 `json:"ts"` + Signature string `json:"signature"` + } + data, err := os.ReadFile(filepath.Join("..", "..", "..", "testdata", "revocation.json")) + if err != nil { + t.Fatal(err) + } + var want vector + if err := json.Unmarshal(data, &want); err != nil { + t.Fatal(err) + } + root, err := hex.DecodeString(want.Root) + if err != nil { + t.Fatal(err) + } + signer, err := DeriveSigner(root) + if err != nil { + t.Fatal(err) + } + record, err := signer.SignRevocation(want.MachineID, time.Unix(want.TS, 0)) + if err != nil { + t.Fatal(err) + } + got := vector{Root: want.Root, V: record.V, OwnerID: record.OwnerID, MachineID: record.MachineID, TS: record.TS, Signature: record.Signature} + if !reflect.DeepEqual(got, want) { + t.Fatalf("revocation vector drifted\n got %+v\nwant %+v", got, want) + } +} diff --git a/go/internal/identity/signer.go b/go/internal/identity/signer.go new file mode 100644 index 0000000..1cd3404 --- /dev/null +++ b/go/internal/identity/signer.go @@ -0,0 +1,54 @@ +package identity + +import ( + "crypto/ed25519" + "crypto/sha256" + "io" + + "golang.org/x/crypto/hkdf" + + "github.com/srcful/terminal-relay/go/internal/base58" + "github.com/srcful/terminal-relay/go/internal/bip39" +) + +var ( + signerSalt = []byte("miranda/identity/signer/v1") + signerInfo = []byte("ed25519") +) + +// Signer is Miranda's neutral Ed25519 owner identity. It is deliberately not a +// cryptocurrency wallet. Mnemonic is only a recovery rendering of the 32-byte +// passkey PRF output; the signing seed is domain-separated with HKDF. +type Signer struct { + Mnemonic string + Priv ed25519.PrivateKey + Pub ed25519.PublicKey + Address string // base58(Pub), used as the opaque owner id +} + +// DeriveSigner deterministically derives the Miranda signing identity from a +// passkey PRF output. Mirrors web/src/identity/signer.js exactly. +func DeriveSigner(root []byte) (*Signer, error) { + mnemonic, err := bip39.EntropyToMnemonic(root) + if err != nil { + return nil, err + } + r := hkdf.New(sha256.New, root, signerSalt, signerInfo) + seed := make([]byte, ed25519.SeedSize) + if _, err := io.ReadFull(r, seed); err != nil { + return nil, err + } + priv := ed25519.NewKeyFromSeed(seed) + pub := priv.Public().(ed25519.PublicKey) + return &Signer{Mnemonic: mnemonic, Priv: priv, Pub: pub, Address: base58.Encode(pub)}, nil +} + +// SignerFromMnemonic restores the root entropy and re-derives the same Miranda +// identity. The phrase is not compatible with or intended for financial wallets. +func SignerFromMnemonic(mnemonic string) (*Signer, error) { + root, err := bip39.MnemonicToEntropy(mnemonic) + if err != nil { + return nil, err + } + return DeriveSigner(root) +} diff --git a/go/internal/identity/signer_test.go b/go/internal/identity/signer_test.go new file mode 100644 index 0000000..1b32ba8 --- /dev/null +++ b/go/internal/identity/signer_test.go @@ -0,0 +1,67 @@ +package identity + +import ( + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +const signerRootHex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" + +func identityVectorPath() string { + return filepath.Join("..", "..", "..", "testdata", "identity-derivation.json") +} + +type identityVector struct { + Root string `json:"root"` + Mnemonic string `json:"mnemonic"` + SignerPriv string `json:"signer_priv"` + SignerPub string `json:"signer_pub"` + OwnerID string `json:"owner_id"` + TransportPub string `json:"transport_pub"` +} + +func TestIdentityDerivationVector(t *testing.T) { + root, _ := hex.DecodeString(signerRootHex) + signer, err := DeriveSigner(root) + if err != nil { + t.Fatal(err) + } + _, transportPub, err := DeriveOwnerKey(root) + if err != nil { + t.Fatal(err) + } + got := identityVector{ + Root: signerRootHex, + Mnemonic: signer.Mnemonic, + SignerPriv: hex.EncodeToString(signer.Priv.Seed()), + SignerPub: hex.EncodeToString(signer.Pub), + OwnerID: signer.Address, + TransportPub: hex.EncodeToString(transportPub), + } + path := identityVectorPath() + if os.Getenv("UPDATE_VECTORS") == "1" { + data, _ := json.MarshalIndent(got, "", " ") + if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { + t.Fatal(err) + } + return + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read vector (run UPDATE_VECTORS=1 first): %v", err) + } + var want identityVector + if err := json.Unmarshal(raw, &want); err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("identity derivation drifted\n got %+v\nwant %+v", got, want) + } + restored, err := SignerFromMnemonic(signer.Mnemonic) + if err != nil || restored.Address != signer.Address { + t.Fatalf("recovery mismatch: %v", err) + } +} diff --git a/go/internal/identity/wallet.go b/go/internal/identity/wallet.go deleted file mode 100644 index ef382db..0000000 --- a/go/internal/identity/wallet.go +++ /dev/null @@ -1,66 +0,0 @@ -// go/internal/identity/wallet.go -package identity - -import ( - "crypto/ed25519" - "fmt" - - "github.com/srcful/terminal-relay/go/internal/base58" - "github.com/srcful/terminal-relay/go/internal/bip39" - "github.com/srcful/terminal-relay/go/internal/slip10" -) - -// WalletPath is the Phantom-importable Solana account-0 derivation path. -// Sub-accounts use m/44'/501'/i'/0'. -const WalletPath = "m/44'/501'/0'/0'" - -// Wallet is the Solana-compatible Ed25519 identity derived from the passkey prf. -// It is independent of the X25519 transport key (DeriveOwnerKey); the two share -// only the prf root. Address (base58 of the public key) is the wallet owner_id. -type Wallet struct { - Mnemonic string // 24-word BIP39 rendering of the prf - Seed []byte // 64-byte BIP39 seed - Priv ed25519.PrivateKey // 64-byte (seed||pub); Priv.Seed() is the 32-byte node key - Pub ed25519.PublicKey // 32-byte Ed25519 public key - Address string // base58(Pub) — the Solana address / wallet owner_id -} - -// DeriveWallet renders the 32-byte prf as a BIP39 mnemonic and derives the -// account-0 Solana wallet. Restoring from the mnemonic reconstructs prf and -// re-derives the same wallet without the passkey. Mirrors -// web/src/identity/wallet.js exactly. -func DeriveWallet(prf []byte) (*Wallet, error) { - return DeriveWalletAccount(prf, 0) -} - -// DeriveWalletAccount derives HD sub-account `account` (m/44'/501'/account'/0'). -func DeriveWalletAccount(prf []byte, account uint32) (*Wallet, error) { - mnemonic, err := bip39.EntropyToMnemonic(prf) - if err != nil { - return nil, err - } - return walletFromMnemonicPath(mnemonic, fmt.Sprintf("m/44'/501'/%d'/0'", account)) -} - -// WalletFromMnemonic derives the account-0 wallet from a BIP39 mnemonic (the -// import path). Empty BIP39 passphrase, matching DeriveWallet. -func WalletFromMnemonic(mnemonic string) (*Wallet, error) { - return walletFromMnemonicPath(mnemonic, WalletPath) -} - -func walletFromMnemonicPath(mnemonic, path string) (*Wallet, error) { - seed := bip39.MnemonicToSeed(mnemonic, "") - node, err := slip10.DerivePath(seed, path) - if err != nil { - return nil, err - } - priv := ed25519.NewKeyFromSeed(node.Key) - pub := priv.Public().(ed25519.PublicKey) - return &Wallet{ - Mnemonic: mnemonic, - Seed: seed, - Priv: priv, - Pub: pub, - Address: base58.Encode(pub), - }, nil -} diff --git a/go/internal/identity/wallet_test.go b/go/internal/identity/wallet_test.go deleted file mode 100644 index 79260ad..0000000 --- a/go/internal/identity/wallet_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package identity - -import ( - "encoding/hex" - "encoding/json" - "os" - "path/filepath" - "testing" -) - -// Same prf as owner-derivation.json; external anchors cross-checked vs bip-utils. -const walletPrfHex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" - -func TestDeriveWalletAnchor(t *testing.T) { - prf, _ := hex.DecodeString(walletPrfHex) - w, err := DeriveWallet(prf) - if err != nil { - t.Fatal(err) - } - if w.Address != "C2XYPfExbj6azVqYLWeUphzsdKK2dQ53dm83Brd3THmS" { - t.Errorf("address = %s", w.Address) - } - if got := hex.EncodeToString(w.Pub); got != "a3d4ab895f8bc2990f27e64b4ee2abcb9396dc132ead962a1ba6664fd938ec41" { - t.Errorf("pub = %s", got) - } - wantMnemonic := "abandon math mimic master filter design carbon crystal rookie group knife wrap absurd much snack melt grid rough chapter fever rubber humble room trophy" - if w.Mnemonic != wantMnemonic { - t.Errorf("mnemonic = %q", w.Mnemonic) - } - // Import path reproduces the same wallet. - w2, err := WalletFromMnemonic(w.Mnemonic) - if err != nil { - t.Fatal(err) - } - if w2.Address != w.Address { - t.Errorf("import mismatch: %s != %s", w2.Address, w.Address) - } -} - -// vectorPath: internal/identity -> repo-root testdata. -func walletVectorPath() string { - return filepath.Join("..", "..", "..", "testdata", "wallet-derivation.json") -} - -type walletVector struct { - PrfOutput string `json:"prf_output"` - Mnemonic string `json:"mnemonic"` - Seed string `json:"seed"` - WalletPriv string `json:"wallet_priv"` // 32-byte node key (ed25519 seed) - WalletPub string `json:"wallet_pub"` - Address string `json:"address"` - OwnerPub string `json:"owner_pub"` // X25519 transport pub — proves it is unchanged -} - -func TestWalletDerivationVector(t *testing.T) { - prf, _ := hex.DecodeString(walletPrfHex) - w, err := DeriveWallet(prf) - if err != nil { - t.Fatal(err) - } - _, ownerPub, err := DeriveOwnerKey(prf) - if err != nil { - t.Fatal(err) - } - got := walletVector{ - PrfOutput: walletPrfHex, - Mnemonic: w.Mnemonic, - Seed: hex.EncodeToString(w.Seed), - WalletPriv: hex.EncodeToString(w.Priv.Seed()), - WalletPub: hex.EncodeToString(w.Pub), - Address: w.Address, - OwnerPub: hex.EncodeToString(ownerPub), - } - - path := walletVectorPath() - if os.Getenv("UPDATE_VECTORS") == "1" { - data, _ := json.MarshalIndent(got, "", " ") - if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { - t.Fatal(err) - } - t.Log("wallet-derivation.json written") - return - } - - raw, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read vector (run UPDATE_VECTORS=1 first): %v", err) - } - var want walletVector - if err := json.Unmarshal(raw, &want); err != nil { - t.Fatal(err) - } - if got != want { - t.Fatalf("wallet derivation drifted from committed vector\n got %+v\nwant %+v", got, want) - } - // Guardrail: the X25519 transport key must not have moved. - if want.OwnerPub != "269863f7f8d945c83cb429b6f16ab5655229a70b08272318267f41b1e8a28613" { - t.Fatalf("owner X25519 pub changed: %s", want.OwnerPub) - } -} diff --git a/go/internal/pairing/code.go b/go/internal/pairing/code.go index 49cddee..993d2e1 100644 --- a/go/internal/pairing/code.go +++ b/go/internal/pairing/code.go @@ -11,11 +11,14 @@ import ( "net/url" ) -// NewToken returns a fresh 16-byte (128-bit) single-use pairing token. -func NewToken() []byte { +// NewToken returns a fresh 16-byte (128-bit) single-use pairing token and fails +// closed if the operating system CSPRNG is unavailable. +func NewToken() ([]byte, error) { b := make([]byte, 16) - _, _ = rand.Read(b) - return b + if _, err := rand.Read(b); err != nil { + return nil, err + } + return b, nil } type codePayload struct { diff --git a/go/internal/pairing/e2e_test.go b/go/internal/pairing/e2e_test.go index 25b0990..9dd4647 100644 --- a/go/internal/pairing/e2e_test.go +++ b/go/internal/pairing/e2e_test.go @@ -18,14 +18,17 @@ func TestPairThroughSignalingServer(t *testing.T) { srv := httptest.NewServer(signal.New().Handler()) defer srv.Close() - token := pairing.NewToken() + token, err := pairing.NewToken() + if err != nil { + t.Fatal(err) + } code := pairing.EncodeCode(srv.URL, token) prf := make([]byte, 32) for i := range prf { prf[i] = byte(i + 1) } - wallet, err := identity.DeriveWallet(prf) + wallet, err := identity.DeriveSigner(prf) if err != nil { t.Fatal(err) } diff --git a/go/internal/pairing/fuzz_test.go b/go/internal/pairing/fuzz_test.go new file mode 100644 index 0000000..e190f14 --- /dev/null +++ b/go/internal/pairing/fuzz_test.go @@ -0,0 +1,12 @@ +package pairing + +import "testing" + +func FuzzDecodeCode(f *testing.F) { + f.Add(EncodeCode("https://relay.example", make([]byte, 16))) + f.Add("") + f.Add("not-base64") + f.Fuzz(func(t *testing.T, code string) { + _, _, _ = DecodeCode(code) + }) +} diff --git a/go/internal/pairing/interop_test.go b/go/internal/pairing/interop_test.go index 4282877..7621544 100644 --- a/go/internal/pairing/interop_test.go +++ b/go/internal/pairing/interop_test.go @@ -71,7 +71,7 @@ func nnpsk0(initiator bool) *noise.HandshakeState { func runFixed(t *testing.T) pairVectors { t.Helper() - wallet, err := identity.DeriveWallet(fxWalletPRF) + wallet, err := identity.DeriveSigner(fxWalletPRF) if err != nil { t.Fatal(err) } diff --git a/go/internal/pairing/pairing.go b/go/internal/pairing/pairing.go index 16614d7..d72c933 100644 --- a/go/internal/pairing/pairing.go +++ b/go/internal/pairing/pairing.go @@ -4,6 +4,7 @@ package pairing import ( "context" "crypto/rand" + "encoding/base64" "encoding/json" "fmt" @@ -20,18 +21,37 @@ const prologue = "terminal-relay/pair/v1" // AgentInfo is what the agent reveals to the client during pairing. type AgentInfo struct { - HostPubHex string `json:"host_pub"` - MachineID string `json:"machine_id"` - Name string `json:"name"` + HostPubHex string `json:"host_pub"` + MachineID string `json:"machine_id"` + Name string `json:"name"` + RegistrationCommitment string `json:"registration_commitment,omitempty"` } -// PairClaim is the initiator's msg1 payload: the base58 wallet it claims to own. -// The claim is later proven by a wallet auth signature over the channel binding -// (msg3), so the responder pins the wallet only after verifying control of it. +// PairClaim is the initiator's msg1 payload. Wallet is the historical wire field +// name; its value is the base58 Miranda owner id. The responder pins it only +// after verifying an owner signature over the channel binding in msg3. type PairClaim struct { Wallet string `json:"wallet"` } +// PairProof is msg3 in the provisioned flow. Signature proves owner control +// over the Noise transcript; Registry is an optional owner-encrypted discovery +// record for this machine. The agent can publish it but cannot decrypt it. +type PairProof struct { + Signature string `json:"signature"` + Registry string `json:"registry,omitempty"` + RegistrationAuth string `json:"registration_auth,omitempty"` +} + +// Provision is owner-created state delivered to the agent only after the +// pairing transcript signature verifies. Neither field is an owner secret. +type Provision struct { + Registry string + RegistrationAuth string +} + +type Provisioner func(*AgentInfo) (string, error) + func newHandshake(initiator bool, token []byte) (*noise.HandshakeState, error) { return noise.NewHandshakeState(noise.Config{ CipherSuite: cipherSuite, @@ -44,17 +64,27 @@ func newHandshake(initiator bool, token []byte) (*noise.HandshakeState, error) { }) } -// RunInitiator is the client side: it sends a PairClaim for its wallet (msg1), -// reads the agent's info (msg2), then proves control of the wallet with an auth +// RunInitiator is the client side: it sends its owner claim (msg1), reads the +// agent's info (msg2), then proves control of the owner identity with an auth // signature over the channel binding (msg3). It returns the agent's info plus the // Noise channel binding (a transcript hash identical on both ends iff there was // no MITM — use sas.FromBinding to show a safety number). -func RunInitiator(ctx context.Context, mc peer.MsgConn, token []byte, wallet *identity.Wallet) (*AgentInfo, []byte, error) { +func RunInitiator(ctx context.Context, mc peer.MsgConn, token []byte, signer *identity.Signer) (*AgentInfo, []byte, error) { + return runInitiator(ctx, mc, token, signer, nil) +} + +// RunInitiatorProvisioned performs owner-side discovery provisioning without +// sending the owner secret or registry key to the agent. +func RunInitiatorProvisioned(ctx context.Context, mc peer.MsgConn, token []byte, signer *identity.Signer, provision Provisioner) (*AgentInfo, []byte, error) { + return runInitiator(ctx, mc, token, signer, provision) +} + +func runInitiator(ctx context.Context, mc peer.MsgConn, token []byte, signer *identity.Signer, provision Provisioner) (*AgentInfo, []byte, error) { hs, err := newHandshake(true, token) if err != nil { return nil, nil, err } - claim, _ := json.Marshal(PairClaim{Wallet: wallet.Address}) + claim, _ := json.Marshal(PairClaim{Wallet: signer.Address}) msg1, _, _, err := hs.WriteMessage(nil, claim) if err != nil { return nil, nil, err @@ -74,56 +104,108 @@ func RunInitiator(ctx context.Context, mc peer.MsgConn, token []byte, wallet *id if err := json.Unmarshal(payload, &info); err != nil { return nil, nil, err } - // msg3: prove control of the wallet by signing the channel binding. The - // signature is public (it binds to the transcript hash), so it travels as a - // plain frame outside the Noise payload. + // msg3: prove owner control and optionally provision an opaque record. binding := hs.ChannelBinding() - if err := mc.Send(wallet.SignAuth(binding)); err != nil { + sig := signer.SignAuth(binding) + if provision == nil { + // Compatibility with v0.6 responders. + if err := mc.Send(sig); err != nil { + return nil, nil, err + } + return &info, binding, nil + } + registry, err := provision(&info) + if err != nil { + return nil, nil, err + } + registrationAuth := "" + if info.RegistrationCommitment != "" { + registrationAuth = base64.StdEncoding.EncodeToString(signer.SignAuth(identity.RegistrationChallenge(info.MachineID, info.RegistrationCommitment))) + } + proof, err := json.Marshal(PairProof{ + Signature: base64.StdEncoding.EncodeToString(sig), + Registry: registry, + RegistrationAuth: registrationAuth, + }) + if err != nil { + return nil, nil, err + } + if err := mc.Send(proof); err != nil { return nil, nil, err } return &info, binding, nil } // RunResponder is the agent side: it reads the client's PairClaim (msg1), sends -// its info (msg2), then verifies the client's wallet auth signature over the -// channel binding (msg3). It returns the proven base58 wallet plus the Noise -// channel binding (see RunInitiator). The wallet is returned only if auth -// verifies — callers pin it directly. +// its info (msg2), then verifies the client's owner signature over the channel +// binding (msg3). It returns the proven base58 owner id plus the Noise channel +// binding. The owner id is returned only if auth verifies. func RunResponder(ctx context.Context, mc peer.MsgConn, token []byte, info AgentInfo) (string, []byte, error) { + ownerID, _, binding, err := runResponder(ctx, mc, token, info) + return ownerID, binding, err +} + +// RunResponderProvisioned returns the opaque record only after owner auth has +// verified. Callers may persist it next to the owner pin. +func RunResponderProvisioned(ctx context.Context, mc peer.MsgConn, token []byte, info AgentInfo) (string, Provision, []byte, error) { + return runResponder(ctx, mc, token, info) +} + +func runResponder(ctx context.Context, mc peer.MsgConn, token []byte, info AgentInfo) (string, Provision, []byte, error) { hs, err := newHandshake(false, token) if err != nil { - return "", nil, err + return "", Provision{}, nil, err } msg1, err := mc.Recv(ctx) if err != nil { - return "", nil, err + return "", Provision{}, nil, err } payload, _, _, err := hs.ReadMessage(nil, msg1) if err != nil { - return "", nil, fmt.Errorf("pairing handshake failed (wrong code?): %w", err) + return "", Provision{}, nil, fmt.Errorf("pairing handshake failed (wrong code?): %w", err) } var claim PairClaim if err := json.Unmarshal(payload, &claim); err != nil { - return "", nil, fmt.Errorf("pairing: bad claim: %w", err) + return "", Provision{}, nil, fmt.Errorf("pairing: bad claim: %w", err) } if pk, derr := base58.Decode(claim.Wallet); derr != nil || len(pk) != 32 { - return "", nil, fmt.Errorf("pairing: bad wallet") + return "", Provision{}, nil, fmt.Errorf("pairing: bad owner id") } infoJSON, _ := json.Marshal(info) msg2, _, _, err := hs.WriteMessage(nil, infoJSON) if err != nil { - return "", nil, err + return "", Provision{}, nil, err } if err := mc.Send(msg2); err != nil { - return "", nil, err + return "", Provision{}, nil, err } binding := hs.ChannelBinding() - sig, err := mc.Recv(ctx) // msg3: the wallet auth signature over the binding + proofBytes, err := mc.Recv(ctx) if err != nil { - return "", nil, err + return "", Provision{}, nil, err + } + sig := proofBytes + provision := Provision{} + if len(proofBytes) != 64 { + var proof PairProof + if err := json.Unmarshal(proofBytes, &proof); err != nil { + return "", Provision{}, nil, fmt.Errorf("pairing: bad owner proof") + } + sig, err = base64.StdEncoding.DecodeString(proof.Signature) + if err != nil { + return "", Provision{}, nil, fmt.Errorf("pairing: bad owner signature encoding") + } + provision.Registry = proof.Registry + provision.RegistrationAuth = proof.RegistrationAuth } if err := identity.VerifyAuth(claim.Wallet, binding, sig); err != nil { - return "", nil, fmt.Errorf("pairing: wallet auth failed: %w", err) + return "", Provision{}, nil, fmt.Errorf("pairing: owner auth failed: %w", err) + } + if info.RegistrationCommitment != "" { + registrationSig, err := base64.StdEncoding.DecodeString(provision.RegistrationAuth) + if err != nil || identity.VerifyAuth(claim.Wallet, identity.RegistrationChallenge(info.MachineID, info.RegistrationCommitment), registrationSig) != nil { + return "", Provision{}, nil, fmt.Errorf("pairing: invalid agent registration authorization") + } } - return claim.Wallet, binding, nil + return claim.Wallet, provision, binding, nil } diff --git a/go/internal/pairing/pairing_test.go b/go/internal/pairing/pairing_test.go index 2b15e6e..61a663c 100644 --- a/go/internal/pairing/pairing_test.go +++ b/go/internal/pairing/pairing_test.go @@ -3,6 +3,7 @@ package pairing import ( "context" + "encoding/base64" "encoding/hex" "encoding/json" "strings" @@ -17,24 +18,79 @@ import ( // testWallet mints a real prf-rooted wallet for pairing tests. The prf seed is // derived from b so each call with a distinct byte yields a distinct wallet. -func testWallet(t *testing.T, b byte) *identity.Wallet { +func testWallet(t *testing.T, b byte) *identity.Signer { t.Helper() prf := make([]byte, 32) for i := range prf { prf[i] = b ^ byte(i) } - w, err := identity.DeriveWallet(prf) + w, err := identity.DeriveSigner(prf) if err != nil { t.Fatalf("DeriveWallet: %v", err) } return w } +func TestProvisionedPairingBindsAgentRegistrationToOwner(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + + token := testToken(t) + owner := testWallet(t, 0x19) + _, hostPub, _ := noise.GenerateStatic() + info := AgentInfo{ + HostPubHex: hex.EncodeToString(hostPub), + MachineID: "machine-registration", + Name: "box", + RegistrationCommitment: strings.Repeat("ab", 32), + } + clientMC, agentMC := peer.Pipe() + type result struct { + owner string + provision Provision + err error + } + resultCh := make(chan result, 1) + go func() { + gotOwner, provision, _, err := RunResponderProvisioned(ctx, agentMC, token, info) + resultCh <- result{owner: gotOwner, provision: provision, err: err} + }() + + if _, _, err := RunInitiatorProvisioned(ctx, clientMC, token, owner, func(*AgentInfo) (string, error) { + return "opaque-registry-record", nil + }); err != nil { + t.Fatalf("initiator: %v", err) + } + r := <-resultCh + if r.err != nil { + t.Fatalf("responder: %v", r.err) + } + if r.owner != owner.Address || r.provision.Registry != "opaque-registry-record" { + t.Fatalf("wrong provision: owner=%q provision=%+v", r.owner, r.provision) + } + sig, err := base64.StdEncoding.DecodeString(r.provision.RegistrationAuth) + if err != nil { + t.Fatalf("registration auth encoding: %v", err) + } + if err := identity.VerifyAuth(owner.Address, identity.RegistrationChallenge(info.MachineID, info.RegistrationCommitment), sig); err != nil { + t.Fatalf("registration auth: %v", err) + } +} + +func testToken(t *testing.T) []byte { + t.Helper() + token, err := NewToken() + if err != nil { + t.Fatal(err) + } + return token +} + func TestPairingExchangesAndPinsKeys(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) defer cancel() - token := NewToken() + token := testToken(t) wallet := testWallet(t, 0x11) // client wallet _, hostPub, _ := noise.GenerateStatic() // agent host key @@ -85,9 +141,9 @@ func TestPairingFailsWithWrongToken(t *testing.T) { clientMC, agentMC := peer.Pipe() info := AgentInfo{HostPubHex: hex.EncodeToString(hostPub), MachineID: "m", Name: "n"} - go func() { _, _, _ = RunResponder(ctx, agentMC, NewToken(), info) }() // different token + go func() { _, _, _ = RunResponder(ctx, agentMC, testToken(t), info) }() // different token - if _, _, err := RunInitiator(ctx, clientMC, NewToken(), wallet); err == nil { + if _, _, err := RunInitiator(ctx, clientMC, testToken(t), wallet); err == nil { t.Fatal("expected pairing to fail with mismatched tokens") } } @@ -101,7 +157,7 @@ func TestPairingRejectsBadWalletAuth(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) defer cancel() - token := NewToken() + token := testToken(t) wallet := testWallet(t, 0x33) _, hostPub, _ := noise.GenerateStatic() clientMC, agentMC := peer.Pipe() @@ -143,7 +199,7 @@ func TestPairingRejectsBadWalletAuth(t *testing.T) { if err == nil { t.Fatal("responder accepted a wallet that did not sign the binding") } - if !strings.Contains(err.Error(), "wallet auth failed") { + if !strings.Contains(err.Error(), "owner auth failed") { t.Fatalf("expected wallet auth failure, got: %v", err) } case <-time.After(2 * time.Second): diff --git a/go/internal/quicmsg/quicmsg.go b/go/internal/quicmsg/quicmsg.go index 9c02d01..eb32ecb 100644 --- a/go/internal/quicmsg/quicmsg.go +++ b/go/internal/quicmsg/quicmsg.go @@ -5,7 +5,7 @@ // QUIC's TLS is treated as *dumb transport* here: it gives us an encrypted, // reliable, ordered byte stream and nothing more. The real authentication — // proving the peer is the right owner/agent — is the Noise-KK handshake plus -// the wallet binding that run *inside* this MsgConn. Because the transport TLS +// the owner binding that run *inside* this MsgConn. Because the transport TLS // identity carries no trust, the client deliberately skips TLS verification // (ClientTLS sets InsecureSkipVerify) and the server presents an ephemeral // self-signed certificate (ServerTLS). Trust is established one layer up, not @@ -136,7 +136,7 @@ func (c *Conn) Close() error { // ServerTLS returns a TLS config for the listener using a freshly generated, // ephemeral self-signed certificate. The cert identity is meaningless on -// purpose: trust comes from Noise-KK + the wallet binding inside the stream, +// purpose: trust comes from Noise-KK + the owner binding inside the stream, // not from this certificate. func ServerTLS() (*tls.Config, error) { key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) @@ -172,7 +172,7 @@ func ServerTLS() (*tls.Config, error) { // ClientTLS returns a TLS config for dialing. It skips verification on purpose: // the QUIC TLS identity carries no trust in Miranda — the real authentication -// is the Noise-KK handshake and wallet binding that run inside the stream. +// is the Noise-KK handshake and owner binding that run inside the stream. func ClientTLS() *tls.Config { return &tls.Config{ InsecureSkipVerify: true, // trust is established by Noise-KK inside, not by TLS diff --git a/go/internal/sas/sas.go b/go/internal/sas/sas.go index 32d1685..a4126b7 100644 --- a/go/internal/sas/sas.go +++ b/go/internal/sas/sas.go @@ -12,11 +12,11 @@ import ( "fmt" ) -// FromBinding renders a Noise channel binding as a 64-bit safety number, in four -// 4-hex-digit groups (e.g. "a3f1-9c2b-77de-4051"). 64 bits resists a real-time -// birthday-collision MITM grinder within an interactive pairing window. +// FromBinding renders a Noise channel binding as a 96-bit safety number in six +// compact groups. Pairing is rare; the extra comparison cost buys a much wider +// defense-in-depth margin if a pairing token is exposed. func FromBinding(binding []byte) string { - h := sha256.Sum256(append([]byte("terminal-relay/sas/v1"), binding...)) - return fmt.Sprintf("%02x%02x-%02x%02x-%02x%02x-%02x%02x", - h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]) + h := sha256.Sum256(append([]byte("miranda/sas/v2"), binding...)) + return fmt.Sprintf("%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x", + h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], h[8], h[9], h[10], h[11]) } diff --git a/go/internal/sas/sas_test.go b/go/internal/sas/sas_test.go index 4541ff0..76e3464 100644 --- a/go/internal/sas/sas_test.go +++ b/go/internal/sas/sas_test.go @@ -11,10 +11,10 @@ func TestFromBindingIsStableAndFormatted(t *testing.T) { if a != FromBinding(b) { t.Fatal("not deterministic") } - // 4 groups of 4 hex, dash-separated. + // 6 groups of 4 hex, dash-separated. parts := strings.Split(a, "-") - if len(parts) != 4 { - t.Fatalf("expected 4 groups, got %q", a) + if len(parts) != 6 { + t.Fatalf("expected 6 groups, got %q", a) } for _, p := range parts { if len(p) != 4 { diff --git a/go/internal/selfupdate/apply.go b/go/internal/selfupdate/apply.go index 22e09ee..54dee4f 100644 --- a/go/internal/selfupdate/apply.go +++ b/go/internal/selfupdate/apply.go @@ -16,11 +16,8 @@ import ( // binary, and atomically replaces targetPath. targetPath should be the absolute // path of the currently running executable (see os.Executable). // -// Verification order matters: we authenticate checksums.txt (cosign keyless, -// when available — see verifyChecksumsSignature) BEFORE trusting any digest it -// contains, then check the archive against that now-trusted digest. When cosign -// isn't installed the update stays quiet (the checksum still guards the download); -// set MIR_REQUIRE_COSIGN to demand signature verification. +// Verification order matters: we authenticate checksums.txt with cosign before +// trusting any digest it contains. Missing verification is a hard failure. func (c *Client) Apply(rel *Release, targetPath string) error { archive, err := c.fetch(rel.AssetURL) if err != nil { diff --git a/go/internal/selfupdate/apply_test.go b/go/internal/selfupdate/apply_test.go index d33f8e3..e4eebd5 100644 --- a/go/internal/selfupdate/apply_test.go +++ b/go/internal/selfupdate/apply_test.go @@ -51,6 +51,10 @@ func TestApplyReplacesTarget(t *testing.T) { _, _ = w.Write(archive) case "/checksums": fmt.Fprintf(w, "%s mir_1.0.0_%s_%s.tar.gz\n", hex.EncodeToString(sum[:]), "os", "arch") + case "/sig": + _, _ = w.Write([]byte("signature")) + case "/pem": + _, _ = w.Write([]byte("certificate")) default: http.NotFound(w, r) } @@ -61,8 +65,13 @@ func TestApplyReplacesTarget(t *testing.T) { if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { t.Fatal(err) } - c := &Client{Binary: "mir", OS: "os", Arch: "arch", HTTP: srv.Client()} - rel := &Release{Tag: "v1.0.0", AssetName: "mir_1.0.0_os_arch.tar.gz", AssetURL: srv.URL + "/asset", ChecksumsURL: srv.URL + "/checksums"} + binDir := t.TempDir() + if err := os.WriteFile(filepath.Join(binDir, "cosign"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", binDir) + c := &Client{Repo: "srcfl/miranda", Binary: "mir", OS: "os", Arch: "arch", HTTP: srv.Client()} + rel := &Release{Tag: "v1.0.0", AssetName: "mir_1.0.0_os_arch.tar.gz", AssetURL: srv.URL + "/asset", ChecksumsURL: srv.URL + "/checksums", ChecksumsSigURL: srv.URL + "/sig", ChecksumsCertURL: srv.URL + "/pem"} if err := c.Apply(rel, target); err != nil { t.Fatal(err) } diff --git a/go/internal/selfupdate/cosign.go b/go/internal/selfupdate/cosign.go index 4115d67..9f74895 100644 --- a/go/internal/selfupdate/cosign.go +++ b/go/internal/selfupdate/cosign.go @@ -36,16 +36,8 @@ func cosignIdentityRegexp(repo string) string { // genuinely came from THIS repo's release pipeline. It does NOT attest to the // source that was built — only to the checksum file's origin. // -// Degradation policy: -// - cosign not on PATH -> stay SILENT and return nil (checksum-only -// fallback). Most users don't have cosign installed, and the per-file SHA256 -// already guards against a corrupted download, so a successful update must not -// look like a failure. Set MIR_REQUIRE_COSIGN to turn this into a hard error. -// - signing assets absent -> if the release predates signing (no .sig/.pem -// URLs at all), fall back silently (or hard-fail under MIR_REQUIRE_COSIGN). If -// cosign is present AND the assets are expected but unfetchable, hard failure. -// - verification fails -> return error; the caller MUST abort the update. -// - verification passes -> emit a positive one-line confirmation via note. +// Verification fails closed: missing cosign, missing signing assets, failed +// downloads, and invalid signatures all abort the update. // // note receives a single human-readable line (no trailing newline) for the success // confirmation; pass a stderr writer in production, nil to silence. @@ -55,17 +47,8 @@ func (c *Client) verifyChecksumsSignature(rel *Release, sums []byte, note func(s note(msg) } } - // soft degrades a missing-provenance case: silent by default (don't nag the - // majority without cosign), a hard error when the operator demands verification. - soft := func(reason string) error { - if os.Getenv("MIR_REQUIRE_COSIGN") != "" { - return fmt.Errorf("%s, and MIR_REQUIRE_COSIGN is set", reason) - } - return nil - } - if _, err := exec.LookPath("cosign"); err != nil { - return soft("cosign is not installed, so the release signature was not verified") + return fmt.Errorf("cosign is required to verify Miranda releases") } // A release cut before signing was introduced carries no .sig/.pem. cosign @@ -73,7 +56,7 @@ func (c *Client) verifyChecksumsSignature(rel *Release, sums []byte, note func(s // upgrading FROM an old release still works. (The next signed tag is the // first one that will actually exercise verification.) if rel.ChecksumsSigURL == "" || rel.ChecksumsCertURL == "" { - return soft("this release has no cosign signature") + return fmt.Errorf("release has no cosign signature; refusing update") } sig, err := c.fetch(rel.ChecksumsSigURL) diff --git a/go/internal/selfupdate/cosign_test.go b/go/internal/selfupdate/cosign_test.go index a903be9..88fef38 100644 --- a/go/internal/selfupdate/cosign_test.go +++ b/go/internal/selfupdate/cosign_test.go @@ -35,10 +35,7 @@ func TestCosignIdentityRegexp(t *testing.T) { } } -// TestVerifyChecksumsSignatureNoCosign pins the graceful-degradation contract: -// with cosign absent from PATH, verification returns nil (checksum-only fallback) -// and stays SILENT — a successful update must not nag the majority who have no -// cosign. We force "cosign not found" by pointing PATH at an empty dir. +// Missing cosign is a hard failure: checksum-only is not provenance. func TestVerifyChecksumsSignatureNoCosign(t *testing.T) { t.Setenv("PATH", t.TempDir()) // no cosign on this PATH if _, err := exec.LookPath("cosign"); err == nil { @@ -48,16 +45,16 @@ func TestVerifyChecksumsSignatureNoCosign(t *testing.T) { var notes []string c := &Client{Repo: "srcfl/miranda"} rel := &Release{ChecksumsSigURL: "http://x/sig", ChecksumsCertURL: "http://x/pem"} - if err := c.verifyChecksumsSignature(rel, []byte("sums"), func(m string) { notes = append(notes, m) }); err != nil { - t.Fatalf("expected nil (fallback) when cosign absent, got %v", err) + if err := c.verifyChecksumsSignature(rel, []byte("sums"), func(m string) { notes = append(notes, m) }); err == nil || !strings.Contains(err.Error(), "cosign is required") { + t.Fatalf("expected hard failure when cosign absent, got %v", err) } if len(notes) != 0 { t.Fatalf("expected NO output when cosign is absent (don't nag), got %v", notes) } } -// TestVerifyChecksumsSignatureStrictRequiresCosign: with MIR_REQUIRE_COSIGN set, -// a missing cosign becomes a hard error so an operator can MANDATE provenance. +// The old strict environment switch remains harmless; verification is strict by +// default now. func TestVerifyChecksumsSignatureStrictRequiresCosign(t *testing.T) { t.Setenv("PATH", t.TempDir()) t.Setenv("MIR_REQUIRE_COSIGN", "1") @@ -67,15 +64,12 @@ func TestVerifyChecksumsSignatureStrictRequiresCosign(t *testing.T) { c := &Client{Repo: "srcfl/miranda"} rel := &Release{ChecksumsSigURL: "http://x/sig", ChecksumsCertURL: "http://x/pem"} err := c.verifyChecksumsSignature(rel, []byte("sums"), nil) - if err == nil || !strings.Contains(err.Error(), "MIR_REQUIRE_COSIGN") { - t.Fatalf("expected a hard error under MIR_REQUIRE_COSIGN, got %v", err) + if err == nil || !strings.Contains(err.Error(), "cosign is required") { + t.Fatalf("expected a hard error, got %v", err) } } -// TestVerifyChecksumsSignatureUnsignedRelease pins that a release WITHOUT -// signing assets (empty .sig/.pem URLs, e.g. a legacy tag) falls back silently -// rather than hard-failing — even when cosign IS installed. We fake a cosign on -// PATH so the LookPath check passes; it must never be invoked on this path. +// An unsigned release is rejected even when cosign itself is installed. func TestVerifyChecksumsSignatureUnsignedRelease(t *testing.T) { dir := t.TempDir() fakeCosign := filepath.Join(dir, "cosign") @@ -88,8 +82,8 @@ func TestVerifyChecksumsSignatureUnsignedRelease(t *testing.T) { var notes []string c := &Client{Repo: "srcfl/miranda"} rel := &Release{} // no ChecksumsSigURL / ChecksumsCertURL - if err := c.verifyChecksumsSignature(rel, []byte("sums"), func(m string) { notes = append(notes, m) }); err != nil { - t.Fatalf("expected nil (fallback) for unsigned release, got %v", err) + if err := c.verifyChecksumsSignature(rel, []byte("sums"), func(m string) { notes = append(notes, m) }); err == nil || !strings.Contains(err.Error(), "no cosign signature") { + t.Fatalf("expected unsigned release rejection, got %v", err) } if len(notes) != 0 { t.Fatalf("expected NO output for an unsigned release, got %v", notes) diff --git a/go/internal/signal/fuzz_test.go b/go/internal/signal/fuzz_test.go new file mode 100644 index 0000000..b820e5f --- /dev/null +++ b/go/internal/signal/fuzz_test.go @@ -0,0 +1,12 @@ +package signal + +import "testing" + +func FuzzDecodeInboundSignal(f *testing.F) { + f.Add([]byte(`{"type":"offer","sdp":"v=0"}`)) + f.Add([]byte(`{"type":"registry","registry":"opaque"}`)) + f.Add([]byte(`not-json`)) + f.Fuzz(func(t *testing.T, data []byte) { + _, _ = decodeInboundSignal(data) + }) +} diff --git a/go/internal/signal/protocol.go b/go/internal/signal/protocol.go index 1cf8037..a71962a 100644 --- a/go/internal/signal/protocol.go +++ b/go/internal/signal/protocol.go @@ -23,7 +23,8 @@ type SignalMsg struct { Session string `json:"session,omitempty"` SDP string `json:"sdp,omitempty"` Reason string `json:"reason,omitempty"` - Binding string `json:"binding,omitempty"` // opaque wallet-binding record; relay forwards, never reads + Binding string `json:"binding,omitempty"` // opaque owner-binding record; relay forwards, never reads + Auth string `json:"auth,omitempty"` // owner signature over session+machine+SDP; relay forwards blindly Registry string `json:"registry,omitempty"` // opaque encrypted device record; relay holds + serves, never reads } diff --git a/go/internal/signal/ratelimit.go b/go/internal/signal/ratelimit.go new file mode 100644 index 0000000..447c054 --- /dev/null +++ b/go/internal/signal/ratelimit.go @@ -0,0 +1,109 @@ +package signal + +import ( + "net/http" + "sync" + "time" +) + +// ratePolicy is a fixed-window admission policy for the initial HTTP/WebSocket +// request. WebSocket frames are bounded separately after upgrade. +type ratePolicy struct { + Limit int + Window time.Duration +} + +var defaultRatePolicies = map[string]ratePolicy{ + "/turn-credentials": {Limit: 30, Window: time.Minute}, + "/pair": {Limit: 20, Window: time.Minute}, + "/attach": {Limit: 120, Window: time.Minute}, + "/agent/signal": {Limit: 60, Window: time.Minute}, + "/registry": {Limit: 120, Window: time.Minute}, + "/revocations": {Limit: 30, Window: time.Minute}, +} + +type rateEntry struct { + count int + start time.Time + seen uint64 +} + +// requestLimiter is deliberately small and dependency-free. Entries are keyed +// by endpoint + source IP and bounded with LRU eviction so an address flood +// cannot turn the limiter itself into an unbounded memory sink. +type requestLimiter struct { + mu sync.Mutex + entries map[string]*rateEntry + policies map[string]ratePolicy + max int + clock uint64 + now func() time.Time +} + +func newRequestLimiter(max int) *requestLimiter { + policies := make(map[string]ratePolicy, len(defaultRatePolicies)) + for path, policy := range defaultRatePolicies { + policies[path] = policy + } + return &requestLimiter{entries: make(map[string]*rateEntry), policies: policies, max: max, now: time.Now} +} + +func (l *requestLimiter) allow(path, ip string) bool { + if l == nil { + return true + } + policy, ok := l.policies[path] + if !ok || policy.Limit <= 0 || policy.Window <= 0 { + return true + } + now := l.now() + key := path + "|" + ip + l.mu.Lock() + defer l.mu.Unlock() + l.clock++ + entry := l.entries[key] + if entry == nil { + if l.max > 0 && len(l.entries) >= l.max { + l.evictLRU() + } + l.entries[key] = &rateEntry{count: 1, start: now, seen: l.clock} + return true + } + entry.seen = l.clock + if now.Sub(entry.start) >= policy.Window { + entry.count = 1 + entry.start = now + return true + } + if entry.count >= policy.Limit { + return false + } + entry.count++ + return true +} + +func (l *requestLimiter) evictLRU() { + var oldestKey string + var oldest uint64 + for key, entry := range l.entries { + if oldestKey == "" || entry.seen < oldest { + oldestKey, oldest = key, entry.seen + } + } + if oldestKey != "" { + delete(l.entries, oldestKey) + } +} + +func (s *Server) rateLimited(path string, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ip := s.remoteIP(r) + if !s.limiter.allow(path, ip) { + w.Header().Set("Retry-After", "60") + s.logf("event=rate_limit path=%s ip=%s", path, ip) + http.Error(w, "rate limit exceeded", http.StatusTooManyRequests) + return + } + next(w, r) + } +} diff --git a/go/internal/signal/ratelimit_test.go b/go/internal/signal/ratelimit_test.go new file mode 100644 index 0000000..8dd9bf5 --- /dev/null +++ b/go/internal/signal/ratelimit_test.go @@ -0,0 +1,88 @@ +package signal + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestRequestLimiterBoundsAndResets(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + l := newRequestLimiter(2) + l.now = func() time.Time { return now } + l.policies = map[string]ratePolicy{"/x": {Limit: 2, Window: time.Minute}} + if !l.allow("/x", "a") || !l.allow("/x", "a") { + t.Fatal("requests within limit were rejected") + } + if l.allow("/x", "a") { + t.Fatal("request above limit was accepted") + } + now = now.Add(time.Minute) + if !l.allow("/x", "a") { + t.Fatal("window did not reset") + } + _ = l.allow("/x", "b") + _ = l.allow("/x", "c") + if len(l.entries) != 2 { + t.Fatalf("entries=%d, want bounded at 2", len(l.entries)) + } +} + +func TestRateLimitReturns429BeforeHandler(t *testing.T) { + s := New() + s.limiter.policies = map[string]ratePolicy{"/registry": {Limit: 1, Window: time.Minute}} + h := s.Handler() + for i, want := range []int{http.StatusBadRequest, http.StatusTooManyRequests} { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/registry", nil) + h.ServeHTTP(rr, req) + if rr.Code != want { + t.Fatalf("request %d status=%d want=%d", i+1, rr.Code, want) + } + if want == http.StatusTooManyRequests && rr.Header().Get("Retry-After") == "" { + t.Fatal("429 response missing Retry-After") + } + } +} + +func TestProxyHeadersRequireExplicitTrust(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + req.RemoteAddr = "192.0.2.10:1234" + req.Header.Set("CF-Connecting-IP", "203.0.113.9") + req.Header.Set("X-Forwarded-For", "203.0.113.8, 198.51.100.4") + s := New() + if got := s.remoteIP(req); got != "192.0.2.10" { + t.Fatalf("untrusted proxy header used: %q", got) + } + s.TrustProxyHeaders = true + if err := s.SetTrustedProxyCIDRs([]string{"192.0.2.0/24"}); err != nil { + t.Fatal(err) + } + if got := s.remoteIP(req); got != "203.0.113.9" { + t.Fatalf("trusted Cloudflare IP=%q", got) + } + req.Header.Del("CF-Connecting-IP") + if got := s.remoteIP(req); got != "203.0.113.8" { + t.Fatalf("trusted XFF IP=%q", got) + } + req.RemoteAddr = "198.51.100.10:1234" + if got := s.remoteIP(req); got != "198.51.100.10" { + t.Fatalf("unlisted direct peer spoofed XFF: %q", got) + } + req.RemoteAddr = "192.0.2.10:1234" + req.Header.Set("X-Forwarded-For", "not-an-ip") + if got := s.remoteIP(req); got != "192.0.2.10" { + t.Fatalf("invalid trusted header used for rate key/logging: %q", got) + } +} + +func TestTrustedProxyCIDRsRejectInvalidOrEmptyConfiguration(t *testing.T) { + s := New() + if err := s.SetTrustedProxyCIDRs(nil); err == nil { + t.Fatal("empty trusted proxy set accepted") + } + if err := s.SetTrustedProxyCIDRs([]string{"not-a-cidr"}); err == nil { + t.Fatal("invalid trusted proxy CIDR accepted") + } +} diff --git a/go/internal/signal/registration_proof_test.go b/go/internal/signal/registration_proof_test.go index 5de3151..6c8ba82 100644 --- a/go/internal/signal/registration_proof_test.go +++ b/go/internal/signal/registration_proof_test.go @@ -1,13 +1,18 @@ package signal import ( + "bytes" "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" "net/http" "net/http/httptest" "testing" "time" "github.com/coder/websocket" + "github.com/srcful/terminal-relay/go/internal/identity" ) const ( @@ -15,6 +20,43 @@ const ( badRegistrationSecret = "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100" ) +func registrationCredentials(t *testing.T, root []byte, machine, secret string) (owner, authorization string) { + t.Helper() + signer, err := identity.DeriveSigner(root) + if err != nil { + t.Fatal(err) + } + raw, err := hex.DecodeString(secret) + if err != nil { + t.Fatal(err) + } + commitment := sha256.Sum256(raw) + sig := signer.SignAuth(identity.RegistrationChallenge(machine, hex.EncodeToString(commitment[:]))) + return signer.Address, base64.StdEncoding.EncodeToString(sig) +} + +func dialAuthorizedAgentStatus(baseURL, owner, machine, secret, authorization string) (*websocket.Conn, *http.Response, error) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + return websocket.Dial(ctx, wsURL(baseURL, "/agent/signal", map[string]string{"owner_id": owner, "machine_id": machine}), &websocket.DialOptions{HTTPHeader: http.Header{ + AgentRegistrationSecretHeader: []string{secret}, + AgentRegistrationAuthHeader: []string{authorization}, + }}) +} + +func registerAuthorizedAgentWithRegistry(t *testing.T, base, owner, machine, secret, authorization, blob string) *websocket.Conn { + t.Helper() + c, resp, err := dialAuthorizedAgentStatus(base, owner, machine, secret, authorization) + if err != nil { + t.Fatalf("dial authorized agent: status=%v err=%v", resp, err) + } + if ready := readMsg(t, c); ready.Type != TypeReady { + t.Fatalf("expected ready, got %q", ready.Type) + } + writeMsg(t, c, SignalMsg{Type: TypeRegistry, Registry: blob}) + return c +} + func dialAgentWithSecret(t *testing.T, baseURL, owner, machine, secret string) *websocket.Conn { t.Helper() c, resp, err := dialAgentWithSecretStatus(baseURL, owner, machine, secret) @@ -132,3 +174,45 @@ func TestAgentRegistrationWithoutProofKeepsLegacyReplacement(t *testing.T) { t.Fatalf("second expected ready, got %q", ready.Type) } } + +func TestOwnerAuthorizedFirstRegistration(t *testing.T) { + const machine = "machine-1" + owner, auth := registrationCredentials(t, bytes.Repeat([]byte{0x51}, 32), machine, goodRegistrationSecret) + + if !agentRegistrationAuthorized(owner, machine, goodRegistrationSecret, auth) { + t.Fatal("valid owner-authorized registration was rejected") + } + if agentRegistrationAuthorized(owner, machine, badRegistrationSecret, auth) { + t.Fatal("authorization must be bound to the exact registration secret") + } + if agentRegistrationAuthorized(owner, "other-machine", goodRegistrationSecret, auth) { + t.Fatal("authorization must be bound to the machine id") + } + if agentRegistrationAuthorized(owner, machine, goodRegistrationSecret, "") { + t.Fatal("a real owner id must fail closed without authorization") + } +} + +func TestOwnerAuthorizedRegistrationBlocksFirstUseSquatting(t *testing.T) { + const machine = "machine-1" + owner, auth := registrationCredentials(t, bytes.Repeat([]byte{0x52}, 32), machine, goodRegistrationSecret) + srv := httptest.NewServer(New().Handler()) + defer srv.Close() + + spoof, resp, err := dialAuthorizedAgentStatus(srv.URL, owner, machine, badRegistrationSecret, auth) + if spoof != nil { + spoof.CloseNow() + } + if err == nil || resp == nil || resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("first-use spoof status=%v err=%v, want 401", resp, err) + } + + legitimate, resp, err := dialAuthorizedAgentStatus(srv.URL, owner, machine, goodRegistrationSecret, auth) + if err != nil { + t.Fatalf("legitimate first registration: status=%v err=%v", resp, err) + } + defer legitimate.CloseNow() + if ready := readMsg(t, legitimate); ready.Type != TypeReady { + t.Fatalf("expected ready, got %q", ready.Type) + } +} diff --git a/go/internal/signal/registry_e2e_test.go b/go/internal/signal/registry_e2e_test.go index 174f52b..1fe4af9 100644 --- a/go/internal/signal/registry_e2e_test.go +++ b/go/internal/signal/registry_e2e_test.go @@ -28,7 +28,7 @@ func TestRegistryE2ESealedRecordRoundTrips(t *testing.T) { if err != nil { t.Fatal(err) } - wallet, err := identity.DeriveWallet(secret) + wallet, err := identity.DeriveSigner(secret) if err != nil { t.Fatal(err) } @@ -45,13 +45,15 @@ func TestRegistryE2ESealedRecordRoundTrips(t *testing.T) { } b64blob := base64.StdEncoding.EncodeToString(blob) - a := registerAgentWithRegistry(t, srv.URL, wallet.Address, machineID, b64blob) + _, auth := registrationCredentials(t, secret, machineID, goodRegistrationSecret) + a := registerAuthorizedAgentWithRegistry(t, srv.URL, wallet.Address, machineID, goodRegistrationSecret, auth, b64blob) defer a.CloseNow() // A second agent under the SAME wallet publishing a FORGED blob (sealed under a // different key) must be served by the (blind) relay but dropped by the fetcher. forgedKey, _ := identity.RegistryKey(bytes.Repeat([]byte{0xff}, 32)) forged, _ := identity.SealRecord(forgedKey, nonce, pt, "m-forged") - f := registerAgentWithRegistry(t, srv.URL, wallet.Address, "m-forged", base64.StdEncoding.EncodeToString(forged)) + _, forgedAuth := registrationCredentials(t, secret, "m-forged", badRegistrationSecret) + f := registerAuthorizedAgentWithRegistry(t, srv.URL, wallet.Address, "m-forged", badRegistrationSecret, forgedAuth, base64.StdEncoding.EncodeToString(forged)) defer f.CloseNow() // Poll until both blobs are live on the relay. diff --git a/go/internal/signal/revocations.go b/go/internal/signal/revocations.go new file mode 100644 index 0000000..a231097 --- /dev/null +++ b/go/internal/signal/revocations.go @@ -0,0 +1,182 @@ +package signal + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/srcful/terminal-relay/go/internal/identity" +) + +const maxRevocationBodyBytes = 16 << 10 + +type revocationFile struct { + V int `json:"v"` + Revocations []identity.Revocation `json:"revocations"` +} + +// LoadRevocations enables durable revocation storage and loads an existing +// file. Every entry is signature-verified before any state is accepted. A +// corrupt file fails startup instead of silently re-enabling revoked machines. +func (s *Server) LoadRevocations(path string) error { + if strings.TrimSpace(path) == "" { + return fmt.Errorf("revocations: empty file path") + } + data, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("revocations: read: %w", err) + } + loaded := make(map[string]identity.Revocation) + if err == nil { + var file revocationFile + if err := json.Unmarshal(data, &file); err != nil { + return fmt.Errorf("revocations: decode: %w", err) + } + if file.V != 1 { + return fmt.Errorf("revocations: unsupported file version %d", file.V) + } + if s.maxRevocations > 0 && len(file.Revocations) > s.maxRevocations { + return fmt.Errorf("revocations: file exceeds capacity") + } + for _, record := range file.Revocations { + if err := identity.VerifyRevocation(&record); err != nil { + return fmt.Errorf("revocations: invalid %s/%s: %w", shortID(record.OwnerID), shortID(record.MachineID), err) + } + loaded[key(record.OwnerID, record.MachineID)] = record + } + } + s.mu.Lock() + s.revoked = loaded + s.revocationsFile = path + s.mu.Unlock() + return nil +} + +func (s *Server) handleRevocations(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + s.getRevocations(w, r) + case http.MethodPost: + s.postRevocation(w, r) + default: + w.Header().Set("Allow", "GET, POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *Server) getRevocations(w http.ResponseWriter, r *http.Request) { + owner := strings.TrimSpace(r.URL.Query().Get("owner_id")) + if owner == "" { + http.Error(w, "missing owner_id", http.StatusBadRequest) + return + } + prefix := owner + "|" + out := make([]identity.Revocation, 0) + s.mu.Lock() + for slot, record := range s.revoked { + if strings.HasPrefix(slot, prefix) { + out = append(out, record) + } + } + s.mu.Unlock() + sort.Slice(out, func(i, j int) bool { return out[i].MachineID < out[j].MachineID }) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(out) +} + +func (s *Server) postRevocation(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRevocationBodyBytes) + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + var record identity.Revocation + if err := dec.Decode(&record); err != nil { + http.Error(w, "invalid revocation", http.StatusBadRequest) + return + } + if err := dec.Decode(&struct{}{}); err != io.EOF { + http.Error(w, "invalid revocation", http.StatusBadRequest) + return + } + if err := identity.VerifyRevocation(&record); err != nil { + s.logf("event=revocation_reject reason=signature ip=%s", s.remoteIP(r)) + http.Error(w, "invalid revocation signature", http.StatusUnauthorized) + return + } + + slot := key(record.OwnerID, record.MachineID) + s.mu.Lock() + if _, exists := s.revoked[slot]; !exists && s.maxRevocations > 0 && len(s.revoked) >= s.maxRevocations { + s.mu.Unlock() + http.Error(w, capacityReason, http.StatusServiceUnavailable) + return + } + _, existed := s.revoked[slot] + s.revoked[slot] = record + ac := s.agents[slot] + delete(s.agents, slot) + err := s.persistRevocationsLocked() + s.mu.Unlock() + if ac != nil { + ac.teardown() + } + if err != nil { + s.logf("event=revocation_persist_error owner=%s machine=%s", shortID(record.OwnerID), shortID(record.MachineID)) + http.Error(w, "could not persist revocation", http.StatusInternalServerError) + return + } + s.logf("event=machine_revoked owner=%s machine=%s duplicate=%t ip=%s", shortID(record.OwnerID), shortID(record.MachineID), existed, s.remoteIP(r)) + w.WriteHeader(http.StatusNoContent) +} + +// persistRevocationsLocked writes the complete signed tombstone set atomically. +// s.mu must be held so concurrent POSTs cannot reorder whole-file snapshots. +func (s *Server) persistRevocationsLocked() error { + if s.revocationsFile == "" { + return nil + } + list := make([]identity.Revocation, 0, len(s.revoked)) + for _, record := range s.revoked { + list = append(list, record) + } + sort.Slice(list, func(i, j int) bool { + if list[i].OwnerID == list[j].OwnerID { + return list[i].MachineID < list[j].MachineID + } + return list[i].OwnerID < list[j].OwnerID + }) + data, err := json.MarshalIndent(revocationFile{V: 1, Revocations: list}, "", " ") + if err != nil { + return err + } + dir := filepath.Dir(s.revocationsFile) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".revocations-*.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, s.revocationsFile) +} diff --git a/go/internal/signal/revocations_test.go b/go/internal/signal/revocations_test.go new file mode 100644 index 0000000..9b5b500 --- /dev/null +++ b/go/internal/signal/revocations_test.go @@ -0,0 +1,157 @@ +package signal + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/srcful/terminal-relay/go/internal/identity" +) + +func signedRevocation(t *testing.T, root []byte, machine string) identity.Revocation { + t.Helper() + signer, err := identity.DeriveSigner(root) + if err != nil { + t.Fatal(err) + } + record, err := signer.SignRevocation(machine, time.Unix(1_700_000_000, 0)) + if err != nil { + t.Fatal(err) + } + return *record +} + +func postRevocation(t *testing.T, base string, record identity.Revocation) *http.Response { + t.Helper() + body, err := json.Marshal(record) + if err != nil { + t.Fatal(err) + } + resp, err := http.Post(base+"/revocations", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + return resp +} + +func TestSignedRevocationDisconnectsAndBlocksMachine(t *testing.T) { + root := bytes.Repeat([]byte{0x73}, 32) + const machine = "machine-1" + owner, authorization := registrationCredentials(t, root, machine, goodRegistrationSecret) + s := New() + server := httptest.NewServer(s.Handler()) + defer server.Close() + + agent := registerAuthorizedAgentWithRegistry(t, server.URL, owner, machine, goodRegistrationSecret, authorization, "opaque") + defer agent.CloseNow() + + record := signedRevocation(t, root, machine) + resp := postRevocation(t, server.URL, record) + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("POST status = %d, want %d", resp.StatusCode, http.StatusNoContent) + } + + // The live slot is removed immediately and can no longer publish discovery. + s.mu.Lock() + _, live := s.agents[key(owner, machine)] + s.mu.Unlock() + if live { + t.Fatal("revoked agent remained live") + } + entries := getRegistry(t, server.URL, owner, http.StatusOK) + if len(entries) != 0 { + t.Fatalf("registry still exposes revoked machine: %+v", entries) + } + + conn, response, err := dialAuthorizedAgentStatus(server.URL, owner, machine, goodRegistrationSecret, authorization) + if conn != nil { + conn.CloseNow() + } + if err == nil || response == nil || response.StatusCode != http.StatusGone { + t.Fatalf("revoked registration: status=%v err=%v, want 410", response, err) + } + if response.Body != nil { + response.Body.Close() + } + + get, err := http.Get(server.URL + "/revocations?owner_id=" + owner) + if err != nil { + t.Fatal(err) + } + defer get.Body.Close() + var records []identity.Revocation + if err := json.NewDecoder(get.Body).Decode(&records); err != nil { + t.Fatal(err) + } + if len(records) != 1 || records[0].MachineID != machine { + t.Fatalf("GET records = %+v", records) + } + if err := identity.VerifyRevocation(&records[0]); err != nil { + t.Fatalf("GET returned invalid record: %v", err) + } +} + +func TestRevocationRejectsTampering(t *testing.T) { + record := signedRevocation(t, bytes.Repeat([]byte{0x74}, 32), "machine-1") + record.MachineID = "machine-2" + server := httptest.NewServer(New().Handler()) + defer server.Close() + resp := postRevocation(t, server.URL, record) + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusUnauthorized) + } +} + +func TestRevocationsPersistAcrossServerRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "revocations.json") + record := signedRevocation(t, bytes.Repeat([]byte{0x75}, 32), "machine-1") + + first := New() + if err := first.LoadRevocations(path); err != nil { + t.Fatal(err) + } + firstHTTP := httptest.NewServer(first.Handler()) + resp := postRevocation(t, firstHTTP.URL, record) + resp.Body.Close() + firstHTTP.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("POST status = %d", resp.StatusCode) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("mode = %o, want 600", info.Mode().Perm()) + } + second := New() + if err := second.LoadRevocations(path); err != nil { + t.Fatal(err) + } + second.mu.Lock() + got, ok := second.revoked[key(record.OwnerID, record.MachineID)] + second.mu.Unlock() + if !ok || got.Signature != record.Signature { + t.Fatalf("persisted record missing or changed: %+v", got) + } +} + +func TestLoadRevocationsFailsClosedOnCorruption(t *testing.T) { + path := filepath.Join(t.TempDir(), "revocations.json") + if err := os.WriteFile(path, []byte(`{"v":1,"revocations":[{"v":1}]}`), 0o600); err != nil { + t.Fatal(err) + } + if err := New().LoadRevocations(path); err == nil { + t.Fatal("corrupt revocation file unexpectedly accepted") + } +} diff --git a/go/internal/signal/server.go b/go/internal/signal/server.go index da034b4..340bfa4 100644 --- a/go/internal/signal/server.go +++ b/go/internal/signal/server.go @@ -4,9 +4,12 @@ package signal import ( "context" "crypto/rand" + "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" "errors" + "fmt" "net" "net/http" "strings" @@ -14,6 +17,9 @@ import ( "time" "github.com/coder/websocket" + + "github.com/srcful/terminal-relay/go/internal/base58" + "github.com/srcful/terminal-relay/go/internal/identity" ) // sendTimeout bounds how long the broker will wait to hand a SignalMsg to a @@ -27,10 +33,20 @@ const sendTimeout = 2 * time.Second // TypeError "machine offline") before the socket is closed. const flushTimeout = 2 * time.Second +// An attach socket only reserves agent capacity while its client constructs the +// first signed SDP offer. Idle sockets are closed promptly. +const firstOfferTimeout = 10 * time.Second + +// Once an offer is forwarded, the agent has a bounded window to answer. A +// malformed or unauthorized offer must not reserve a browser slot forever. +const offerAnswerTimeout = 30 * time.Second + const ( maxSignalMessageBytes = 256 << 10 defaultMaxAgentSessions = 128 defaultMaxPairRooms = 1024 + defaultMaxRateEntries = 8192 + defaultMaxRevocations = 100000 capacityReason = "server capacity reached" ) @@ -68,17 +84,26 @@ var acceptOpts = &websocket.AcceptOptions{OriginPatterns: []string{"*"}} // It authenticates only relay registration replacement for one owner|machine // slot; terminal data and peer authentication remain end-to-end on the data // plane. -const AgentRegistrationSecretHeader = "X-TR-Agent-Registration-Secret" +const AgentRegistrationSecretHeader = "X-Miranda-Agent-Registration-Secret" + +// AgentRegistrationAuthHeader carries an owner signature over the machine id +// and SHA-256 commitment of AgentRegistrationSecretHeader. It closes the +// trust-on-first-registration gap without giving the agent an owner secret. +const AgentRegistrationAuthHeader = "X-Miranda-Agent-Registration-Authorization" // Server brokers SDP between agents and browsers. It never carries terminal // data — only SignalMsg (SDP + routing). Once a DataChannel is up P2P, the two // signaling sockets for that session are no longer needed. type Server struct { - mu sync.Mutex - agents map[string]*agentConn // owner|machine -> live agent - proofs *proofStore // owner|machine -> learned registration proof (bounded) - pair *pairRooms // roomID -> waiting pairing party (blind bridge) - flaps *flapCounter // owner|machine -> recent replacement timestamps (bounded) + mu sync.Mutex + agents map[string]*agentConn // owner|machine -> live agent + proofs *proofStore // owner|machine -> learned registration proof (bounded) + pair *pairRooms // roomID -> waiting pairing party (blind bridge) + flaps *flapCounter // owner|machine -> recent replacement timestamps (bounded) + revoked map[string]identity.Revocation // owner|machine -> permanent owner-signed tombstone + + revocationsFile string + maxRevocations int // Logf records one structured line per relay event (register, replace, // reject, gone, attach, flap, stats). It is never nil at runtime: New() @@ -94,6 +119,13 @@ type Server struct { maxAgentSessions int maxPairRooms int + limiter *requestLimiter + + // TrustProxyHeaders enables CF-Connecting-IP/X-Forwarded-For parsing. Keep it + // false unless the origin is reachable only through a trusted reverse proxy; + // otherwise clients can spoof those headers to evade per-IP limits. + TrustProxyHeaders bool + trustedProxyNets []*net.IPNet } // agentConn is one agent control socket. It owns the set of browser sessions @@ -227,9 +259,12 @@ func New() *Server { proofs: newProofStore(defaultMaxAgentProofs), pair: newPairRooms(), flaps: newFlapCounter(flapThreshold, flapWindow, defaultMaxAgentProofs), + revoked: map[string]identity.Revocation{}, Logf: func(string, ...any) {}, // no-op until the binary wires a real logger maxAgentSessions: defaultMaxAgentSessions, maxPairRooms: defaultMaxPairRooms, + limiter: newRequestLimiter(defaultMaxRateEntries), + maxRevocations: defaultMaxRevocations, } } @@ -256,18 +291,24 @@ func shortID(id string) string { // Cloudflare, so the real client address is in CF-Connecting-IP (preferred) or // the first hop of X-Forwarded-For; r.RemoteAddr would otherwise just be the // Cloudflare edge. Falls back to the host part of RemoteAddr. -func remoteIP(r *http.Request) string { - if cf := strings.TrimSpace(r.Header.Get("CF-Connecting-IP")); cf != "" { - return cf - } - if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - // X-Forwarded-For is "client, proxy1, proxy2"; the left-most entry is the - // original client. - if i := strings.IndexByte(xff, ','); i >= 0 { - xff = xff[:i] +func (s *Server) remoteIP(r *http.Request) string { + if s.TrustProxyHeaders && s.isTrustedProxy(r.RemoteAddr) { + if cf := strings.TrimSpace(r.Header.Get("CF-Connecting-IP")); cf != "" { + if ip := net.ParseIP(cf); ip != nil { + return ip.String() + } } - if xff = strings.TrimSpace(xff); xff != "" { - return xff + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + // X-Forwarded-For is "client, proxy1, proxy2"; the left-most entry is the + // original client. + if i := strings.IndexByte(xff, ','); i >= 0 { + xff = xff[:i] + } + if xff = strings.TrimSpace(xff); xff != "" { + if ip := net.ParseIP(xff); ip != nil { + return ip.String() + } + } } } if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { @@ -276,31 +317,71 @@ func remoteIP(r *http.Request) string { return r.RemoteAddr } +// SetTrustedProxyCIDRs constrains which immediate peers may supply client-IP +// headers. Without this allow-list, a direct origin request could spoof +// CF-Connecting-IP and bypass the per-IP limiter. +func (s *Server) SetTrustedProxyCIDRs(cidrs []string) error { + nets := make([]*net.IPNet, 0, len(cidrs)) + for _, raw := range cidrs { + _, network, err := net.ParseCIDR(strings.TrimSpace(raw)) + if err != nil { + return fmt.Errorf("invalid trusted proxy CIDR %q: %w", raw, err) + } + nets = append(nets, network) + } + if len(nets) == 0 { + return fmt.Errorf("at least one trusted proxy CIDR is required") + } + s.trustedProxyNets = nets + return nil +} + +func (s *Server) isTrustedProxy(remoteAddr string) bool { + host := remoteAddr + if parsed, _, err := net.SplitHostPort(remoteAddr); err == nil { + host = parsed + } + ip := net.ParseIP(host) + if ip == nil { + return false + } + for _, network := range s.trustedProxyNets { + if network.Contains(ip) { + return true + } + } + return false +} + func (s *Server) Handler() http.Handler { mux := http.NewServeMux() - mux.HandleFunc("/agent/signal", s.handleAgent) - mux.HandleFunc("/attach", s.handleAttach) - mux.HandleFunc("/pair", s.handlePair) - mux.HandleFunc("/turn-credentials", s.handleTURN) - mux.HandleFunc("/registry", s.handleRegistry) + mux.HandleFunc("/agent/signal", s.rateLimited("/agent/signal", s.handleAgent)) + mux.HandleFunc("/attach", s.rateLimited("/attach", s.handleAttach)) + mux.HandleFunc("/pair", s.rateLimited("/pair", s.handlePair)) + mux.HandleFunc("/turn-credentials", s.rateLimited("/turn-credentials", s.handleTURN)) + mux.HandleFunc("/registry", s.rateLimited("/registry", s.handleRegistry)) + mux.HandleFunc("/revocations", s.rateLimited("/revocations", s.handleRevocations)) mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) return mux } -// handleRegistry serves GET /registry?wallet=W -> [{machine_id, blob}] for the -// agents currently registered under wallet W. It is a blind, stateless, +// handleRegistry serves GET /registry?owner_id=O -> [{machine_id, blob}] for the +// agents currently registered under owner O. It is a blind, stateless, // unauthenticated pass-through: it lists the opaque blobs published on the live // agentConns and never decrypts, verifies, or persists them. A fresh Server has // nothing to serve; a relay restart loses every blob and agents re-publish on -// reconnect. The blobs self-authenticate via their wallet-derived AEAD, so the -// relay needs no auth here — only a wallet-holder can produce a blob that opens. +// reconnect. The blobs self-authenticate via their owner-root-derived AEAD, so +// the relay needs no auth here — only an owner client can produce a blob that opens. func (s *Server) handleRegistry(w http.ResponseWriter, r *http.Request) { - wallet := r.URL.Query().Get("wallet") - if wallet == "" { - http.Error(w, "missing wallet", http.StatusBadRequest) + owner := r.URL.Query().Get("owner_id") + if owner == "" { + owner = r.URL.Query().Get("wallet") // v0.6 migration compatibility + } + if owner == "" { + http.Error(w, "missing owner_id", http.StatusBadRequest) return } - prefix := wallet + "|" + prefix := owner + "|" type entry struct { MachineID string `json:"machine_id"` Blob string `json:"blob"` @@ -311,6 +392,9 @@ func (s *Server) handleRegistry(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(k, prefix) { continue } + if _, isRevoked := s.revoked[k]; isRevoked { + continue + } // Copy the blob string out; do not hold ac's mutex across the s.mu loop. if blob := ac.registryBlob(); blob != "" { out = append(out, entry{MachineID: strings.TrimPrefix(k, prefix), Blob: blob}) @@ -323,10 +407,12 @@ func (s *Server) handleRegistry(w http.ResponseWriter, r *http.Request) { func key(owner, machine string) string { return owner + "|" + machine } -func newSessionID() string { +func newSessionID() (string, error) { b := make([]byte, 16) - _, _ = rand.Read(b) - return hex.EncodeToString(b) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil } func acceptSignal(w http.ResponseWriter, r *http.Request) (*websocket.Conn, error) { @@ -353,10 +439,24 @@ func (s *Server) handleAgent(w http.ResponseWriter, r *http.Request) { return } proof := r.Header.Get(AgentRegistrationSecretHeader) - ip := remoteIP(r) + authorization := r.Header.Get(AgentRegistrationAuthHeader) + ip := s.remoteIP(r) ownerS, machineS := shortID(owner), shortID(machine) k := key(owner, machine) s.mu.Lock() + _, isRevoked := s.revoked[k] + s.mu.Unlock() + if isRevoked { + s.logf("event=agent_reject reason=revoked owner=%s machine=%s ip=%s", ownerS, machineS, ip) + http.Error(w, "machine revoked", http.StatusGone) + return + } + if !agentRegistrationAuthorized(owner, machine, proof, authorization) { + s.logf("event=agent_reject reason=owner_auth owner=%s machine=%s ip=%s", ownerS, machineS, ip) + http.Error(w, "agent registration authorization required", http.StatusUnauthorized) + return + } + s.mu.Lock() if !s.agentProofOKLocked(k, proof) { s.mu.Unlock() s.logf("event=agent_reject reason=proof owner=%s machine=%s ip=%s", ownerS, machineS, ip) @@ -377,6 +477,12 @@ func (s *Server) handleAgent(w http.ResponseWriter, r *http.Request) { // bound to it — must be torn down so nothing keeps routing offers to the // dead one. s.mu.Lock() + if _, isRevoked := s.revoked[k]; isRevoked { + s.mu.Unlock() + s.logf("event=agent_reject reason=revoked owner=%s machine=%s ip=%s", ownerS, machineS, ip) + c.Close(websocket.StatusPolicyViolation, "machine revoked") + return + } if !s.agentProofOKLocked(k, proof) { s.mu.Unlock() s.logf("event=agent_reject reason=proof owner=%s machine=%s ip=%s", ownerS, machineS, ip) @@ -455,6 +561,7 @@ func (s *Server) handleAgent(w http.ResponseWriter, r *http.Request) { // so an answer can only ever reach a session the agent owns. if bc := ac.session(m.Session); bc != nil { bc.notify(SignalMsg{Type: TypeAnswer, SDP: m.SDP}) + bc.close() // signaling is one-shot; writer flushes the queued answer } } } @@ -464,6 +571,27 @@ func (s *Server) handleAgent(w http.ResponseWriter, r *http.Request) { writeUntil(r.Context(), ac.done, c, ac.out, SignalMsg{Type: TypeReady}) } +// agentRegistrationAuthorized protects real Miranda owner ids from first-use +// slot squatting. Old development/test identities that are not 32-byte base58 +// public keys retain the bounded legacy proof policy; production identities are +// always valid Ed25519 public keys and must present an owner authorization. +func agentRegistrationAuthorized(owner, machine, proof, authorization string) bool { + ownerPub, err := base58.Decode(owner) + if err != nil || len(ownerPub) != 32 { + return true + } + secret, err := hex.DecodeString(proof) + if err != nil || len(secret) != 32 { + return false + } + sig, err := base64.StdEncoding.DecodeString(authorization) + if err != nil { + return false + } + commitment := sha256.Sum256(secret) + return identity.VerifyAuth(owner, identity.RegistrationChallenge(machine, hex.EncodeToString(commitment[:])), sig) == nil +} + // agentProofOKLocked reports whether proof may (re)register slot k. Callers must // hold s.mu (the proof store is not internally synchronized). func (s *Server) agentProofOKLocked(k, proof string) bool { @@ -485,8 +613,9 @@ func (s *Server) RunStats(ctx context.Context) { s.mu.Lock() agents := len(s.agents) proofs := s.proofs.len() + revocations := len(s.revoked) s.mu.Unlock() - s.logf("event=stats agents=%d proofs=%d", agents, proofs) + s.logf("event=stats agents=%d proofs=%d revocations=%d", agents, proofs, revocations) } } } @@ -498,7 +627,7 @@ func (s *Server) handleAttach(w http.ResponseWriter, r *http.Request) { http.Error(w, "missing owner_id/machine_id", http.StatusBadRequest) return } - ip := remoteIP(r) + ip := s.remoteIP(r) ownerS, machineS := shortID(owner), shortID(machine) c, err := acceptSignal(w, r) if err != nil { @@ -510,15 +639,25 @@ func (s *Server) handleAttach(w http.ResponseWriter, r *http.Request) { k := key(owner, machine) s.mu.Lock() + _, isRevoked := s.revoked[k] ac := s.agents[k] s.mu.Unlock() + if isRevoked { + s.logf("event=attach_revoked owner=%s machine=%s ip=%s", ownerS, machineS, ip) + writeSignalErrorAndClose(readCtx, c, "machine revoked", websocket.StatusPolicyViolation) + return + } if ac == nil { s.logf("event=attach_offline owner=%s machine=%s ip=%s", ownerS, machineS, ip) writeSignalErrorAndClose(readCtx, c, "machine offline", websocket.StatusGoingAway) return } - sess := newSessionID() + sess, err := newSessionID() + if err != nil { + writeSignalErrorAndClose(readCtx, c, "server entropy unavailable", websocket.StatusInternalError) + return + } bc := newBrowserConn() // Bind this session to the agent so the agent's teardown (death or @@ -542,6 +681,9 @@ func (s *Server) handleAttach(w http.ResponseWriter, r *http.Request) { ac.unbind(sess) bc.close() }() + signalDeadline := time.AfterFunc(firstOfferTimeout, func() { bc.fail("offer timeout") }) + defer signalDeadline.Stop() + var offered sync.Once // Notify the agent that a browser wants it. Never a bare blocking send: bound // to the agent's done and a timeout so a wedged agent can't hang this attach. @@ -564,6 +706,18 @@ func (s *Server) handleAttach(w http.ResponseWriter, r *http.Request) { continue } if m.Type == TypeOffer { + // Each signaling socket is one-shot; later offers are ignored. The + // relay deliberately does not interpret auth/binding fields — the + // agent is the authorization boundary — but the answer deadline + // still prevents an invalid offer from reserving this slot forever. + accepted := false + offered.Do(func() { + accepted = true + signalDeadline.Reset(offerAnswerTimeout) + }) + if !accepted { + continue + } // Re-look-up the live agent for this key on every offer rather // than trusting the pointer captured at attach time. On agent // re-registration the captured ac is stale; routing the offer to @@ -575,7 +729,7 @@ func (s *Server) handleAttach(w http.ResponseWriter, r *http.Request) { bc.fail("machine offline") return } - if !agentSend(live, bc.done, SignalMsg{Type: TypeOffer, Session: sess, SDP: m.SDP, Binding: m.Binding}) { + if !agentSend(live, bc.done, SignalMsg{Type: TypeOffer, Session: sess, SDP: m.SDP, Binding: m.Binding, Auth: m.Auth}) { bc.fail("machine offline") return } @@ -584,7 +738,7 @@ func (s *Server) handleAttach(w http.ResponseWriter, r *http.Request) { }() // Writer: drain bc.out to the browser until bc.done (or r.Context()) fires. - writeUntil(r.Context(), bc.done, c, bc.out, SignalMsg{}) + writeUntil(r.Context(), bc.done, c, bc.out, SignalMsg{Type: TypeReady, Session: sess}) } func writeSignalErrorAndClose(ctx context.Context, c *websocket.Conn, reason string, code websocket.StatusCode) { diff --git a/go/internal/signal/server_recovery_test.go b/go/internal/signal/server_recovery_test.go index 02b02ed..dcdafcf 100644 --- a/go/internal/signal/server_recovery_test.go +++ b/go/internal/signal/server_recovery_test.go @@ -25,6 +25,9 @@ func TestAgentDeathNotifiesAttachedBrowsers(t *testing.T) { } browser := dialJSON(t, wsURL(srv.URL, "/attach", map[string]string{"owner_id": "o", "machine_id": "m"})) + if ready := readMsg(t, browser); ready.Type != TypeReady { + t.Fatalf("browser expected ready, got %+v", ready) + } // Agent learns about the attach. if attach := readMsg(t, agent); attach.Type != TypeAttach || attach.Session == "" { @@ -98,6 +101,9 @@ func TestAgentReRegistrationTearsDownOldSessions(t *testing.T) { } browser := dialJSON(t, wsURL(srv.URL, "/attach", map[string]string{"owner_id": "o", "machine_id": "m"})) + if ready := readMsg(t, browser); ready.Type != TypeReady { + t.Fatalf("browser expected ready, got %+v", ready) + } if attach := readMsg(t, a1); attach.Type != TypeAttach { t.Fatalf("a1 expected attach, got %+v", attach) } @@ -119,6 +125,9 @@ func TestAgentReRegistrationTearsDownOldSessions(t *testing.T) { // A fresh attach now reaches the live agent a2 (and not the dead a1). browser2 := dialJSON(t, wsURL(srv.URL, "/attach", map[string]string{"owner_id": "o", "machine_id": "m"})) defer browser2.CloseNow() + if ready := readMsg(t, browser2); ready.Type != TypeReady { + t.Fatalf("browser2 expected ready, got %+v", ready) + } attach := readMsg(t, a2) if attach.Type != TypeAttach || attach.Session == "" { t.Fatalf("a2 expected attach with session, got %+v", attach) diff --git a/go/internal/signal/server_test.go b/go/internal/signal/server_test.go index feef2ac..dab7e7f 100644 --- a/go/internal/signal/server_test.go +++ b/go/internal/signal/server_test.go @@ -91,6 +91,9 @@ func TestOfferReachesAgentAnswerReachesBrowser(t *testing.T) { } browser := dialJSON(t, wsURL(srv.URL, "/attach", map[string]string{"owner_id": "o", "machine_id": "m"})) + if ready := readMsg(t, browser); ready.Type != TypeReady || ready.Session == "" { + t.Fatalf("browser expected ready, got %+v", ready) + } // Agent is notified of the attach with a session id. attach := readMsg(t, agent) @@ -123,6 +126,9 @@ func TestOfferBindingReachesAgentVerbatim(t *testing.T) { } browser := dialJSON(t, wsURL(srv.URL, "/attach", map[string]string{"owner_id": "o", "machine_id": "m"})) + if ready := readMsg(t, browser); ready.Type != TypeReady || ready.Session == "" { + t.Fatalf("browser expected ready, got %+v", ready) + } attach := readMsg(t, agent) if attach.Type != TypeAttach || attach.Session == "" { t.Fatalf("expected attach with session, got %+v", attach) diff --git a/go/internal/signal/spike_test.go b/go/internal/signal/spike_test.go index d4a948f..685bd20 100644 --- a/go/internal/signal/spike_test.go +++ b/go/internal/signal/spike_test.go @@ -90,6 +90,14 @@ func TestSpikeFullPathThroughSignalingServer(t *testing.T) { if err != nil { t.Fatal(err) } + _, readyData, err := bc.Read(ctx) + if err != nil { + t.Fatal(err) + } + ready, err := decodeSignal(readyData) + if err != nil || ready.Type != TypeReady || ready.Session == "" { + t.Fatalf("expected attach ready, got %+v (%v)", ready, err) + } off, offOpened, err := peer.NewOfferer(nil) if err != nil { t.Fatal(err) diff --git a/go/internal/slip10/slip10.go b/go/internal/slip10/slip10.go deleted file mode 100644 index 751c837..0000000 --- a/go/internal/slip10/slip10.go +++ /dev/null @@ -1,69 +0,0 @@ -// Package slip10 implements SLIP-0010 HD key derivation for the ed25519 curve. -// ed25519 supports hardened derivation only. Tiny and dependency-free for -// byte-identical parity with web/src/wallet/slip10.js. -// -// master = HMAC-SHA512("ed25519 seed", seed) -> key=left32, chain=right32 -// child_i = HMAC-SHA512(chain, 0x00 || key || ser32(i)) (i always hardened) -package slip10 - -import ( - "crypto/hmac" - "crypto/sha512" - "encoding/binary" - "fmt" - "strconv" - "strings" -) - -const hardenedOffset = 0x80000000 - -// Node is a derived key: a 32-byte key (the ed25519 seed at this node) and its -// 32-byte chain code. -type Node struct { - Key []byte - Chain []byte -} - -func master(seed []byte) Node { - h := hmac.New(sha512.New, []byte("ed25519 seed")) - h.Write(seed) - sum := h.Sum(nil) - return Node{Key: sum[:32], Chain: sum[32:]} -} - -// child derives the hardened child at the given full index (already including -// the hardened offset). -func (n Node) child(index uint32) Node { - var data [37]byte - data[0] = 0x00 - copy(data[1:33], n.Key) - binary.BigEndian.PutUint32(data[33:], index) - h := hmac.New(sha512.New, n.Chain) - h.Write(data[:]) - sum := h.Sum(nil) - return Node{Key: sum[:32], Chain: sum[32:]} -} - -// DerivePath derives a node along a path such as "m/44'/501'/0'/0'". Every -// segment must be hardened (ed25519 only supports hardened derivation). -func DerivePath(seed []byte, path string) (Node, error) { - n := master(seed) - path = strings.TrimSpace(path) - if path == "m" || path == "" { - return n, nil - } - if !strings.HasPrefix(path, "m/") { - return Node{}, fmt.Errorf("slip10: path must start with m/, got %q", path) - } - for _, seg := range strings.Split(path[2:], "/") { - if !strings.HasSuffix(seg, "'") { - return Node{}, fmt.Errorf("slip10: ed25519 requires hardened indices, got %q", seg) - } - num, err := strconv.ParseUint(strings.TrimSuffix(seg, "'"), 10, 32) - if err != nil || num >= hardenedOffset { - return Node{}, fmt.Errorf("slip10: bad index %q", seg) - } - n = n.child(uint32(num) + hardenedOffset) - } - return n, nil -} diff --git a/go/internal/slip10/slip10_test.go b/go/internal/slip10/slip10_test.go deleted file mode 100644 index f6b2d60..0000000 --- a/go/internal/slip10/slip10_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package slip10 - -import ( - "crypto/ed25519" - "encoding/hex" - "testing" -) - -// SLIP-0010 official Test Vector 1 for ed25519 (seed 000102...0f). -// pub is the 33-byte 0x00||pubkey form from the spec. -var tv1 = []struct{ path, chain, priv, pub string }{ - {"m", "90046a93de5380a72b5e45010748567d5ea02bbf6522f979e05c0d8d8ca9fffb", "2b4be7f19ee27bbf30c667b642d5f4aa69fd169872f8fc3059c08ebae2eb19e7", "00a4b2856bfec510abab89753fac1ac0e1112364e7d250545963f135f2a33188ed"}, - {"m/0'", "8b59aa11380b624e81507a27fedda59fea6d0b779a778918a2fd3590e16e9c69", "68e0fe46dfb67e368c75379acec591dad19df3cde26e63b93a8e704f1dade7a3", "008c8a13df77a28f3445213a0f432fde644acaa215fc72dcdf300d5efaa85d350c"}, - {"m/0'/1'", "a320425f77d1b5c2505a6b1b27382b37368ee640e3557c315416801243552f14", "b1d0bad404bf35da785a64ca1ac54b2617211d2777696fbffaf208f746ae84f2", "001932a5270f335bed617d5b935c80aedb1a35bd9fc1e31acafd5372c30f5c1187"}, - {"m/0'/1'/2'", "2e69929e00b5ab250f49c3fb1c12f252de4fed2c1db88387094a0f8c4c9ccd6c", "92a5b23c0b8a99e37d07df3fb9966917f5d06e02ddbd909c7e184371463e9fc9", "00ae98736566d30ed0e9d2f4486a64bc95740d89c7db33f52121f8ea8f76ff0fc1"}, - {"m/0'/1'/2'/2'", "8f6d87f93d750e0efccda017d662a1b31a266e4a6f5993b15f5c1f07f74dd5cc", "30d1dc7e5fc04c31219ab25a27ae00b50f6fd66622f6e9c913253d6511d1e662", "008abae2d66361c879b900d204ad2cc4984fa2aa344dd7ddc46007329ac76c429c"}, - {"m/0'/1'/2'/2'/1000000000'", "68789923a0cac2cd5a29172a475fe9e0fb14cd6adb5ad98a3fa70333e7afa230", "8f94d394a8e8fd6b1bc2f3f49f5c47e385281d5c17e65324b0f62483e37e8793", "003c24da049451555d51a7014a37337aa4e12d41e485abccfa46b47dfb2af54b7a"}, -} - -func TestVector1(t *testing.T) { - seed, _ := hex.DecodeString("000102030405060708090a0b0c0d0e0f") - for _, c := range tv1 { - n, err := DerivePath(seed, c.path) - if err != nil { - t.Fatalf("DerivePath(%q): %v", c.path, err) - } - if got := hex.EncodeToString(n.Chain); got != c.chain { - t.Errorf("%s chain = %s, want %s", c.path, got, c.chain) - } - if got := hex.EncodeToString(n.Key); got != c.priv { - t.Errorf("%s key = %s, want %s", c.path, got, c.priv) - } - // SLIP-0010 ed25519 public key = 0x00 || ed25519.pub(seed=key). - pub := ed25519.NewKeyFromSeed(n.Key).Public().(ed25519.PublicKey) - if got := "00" + hex.EncodeToString(pub); got != c.pub { - t.Errorf("%s pub = %s, want %s", c.path, got, c.pub) - } - } -} - -func TestRejectsNonHardened(t *testing.T) { - seed, _ := hex.DecodeString("000102030405060708090a0b0c0d0e0f") - for _, bad := range []string{"m/44/501'/0'/0'", "m/0", "x/0'"} { - if _, err := DerivePath(seed, bad); err == nil { - t.Errorf("DerivePath(%q) should error", bad) - } - } -} diff --git a/install.sh b/install.sh index 2d51785..985e0c7 100755 --- a/install.sh +++ b/install.sh @@ -42,7 +42,7 @@ verify_sha256() { [ "$_want" = "$_got" ] } -# verify_checksums_sig -> 0 if verified OR cosign unavailable. +# verify_checksums_sig -> 0 only after verification. # # TRUST MODEL. The per-archive SHA256 in checksums.txt is only as trustworthy as # checksums.txt itself. Without a signature, anyone who can publish a GitHub @@ -56,16 +56,13 @@ verify_sha256() { # repo's release pipeline. It does NOT vouch for the source code that was built — # only for the provenance of the checksum file. # -# Graceful degradation: cosign is optional tooling. If it is not installed we -# print one warning and fall back to checksum-only verification (still protects -# against corrupted downloads / CDN tampering, just not a malicious publisher). -# If cosign IS present and verification FAILS, that is an active attack signal — -# abort loudly. +# Fail closed: a checksum downloaded beside a binary is not a trust anchor. +# Cosign and both signing assets are therefore mandatory. verify_checksums_sig() { _base="$1"; _tmp="$2" if ! command -v cosign >/dev/null 2>&1; then - echo "warning: cosign not found; skipping signature check of checksums.txt (install cosign for supply-chain verification) — falling back to checksum-only" >&2 - return 0 + echo "cosign is required to verify Miranda releases; install cosign and retry" >&2 + return 1 fi # Both signing artifacts must be present to verify; treat a missing one as a # hard failure (an attacker stripping the .sig must not silently downgrade us). @@ -130,6 +127,13 @@ for bin in $bins; do echo "installed $bin -> $dir/$bin" done +if [ "$os" = linux ] && [ "$WHICH" != agent ] && ! command -v secret-tool >/dev/null 2>&1; then + echo + echo "note: owner-client commands require Linux Secret Service's secret-tool" + echo " (Debian/Ubuntu: sudo apt install libsecret-tools)." + echo " Miranda has no plaintext owner-secret fallback; target-only agent use is unaffected." +fi + case ":$PATH:" in *":$dir:"*) : ;; *) echo; echo "note: $dir is not on your PATH. Add it:"; echo " export PATH=\"$dir:\$PATH\"" ;; diff --git a/scripts/verify-reproducible.sh b/scripts/verify-reproducible.sh new file mode 100755 index 0000000..a221211 --- /dev/null +++ b/scripts/verify-reproducible.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Build each release binary twice in independent build caches and compare bytes. +# This proves same-source reproducibility for the current OS/architecture. Run it +# on every target platform when independently verifying a published release. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/miranda-repro.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT + +VERSION="${VERSION:-$(git -C "$ROOT" describe --tags --always --dirty)}" +VERSION="${VERSION#v}" +COMMIT="$(git -C "$ROOT" rev-parse --short=8 HEAD)" +DATE="$(git -C "$ROOT" show -s --format=%cI HEAD)" +export SOURCE_DATE_EPOCH="$(git -C "$ROOT" show -s --format=%ct HEAD)" + +build_set() { + local output="$1" cache="$2" + mkdir -p "$output" "$cache" + while read -r binary package; do + ( + cd "$ROOT/go" + CGO_ENABLED=0 GOCACHE="$cache" go build \ + -trimpath -buildvcs=false \ + -ldflags "-s -w -buildid= -X github.com/srcful/terminal-relay/go/internal/version.Version=$VERSION -X github.com/srcful/terminal-relay/go/internal/version.Commit=$COMMIT -X github.com/srcful/terminal-relay/go/internal/version.Date=$DATE" \ + -o "$output/$binary" "$package" + ) + done <<'EOF' +mir ./cmd/mir +mir-agent ./cmd/mir-agent +mir-signal ./cmd/mir-signal +EOF +} + +build_set "$TMP/one" "$TMP/cache-one" +build_set "$TMP/two" "$TMP/cache-two" + +hash_file() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' + else shasum -a 256 "$1" | awk '{print $1}'; fi +} + +for binary in mir mir-agent mir-signal; do + if ! cmp -s "$TMP/one/$binary" "$TMP/two/$binary"; then + echo "FAIL: $binary differs between clean build caches" >&2 + exit 1 + fi + echo "$(hash_file "$TMP/one/$binary") $binary" +done +echo "reproducible: all binaries are byte-identical for $(go env GOOS)/$(go env GOARCH)" diff --git a/test/install_test.sh b/test/install_test.sh index 1ebbd98..dbf226c 100755 --- a/test/install_test.sh +++ b/test/install_test.sh @@ -17,4 +17,13 @@ verify_sha256 "$tmp" "payload.tar.gz" "$tmp.sums" || fail "verify_sha256 rejecte printf 'deadbeef payload.tar.gz\n' > "$tmp.bad" if verify_sha256 "$tmp" "payload.tar.gz" "$tmp.bad"; then fail "verify_sha256 accepted a bad checksum"; fi +# Supply-chain verification is fail-closed when cosign is unavailable. +empty_path=$(mktemp -d) +old_path=$PATH +PATH=$empty_path +if verify_checksums_sig "https://invalid.example" "$empty_path" 2>/dev/null; then + fail "verify_checksums_sig accepted a checksum without cosign" +fi +PATH=$old_path + echo "OK install_test" diff --git a/testdata/identity-binding.json b/testdata/identity-binding.json new file mode 100644 index 0000000..9d2d660 --- /dev/null +++ b/testdata/identity-binding.json @@ -0,0 +1,10 @@ +{ + "wallet": "EJXgN4y1m4B9NSk6F7Bx2Uj6cWWbksWMs4Lor9qswz76", + "wallet_priv": "1ed668a74f04dcaee81b48d383293500e16c98ab64d70042ffec10261b983e82", + "device": "a1b2c3d4e5f60718", + "x25519": "269863f7f8d945c83cb429b6f16ab5655229a70b08272318267f41b1e8a28613", + "ts": 1749600000, + "canonical": "{\"v\":1,\"wallet\":\"EJXgN4y1m4B9NSk6F7Bx2Uj6cWWbksWMs4Lor9qswz76\",\"device\":\"a1b2c3d4e5f60718\",\"x25519\":\"269863f7f8d945c83cb429b6f16ab5655229a70b08272318267f41b1e8a28613\",\"ts\":1749600000}", + "sig": "4doKosp3PY4xErK4tHMzGt2gHwzSoCqVcsskdyLziJXVqFK5hdS1SVJUZd8muX3LBqXnQkmxyZc9diigSHuxJ5g", + "record": "{\"v\":1,\"wallet\":\"EJXgN4y1m4B9NSk6F7Bx2Uj6cWWbksWMs4Lor9qswz76\",\"device\":\"a1b2c3d4e5f60718\",\"x25519\":\"269863f7f8d945c83cb429b6f16ab5655229a70b08272318267f41b1e8a28613\",\"ts\":1749600000,\"sig\":\"4doKosp3PY4xErK4tHMzGt2gHwzSoCqVcsskdyLziJXVqFK5hdS1SVJUZd8muX3LBqXnQkmxyZc9diigSHuxJ5g\"}" +} diff --git a/testdata/identity-derivation.json b/testdata/identity-derivation.json new file mode 100644 index 0000000..bbbfa61 --- /dev/null +++ b/testdata/identity-derivation.json @@ -0,0 +1,8 @@ +{ + "root": "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + "mnemonic": "abandon math mimic master filter design carbon crystal rookie group knife wrap absurd much snack melt grid rough chapter fever rubber humble room trophy", + "signer_priv": "1ed668a74f04dcaee81b48d383293500e16c98ab64d70042ffec10261b983e82", + "signer_pub": "c5a57e068845d86ea1d824733a01998d2809fe6dc84982c0764c0f06ec707a75", + "owner_id": "EJXgN4y1m4B9NSk6F7Bx2Uj6cWWbksWMs4Lor9qswz76", + "transport_pub": "269863f7f8d945c83cb429b6f16ab5655229a70b08272318267f41b1e8a28613" +} diff --git a/testdata/pair-interop.json b/testdata/pair-interop.json index 1c29f94..246a956 100644 --- a/testdata/pair-interop.json +++ b/testdata/pair-interop.json @@ -1,13 +1,13 @@ { "token": "00112233445566778899aabbccddeeff", "wallet_prf": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf", - "wallet": "5hoo57BiX5M1dQJF6BKtFm4iRpBAgmZGEQKeqqeJjGX5", - "claim": "{\"wallet\":\"5hoo57BiX5M1dQJF6BKtFm4iRpBAgmZGEQKeqqeJjGX5\"}", + "wallet": "8agxrqVokTQaYnCNPKG1PjgfEBWPWCyu2JNoAHWPyq21", + "claim": "{\"wallet\":\"8agxrqVokTQaYnCNPKG1PjgfEBWPWCyu2JNoAHWPyq21\"}", "info_json": "{\"host_pub\":\"5051525354555657585950515253545550515253545556575859505152535455\",\"machine_id\":\"m42\",\"name\":\"box\"}", "room_id": "812dd0fd5dc86afd4c6a69914a65e5ab", "psk": "964186805c72ef0d89a0425c747e3e421d5034eaf2441b33bc066ce041175a09", - "msg1": "07a37cbc142093c8b755dc1b10e86cb426374ad16aa853ed0bdfc0b2b86d1c7c9997ab25df018284da2c0e5cde798b2daa097b37bc4fc53344af55832f618a815f201f441be7e26f0fdfd31b0acf2ef29443a0c57d2c8def0268ab8073113eb4aaa21fd5fb45c16417", - "msg2": "5869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67b8f6936500f161e71050cedf7e12066e682ebfb1c13c196ee66c7cc60cae5483361738d4243b8640ce7bf79af15f6350925435efd75175002c9ba8a82beade9bb6a2b9eb17300292187ca50c8e610d0f51afaf4272554c9e467a92ec69049cfb0a428e63386b5447260501e3c7850f91462425a1fe37205e7f2f356c681fa0a", - "msg3": "0c9848e0feb2e25f26cca49319333e6c4e343b9b4d14f33900319f1a2695bfcb9369ecf1ebbd75a6ec7db075d56467e5b878a65b8942143c13e80e6cd3a65a0b", - "safety_number": "a643-3d65-682b-4298" + "msg1": "07a37cbc142093c8b755dc1b10e86cb426374ad16aa853ed0bdfc0b2b86d1c7c9997ab25df018284da2c0e51d7719c6aec1d7d04dd53950e7ba65de5266dcf975873105329f2f35f21fce16c11ca24c2ad71bad64b4689ef02fd383697767822408475f45f189dfdab", + "msg2": "5869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67b8f6936500f161e71050cedf7e12066e682ebfb1c13c196ee66c7cc60cae5483361738d4243b8640ce7bf79af15f6350925435efd75175002c9ba8a82beade9bb6a2b9eb17300292187ca50c8e610d0f51afaf4272554c9e467a92ec69049cfb0a428e63386b5447260501e3c7850f9f2a12b17f05c2d0b23e78febae337bad", + "msg3": "30f54ede107472331fd0369a04a80d630162fc8ec81e8b5ead5088e088cbb7185e0ecfea9d8445be9c4ff1a13b30d4678b78ff8f196c71b171b83f318455e20e", + "safety_number": "b42a-5375-9ecd-5ce3-a600-a5f0" } \ No newline at end of file diff --git a/testdata/revocation.json b/testdata/revocation.json new file mode 100644 index 0000000..d5073ef --- /dev/null +++ b/testdata/revocation.json @@ -0,0 +1,8 @@ +{ + "root": "6161616161616161616161616161616161616161616161616161616161616161", + "v": 1, + "owner_id": "H6M2ZC8VZ4SCbGXVGHSWaQ5yCpWQYLwJhg1xoYm5achm", + "machine_id": "machine-1", + "ts": 1700000000, + "signature": "iLAAWOjVpAI0iYvdxbSxX3Vsb9xiBnBL3G+E5okp4/ljzJVopeT1hLkcqLtaUAxHD5mjPWbzyS8Izs997NSBBw==" +} diff --git a/testdata/wallet-binding.json b/testdata/wallet-binding.json deleted file mode 100644 index 301fab5..0000000 --- a/testdata/wallet-binding.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "wallet": "C2XYPfExbj6azVqYLWeUphzsdKK2dQ53dm83Brd3THmS", - "wallet_priv": "fb0d9e4a24019fc5d35ba3d44561d1b03d111ff670347af59c27d57304dafcd5", - "device": "a1b2c3d4e5f60718", - "x25519": "269863f7f8d945c83cb429b6f16ab5655229a70b08272318267f41b1e8a28613", - "ts": 1749600000, - "canonical": "{\"v\":1,\"wallet\":\"C2XYPfExbj6azVqYLWeUphzsdKK2dQ53dm83Brd3THmS\",\"device\":\"a1b2c3d4e5f60718\",\"x25519\":\"269863f7f8d945c83cb429b6f16ab5655229a70b08272318267f41b1e8a28613\",\"ts\":1749600000}", - "sig": "T5qdAoXkYPBaJmdihCpJdCTVQteMJasBykpfJMYN461wUxz4HLcHxrspQAenVbNbKVtkoKEC4HvK6UNb7WVwzKT", - "record": "{\"v\":1,\"wallet\":\"C2XYPfExbj6azVqYLWeUphzsdKK2dQ53dm83Brd3THmS\",\"device\":\"a1b2c3d4e5f60718\",\"x25519\":\"269863f7f8d945c83cb429b6f16ab5655229a70b08272318267f41b1e8a28613\",\"ts\":1749600000,\"sig\":\"T5qdAoXkYPBaJmdihCpJdCTVQteMJasBykpfJMYN461wUxz4HLcHxrspQAenVbNbKVtkoKEC4HvK6UNb7WVwzKT\"}" -} diff --git a/testdata/wallet-derivation.json b/testdata/wallet-derivation.json deleted file mode 100644 index 125a8c4..0000000 --- a/testdata/wallet-derivation.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "prf_output": "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", - "mnemonic": "abandon math mimic master filter design carbon crystal rookie group knife wrap absurd much snack melt grid rough chapter fever rubber humble room trophy", - "seed": "559da5e7655dd1fbe657c100870512afb2b654b0acfd32f2c549344407e555bc16c2e71219eefc24acc7ed2cfaeac8a1808d543a5de4890bb2d95a7bb58af5b7", - "wallet_priv": "fb0d9e4a24019fc5d35ba3d44561d1b03d111ff670347af59c27d57304dafcd5", - "wallet_pub": "a3d4ab895f8bc2990f27e64b4ee2abcb9396dc132ead962a1ba6664fd938ec41", - "address": "C2XYPfExbj6azVqYLWeUphzsdKK2dQ53dm83Brd3THmS", - "owner_pub": "269863f7f8d945c83cb429b6f16ab5655229a70b08272318267f41b1e8a28613" -} diff --git a/web/index.html b/web/index.html index e25a163..6ccda51 100644 --- a/web/index.html +++ b/web/index.html @@ -19,7 +19,6 @@ "@noble/ciphers/chacha": "/vendor/noble-ciphers-chacha.js", "@noble/hashes/sha2": "/vendor/noble-hashes-sha2.js", "@noble/hashes/hmac": "/vendor/noble-hashes-hmac.js", - "@noble/hashes/pbkdf2": "/vendor/noble-hashes-pbkdf2.js", "@noble/hashes/hkdf": "/vendor/noble-hashes-hkdf.js", "@noble/hashes/utils": "/vendor/noble-hashes-utils.js", "@xterm/xterm": "/vendor/xterm.js", diff --git a/web/manifest.json b/web/manifest.json index 5ae1908..cff5343 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -1,7 +1,7 @@ { "name": "Miranda", "short_name": "Miranda", - "description": "Passkey-authenticated, end-to-end encrypted terminal in your browser.", + "description": "Leave your desk. Keep your terminal. Passkey-native terminal continuity.", "id": "/", "start_url": "/", "scope": "/", diff --git a/web/package-lock.json b/web/package-lock.json index b306551..08ab9a5 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,11 +1,11 @@ { - "name": "terminal-relay-web", + "name": "miranda-web", "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "terminal-relay-web", + "name": "miranda-web", "version": "0.0.1", "dependencies": { "@noble/ciphers": "^1.2.0", diff --git a/web/package.json b/web/package.json index 764c2ad..e5e3af6 100644 --- a/web/package.json +++ b/web/package.json @@ -1,5 +1,5 @@ { - "name": "terminal-relay-web", + "name": "miranda-web", "version": "0.0.1", "type": "module", "private": true, diff --git a/web/src/app.js b/web/src/app.js index f3abed3..d9a2b3d 100644 --- a/web/src/app.js +++ b/web/src/app.js @@ -14,6 +14,8 @@ import { pairWithCode } from './pair.js'; import { confirmPairingSafety, machineAfterConfirmedPairing, pendingPairingConfirmation } from './pairing/confirm.js'; import { registerPasskey, signInPasskey, devOwnerKey, passkeySupported, isLocalhost } from './identity.js'; import { signBinding, recordJSON } from './identity/binding.js'; +import { signAuth, attachChallenge } from './identity/auth.js'; +import { filterRevoked, isMachineRevoked, loadRevocations, revokeMachine, syncRevocations } from './revocations.js'; import { makeKeybar, shouldShowKeybar } from './ui/keybar.js'; import jsQR from '/vendor/jsqr.js'; @@ -40,19 +42,18 @@ async function iceServers(signalURL) { // Resolved once at the sign-in gate and cached; ownerKey() stays synchronous so // attach()/pairWithCode() are untouched (passkey get() is async + needs a user // gesture, so it can't run inside a sync call). -// _id holds the full prf-rooted identity { owner, wallet }: `owner` is the X25519 -// transport keypair (Noise-KK static, unchanged) and `wallet` is the Ed25519 Solana -// wallet ({ address, priv, ... }) that now serves as owner_id on the wire. +// _id holds { owner, signer, secret }: an X25519 transport key, a neutral +// Ed25519 owner signer, and the in-memory-only passkey PRF result. let _id = null; export function ownerKey() { if (!_id) throw new Error('not signed in'); return _id.owner; } -export function walletKey() { +export function signerKey() { if (!_id) throw new Error('not signed in'); - return _id.wallet; + return _id.signer; } -function setOwner(id) { _id = id; window.__ownerPub = bytesToHex(id.owner.pub); window.__wallet = id.wallet.address; } +function setOwner(id) { _id = id; window.__ownerPub = bytesToHex(id.owner.pub); window.__identity = id.signer.address; } // deviceID returns a stable per-browser device id (binding.device), generated once. function deviceID() { @@ -74,11 +75,12 @@ const wsBase = (signalURL) => 'ws' + signalURL.slice(4); // http->ws, https->wss // tmux FrameWindows snapshot. The caller (makeTerminal) owns the terminal + teardown. export async function connectOnce(machine, term, current, onConnected, onWindows) { const owner = ownerKey(); - const wallet = walletKey(); - // owner_id is the base58 wallet address; the agent recovers the X25519 KK pin from - // the signed binding carried on the offer (it cannot hex-decode the wallet). - const ownerId = wallet.address; - const binding = recordJSON(signBinding(wallet, deviceID(), bytesToHex(owner.pub), Math.floor(Date.now() / 1000))); + const signer = signerKey(); + if (isMachineRevoked(machine.machine_id, signer.address)) throw new Error('machine revoked'); + // owner_id is the neutral Ed25519 address; the signed binding authorizes this + // browser's X25519 transport key for the Noise handshake. + const ownerId = signer.address; + const binding = recordJSON(signBinding(signer, deviceID(), bytesToHex(owner.pub), Math.floor(Date.now() / 1000))); const diag = { step: 'start', ws: 'init', gather: '', iceConn: '', conn: '', dc: 'init' }; window.__diag = diag; @@ -127,9 +129,13 @@ export async function connectOnce(machine, term, current, onConnected, onWindows let failSetup; const setupFail = new Promise((_, rej) => { failSetup = rej; }); setupFail.catch(() => {}); + let readySession; + let resolveReady; + const ready = new Promise((resolve) => { resolveReady = resolve; }); ws.onmessage = async (ev) => { const m = JSON.parse(ev.data); - if (m.type === 'answer') { diag.step = 'got-answer'; await pc.setRemoteDescription({ type: 'answer', sdp: m.sdp }); } + if (m.type === 'ready' && m.session) { readySession = m.session; resolveReady(m.session); } + else if (m.type === 'answer') { diag.step = 'got-answer'; await pc.setRemoteDescription({ type: 'answer', sdp: m.sdp }); } else if (m.type === 'error') { diag.step = 'signal-error'; failSetup(new Error('relay: ' + (m.reason || 'agent unavailable'))); } }; ws.onclose = () => { if (!connected) failSetup(new Error('signal socket closed')); }; @@ -138,6 +144,7 @@ export async function connectOnce(machine, term, current, onConnected, onWindows try { diag.step = 'ws-connecting'; await Promise.race([wsOpen, setupFail]); + await Promise.race([ready, setupFail]); diag.step = 'creating-offer'; await pc.setLocalDescription(await pc.createOffer()); // non-trickle: send once gathering completes OR after a cap (a slow/unreachable STUN must not hang). @@ -148,7 +155,10 @@ export async function connectOnce(machine, term, current, onConnected, onWindows pc.addEventListener('icegatheringstatechange', () => { diag.gather = pc.iceGatheringState; if (pc.iceGatheringState === 'complete') finish(); }); }); diag.step = 'offer-sent'; - ws.send(JSON.stringify({ type: 'offer', sdp: pc.localDescription.sdp, binding })); + const authSig = signAuth(signer, attachChallenge(readySession, machine.machine_id, pc.localDescription.sdp)); + let authBinary = ''; + for (const b of authSig) authBinary += String.fromCharCode(b); + ws.send(JSON.stringify({ type: 'offer', sdp: pc.localDescription.sdp, binding, auth: btoa(authBinary) })); diag.step = 'awaiting-datachannel'; const inbox = []; @@ -294,7 +304,10 @@ function newDevices(discovered) { return fresh; } +let visibleMachines = []; + function renderMachines(root, machines, fresh) { + visibleMachines = machines; const grid = el('div', { className: 'grid' }); for (const m of machines) { grid.append(el('button', { className: 'card machine', onclick: () => viewTerminal(root, m) }, @@ -305,7 +318,7 @@ function renderMachines(root, machines, fresh) { el('div', { className: 'plus' }, '+'), el('div', { className: 'sub' }, 'Pair a machine'))); const kids = [ el('h1', {}, 'your machines'), - el('p', { className: 'muted' }, 'Reach a shell on any of them — peer-to-peer, end-to-end encrypted.'), + el('p', { className: 'muted' }, 'Your live terminals. Leave one device, continue on another.'), ]; if (fresh && fresh.length) { kids.push(el('p', { className: 'muted' }, '📣 new device joined: ' + fresh.map((m) => m.name || m.machine_id).join(', '))); @@ -315,16 +328,25 @@ function renderMachines(root, machines, fresh) { } // viewMachines renders the locally-stored machines immediately, then enriches the -// list from the wallet's encrypted registry (B2) — your machines appear by name with +// list from the owner's encrypted registry (B2) — your machines appear by name with // no manual pairing. The fetch is same-origin (the relay that served this app) and // best-effort: a failure just leaves the local list. Discovery only. function viewMachines(root) { - renderMachines(root, listMachines(), []); + let localRevocations; + try { localRevocations = loadRevocations(signerKey().address); } + catch (e) { + mount(root, el('div', { className: 'view' }, + el('h1', {}, 'security check failed'), + el('p', { className: 'muted' }, e && e.message || String(e)))); + return; + } + renderMachines(root, filterRevoked(listMachines(), localRevocations), []); (async () => { try { - const discovered = await fetchMachines(location.origin, walletKey(), _id.secret); - if (!discovered.length) return; - renderMachines(root, mergeMachines(listMachines(), discovered), newDevices(discovered)); + const revocations = await syncRevocations(location.origin, signerKey().address); + const discovered = await fetchMachines(location.origin, signerKey(), _id.secret); + const visibleDiscovered = filterRevoked(discovered, revocations); + renderMachines(root, filterRevoked(mergeMachines(listMachines(), visibleDiscovered), revocations), newDevices(visibleDiscovered)); } catch { /* not signed in / relay unreachable — keep the local list */ } })(); } @@ -392,14 +414,14 @@ function viewPair(root, prefill = '', auto = false) { mount(root, el('div', { className: 'view' }, el('h1', {}, 'pairing…'), status)); status.textContent = 'pairing…'; try { - const { machine, safetyNumber } = await pairWithCode(code, walletKey()); + const { machine, safetyNumber } = await pairWithCode(code, signerKey(), _id.secret); const pending = pendingPairingConfirmation(machine, safetyNumber); window.__lastSafety = safetyNumber; status.innerHTML = ''; status.append( el('div', { className: 'ok' }, 'Compare safety numbers before trusting ' + (machine.name || machine.machine_id)), el('div', { className: 'sas' }, pending.safetyNumber), - el('div', { className: 'muted' }, 'Find the safety number printed by `mir-agent pair` on the machine. Continue only if every group matches this number exactly.'), + el('div', { className: 'muted' }, 'Find the safety number printed by `mir pair` on the machine. Continue only if all six groups match exactly.'), el('div', { className: 'actions' }, el('button', { className: 'btn', onclick: () => { const confirmed = confirmPairingSafety(pending); @@ -427,7 +449,7 @@ function viewPair(root, prefill = '', auto = false) { const sStatus = el('div', { className: 'status' }); mount(root, el('div', { className: 'view' }, el('h1', {}, 'scan the QR'), - el('p', { className: 'muted' }, 'Point at the QR shown by `mir-agent pair`.'), + el('p', { className: 'muted' }, 'Point at the QR shown by `mir pair` on the machine.'), video, sStatus, el('button', { className: 'link', onclick: () => leaveScanner(() => viewPair(root)) }, '✕ cancel'))); const stop = await scanQR(video, (text) => pairCode(codeFromScan(text)), (err) => { sStatus.textContent = err; }); @@ -442,7 +464,7 @@ function viewPair(root, prefill = '', auto = false) { const input = el('input', { className: 'code', placeholder: 'or paste the code', value: prefill }); mount(root, el('div', { className: 'view' }, el('h1', {}, 'pair a machine'), - el('p', { className: 'muted' }, 'Run `mir-agent pair` on the machine, then scan its QR.'), + el('p', { className: 'muted' }, 'Run `mir pair` on the machine, then scan its QR.'), el('button', { className: 'btn', onclick: startScan }, '📷 Scan QR'), input, el('button', { className: 'link', onclick: () => pairCode(input.value) }, 'pair with pasted code'), @@ -457,6 +479,16 @@ function viewTerminal(root, machine) { let snap = null; // latest FrameWindows snapshot: v2 {v,sess:[...]}, or null (non-tmux) const close = () => { try { handle && handle.close(); } catch {} }; const focus = () => { window.__term && window.__term.focus(); }; + const revoke = async () => { + if (!window.confirm('Permanently revoke ' + (machine.name || machine.machine_id) + '? It must be paired again under a new owner identity to return.')) return; + close(); + try { + await revokeMachine([machine.signal, location.origin], signerKey(), machine.machine_id); + } catch (e) { + window.alert(e && e.message || String(e)); + } + viewMachines(root); + }; // tmux control: the AGENT runs the command directly (robust — no prefix // dependence, no command-prompt/Enter fragility, no keystroke timing). Target @@ -487,9 +519,10 @@ function viewTerminal(root, machine) { const termBox = el('div', { className: 'termbox' }); const back = el('button', { className: 'tb-btn', onclick: () => { close(); viewMachines(root); } }, '‹ Machines'); const sw = el('button', { className: 'tb-btn', title: 'switch machine', onclick: () => openSwitcher() }, '⇄'); + const revokeBtn = el('button', { className: 'tb-btn', title: 'revoke machine', onclick: revoke }, '⊘'); const strip = el('div', { className: 'winbar' }); const view = el('div', { className: 'view term' }, - el('div', { className: 'topbar' }, back, el('div', { className: 'tb-title' }, machine.name || machine.machine_id), sw), + el('div', { className: 'topbar' }, back, el('div', { className: 'tb-title' }, machine.name || machine.machine_id), sw, revokeBtn), strip, termBox); mount(root, view); @@ -587,7 +620,7 @@ function viewTerminal(root, machine) { function openSwitcher() { const card = el('div', { className: 'sheet-card' }, el('div', { className: 'sheet-title' }, 'switch machine')); const sheet = el('div', { className: 'sheet', onclick: (e) => { if (e.target === sheet) sheet.remove(); } }, card); - for (const m of listMachines().filter((x) => x.machine_id !== machine.machine_id)) { + for (const m of visibleMachines.filter((x) => x.machine_id !== machine.machine_id)) { card.append(el('button', { className: 'sheet-item', onclick: () => { sheet.remove(); close(); viewTerminal(root, m); } }, m.name || m.machine_id)); } card.append(el('button', { className: 'sheet-item add', onclick: () => { sheet.remove(); close(); viewPair(root); } }, '+ Pair a machine')); @@ -682,7 +715,7 @@ function viewIdentityGate(root, pendingFrag) { const kids = [ el('h1', {}, 'Miranda'), - el('p', { className: 'muted' }, 'Your terminals, on every device — peer-to-peer, end-to-end encrypted. Log in on any device and your machines appear. The relay never sees your identity.'), + el('p', { className: 'muted' }, 'Leave your desk. Keep your terminal. Your passkey finds your machines; the relay never receives the private identity key or plaintext terminal data.'), ]; if (passkeySupported && !isLocalhost()) { kids.push(el('button', { className: 'btn', onclick: login }, 'Log in with passkey')); @@ -719,5 +752,5 @@ export function start(root) { window.__useDevKey = () => { setOwner(devOwnerKey()); localStorage.setItem('tr_identity_mode', 'dev'); viewMachines(root); }; } window.trAttach = (m) => attach(m, root.querySelector('.termbox') || root); - window.trPair = (code) => pairWithCode(code, walletKey()); + window.trPair = (code) => pairWithCode(code, signerKey(), _id.secret); } diff --git a/web/src/identity.js b/web/src/identity.js index f226dec..30fbfbc 100644 --- a/web/src/identity.js +++ b/web/src/identity.js @@ -1,29 +1,31 @@ // web/src/identity.js — owner identity from a passkey (WebAuthn prf), with a // degraded dev fallback. The prf output (deterministic per credential+salt, and // the same on every device the synced passkey reaches) is fed UNCHANGED into -// BOTH deriveOwnerKey() (X25519 transport key) and deriveWallet() (the Ed25519 -// Solana wallet that anchors ownership). They share only the prf root, so the -// owner_id (wallet address) and transport key follow you across devices and are +// BOTH deriveOwnerKey() (X25519 transport key) and deriveSigner() (the neutral +// Ed25519 identity that anchors ownership). They share only the PRF root, so the +// owner_id and transport key follow you across devices and are // gated by Face ID / Touch ID. The relay never sees any of this. import { deriveOwnerKey } from './identity/owner.js'; -import { deriveWallet } from './identity/wallet.js'; +import { deriveSigner } from './identity/signer.js'; import { resolveRPID } from './rp.js'; import { randomBytes } from '@noble/hashes/utils'; import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; -// identityFromPRF derives the full identity ({ owner, wallet, secret }) rooted in +// identityFromPRF derives the full identity ({ owner, signer, secret }) rooted in // one 32-byte secret — the passkey prf output, or the dev secret. Mirrors how the // Go owner.json roots both keys in a single seed. `secret` is kept IN MEMORY only // (never persisted) so the session can derive the registry key (B2); it is the same -// secret deriveWallet/deriveOwnerKey consume. +// secret deriveSigner/deriveOwnerKey consume. function identityFromPRF(secret) { - return { owner: deriveOwnerKey(secret), wallet: deriveWallet(secret), secret }; + return { owner: deriveOwnerKey(secret), signer: deriveSigner(secret), secret }; } const enc = new TextEncoder(); // rp.id is scoped to the exact production app host. Localhost remains separate // for dev. Do not use the parent domain as the RP trust root. const RP_ID = resolveRPID(location.hostname); +// Protocol identifiers stay fixed for credential compatibility even though the +// product is now named Miranda. const SALT = enc.encode('terminal-relay/owner/v1'); // prf eval salt — FIXED forever const USER_ID = enc.encode('terminal-relay/owner'); // fixed, no PII; re-enroll overwrites @@ -48,7 +50,7 @@ function unb64url(s) { export async function registerPasskey() { const cred = await navigator.credentials.create({ publicKey: { rp: { id: RP_ID, name: 'Terminal Relay' }, - user: { id: USER_ID, name: 'terminal-relay', displayName: 'Terminal Relay owner' }, + user: { id: USER_ID, name: 'miranda', displayName: 'Miranda owner' }, challenge: crypto.getRandomValues(new Uint8Array(32)), pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }], authenticatorSelection: { residentKey: 'required', requireResidentKey: true, userVerification: 'required' }, @@ -89,21 +91,21 @@ export async function signInPasskey() { // devOwnerKey is the DEGRADED localhost-only fallback: a plaintext 32-byte secret // in localStorage (not biometric-gated, not synced) that roots BOTH the X25519 -// transport key and the Ed25519 wallet — exactly as the prf output does on the +// transport key and the Ed25519 signer — exactly as the prf output does on the // real path, mirroring Go's owner.json single-seed model. It is hard-guarded to // localhost so a real owner identity can NEVER be minted/persisted in the clear // on a production origin (where any same-origin script could read it) — not even // when the browser lacks WebAuthn. On a public host the passkey path is the only -// way in. Returns { owner, wallet }. +// way in. Returns { owner, signer, secret }. export function devOwnerKey() { if (!isLocalhost()) throw new Error('dev key is localhost-only; use a passkey on a public origin'); let h = localStorage.getItem('tr_owner'); if (!h) { - // 32-byte secret seed: the BIP39/wallet derivation needs 16..32 bytes. + // 32-byte secret seed: the recovery rendering accepts 16..32 bytes. h = bytesToHex(randomBytes(32)); localStorage.setItem('tr_owner', h); } // Migration: a pre-B1.5 tr_owner held a raw 32-byte x25519 priv. Reuse it as the - // secret seed so an existing dev install keeps a stable (if re-rooted) identity. +// secret seed. The v0.7 identity migration intentionally requires re-pairing. return identityFromPRF(hexToBytes(h)); } diff --git a/web/src/identity/auth.js b/web/src/identity/auth.js index 379ae51..82709a2 100644 --- a/web/src/identity/auth.js +++ b/web/src/identity/auth.js @@ -1,9 +1,13 @@ -// web/src/identity/auth.js — mirrors go/internal/identity/auth.go. Wallet control +// web/src/identity/auth.js — mirrors go/internal/identity/auth.go. Owner control // proof over a fresh challenge (e.g. a pairing channel binding). import { ed25519 } from '@noble/curves/ed25519'; +import { sha256 } from '@noble/hashes/sha2'; +import { bytesToHex } from '@noble/hashes/utils'; import { decode as b58decode } from '../wallet/base58.js'; const AUTH_DOMAIN = 'miranda/auth/v1'; +const ATTACH_DOMAIN = 'miranda/attach/v1'; +const REGISTRATION_DOMAIN = 'miranda/agent-registration/v1'; const enc = new TextEncoder(); function authMessage(challenge) { @@ -15,14 +19,26 @@ function authMessage(challenge) { } // signAuth returns the raw 64-byte Ed25519 signature over AUTH_DOMAIN||challenge. -export function signAuth(wallet, challenge) { - return ed25519.sign(authMessage(challenge), wallet.priv); +export function signAuth(signer, challenge) { + return ed25519.sign(authMessage(challenge), signer.priv); } -// verifyAuth checks a signAuth signature against a base58 wallet address. -export function verifyAuth(walletAddress, challenge, sig) { +// verifyAuth checks a signAuth signature against a base58 Miranda owner id. +export function verifyAuth(ownerID, challenge, sig) { let pub; - try { pub = b58decode(walletAddress); } catch { return false; } + try { pub = b58decode(ownerID); } catch { return false; } if (pub.length !== 32 || sig.length !== 64) return false; return ed25519.verify(sig, authMessage(challenge), pub); } + +// attachChallenge is byte-identical to Go's identity.AttachChallenge. +export function attachChallenge(session, machineID, sdp) { + const digest = bytesToHex(sha256(enc.encode(sdp))); + return enc.encode(`${ATTACH_DOMAIN}\n${session}\n${machineID}\n${digest}`); +} + +// registrationChallenge is byte-identical to Go's +// identity.RegistrationChallenge. +export function registrationChallenge(machineID, commitment) { + return enc.encode(`${REGISTRATION_DOMAIN}\n${machineID}\n${commitment}`); +} diff --git a/web/src/identity/binding.js b/web/src/identity/binding.js index c3c5465..d0ba72e 100644 --- a/web/src/identity/binding.js +++ b/web/src/identity/binding.js @@ -1,6 +1,6 @@ // web/src/identity/binding.js -// Mirrors go/internal/identity/binding.go: a wallet-signed authorization of a -// device's X25519 transport key. Byte-identical canonical message and signature. +// Mirrors go/internal/identity/binding.go: an owner-signed authorization of a +// device's X25519 key. `wallet` is retained only as the historical wire field. import { ed25519 } from '@noble/curves/ed25519'; import { encode as b58encode, decode as b58decode } from '../wallet/base58.js'; @@ -15,9 +15,9 @@ function validate(b) { try { pk = b58decode(b.wallet); } catch { - throw new Error('binding: wallet is not base58'); + throw new Error('binding: owner is not base58'); } - if (pk.length !== 32) throw new Error('binding: wallet is not a 32-byte key'); + if (pk.length !== 32) throw new Error('binding: owner is not a 32-byte key'); if (typeof b.device !== 'string' || !DEVICE_RE.test(b.device)) throw new Error('binding: device has unsafe characters'); if (typeof b.x25519 !== 'string' || !HEX64_RE.test(b.x25519)) throw new Error('binding: x25519 must be 64 lowercase hex chars'); if (!Number.isInteger(b.ts) || b.ts <= 0) throw new Error('binding: ts must be a positive integer'); @@ -31,16 +31,16 @@ export function canonical(b) { return `{"v":${b.v},"wallet":"${b.wallet}","device":"${b.device}","x25519":"${b.x25519}","ts":${b.ts}}`; } -// signBinding signs a binding authorizing device + x25519 under the given wallet -// ({ address, priv }). Returns the wire record { v, wallet, device, x25519, ts, sig }. -export function signBinding(wallet, device, x25519, ts) { - const b = { v: 1, wallet: wallet.address, device, x25519, ts }; +// signBinding signs a binding under the given owner signer. The record retains +// the legacy `wallet` key for protocol compatibility. +export function signBinding(signer, device, x25519, ts) { + const b = { v: 1, wallet: signer.address, device, x25519, ts }; const canon = canonical(b); - const sig = ed25519.sign(enc.encode(DOMAIN + canon), wallet.priv); + const sig = ed25519.sign(enc.encode(DOMAIN + canon), signer.priv); return { ...b, sig: b58encode(sig) }; } -// verifyBinding checks the signature against the wallet pubkey embedded in the +// verifyBinding checks the signature against the owner pubkey embedded in the // record. Returns true iff valid; never throws. export function verifyBinding(sb) { let canon; diff --git a/web/src/identity/registry.js b/web/src/identity/registry.js index 7cb858f..72f35c3 100644 --- a/web/src/identity/registry.js +++ b/web/src/identity/registry.js @@ -1,7 +1,7 @@ // web/src/identity/registry.js -// Mirrors go/internal/identity/registry.go: a wallet-derived symmetric key and a +// Mirrors go/internal/identity/registry.go: an owner-root-derived symmetric key and a // ChaCha20-Poly1305 (IETF, 12-byte nonce) record seal/open. Byte-identical to Go, -// gated by testdata/registry-vector.json. Only wallet-holders can derive K_reg; +// gated by testdata/registry-vector.json. Only owner clients can derive K_reg; // the relay only ever holds the opaque nonce||ciphertext||tag blob. import { chacha20poly1305 } from '@noble/ciphers/chacha'; import { hkdf } from '@noble/hashes/hkdf'; @@ -11,7 +11,7 @@ const enc = new TextEncoder(); const SALT = enc.encode('miranda/registry/v1'); const INFO = enc.encode('aead-key'); -// registryKey derives the 32-byte registry AEAD key from the wallet's 32-byte prf +// registryKey derives the 32-byte registry AEAD key from the owner's 32-byte PRF // secret. K_reg = HKDF-SHA256(secret, salt='miranda/registry/v1', info='aead-key'). export function registryKey(secret) { return hkdf(sha256, secret, SALT, INFO, 32); diff --git a/web/src/identity/signer.js b/web/src/identity/signer.js new file mode 100644 index 0000000..79cd262 --- /dev/null +++ b/web/src/identity/signer.js @@ -0,0 +1,23 @@ +// Neutral Miranda Ed25519 owner identity. The passkey PRF output is rendered as +// BIP39 only for human recovery; the signing seed is domain-separated with HKDF +// and is not a cryptocurrency derivation path. +import { ed25519 } from '@noble/curves/ed25519'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; +import { entropyToMnemonic, mnemonicToEntropy } from '../wallet/bip39.js'; +import { encode as base58encode } from '../wallet/base58.js'; + +const enc = new TextEncoder(); +const SALT = enc.encode('miranda/identity/signer/v1'); +const INFO = enc.encode('ed25519'); + +export function deriveSigner(root) { + const mnemonic = entropyToMnemonic(root); + const priv = hkdf(sha256, root, SALT, INFO, 32); + const pub = ed25519.getPublicKey(priv); + return { mnemonic, priv, pub, address: base58encode(pub) }; +} + +export function signerFromMnemonic(mnemonic) { + return deriveSigner(mnemonicToEntropy(mnemonic)); +} diff --git a/web/src/identity/wallet.js b/web/src/identity/wallet.js deleted file mode 100644 index 2ae9217..0000000 --- a/web/src/identity/wallet.js +++ /dev/null @@ -1,32 +0,0 @@ -// web/src/identity/wallet.js -// Mirrors go/internal/identity/wallet.go: prf -> BIP39 -> SLIP-0010-ed25519 -// m/44'/501'/0'/0' -> Ed25519 -> base58 Solana address. Independent of the -// X25519 transport key (owner.js); the two share only the prf root. -import { ed25519 } from '@noble/curves/ed25519'; -import { entropyToMnemonic, mnemonicToSeed } from '../wallet/bip39.js'; -import { derivePath } from '../wallet/slip10.js'; -import { encode as base58encode } from '../wallet/base58.js'; - -// WALLET_PATH is the Phantom-importable Solana account-0 path. -export const WALLET_PATH = "m/44'/501'/0'/0'"; - -// deriveWallet renders the 32-byte prf as a BIP39 mnemonic and derives the -// account-0 Solana wallet. Returns { mnemonic, seed, priv, pub, address } with -// priv = the 32-byte Ed25519 node key (the signing key for @noble/curves). -export function deriveWallet(prf) { - return walletFromMnemonic(entropyToMnemonic(prf)); -} - -// walletFromMnemonic derives the account-0 wallet from a mnemonic (import path). -export function walletFromMnemonic(mnemonic) { - const seed = mnemonicToSeed(mnemonic, ''); - const node = derivePath(seed, WALLET_PATH); - const pub = ed25519.getPublicKey(node.key); - return { - mnemonic, - seed, - priv: node.key, // 32-byte ed25519 seed/private key - pub, // 32-byte ed25519 public key - address: base58encode(pub), - }; -} diff --git a/web/src/pair.js b/web/src/pair.js index 86a1e88..87c73ea 100644 --- a/web/src/pair.js +++ b/web/src/pair.js @@ -4,6 +4,7 @@ import { runInitiator } from './pairing/nnpsk0.js'; import { decodeCode, roomID } from './pairing/code.js'; import { safetyNumber } from './pairing/sas.js'; +import { registryKey, sealRecord } from './identity/registry.js'; const wsBase = (signalURL) => 'ws' + signalURL.slice(4); // http->ws, https->wss @@ -12,10 +13,10 @@ const wsBase = (signalURL) => 'ws' + signalURL.slice(4); // http->ws, https->wss // "pairing…" forever. 30s is generous for a human-paced QR scan + two round trips. const PAIR_TIMEOUT_MS = 30000; -// pairWithCode runs the pairing handshake using `wallet` ({ address, priv }) as our -// identity: it sends the base58 wallet as a PairClaim and proves control with an auth +// pairWithCode runs the pairing handshake using `signer` ({ address, priv }) as our +// identity: it sends the legacy-wire owner field and proves control with an auth // signature over the channel binding. Returns { machine, safetyNumber }. -export async function pairWithCode(code, wallet) { +export async function pairWithCode(code, signer, secret = null) { const { signalURL, token } = decodeCode(code); const ws = new WebSocket(wsBase(signalURL) + '/pair?room=' + roomID(token)); @@ -63,7 +64,20 @@ export async function pairWithCode(code, wallet) { }), }; - const { info, binding } = await runInitiator(mc, token, wallet); + const provisioner = secret ? (info) => { + const record = new TextEncoder().encode(JSON.stringify({ + v: 1, + name: info.name, + host_pub: info.host_pub, + signal_url: signalURL, + ts: Math.floor(Date.now() / 1000), + })); + const blob = sealRecord(registryKey(secret), crypto.getRandomValues(new Uint8Array(12)), record, info.machine_id); + let binary = ''; + for (const b of blob) binary += String.fromCharCode(b); + return btoa(binary); + } : null; + const { info, binding } = await runInitiator(mc, token, signer, null, provisioner); return { machine: { machine_id: info.machine_id, host_pub: info.host_pub, name: info.name, signal: signalURL }, safetyNumber: safetyNumber(binding), diff --git a/web/src/pairing/nnpsk0.js b/web/src/pairing/nnpsk0.js index 20d482c..ad81c36 100644 --- a/web/src/pairing/nnpsk0.js +++ b/web/src/pairing/nnpsk0.js @@ -13,7 +13,7 @@ import { chacha20poly1305 } from '@noble/ciphers/chacha'; import { sha256 } from '@noble/hashes/sha2'; import { hmac } from '@noble/hashes/hmac'; import { pskFromToken } from './code.js'; -import { signAuth, verifyAuth } from '../identity/auth.js'; +import { registrationChallenge, signAuth, verifyAuth } from '../identity/auth.js'; const PROTOCOL_NAME = 'Noise_NNpsk0_25519_ChaChaPoly_SHA256'; const PROLOGUE = new TextEncoder().encode('terminal-relay/pair/v1'); @@ -226,24 +226,39 @@ export class HandshakeNNpsk0 { } } -// runInitiator (client): sends PairClaim{wallet} in msg1, reads agent info from -// msg2, then proves wallet control with msg3 = signAuth(channelBinding). +// runInitiator (client): sends the owner id in the historical `wallet` wire +// field, then proves owner control with msg3 = signAuth(channelBinding). // Returns { info, binding }. `ephemeralPriv` is optional (deterministic tests). -export async function runInitiator(mc, token, wallet, ephemeralPriv = null) { +export async function runInitiator(mc, token, signer, ephemeralPriv = null, provisioner = null) { const hs = new HandshakeNNpsk0({ initiator: true, psk: pskFromToken(token), ephemeralPriv }); - const msg1 = hs.writeMessage(new TextEncoder().encode(JSON.stringify({ wallet: wallet.address }))); + const msg1 = hs.writeMessage(new TextEncoder().encode(JSON.stringify({ wallet: signer.address }))); await mc.send(msg1); const msg2 = await mc.recv(); const payload = hs.readMessage(msg2); const info = JSON.parse(new TextDecoder().decode(payload)); const binding = hs.binding(); - await mc.send(signAuth(wallet, binding)); // msg3, raw 64-byte sig + const sig = signAuth(signer, binding); + if (!provisioner) { + await mc.send(sig); // compatibility with v0.6 responders + } else { + const registry = await provisioner(info); + let binary = ''; + for (const b of sig) binary += String.fromCharCode(b); + let registration_auth = ''; + if (info.registration_commitment) { + const registrationSig = signAuth(signer, registrationChallenge(info.machine_id, info.registration_commitment)); + let registrationBinary = ''; + for (const b of registrationSig) registrationBinary += String.fromCharCode(b); + registration_auth = btoa(registrationBinary); + } + await mc.send(new TextEncoder().encode(JSON.stringify({ signature: btoa(binary), registry, registration_auth }))); + } return { info, binding }; } -// runResponder (agent): reads PairClaim{wallet} from msg1, sends info in msg2, -// then verifies msg3 (wallet auth over the channel binding). Returns -// { wallet, binding }. `ephemeralPriv` is optional (deterministic tests). +// runResponder (agent/test mirror): reads the legacy-wire owner claim, sends info +// in msg2, then verifies msg3 (owner auth over the channel binding and, when +// requested, the agent-registration commitment). `ephemeralPriv` is optional. export async function runResponder(mc, token, info, ephemeralPriv = null) { const hs = new HandshakeNNpsk0({ initiator: false, psk: pskFromToken(token), ephemeralPriv }); const msg1 = await mc.recv(); @@ -252,7 +267,29 @@ export async function runResponder(mc, token, info, ephemeralPriv = null) { const msg2 = hs.writeMessage(infoJSON); await mc.send(msg2); const binding = hs.binding(); - const sig = await mc.recv(); // msg3 - if (!verifyAuth(claim.wallet, binding, sig)) throw new Error('pairing: wallet auth failed'); - return { wallet: claim.wallet, binding }; + const proof = await mc.recv(); // msg3: legacy raw signature or proof JSON + let sig = proof; + let registry = ''; + let registrationAuth = ''; + if (proof.length !== 64) { + const p = JSON.parse(new TextDecoder().decode(proof)); + const binary = atob(p.signature || ''); + sig = Uint8Array.from(binary, (c) => c.charCodeAt(0)); + registry = p.registry || ''; + registrationAuth = p.registration_auth || ''; + } + if (!verifyAuth(claim.wallet, binding, sig)) throw new Error('pairing: owner auth failed'); + if (info.registration_commitment) { + let registrationSig; + try { + const binary = atob(registrationAuth); + registrationSig = Uint8Array.from(binary, (c) => c.charCodeAt(0)); + } catch { + throw new Error('pairing: invalid agent registration authorization'); + } + if (!verifyAuth(claim.wallet, registrationChallenge(info.machine_id, info.registration_commitment), registrationSig)) { + throw new Error('pairing: invalid agent registration authorization'); + } + } + return { wallet: claim.wallet, registry, registration_auth: registrationAuth, binding }; } diff --git a/web/src/pairing/sas.js b/web/src/pairing/sas.js index a9ebca1..c3d17d1 100644 --- a/web/src/pairing/sas.js +++ b/web/src/pairing/sas.js @@ -1,15 +1,15 @@ // web/src/pairing/sas.js // Safety number ("SAS") from a Noise channel binding. Mirrors the Go reference -// (go/internal/sas/sas.go): SHA256("terminal-relay/sas/v1"||binding) first 8 -// bytes, rendered as four 4-hex-digit groups. +// (go/internal/sas/sas.go): first 12 bytes of the v2 domain hash, rendered as +// six 4-hex-digit groups (96 bits). import { sha256 } from '@noble/hashes/sha2'; import { bytesToHex } from '@noble/hashes/utils'; const enc = new TextEncoder(); export function safetyNumber(binding) { - const h = sha256(concat(enc.encode('terminal-relay/sas/v1'), binding)).slice(0, 8); + const h = sha256(concat(enc.encode('miranda/sas/v2'), binding)).slice(0, 12); const hex = bytesToHex(h); - return `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`; + return `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 24)}`; } function concat(a, b) { const o = new Uint8Array(a.length + b.length); diff --git a/web/src/registry.js b/web/src/registry.js index 95ecd2c..6a80291 100644 --- a/web/src/registry.js +++ b/web/src/registry.js @@ -1,6 +1,6 @@ // web/src/registry.js — discover your machines from the relay's encrypted device // registry (B2). Mirrors go/internal/client/registry.go. The relay serves opaque -// blobs keyed by wallet; only a wallet-holder (registryKey) can open them, so a +// blobs keyed by owner id; only an owner-root holder (registryKey) can open them, so a // forged/garbage blob fails to open and is silently dropped. Discovery only — the // Noise data plane and attach path are unchanged. import { registryKey, openRecord } from './identity/registry.js'; @@ -15,7 +15,7 @@ function b64ToBytes(s) { } // decodeRegistry turns the relay's `[{machine_id, blob}]` into machines, dropping -// any blob that fails to open (a forgery, or one sealed under a different wallet). +// any blob that fails to open (a forgery, or one sealed under a different owner root). // fallbackSignal is used when a record carries no signal_url of its own. export function decodeRegistry(entries, secret, fallbackSignal) { const key = registryKey(secret); @@ -37,12 +37,12 @@ export function decodeRegistry(entries, secret, fallbackSignal) { return out; } -// fetchMachines GETs the wallet's registry from `origin` and decodes it. Best-effort: +// fetchMachines GETs the owner's registry from `origin` and decodes it. Best-effort: // any failure (relay down, not served same-origin, bad JSON) returns [] so the caller // falls back to the locally-stored machine list without surfacing noise. -export async function fetchMachines(origin, wallet, secret) { +export async function fetchMachines(origin, signer, secret) { try { - const url = origin.replace(/\/$/, '') + '/registry?wallet=' + encodeURIComponent(wallet.address); + const url = origin.replace(/\/$/, '') + '/registry?owner_id=' + encodeURIComponent(signer.address); const r = await fetch(url); if (!r.ok) return []; return decodeRegistry(await r.json(), secret, origin); diff --git a/web/src/revocations.js b/web/src/revocations.js new file mode 100644 index 0000000..bf344ee --- /dev/null +++ b/web/src/revocations.js @@ -0,0 +1,125 @@ +// Owner-signed, permanent machine tombstones. The relay is an availability +// channel only: every browser verifies signatures before enforcing a record. +import { signAuth, verifyAuth } from './identity/auth.js'; + +const DOMAIN = 'miranda/revocation/v1'; +const KEY = 'mir_revocations_v1'; +const enc = new TextEncoder(); +const MACHINE_ID = /^[0-9A-Za-z._-]{1,128}$/; + +function bytesToB64(bytes) { + let binary = ''; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary); +} + +function b64ToBytes(value) { + const binary = atob(value); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); + return out; +} + +// Byte-identical to identity.RevocationChallenge in Go. +export function revocationChallenge(machineID, ts) { + return enc.encode(`${DOMAIN}\n${machineID}\n${ts}`); +} + +export function signRevocation(signer, machineID, ts = Math.floor(Date.now() / 1000)) { + if (!MACHINE_ID.test(machineID) || !Number.isSafeInteger(ts) || ts <= 0) { + throw new Error('invalid revocation fields'); + } + return { + v: 1, + owner_id: signer.address, + machine_id: machineID, + ts, + signature: bytesToB64(signAuth(signer, revocationChallenge(machineID, ts))), + }; +} + +export function verifyRevocation(record) { + if (!record || record.v !== 1 || !MACHINE_ID.test(record.machine_id || '') || + !Number.isSafeInteger(record.ts) || record.ts <= 0 || typeof record.owner_id !== 'string') return false; + try { + return verifyAuth(record.owner_id, revocationChallenge(record.machine_id, record.ts), b64ToBytes(record.signature)); + } catch { + return false; + } +} + +// Local corruption is a security error, not an empty deny-list. Callers should +// stop before rendering an attachable machine list if this throws. +export function loadRevocations(ownerID) { + let records; + try { records = JSON.parse(localStorage.getItem(KEY) || '[]'); } + catch { throw new Error('local revocation store is corrupt'); } + if (!Array.isArray(records) || records.some((r) => !verifyRevocation(r))) { + throw new Error('local revocation store failed signature verification'); + } + return records.filter((r) => r.owner_id === ownerID); +} + +export function recordRevocation(record) { + if (!verifyRevocation(record)) throw new Error('invalid revocation signature'); + let records; + try { records = JSON.parse(localStorage.getItem(KEY) || '[]'); } + catch { throw new Error('local revocation store is corrupt'); } + if (!Array.isArray(records) || records.some((r) => !verifyRevocation(r))) { + throw new Error('local revocation store failed signature verification'); + } + records = records.filter((r) => !(r.owner_id === record.owner_id && r.machine_id === record.machine_id)); + records.push(record); + records.sort((a, b) => (a.owner_id + '\0' + a.machine_id).localeCompare(b.owner_id + '\0' + b.machine_id)); + localStorage.setItem(KEY, JSON.stringify(records)); + return record; +} + +export function filterRevoked(machines, records) { + const blocked = new Set(records.map((r) => r.machine_id)); + return machines.filter((m) => !blocked.has(m.machine_id)); +} + +export function isMachineRevoked(machineID, ownerID) { + return loadRevocations(ownerID).some((r) => r.machine_id === machineID); +} + +export async function fetchRevocations(origin, ownerID) { + const url = origin.replace(/\/$/, '') + '/revocations?owner_id=' + encodeURIComponent(ownerID); + const response = await fetch(url); + if (!response.ok) throw new Error('revocation relay returned ' + response.status); + const records = await response.json(); + if (!Array.isArray(records) || records.some((r) => r.owner_id !== ownerID || !verifyRevocation(r))) { + throw new Error('revocation relay returned an invalid signed record'); + } + return records; +} + +export async function syncRevocations(origin, ownerID) { + const records = await fetchRevocations(origin, ownerID); + for (const record of records) recordRevocation(record); + return loadRevocations(ownerID); +} + +// Persist locally before POSTing, so an offline or malicious relay cannot make +// this browser trust the target again. +export async function revokeMachine(origins, signer, machineID) { + const record = signRevocation(signer, machineID); + recordRevocation(record); + const unique = [...new Set((Array.isArray(origins) ? origins : [origins]).filter(Boolean).map((x) => x.replace(/\/$/, '')))]; + const failures = []; + for (const origin of unique) { + try { + const response = await fetch(origin + '/revocations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(record), + }); + if (!response.ok) failures.push(origin + ' (' + response.status + ')'); + } catch { + failures.push(origin + ' (unreachable)'); + } + } + if (failures.length) throw new Error('saved locally, but relay publication failed: ' + failures.join(', ')); + return record; +} diff --git a/web/src/rp.js b/web/src/rp.js index b62224c..605650b 100644 --- a/web/src/rp.js +++ b/web/src/rp.js @@ -1,9 +1,9 @@ // web/src/rp.js — WebAuthn RP ID scoping. // -// Production passkeys are bound to the exact terminal app host. Do not widen -// this to the parent domain; sibling subdomains must not share this trust root. -export const PRODUCTION_RP_ID = 'term.sourceful-labs.net'; - +// Bind passkeys to the exact host serving this build. This keeps sibling origins +// isolated and makes a self-hosted Miranda deployment work on its own domain. export function resolveRPID(hostname) { - return hostname === 'localhost' ? 'localhost' : PRODUCTION_RP_ID; + const host = String(hostname || '').trim().toLowerCase(); + if (!host || host.includes('/') || host.includes(':')) throw new Error('invalid WebAuthn RP host'); + return host; } diff --git a/web/src/wallet/bip39.js b/web/src/wallet/bip39.js index d24c6e5..1a263a4 100644 --- a/web/src/wallet/bip39.js +++ b/web/src/wallet/bip39.js @@ -1,13 +1,9 @@ // web/src/wallet/bip39.js -// Mirrors go/internal/bip39/bip39.go: BIP39 entropy<->mnemonic + mnemonic->seed. +// Mirrors go/internal/bip39/bip39.go: BIP39 entropy<->mnemonic recovery rendering. // Byte-identical to the Go side. import { sha256 } from '@noble/hashes/sha2'; -import { sha512 } from '@noble/hashes/sha2'; -import { pbkdf2 } from '@noble/hashes/pbkdf2'; import { wordlist } from './wordlist.js'; -const enc = new TextEncoder(); - // entropyToMnemonic renders entropy (16..32 bytes, multiple of 4) as a BIP39 // English mnemonic. 32 bytes -> 24 words (Miranda's prf case). export function entropyToMnemonic(entropy) { @@ -32,14 +28,6 @@ export function entropyToMnemonic(entropy) { return words.join(' '); } -// mnemonicToSeed derives the 64-byte BIP39 seed (PBKDF2-HMAC-SHA512, 2048, -// salt "mnemonic"+passphrase). NFKD-normalizes per spec; ASCII inputs match Go. -export function mnemonicToSeed(mnemonic, passphrase = '') { - const m = enc.encode(mnemonic.normalize('NFKD')); - const salt = enc.encode(('mnemonic' + passphrase).normalize('NFKD')); - return pbkdf2(sha512, m, salt, { c: 2048, dkLen: 64 }); -} - const wordIndexMap = (() => { const m = new Map(); for (let i = 0; i < wordlist.length; i++) m.set(wordlist[i], i); diff --git a/web/src/wallet/slip10.js b/web/src/wallet/slip10.js deleted file mode 100644 index 092a9a9..0000000 --- a/web/src/wallet/slip10.js +++ /dev/null @@ -1,45 +0,0 @@ -// web/src/wallet/slip10.js -// Mirrors go/internal/slip10/slip10.go: SLIP-0010 ed25519 HD derivation -// (hardened-only). Byte-identical to the Go side. -// -// master = HMAC-SHA512("ed25519 seed", seed) -> key=left32, chain=right32 -// child_i = HMAC-SHA512(chain, 0x00 || key || ser32(i)) (i always hardened) -import { hmac } from '@noble/hashes/hmac'; -import { sha512 } from '@noble/hashes/sha2'; - -const HARDENED = 0x80000000; -const SEED_KEY = new TextEncoder().encode('ed25519 seed'); - -function master(seed) { - const I = hmac(sha512, SEED_KEY, seed); - return { key: I.slice(0, 32), chain: I.slice(32) }; -} - -function child(node, index) { - const data = new Uint8Array(37); - data[0] = 0x00; - data.set(node.key, 1); - data[33] = (index >>> 24) & 0xff; - data[34] = (index >>> 16) & 0xff; - data[35] = (index >>> 8) & 0xff; - data[36] = index & 0xff; - const I = hmac(sha512, node.chain, data); - return { key: I.slice(0, 32), chain: I.slice(32) }; -} - -// derivePath derives a node for a path like "m/44'/501'/0'/0'". Every segment -// must be hardened (ed25519 only supports hardened derivation). Returns -// { key, chain } as 32-byte Uint8Arrays. -export function derivePath(seed, path) { - let node = master(seed); - path = path.trim(); - if (path === 'm' || path === '') return node; - if (!path.startsWith('m/')) throw new Error(`slip10: path must start with m/, got ${JSON.stringify(path)}`); - for (const seg of path.slice(2).split('/')) { - if (!seg.endsWith("'")) throw new Error(`slip10: ed25519 requires hardened indices, got ${JSON.stringify(seg)}`); - const num = Number(seg.slice(0, -1)); - if (!Number.isInteger(num) || num < 0 || num >= HARDENED) throw new Error(`slip10: bad index ${JSON.stringify(seg)}`); - node = child(node, (num + HARDENED) >>> 0); - } - return node; -} diff --git a/web/sw.js b/web/sw.js index bc77cac..f5824eb 100644 --- a/web/sw.js +++ b/web/sw.js @@ -13,7 +13,7 @@ // relay serves sw.js no-store — so a new deploy replaces this worker on the // next online load. -const CACHE = 'mir-shell-v1'; +const CACHE = 'mir-shell-v2'; // Everything the app needs to boot. test/sw.test.js fails if this list drifts // from the files on disk — add new modules here when you add them to src/. @@ -31,7 +31,7 @@ const SHELL = [ '/src/identity/binding.js', '/src/identity/owner.js', '/src/identity/registry.js', - '/src/identity/wallet.js', + '/src/identity/signer.js', '/src/net/backoff.js', '/src/net/reconnect.js', '/src/net/ws-open.js', @@ -43,19 +43,18 @@ const SHELL = [ '/src/pairing/nnpsk0.js', '/src/pairing/sas.js', '/src/registry.js', + '/src/revocations.js', '/src/rp.js', '/src/store.js', '/src/ui/keybar.js', '/src/wallet/base58.js', '/src/wallet/bip39.js', - '/src/wallet/slip10.js', '/src/wallet/wordlist.js', '/vendor/jsqr.js', '/vendor/noble-ciphers-chacha.js', '/vendor/noble-curves-ed25519.js', '/vendor/noble-hashes-hkdf.js', '/vendor/noble-hashes-hmac.js', - '/vendor/noble-hashes-pbkdf2.js', '/vendor/noble-hashes-sha2.js', '/vendor/noble-hashes-utils.js', '/vendor/xterm-addon-fit.js', @@ -66,7 +65,7 @@ const SHELL = [ // Live conversations with the relay — never cached, never intercepted. // (WebSocket upgrades bypass the fetch handler anyway; this covers the plain // HTTP ones like /turn-credentials and keeps the list in one place.) -const SIGNALING = ['/agent/signal', '/attach', '/pair', '/turn-credentials', '/healthz', '/registry']; +const SIGNALING = ['/agent/signal', '/attach', '/pair', '/turn-credentials', '/healthz', '/registry', '/revocations']; self.addEventListener('install', (event) => { event.waitUntil( diff --git a/web/test/auth.test.js b/web/test/auth.test.js index 47998c1..3e014f4 100644 --- a/web/test/auth.test.js +++ b/web/test/auth.test.js @@ -5,10 +5,10 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { hexToBytes } from '@noble/hashes/utils'; -import { deriveWallet } from '../src/identity/wallet.js'; -import { signAuth, verifyAuth } from '../src/identity/auth.js'; +import { deriveSigner } from '../src/identity/signer.js'; +import { registrationChallenge, signAuth, verifyAuth } from '../src/identity/auth.js'; -const wallet = deriveWallet(hexToBytes('00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff')); +const wallet = deriveSigner(hexToBytes('00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff')); test('signAuth round-trips through verifyAuth', () => { const challenge = crypto.getRandomValues(new Uint8Array(32)); @@ -36,7 +36,7 @@ test('a tampered signature does not verify', () => { test('the wrong wallet does not verify', () => { const challenge = crypto.getRandomValues(new Uint8Array(32)); const sig = signAuth(wallet, challenge); - const other = deriveWallet(hexToBytes('ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff')); + const other = deriveSigner(hexToBytes('ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff')); assert.equal(verifyAuth(other.address, challenge, sig), false); }); @@ -45,3 +45,15 @@ test('a bad wallet address or wrong-length sig never throws, returns false', () assert.equal(verifyAuth('not-base58-0OIl', challenge, new Uint8Array(64)), false); assert.equal(verifyAuth(wallet.address, challenge, new Uint8Array(10)), false); }); + +test('agent registration authorization is bound to machine and commitment', () => { + const challenge = registrationChallenge('machine-1', 'ab'.repeat(32)); + assert.equal( + new TextDecoder().decode(challenge), + `miranda/agent-registration/v1\nmachine-1\n${'ab'.repeat(32)}`, + ); + const sig = signAuth(wallet, challenge); + assert.equal(verifyAuth(wallet.address, challenge, sig), true); + assert.equal(verifyAuth(wallet.address, registrationChallenge('machine-2', 'ab'.repeat(32)), sig), false); + assert.equal(verifyAuth(wallet.address, registrationChallenge('machine-1', 'cd'.repeat(32)), sig), false); +}); diff --git a/web/test/binding.test.js b/web/test/binding.test.js index e7c5fc3..04b0af6 100644 --- a/web/test/binding.test.js +++ b/web/test/binding.test.js @@ -1,4 +1,4 @@ -// web/test/binding.test.js — asserts the Go-written wallet-binding.json vector. +// Cross-language owner-binding vector. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -6,15 +6,15 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { hexToBytes } from '@noble/hashes/utils'; import { canonical, signBinding, verifyBinding, recordJSON } from '../src/identity/binding.js'; -import { walletFromMnemonic } from '../src/identity/wallet.js'; +import { signerFromMnemonic } from '../src/identity/signer.js'; const here = dirname(fileURLToPath(import.meta.url)); const td = (f) => JSON.parse(readFileSync(join(here, '..', '..', 'testdata', f), 'utf8')); -const v = td('wallet-binding.json'); -const wv = td('wallet-derivation.json'); +const v = td('identity-binding.json'); +const wv = td('identity-derivation.json'); // Reconstruct the signing wallet from the committed mnemonic. -const wallet = walletFromMnemonic(wv.mnemonic); +const signer = signerFromMnemonic(wv.mnemonic); test('canonical message matches the Go vector', () => { const c = canonical({ v: 1, wallet: v.wallet, device: v.device, x25519: v.x25519, ts: v.ts }); @@ -22,7 +22,7 @@ test('canonical message matches the Go vector', () => { }); test('signature is byte-identical to the Go vector (deterministic ed25519)', () => { - const sb = signBinding(wallet, v.device, v.x25519, v.ts); + const sb = signBinding(signer, v.device, v.x25519, v.ts); assert.equal(sb.sig, v.sig); assert.equal(recordJSON(sb), v.record); }); @@ -41,7 +41,7 @@ test('verify rejects tampered fields', () => { test('signing rejects unsafe device / x25519', () => { for (const dev of ['a"b', 'a\\b', 'a b', 'a,b', '']) { - assert.throws(() => signBinding(wallet, dev, v.x25519, v.ts)); + assert.throws(() => signBinding(signer, dev, v.x25519, v.ts)); } - assert.throws(() => signBinding(wallet, v.device, 'ZZZ', v.ts)); + assert.throws(() => signBinding(signer, v.device, 'ZZZ', v.ts)); }); diff --git a/web/test/bip39.test.js b/web/test/bip39.test.js index a0fd3b1..c90862c 100644 --- a/web/test/bip39.test.js +++ b/web/test/bip39.test.js @@ -2,7 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; -import { entropyToMnemonic, mnemonicToSeed, mnemonicToEntropy } from '../src/wallet/bip39.js'; +import { entropyToMnemonic, mnemonicToEntropy } from '../src/wallet/bip39.js'; import { wordlist } from '../src/wallet/wordlist.js'; test('wordlist integrity', () => { @@ -19,15 +19,12 @@ test('zero-entropy anchors', () => { assert.equal(entropyToMnemonic(new Uint8Array(32)), 'abandon '.repeat(23) + 'art'); }); -test('prf -> mnemonic -> seed external anchor', () => { +test('prf -> recovery mnemonic external anchor', () => { const prf = hexToBytes('00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff'); const wantMnemonic = 'abandon math mimic master filter design carbon crystal rookie group knife wrap absurd much snack melt grid rough chapter fever rubber humble room trophy'; - const wantSeed = - '559da5e7655dd1fbe657c100870512afb2b654b0acfd32f2c549344407e555bc16c2e71219eefc24acc7ed2cfaeac8a1808d543a5de4890bb2d95a7bb58af5b7'; const m = entropyToMnemonic(prf); assert.equal(m, wantMnemonic); - assert.equal(bytesToHex(mnemonicToSeed(m, '')), wantSeed); }); test('entropy bounds', () => { diff --git a/web/test/pair.test.js b/web/test/pair.test.js index cc1c571..a664d7d 100644 --- a/web/test/pair.test.js +++ b/web/test/pair.test.js @@ -8,7 +8,7 @@ import { hexToBytes } from '@noble/hashes/utils'; import { encodeCode } from '../src/pairing/code.js'; import { runResponder } from '../src/pairing/nnpsk0.js'; import { safetyNumber } from '../src/pairing/sas.js'; -import { deriveWallet } from '../src/identity/wallet.js'; +import { deriveSigner } from '../src/identity/signer.js'; // pairWithCode is the browser pairing entry point. These tests pin its FAILURE // handling — the bug being fixed: with no recv timeout and close/error wired only @@ -20,7 +20,7 @@ import { deriveWallet } from '../src/identity/wallet.js'; // used to build msg1 (runInitiator's first await is a recv()), so any valid wallet // is fine; the happy path derives the wallet from the Go vector's prf root. -const wallet = deriveWallet(new Uint8Array(32)); +const wallet = deriveSigner(new Uint8Array(32)); // A well-formed code: 16-byte (32-hex) token + an https relay URL passes decodeCode. const CODE = encodeCode('https://relay.example.test', new Uint8Array(16).fill(7)); @@ -152,7 +152,7 @@ test('completes the handshake and returns the machine + safety number', async () const ws = installFakeWS(); try { const token = hexToBytes(vec.token); - const vecWallet = deriveWallet(hexToBytes(vec.wallet_prf)); + const vecWallet = deriveSigner(hexToBytes(vec.wallet_prf)); const goodCode = encodeCode('https://relay.example.test', token); const info = JSON.parse(vec.info_json); @@ -184,7 +184,7 @@ test('completes the handshake and returns the machine + safety number', async () assert.equal(result.machine.name, 'box'); assert.equal(result.machine.host_pub, info.host_pub); assert.equal(result.machine.signal, 'https://relay.example.test'); - assert.match(result.safetyNumber, /^[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}$/, 'well-formed SAS'); + assert.match(result.safetyNumber, /^[0-9a-f]{4}(-[0-9a-f]{4}){5}$/, 'well-formed SAS'); assert.equal(result.safetyNumber, safetyNumber(resp.binding), 'both peers derive the SAME safety number'); assert.ok(sock[0].closed, 'socket closed after a successful pairing'); } finally { diff --git a/web/test/pairing-interop.test.js b/web/test/pairing-interop.test.js index d62cd89..f28d8e7 100644 --- a/web/test/pairing-interop.test.js +++ b/web/test/pairing-interop.test.js @@ -7,7 +7,7 @@ import { dirname, join } from 'node:path'; import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; import { runInitiator, runResponder } from '../src/pairing/nnpsk0.js'; import { safetyNumber } from '../src/pairing/sas.js'; -import { deriveWallet } from '../src/identity/wallet.js'; +import { deriveSigner } from '../src/identity/signer.js'; const here = dirname(fileURLToPath(import.meta.url)); const v = JSON.parse(readFileSync(join(here, '..', '..', 'testdata', 'pair-interop.json'), 'utf8')); @@ -39,7 +39,7 @@ test('JS NNpsk0 reproduces the Go pairing wire bytes + wallet auth + safety numb // The wallet is derived from the SAME prf root the Go vector used, so the JS // side reproduces msg1 (PairClaim) and msg3 (the Ed25519 auth signature, which // is deterministic) byte-for-byte. - const wallet = deriveWallet(hexToBytes(v.wallet_prf)); + const wallet = deriveSigner(hexToBytes(v.wallet_prf)); const info = JSON.parse(v.info_json); // fixed ephemerals so the bytes are deterministic (match the Go vectors) @@ -58,3 +58,21 @@ test('JS NNpsk0 reproduces the Go pairing wire bytes + wallet auth + safety numb assert.equal(safetyNumber(client.binding), v.safety_number); assert.equal(safetyNumber(agent.binding), v.safety_number); }); + +test('provisioned pairing authorizes the exact agent registration commitment', async () => { + const [clientMC, agentMC] = pipe(); + const token = crypto.getRandomValues(new Uint8Array(16)); + const signer = deriveSigner(crypto.getRandomValues(new Uint8Array(32))); + const info = { + host_pub: '11'.repeat(32), + machine_id: 'machine-registration', + name: 'box', + registration_commitment: 'ab'.repeat(32), + }; + const agentP = runResponder(agentMC, token, info); + await runInitiator(clientMC, token, signer, null, () => 'opaque-record'); + const agent = await agentP; + assert.equal(agent.wallet, signer.address); + assert.equal(agent.registry, 'opaque-record'); + assert.ok(agent.registration_auth, 'owner registration authorization is provisioned'); +}); diff --git a/web/test/pairing-sas.test.js b/web/test/pairing-sas.test.js index 44e888a..ac63759 100644 --- a/web/test/pairing-sas.test.js +++ b/web/test/pairing-sas.test.js @@ -3,8 +3,8 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { safetyNumber } from '../src/pairing/sas.js'; -test('safety number is 4 groups of 4 hex, deterministic', () => { +test('safety number is 6 groups of 4 hex, deterministic', () => { const a = safetyNumber(new Uint8Array([1, 2, 3, 4, 5])); - assert.match(a, /^[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}$/); + assert.match(a, /^[0-9a-f]{4}(-[0-9a-f]{4}){5}$/); assert.equal(a, safetyNumber(new Uint8Array([1, 2, 3, 4, 5]))); }); diff --git a/web/test/revocations.test.js b/web/test/revocations.test.js new file mode 100644 index 0000000..c6dfa34 --- /dev/null +++ b/web/test/revocations.test.js @@ -0,0 +1,63 @@ +import { test, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { hexToBytes } from '@noble/hashes/utils'; +import { deriveSigner } from '../src/identity/signer.js'; +import { + filterRevoked, loadRevocations, recordRevocation, revocationChallenge, + signRevocation, verifyRevocation, +} from '../src/revocations.js'; + +const signer = deriveSigner(hexToBytes('00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff')); +const here = dirname(fileURLToPath(import.meta.url)); +const vector = JSON.parse(readFileSync(join(here, '..', '..', 'testdata', 'revocation.json'), 'utf8')); + +beforeEach(() => { + const values = new Map(); + globalThis.localStorage = { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, String(value)), + }; +}); + +test('revocation challenge is byte-identical to Go', () => { + assert.equal(new TextDecoder().decode(revocationChallenge('machine-1', 1700000000)), + 'miranda/revocation/v1\nmachine-1\n1700000000'); +}); + +test('signed revocation round-trips and tampering fails', () => { + const record = signRevocation(signer, 'machine-1', 1700000000); + assert.equal(verifyRevocation(record), true); + assert.equal(verifyRevocation({ ...record, machine_id: 'machine-2' }), false); + assert.equal(verifyRevocation({ ...record, ts: record.ts + 1 }), false); +}); + +test('signature is byte-identical to the committed Go vector', () => { + const vectorSigner = deriveSigner(hexToBytes(vector.root)); + const record = signRevocation(vectorSigner, vector.machine_id, vector.ts); + assert.deepEqual(record, { + v: vector.v, + owner_id: vector.owner_id, + machine_id: vector.machine_id, + ts: vector.ts, + signature: vector.signature, + }); +}); + +test('verified local tombstones filter machines', () => { + const record = signRevocation(signer, 'machine-1', 1700000000); + recordRevocation(record); + recordRevocation(record); + const records = loadRevocations(signer.address); + assert.equal(records.length, 1); + assert.deepEqual(filterRevoked([{ machine_id: 'machine-1' }, { machine_id: 'machine-2' }], records), [{ machine_id: 'machine-2' }]); +}); + +test('corrupt local tombstone store fails closed', () => { + const record = signRevocation(signer, 'machine-1', 1700000000); + recordRevocation(record); + localStorage.setItem('mir_revocations_v1', JSON.stringify([{ ...record, machine_id: 'machine-2' }])); + assert.throws(() => loadRevocations(signer.address), /signature verification/); +}); diff --git a/web/test/rp.test.js b/web/test/rp.test.js index 81b845d..3b14ada 100644 --- a/web/test/rp.test.js +++ b/web/test/rp.test.js @@ -1,17 +1,21 @@ // web/test/rp.test.js import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { PRODUCTION_RP_ID, resolveRPID } from '../src/rp.js'; +import { resolveRPID } from '../src/rp.js'; test('production WebAuthn RP ID is the exact terminal app host', () => { - assert.equal(PRODUCTION_RP_ID, 'term.sourceful-labs.net'); assert.equal(resolveRPID('term.sourceful-labs.net'), 'term.sourceful-labs.net'); }); test('production RP ID is not widened to the parent domain', () => { assert.notEqual(resolveRPID('term.sourceful-labs.net'), 'sourceful-labs.net'); - assert.equal(resolveRPID('app.sourceful-labs.net'), 'term.sourceful-labs.net'); - assert.equal(resolveRPID('relay.sourceful-labs.net'), 'term.sourceful-labs.net'); + assert.equal(resolveRPID('app.sourceful-labs.net'), 'app.sourceful-labs.net'); + assert.equal(resolveRPID('relay.sourceful-labs.net'), 'relay.sourceful-labs.net'); +}); + +test('self-hosted domains get their own exact RP ID', () => { + assert.equal(resolveRPID('terminal.example.com'), 'terminal.example.com'); + assert.throws(() => resolveRPID('terminal.example.com:443')); }); test('localhost keeps its local development RP ID', () => { diff --git a/web/test/signer.test.js b/web/test/signer.test.js new file mode 100644 index 0000000..ca72b77 --- /dev/null +++ b/web/test/signer.test.js @@ -0,0 +1,27 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; +import { deriveSigner, signerFromMnemonic } from '../src/identity/signer.js'; +import { deriveOwnerKey } from '../src/identity/owner.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const vector = JSON.parse(readFileSync(join(here, '..', '..', 'testdata', 'identity-derivation.json'), 'utf8')); + +test('neutral signer derivation matches the Go vector', () => { + const signer = deriveSigner(hexToBytes(vector.root)); + assert.equal(signer.mnemonic, vector.mnemonic); + assert.equal(bytesToHex(signer.priv), vector.signer_priv); + assert.equal(bytesToHex(signer.pub), vector.signer_pub); + assert.equal(signer.address, vector.owner_id); +}); + +test('recovery phrase reproduces the same Miranda identity', () => { + assert.equal(signerFromMnemonic(vector.mnemonic).address, vector.owner_id); +}); + +test('X25519 transport derivation remains independently domain-separated', () => { + assert.equal(bytesToHex(deriveOwnerKey(hexToBytes(vector.root)).pub), vector.transport_pub); +}); diff --git a/web/test/slip10.test.js b/web/test/slip10.test.js deleted file mode 100644 index 9e50a3a..0000000 --- a/web/test/slip10.test.js +++ /dev/null @@ -1,34 +0,0 @@ -// web/test/slip10.test.js — mirrors go/internal/slip10/slip10_test.go. -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; -import { ed25519 } from '@noble/curves/ed25519'; -import { derivePath } from '../src/wallet/slip10.js'; - -// SLIP-0010 official Test Vector 1 for ed25519 (seed 000102...0f). -const tv1 = [ - ['m', '90046a93de5380a72b5e45010748567d5ea02bbf6522f979e05c0d8d8ca9fffb', '2b4be7f19ee27bbf30c667b642d5f4aa69fd169872f8fc3059c08ebae2eb19e7', '00a4b2856bfec510abab89753fac1ac0e1112364e7d250545963f135f2a33188ed'], - ["m/0'", '8b59aa11380b624e81507a27fedda59fea6d0b779a778918a2fd3590e16e9c69', '68e0fe46dfb67e368c75379acec591dad19df3cde26e63b93a8e704f1dade7a3', '008c8a13df77a28f3445213a0f432fde644acaa215fc72dcdf300d5efaa85d350c'], - ["m/0'/1'", 'a320425f77d1b5c2505a6b1b27382b37368ee640e3557c315416801243552f14', 'b1d0bad404bf35da785a64ca1ac54b2617211d2777696fbffaf208f746ae84f2', '001932a5270f335bed617d5b935c80aedb1a35bd9fc1e31acafd5372c30f5c1187'], - ["m/0'/1'/2'", '2e69929e00b5ab250f49c3fb1c12f252de4fed2c1db88387094a0f8c4c9ccd6c', '92a5b23c0b8a99e37d07df3fb9966917f5d06e02ddbd909c7e184371463e9fc9', '00ae98736566d30ed0e9d2f4486a64bc95740d89c7db33f52121f8ea8f76ff0fc1'], - ["m/0'/1'/2'/2'", '8f6d87f93d750e0efccda017d662a1b31a266e4a6f5993b15f5c1f07f74dd5cc', '30d1dc7e5fc04c31219ab25a27ae00b50f6fd66622f6e9c913253d6511d1e662', '008abae2d66361c879b900d204ad2cc4984fa2aa344dd7ddc46007329ac76c429c'], - ["m/0'/1'/2'/2'/1000000000'", '68789923a0cac2cd5a29172a475fe9e0fb14cd6adb5ad98a3fa70333e7afa230', '8f94d394a8e8fd6b1bc2f3f49f5c47e385281d5c17e65324b0f62483e37e8793', '003c24da049451555d51a7014a37337aa4e12d41e485abccfa46b47dfb2af54b7a'], -]; - -test('slip10 ed25519 matches Test Vector 1', () => { - const seed = hexToBytes('000102030405060708090a0b0c0d0e0f'); - for (const [path, chain, priv, pub] of tv1) { - const node = derivePath(seed, path); - assert.equal(bytesToHex(node.chain), chain, `${path} chain`); - assert.equal(bytesToHex(node.key), priv, `${path} key`); - // SLIP-0010 ed25519 public = 0x00 || ed25519.pub(seed=key). - assert.equal('00' + bytesToHex(ed25519.getPublicKey(node.key)), pub, `${path} pub`); - } -}); - -test('slip10 rejects non-hardened paths', () => { - const seed = hexToBytes('000102030405060708090a0b0c0d0e0f'); - for (const bad of ["m/44/501'/0'/0'", 'm/0', 'x/0']) { - assert.throws(() => derivePath(seed, bad)); - } -}); diff --git a/web/test/sw.test.js b/web/test/sw.test.js index a63a100..fdcad4d 100644 --- a/web/test/sw.test.js +++ b/web/test/sw.test.js @@ -102,7 +102,7 @@ test('network-first: serves the fresh response and rewrites the cache', async () request: getRequest('/src/app.js'), }); assert.equal(await response.text(), 'shell:/src/app.js'); - assert.equal(await stores.get('mir-shell-v1').get('/src/app.js').clone().text(), 'shell:/src/app.js'); + assert.equal(await stores.get('mir-shell-v2').get('/src/app.js').clone().text(), 'shell:/src/app.js'); }); test('relay unreachable: falls back to the cached shell', async () => { @@ -140,6 +140,8 @@ test('never intercepts signaling, non-GET, or cross-origin requests', async () = getRequest('/pair'), getRequest('/turn-credentials'), getRequest('/healthz'), + getRequest('/registry'), + getRequest('/revocations'), { method: 'POST', url: ORIGIN + '/anything', mode: 'no-cors' }, { method: 'GET', url: 'https://evil.example/x', mode: 'no-cors' }, ]) { diff --git a/web/test/wallet.test.js b/web/test/wallet.test.js deleted file mode 100644 index 33575f2..0000000 --- a/web/test/wallet.test.js +++ /dev/null @@ -1,35 +0,0 @@ -// web/test/wallet.test.js — asserts the Go-written wallet-derivation.json vector, -// mirroring how owner.test.js gates owner-derivation.json. -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; -import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; -import { deriveWallet, walletFromMnemonic } from '../src/identity/wallet.js'; -import { deriveOwnerKey } from '../src/identity/owner.js'; - -const here = dirname(fileURLToPath(import.meta.url)); -const vector = JSON.parse( - readFileSync(join(here, '..', '..', 'testdata', 'wallet-derivation.json'), 'utf8'), -); - -test('wallet derivation matches the Go vector', () => { - const prf = hexToBytes(vector.prf_output); - const w = deriveWallet(prf); - assert.equal(w.mnemonic, vector.mnemonic); - assert.equal(bytesToHex(w.seed), vector.seed); - assert.equal(bytesToHex(w.priv), vector.wallet_priv); - assert.equal(bytesToHex(w.pub), vector.wallet_pub); - assert.equal(w.address, vector.address); -}); - -test('import from mnemonic reproduces the same wallet', () => { - const w = walletFromMnemonic(vector.mnemonic); - assert.equal(w.address, vector.address); -}); - -test('X25519 transport key is unchanged (independent of the wallet)', () => { - const { pub } = deriveOwnerKey(hexToBytes(vector.prf_output)); - assert.equal(bytesToHex(pub), vector.owner_pub); -}); diff --git a/web/vendor/noble-hashes-pbkdf2.js b/web/vendor/noble-hashes-pbkdf2.js deleted file mode 100644 index 3e6f54d..0000000 --- a/web/vendor/noble-hashes-pbkdf2.js +++ /dev/null @@ -1,6 +0,0 @@ -function R(t){return t instanceof Uint8Array||ArrayBuffer.isView(t)&&t.constructor.name==="Uint8Array"}function g(t){if(!Number.isSafeInteger(t)||t<0)throw new Error("positive integer expected, got "+t)}function L(t,...e){if(!R(t))throw new Error("Uint8Array expected");if(e.length>0&&!e.includes(t.length))throw new Error("Uint8Array expected of length "+e+", got length="+t.length)}function m(t){if(typeof t!="function"||typeof t.create!="function")throw new Error("Hash should be wrapped by utils.createHasher");g(t.outputLen),g(t.blockLen)}function A(t,e=!0){if(t.destroyed)throw new Error("Hash instance has been destroyed");if(e&&t.finished)throw new Error("Hash#digest() has already been called")}function H(...t){for(let e=0;e{};async function B(t,e,i){let r=Date.now();for(let s=0;s=0&&ns?e.create().update(r).digest():r);for(let o=0;onew k(t,e).update(i).digest();E.create=(t,e)=>new k(t,e);function F(t,e,i,r){m(t);let s=_({dkLen:32,asyncTick:10},r),{c:n,dkLen:o,asyncTick:c}=s;if(g(n),g(o),g(c),n<1)throw new Error("iterations (c) should be >= 1");let u=U(e),a=U(i),p=new Uint8Array(o),y=E.create(t,u),f=y._cloneInto().update(a);return{c:n,dkLen:o,asyncTick:c,DK:p,PRF:y,PRFSalt:f}}function S(t,e,i,r,s){return t.destroy(),e.destroy(),r&&r.destroy(),H(s),i}function X(t,e,i,r){let{c:s,dkLen:n,DK:o,PRF:c,PRFSalt:u}=F(t,e,i,r),a,p=new Uint8Array(4),y=I(p),f=new Uint8Array(c.outputLen);for(let d=1,x=0;x{u._cloneInto(p).update(d).digestInto(d);for(let l=0;l