Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ mir attach laptop macmini linux
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`.

**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`).

**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
Expand Down
12 changes: 12 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,18 @@ the network is hostile — provided the trust roots below are intact.
(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.

Expand Down
200 changes: 200 additions & 0 deletions go/internal/agent/registry_publish_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// go/internal/agent/registry_publish_test.go
package agent

import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"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",
}
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)
}
pt, err := identity.OpenRecord(key, blob, cfg.MachineID)
if err != nil {
t.Fatalf("OpenRecord (right machine_id): %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 {
t.Fatalf("record name = %v, want %q", rec["name"], cfg.MachineName)
}
if rec["host_pub"] != cfg.HostPubHex {
t.Fatalf("record host_pub = %v, want %q", rec["host_pub"], cfg.HostPubHex)
}
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")
}
}

// 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")
}
}

// 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"

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
}
_, data, err := c.Read(r.Context())
if err != nil {
return
}
var m signal.SignalMsg
if json.Unmarshal(data, &m) == nil {
first <- m
}
// hold the registration open until the test ends
_, _, _ = c.Read(r.Context())
}))
defer srv.Close()

cfg := &Config{
SignalURL: srv.URL,
MachineID: "machine-pub-1",
MachineName: "publisher",
HostPubHex: "0011223344556677",
PairedOwners: []string{wallet},
}
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) }()

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)
}
case <-ctx.Done():
t.Fatal("relay never received the first registry message")
}
}

// 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
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
}
}
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"},
}
rt := NewRuntime(cfg, []string{"sh"}, nil)
rt.WalletSecret = secret
rt.WalletAddress = "SelfWallet"

ctx, cancel := context.WithTimeout(context.Background(), 700*time.Millisecond)
defer cancel()
go func() { _, _, _ = rt.serveOnce(ctx, "OtherOwner") }()

select {
case typ := <-got:
if typ == signal.TypeRegistry {
t.Fatal("agent published a registry blob for a non-self owner")
}
case <-ctx.Done():
// no message at all is also correct (the agent only sends on offers).
}
}
60 changes: 60 additions & 0 deletions go/internal/agent/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package agent

import (
"context"
cryptorand "crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
Expand Down Expand Up @@ -76,6 +78,15 @@ type Runtime struct {
Logf func(string, ...any) // optional reconnect/status log (set by the CLI)

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
Expand Down Expand Up @@ -223,6 +234,43 @@ 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).
Expand All @@ -246,6 +294,18 @@ 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)
}
}
}

for {
_, data, err := c.Read(ctx)
if err != nil {
Expand Down
28 changes: 28 additions & 0 deletions go/internal/cli/agent_cmds.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ 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
Expand All @@ -97,6 +103,28 @@ 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.
Expand Down
Loading
Loading