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) [](#install) [](go/go.mod) -[](#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.
+ 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