diff --git a/README.md b/README.md index 1d34913e..9837b0f5 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in - **Non-Interactive Mode**: Script-friendly positional args with JSON output - **GitHub Integration**: Scaffold a GitHub Actions workflow with `kit github install` to run Kit as a collaborator/reviewer on `/kit` comments - **ACP Server**: Run Kit as an [Agent Client Protocol](https://agentclientprotocol.com) agent over stdio -- **Remote Sessions**: `kit daemon` on one machine, `kit --remote CODE` from another — end-to-end encrypted iroh transport, per-client sessions, pairing-code security, and systemd service support +- **Remote Sessions**: `kit daemon` on one machine; pair a client once with `kit daemon pair` + `kit remote --pair ` (accept/reject on the host), then reconnect any time with `kit remote --host ` — end-to-end encrypted iroh transport, per-client sessions, revocable public-key credentials, systemd service support - **Go SDK**: Embed Kit in your own applications with full agent lifecycle events (30+ event types) and behavior-modifying hooks ## Installation diff --git a/cmd/daemon.go b/cmd/daemon.go index 0d4307fe..e6c0ff15 100644 --- a/cmd/daemon.go +++ b/cmd/daemon.go @@ -1,10 +1,7 @@ package cmd import ( - "context" "fmt" - "os" - "os/signal" "time" "github.com/spf13/cobra" @@ -12,34 +9,78 @@ import ( "github.com/mark3labs/kit/internal/daemon" ) -var daemonCode string +var pairCode string var daemonCmd = &cobra.Command{ Use: "daemon", - Short: "Run Kit as a remote daemon, waiting for a pairing connection", + Short: "Run Kit as a remote daemon, hosting sessions for paired clients", Long: `Run Kit as a remote daemon. -Generates a pairing code and waits for remote peers to connect with -"kit --remote CODE". Each verified peer picks a working directory -(starting in this user's home directory) and gets its own session: -the session runs entirely on this machine, rendered inside the peer's -terminal. Multiple clients can hold sessions at the same time, and -exiting a session only disconnects that client. - -The pairing code stays valid while the daemon runs; press Ctrl+C to -stop. Only one daemon may run per user; use "kit daemon status" to -inspect a running instance and "kit daemon service install" to manage -it via systemd (user service).`, +Hosts remote sessions over an end-to-end encrypted iroh connection for +clients paired with this machine. Each paired client picks a working +directory (starting in this user's home directory) and gets its own +session: the session runs entirely on this machine, rendered inside the +peer's terminal. Multiple clients can hold sessions at the same time, +and exiting a session only disconnects that client. + +Pair a new client with 'kit daemon pair' — it shows a one-time code and +asks you to accept or reject the client on this terminal. Only one +daemon may run per user; use 'kit daemon status' to inspect a running +instance and 'kit daemon service install' to manage it via systemd.`, + RunE: func(cmd *cobra.Command, _ []string) error { + return daemon.Serve(cmd.Context()) + }, +} + +var daemonPairCmd = &cobra.Command{ + Use: "pair", + Short: "Pair a new client: show a one-time code and confirm on this terminal", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) - defer stop() - return daemon.Serve(ctx, daemon.ServeOptions{Code: daemonCode}) + if pairList { + return runPairList() + } + if pairRevoke != "" { + removed, err := daemon.RevokeClient(pairRevoke) + if err != nil { + return err + } + fmt.Printf("Revoked client %s (paired since %s)\n", + removed.FP, removed.AddedAt.Format("2006-01-02")) + return nil + } + return daemon.RunPairWindow(cmd.Context(), daemon.PairWindowOptions{Code: pairCode}) }, } +var ( + pairList bool + pairRevoke string +) + +// runPairList prints the authorized clients table. +func runPairList() error { + clients, err := daemon.ListAuthorized() + if err != nil { + return err + } + if len(clients) == 0 { + fmt.Println("No paired clients. Pair one with: kit daemon pair") + return nil + } + fmt.Printf("%-18s %-10s %s\n", "FINGERPRINT", "PAIRED", "LAST SEEN") + for _, c := range clients { + fmt.Printf("%-18s %-10s %s\n", + c.FP, + c.AddedAt.Format("2006-01-02"), + c.LastSeen.Format("2006-01-02 15:04")) + } + return nil +} + var daemonStatusCmd = &cobra.Command{ Use: "status", - Short: "Show the pairing code and state of a running daemon", + Short: "Show the state of a running daemon", Args: cobra.NoArgs, RunE: func(_ *cobra.Command, _ []string) error { st := daemon.ReadStatus() @@ -47,40 +88,28 @@ var daemonStatusCmd = &cobra.Command{ fmt.Println("kit daemon is not running.") fmt.Println("Start one with: kit daemon (or: kit daemon service install)") if st.State != nil { - fmt.Printf("(stale state on disk from pid %d, started %s, code %s)\n", - st.State.PID, st.State.StartedAt.Format("2006-01-02 15:04"), st.State.Code) + fmt.Printf("(stale state on disk from pid %d, started %s)\n", + st.State.PID, st.State.StartedAt.Format("2006-01-02 15:04")) } return nil } s := st.State if s == nil { - fmt.Printf("kit daemon is running (pid unknown — state file not written yet)\n") + fmt.Println("kit daemon is running (pid unknown — state file not written yet)") return nil } uptime := time.Since(s.StartedAt).Round(time.Second) fmt.Printf("kit daemon is running (pid %d, up %s)\n", s.PID, uptime) - fmt.Printf(" Pairing code: %s\n", s.Code) - fmt.Printf(" Connect with: kit --remote %s\n", normalizeForHint(s.Code)) if s.Endpoint != "" { fmt.Printf(" Endpoint: %s\n", s.Endpoint) } + clients, _ := daemon.ListAuthorized() + fmt.Printf(" Paired clients: %d\n", len(clients)) fmt.Printf(" Active sessions: %d\n", s.SessionsActive) return nil }, } -// normalizeForHint strips the display dash so the connect hint is -// copy-pasteable. -func normalizeForHint(displayCode string) string { - out := make([]byte, 0, len(displayCode)) - for i := 0; i < len(displayCode); i++ { - if displayCode[i] != '-' { - out = append(out, displayCode[i]) - } - } - return string(out) -} - var daemonServiceCmd = &cobra.Command{ Use: "service", Short: "Manage the kit daemon systemd user service", @@ -106,8 +135,12 @@ var daemonServiceRemoveCmd = &cobra.Command{ } func init() { - daemonCmd.Flags().StringVar(&daemonCode, "code", "", "use a fixed pairing code instead of a random one (testing)") - _ = daemonCmd.Flags().MarkHidden("code") + daemonPairCmd.Flags().StringVar(&pairCode, "code", "", "use a fixed pairing code instead of a random one (testing)") + _ = daemonPairCmd.Flags().MarkHidden("code") + daemonPairCmd.Flags().BoolVar(&pairList, "list", false, "list paired clients") + daemonPairCmd.Flags().StringVar(&pairRevoke, "revoke", "", "revoke a paired client by fingerprint (or unique prefix)") + + daemonCmd.AddCommand(daemonPairCmd) daemonCmd.AddCommand(daemonStatusCmd) daemonServiceCmd.AddCommand(daemonServiceInstallCmd) daemonServiceCmd.AddCommand(daemonServiceRemoveCmd) diff --git a/cmd/remote.go b/cmd/remote.go new file mode 100644 index 00000000..6b87b96a --- /dev/null +++ b/cmd/remote.go @@ -0,0 +1,79 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/mark3labs/kit/internal/daemon" +) + +// Remote connection flags for `kit remote`. +var ( + remotePair string + remoteHost string + remoteList bool + remoteForget string + remotePairCode string // hidden: fixed code for tests +) + +var remoteCmd = &cobra.Command{ + Use: "remote", + Short: "Connect to a paired kit daemon and run a session on it", + Long: `Connect to a kit daemon and run a session on the remote host. + +First-time use pairs this machine with the host: + + kit remote --pair A1B2C3D4 # code shown by 'kit daemon pair' on the host + +After pairing, reconnect by the name you saved — no code needed: + + kit remote --host zora + +The session runs entirely on the host; this terminal just renders it. +Ctrl-] detaches; /quit ends the session.`, + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + switch { + case remoteList: + hosts, err := daemon.ListHosts() + if err != nil { + return err + } + if len(hosts) == 0 { + fmt.Println("No paired hosts. Pair one with: kit remote --pair ") + return nil + } + fmt.Printf("%-16s %-12s %s\n", "NAME", "PAIRED", "LAST USED") + for _, h := range hosts { + fmt.Printf("%-16s %-12s %s\n", + h.Name, + h.AddedAt.Format("2006-01-02"), + h.LastUsed.Format("2006-01-02 15:04")) + } + return nil + case remoteForget != "": + return daemon.ForgetHost(remoteForget) + case remotePair != "": + code := remotePair + if remotePairCode != "" { + code = remotePairCode // hidden testing override + } + return daemon.RunPair(ctx, daemon.PairOptions{Code: code, Name: remoteHost}) + case remoteHost != "": + return daemon.RunHost(ctx, remoteHost) + default: + return cmd.Help() + } + }, +} + +func init() { + remoteCmd.Flags().StringVar(&remotePair, "pair", "", "pair with a host using the code from 'kit daemon pair'") + remoteCmd.Flags().StringVar(&remoteHost, "host", "", "connect to a paired host by saved name") + remoteCmd.Flags().BoolVar(&remoteList, "list", false, "list paired hosts") + remoteCmd.Flags().StringVar(&remoteForget, "forget", "", "forget a paired host by name") + remoteCmd.Flags().StringVar(&remotePairCode, "code", "", "use a fixed pairing code (testing)") + _ = remoteCmd.Flags().MarkHidden("code") + rootCmd.AddCommand(remoteCmd) +} diff --git a/cmd/root.go b/cmd/root.go index 62ba7def..21f80a53 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,13 +8,13 @@ import ( "log" "os" "path/filepath" + "slices" "strings" tea "charm.land/bubbletea/v2" charmlog "github.com/charmbracelet/log" "github.com/mark3labs/kit/internal/app" "github.com/mark3labs/kit/internal/config" - "github.com/mark3labs/kit/internal/daemon" "github.com/mark3labs/kit/internal/extensions" "github.com/mark3labs/kit/internal/models" "github.com/mark3labs/kit/internal/prompts" @@ -98,10 +98,9 @@ var ( promptTemplatePaths []string noPromptTemplates bool - // Remote sessions (--remote) and the daemon's directory picker + // The daemon's directory picker // (--pick-dir, hidden — spawned by `kit daemon`). - remoteCodeFlag string - pickDirFlag bool + pickDirFlag bool // Preference restoration flags — set in RunE after cobra parses, used // in runNormalMode to decide whether to apply saved preferences. @@ -183,6 +182,12 @@ func GetRootCommand(v string) *cobra.Command { // InitConfig, injecting the CLI-specific configFile flag and debug mode. // This function is automatically called by cobra before command execution. func InitConfig() { + // Remote client flows never read local configuration: a broken local + // config must not block attaching to a daemon, and the client performs + // no local-session work that could consume it. + if remoteSubcommandSelected(os.Args[1:]) { + return + } if err := kit.InitConfigWithOptions(kit.ConfigInitOptions{ ConfigFile: configFile, Debug: debugMode, @@ -196,6 +201,41 @@ func InitConfig() { models.ReloadGlobalRegistry() } +// remoteSubcommandSelected reports whether the invoked command line +// selects the `kit remote` subcommand. Flag-aware: global flags (with or +// without values) before the subcommand are skipped, so +// `kit --config x remote --list` is recognized just like `kit remote`. +func remoteSubcommandSelected(args []string) bool { + for i := 0; i < len(args); i++ { + arg := args[i] + if arg == "remote" { + return true + } + if strings.HasPrefix(arg, "-") { + // Flags that take a value consume the next token unless the + // value is attached with '='. + if !strings.Contains(arg, "=") && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { + isBoolean := slices.Contains(globalBoolFlags, strings.TrimLeft(arg, "-")) + if !isBoolean { + i++ + } + } + } + } + return false +} + +// globalBoolFlags lists root persistent flags that do not take a value +// (long and short forms); used by remoteSubcommandSelected to walk the +// argv correctly. Shorthands with values (-m, -s, -e) must NOT appear here. +var globalBoolFlags = []string{ + "bare", "debug", "quiet", "json", "no-exit", "no-session", + "continue", "resume", "auto-compact", "compact", "stream", + "no-extensions", "no-prompt-templates", "no-skills", "no-agents", + "no-core-tools", "tls-skip-verify", "pick-dir", "version", + "c", "r", // -c (continue), -r (resume) +} + // adaptiveOrDefault converts a config.AdaptiveColor to a resolved color.Color, // falling back to fallback when both Light and Dark are empty. func adaptiveOrDefault(ac config.AdaptiveColor, fallback color.Color) color.Color { @@ -333,8 +373,6 @@ func init() { StringVar(&skillsDir, "skills-dir", "", "scan this directory directly for skills (overrides auto-discovery)") rootCmd.PersistentFlags(). StringSliceVar(&skillsDisable, "skill-disable", nil, "hide a skill from the model catalog by name (repeatable); still usable via /skill:") - rootCmd.Flags(). - StringVar(&remoteCodeFlag, "remote", "", "connect to a kit daemon using a pairing code (e.g. kit --remote A1B2C3D4)") rootCmd.Flags(). BoolVar(&pickDirFlag, "pick-dir", false, "choose a working directory with a picker before starting") _ = rootCmd.Flags().MarkHidden("pick-dir") @@ -467,48 +505,27 @@ func processPositionalArgs(args []string) { } } -// preInitDispatch handles the remote-session entry points before any -// configuration is loaded. Cobra runs initializers in registration order, -// ahead of RunE, so: -// -// - `--remote` dispatches without touching local config: a broken -// ~/.config/kit or project config must not block attaching to a daemon, -// and a remote attachment must never load project settings. -// - `--pick-dir` changes the working directory before config discovery, -// so project-level configuration (.kit.* in the chosen directory) is -// honored instead of the directory kit happened to start in. -// -// The dispatcher exits the process directly; neither path returns into the -// normal startup flow. +// preInitDispatch handles the directory-picker entry point before any +// configuration is loaded, so project-level configuration discovery +// (.kit.* in the chosen directory) resolves against the chosen directory +// instead of whatever directory kit happened to start in. The dispatcher +// exits the process directly on cancellation or failure. func preInitDispatch() { - // Remote mode: attach this terminal to a kit daemon session. The client - // performs no local-session work itself. - if remoteCodeFlag != "" { - if args := rootCmd.Flags().Args(); len(args) > 0 { - fmt.Fprintln(os.Stderr, "prompt arguments cannot be combined with --remote") - os.Exit(1) - } - if err := daemon.RunRemote(context.Background(), remoteCodeFlag); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - os.Exit(0) + if !pickDirFlag { + return } - - if pickDirFlag { - home, _ := os.UserHomeDir() - chosen, err := ui.RunDirPicker(home) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - if chosen == "" { - os.Exit(0) // cancelled - } - if err := os.Chdir(chosen); err != nil { - fmt.Fprintf(os.Stderr, "change to %s: %v\n", chosen, err) - os.Exit(1) - } + home, _ := os.UserHomeDir() + chosen, err := ui.RunDirPicker(home) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if chosen == "" { + os.Exit(0) // cancelled + } + if err := os.Chdir(chosen); err != nil { + fmt.Fprintf(os.Stderr, "change to %s: %v\n", chosen, err) + os.Exit(1) } } diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 00000000..e548a025 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,30 @@ +package cmd + +import "testing" + +func TestRemoteSubcommandSelected(t *testing.T) { + cases := []struct { + name string + args []string + want bool + }{ + {"plain", []string{"remote", "--host", "zora"}, true}, + {"global flag with value before remote", []string{"--config", "/tmp/broken.yml", "remote", "--list"}, true}, + {"attached value", []string{"--config=/tmp/x", "remote", "--pair", "ABCD2345"}, true}, + {"bool flag before remote", []string{"--debug", "remote", "--list"}, true}, + {"no remote", []string{"--config", "/tmp/x"}, false}, + {"other subcommand", []string{"daemon", "pair"}, false}, + {"empty", nil, false}, + {"short bool alias before remote", []string{"-c", "remote", "--list"}, true}, + {"short resume alias", []string{"-r", "remote", "--pair", "ABCD2345"}, true}, + {"short value flag consumes remote", []string{"-m", "remote"}, false}, + {"remote as flag value", []string{"--model", "remote"}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := remoteSubcommandSelected(tc.args); got != tc.want { + t.Fatalf("remoteSubcommandSelected(%v) = %v, want %v", tc.args, got, tc.want) + } + }) + } +} diff --git a/contrib/kit-tunnel/Cargo.lock b/contrib/kit-tunnel/Cargo.lock index 47117dd5..4b775099 100644 --- a/contrib/kit-tunnel/Cargo.lock +++ b/contrib/kit-tunnel/Cargo.lock @@ -301,6 +301,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -444,6 +450,22 @@ dependencies = [ "cmov", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + [[package]] name = "curve25519-dalek" version = "5.0.0" @@ -454,7 +476,7 @@ dependencies = [ "cpufeatures 0.3.1", "curve25519-dalek-derive", "digest 0.11.3", - "fiat-crypto", + "fiat-crypto 0.3.0", "rand_core 0.10.1", "rustc_version", "serde", @@ -499,13 +521,23 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + [[package]] name = "der" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ - "const-oid", + "const-oid 0.10.2", "pem-rfc7468", "zeroize", ] @@ -600,15 +632,39 @@ dependencies = [ "winapi", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + [[package]] name = "ed25519" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ - "pkcs8", + "pkcs8 0.11.0", "serdect", - "signature", + "signature 3.0.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", ] [[package]] @@ -617,12 +673,12 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ - "curve25519-dalek", - "ed25519", + "curve25519-dalek 5.0.0", + "ed25519 3.0.0", "rand_core 0.10.1", "serde", "sha2 0.11.0", - "signature", + "signature 3.0.0", "subtle", "zeroize", ] @@ -678,6 +734,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "fiat-crypto" version = "0.3.0" @@ -1370,7 +1432,7 @@ dependencies = [ "ctutils", "data-encoding", "derive_more", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "futures-util", "getrandom 0.4.3", "hickory-resolver", @@ -1414,11 +1476,11 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ccf4cde68a02ef03c0095ab2c031c6adfe508f3bc8e7c258558a027ebe64a8e" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 5.0.0", "data-encoding", "data-encoding-macro", "derive_more", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "getrandom 0.4.3", "n0-error", "rand 0.10.2", @@ -1619,6 +1681,7 @@ name = "kit-tunnel" version = "0.1.0" dependencies = [ "anyhow", + "ed25519-dalek 2.2.0", "hex", "hkdf", "hmac", @@ -2253,14 +2316,24 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + [[package]] name = "pkcs8" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der", - "spki", + "der 0.8.1", + "spki 0.8.0", ] [[package]] @@ -2464,6 +2537,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "rand_core" version = "0.9.5" @@ -2847,6 +2929,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "signature" version = "3.0.0" @@ -2926,6 +3017,16 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + [[package]] name = "spki" version = "0.8.0" @@ -2933,7 +3034,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der", + "der 0.8.1", ] [[package]] diff --git a/contrib/kit-tunnel/Cargo.toml b/contrib/kit-tunnel/Cargo.toml index d8ae6568..cb5be535 100644 --- a/contrib/kit-tunnel/Cargo.toml +++ b/contrib/kit-tunnel/Cargo.toml @@ -15,6 +15,7 @@ subtle = "2" rand = "0.9" hex = "0.4" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +ed25519-dalek = "2" [profile.release] strip = true diff --git a/contrib/kit-tunnel/src/main.rs b/contrib/kit-tunnel/src/main.rs index 620c776b..d9e49e8f 100644 --- a/contrib/kit-tunnel/src/main.rs +++ b/contrib/kit-tunnel/src/main.rs @@ -4,8 +4,10 @@ //! and the pairing handshake. It exposes a byte-pump interface on its own //! stdio so the Go side (kit daemon / kit --remote) needs no iroh code. //! -//! kit-tunnel serve --seed-hex <64 hex> (daemon side; long-lived) -//! kit-tunnel dial --seed-hex <64 hex> (client side; one connection) +//! kit-tunnel serve --secret-hex <64 hex> (daemon main endpoint) +//! kit-tunnel serve-pair --pair-seed-hex <64 hex> (pairing window) +//! kit-tunnel dial-host --endpoint-id <64 hex> --client-seed-hex <64 hex> +//! kit-tunnel dial-pair --pair-seed-hex <64 hex> --client-pub-hex <64 hex> //! //! `serve` accepts multiple connections over ONE endpoint. Each verified //! connection becomes a session with its own id; frames on stdio are @@ -26,15 +28,45 @@ //! DATA, RESIZE and BYE frames verbatim in both directions; PING/PONG are //! reserved for a future keepalive. //! -//! Handshake flow (the client speaks first: in QUIC an open_bi stream -//! carries no bytes until the initiator writes, so a server-first hello on -//! a client-opened stream would never reach accept_bi): +//! Protocol v3 — pairing model. The daemon owns a STABLE ed25519 identity +//! (--secret-hex); its endpoint id is that public key, and clients store it +//! after pairing, so iroh's QUIC handshake authenticates the host against +//! the pinned id. Clients hold their own ed25519 signing key; the host +//! keeps an allowlist of client public keys (Go side), and reconnects are +//! authenticated by signature — no shared code anywhere in the steady +//! state. //! -//! client -> CLIENT_HELLO {ver, c_nonce} (also materializes the stream) +//! Main-endpoint handshake (client speaks first: in QUIC an open_bi stream +//! carries no bytes until the initiator writes): +//! +//! client -> CLIENT_HELLO {ver, c_nonce, client_pub} //! server -> SERVER_HELLO {ver, s_nonce} -//! client -> CLIENT_AUTH {HMAC(key, "kit-client" | s_nonce | c_nonce)} -//! server -> SERVER_OK {HMAC(key, "kit-server" | c_nonce | s_nonce)} | DENIED -//! server -> SESSION_ASSIGN {id} (multi-session: this connection's id) +//! server -> daemon AUTH_REQUEST {c_nonce, s_nonce, client_pub} +//! client -> CLIENT_AUTH {ed25519_sig("kit-remote-v3-auth"|c_nonce|s_nonce)} +//! server -> daemon AUTH_PAYLOAD {c_nonce, sig} +//! daemon -> server AUTH_DECISION {c_nonce, 0|1[, reason]} +//! server -> SERVER_OK {} | DENIED {reason} +//! server -> SESSION_ASSIGN {id} +//! +//! The AUTH_* frames travel on the sidecar's stdio: signature verification +//! against the allowlist is policy and stays in Go. Concurrent handshakes +//! are correlated by c_nonce. +//! +//! Pairing window (serve-pair, bootstrap endpoint derived from a one-time +//! code — the ONLY place the code is ever used, and it expires with the +//! window): +//! +//! client -> PAIR_CLIENT_HELLO {ver, c_nonce, client_pub, tag} +//! tag = HMAC(pair_key, "kit-pair-client" | c_nonce) +//! server -> daemon PAIR_REQUEST {c_nonce, client_pub} +//! ... human accept/reject on the host terminal ... +//! daemon -> server PAIR_DECISION {c_nonce, 0} | {c_nonce, 1, host_endpoint_id} +//! server -> PAIR_SERVER_OK {s_nonce, tag2, host_endpoint_id} | DENIED +//! tag2 = HMAC(pair_key, "kit-pair-server" | c_nonce | s_nonce) +//! +//! tag proves the peer knows the code before the human is bothered; the +//! host_endpoint_id (the daemon's stable public key) is what the client +//! stores for future codeless reconnection. //! //! Human-facing status goes to stderr as lines of the form: //! @@ -48,12 +80,10 @@ //! STATUS ERROR msg= //! //! The pairing seed is 32 bytes of HKDF-SHA256 output derived from the -//! pairing code (see kit's internal/daemon/pairing.go). The server endpoint -//! identity is derived from the seed: anyone who can compute the endpoint id -//! is holding the code. The HMAC handshake additionally protects the live -//! endpoint from peers that learn its id without the code (e.g. by -//! observing DNS/relay traffic). Failed handshakes back off exponentially -//! (up to 8s) since the code no longer rotates per attempt. +//! one-time code (see kit's internal/daemon/pairing.go). It exists only for +//! the pairing window and only proves code knowledge; access itself is +//! granted by the human accept and persisted as a public-key allowlist +//! entry. Failed pairings back off exponentially (up to 8s). use std::collections::HashMap; use std::io::{self, Read, Write}; @@ -61,18 +91,27 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; +use ed25519_dalek::{Signature, Signer, SigningKey}; use hkdf::Hkdf; use hmac::{Hmac, Mac}; use iroh::endpoint::{ presets, IdleTimeout, Incoming, QuicTransportConfig, ReadExactError, RecvStream, SendStream, }; -use iroh::{Endpoint, SecretKey}; -use sha2::Sha256; +use iroh::{Endpoint, EndpointId, SecretKey}; +use sha2::{Digest, Sha256}; use subtle::ConstantTimeEq; use tokio::sync::{mpsc, Semaphore}; const ALPN: &[u8] = b"kit/remote/1"; -const PROTOCOL_VERSION: u16 = 2; +const PROTOCOL_VERSION: u16 = 3; + +/// Domain separator for reconnect handshake signatures. Both ends +/// (Rust ed25519-dalek, Go crypto/ed25519) sign/verify this exact prefix. +const SIGN_CONTEXT: &[u8] = b"kit-remote-v3-auth"; +const SIGNATURE_LEN: usize = 64; +const ED25519_PUB_LEN: usize = 32; +const PAIR_TAG_ROLE_CLIENT: &[u8] = b"kit-pair-client"; +const PAIR_TAG_ROLE_SERVER: &[u8] = b"kit-pair-server"; const MAX_PAYLOAD: usize = 65535; /// Cap on concurrent sessions over one endpoint; extra peers are denied. @@ -89,6 +128,16 @@ const FRAME_RESIZE: u8 = 0x02; const FRAME_BYE: u8 = 0x03; const FRAME_PING: u8 = 0x04; const FRAME_PONG: u8 = 0x05; +// Pairing-model control frames on the sidecar's stdio: the daemon<->sidecar +// consultation channel that keeps authentication policy in Go (v3). +const FRAME_AUTH_REQUEST: u8 = 0x30; +const FRAME_AUTH_PAYLOAD: u8 = 0x31; +const FRAME_AUTH_DECISION: u8 = 0x32; +const FRAME_PAIR_REQUEST: u8 = 0x40; +const FRAME_PAIR_DECISION: u8 = 0x41; +// Client<->bootstrap-endpoint pairing handshake frames (iroh stream, v3). +const FRAME_PAIR_CLIENT_HELLO: u8 = 0x20; +const FRAME_PAIR_SERVER_OK: u8 = 0x21; // Handshake + session control (in-tunnel only; never forwarded). const FRAME_SERVER_HELLO: u8 = 0x10; const FRAME_CLIENT_HELLO: u8 = 0x11; @@ -139,6 +188,24 @@ fn random_nonce() -> [u8; NONCE_LEN] { rand::random() } +/// Write a DENIED verdict and finish the stream. Dropping the send side +/// without finishing would reset the stream and silently discard the +/// verdict — the client would only see a connection loss. +async fn deny_and_finish(send: &mut SendStream, reason: &str) { + let _ = write_frame(send, FRAME_DENIED, 0, reason.as_bytes()).await; + let _ = send.finish(); + // Hold the streams open while the peer drains the verdict: dropping + // them closes the connection and can overtake the in-flight data. + tokio::time::sleep(Duration::from_secs(2)).await; +} + +/// Short public identity of a key: first 16 hex chars of SHA-256 over the +/// raw bytes. Mirrors Go's daemon.Fingerprint. +fn fingerprint(b: &[u8]) -> String { + let digest = Sha256::digest(b); + hex::encode(digest)[..16].to_string() +} + fn secret_from_seed(seed: &[u8]) -> SecretKey { let mut bytes = [0u8; 32]; bytes.copy_from_slice(seed); @@ -149,6 +216,23 @@ fn parse_seed(hex_seed: &str) -> Vec { hex::decode(hex_seed.trim()).unwrap_or_else(|e| fail(&format!("bad seed hex: {e}"))) } +/// Key material never travels in argv (world-readable via ps); the Go side +/// passes it in the child's environment and the mode flag selects the +/// variable to read. +fn secret_material(flags: &Flags, env_var: &str) -> String { + if let Some(flag) = ["secret-hex", "pair-seed-hex", "client-seed-hex"] + .iter() + .find_map(|f| { + let v = flags.get(f); + (!v.is_empty()).then_some(v) + }) + { + // Direct hex flag (used by tests and manual runs). + return flag; + } + std::env::var(env_var).unwrap_or_else(|_| fail(&format!("missing key material: set {env_var}"))) +} + /// Transport tuning: a keep-alive plus a hard idle timeout so a silently /// vanished peer (killed process, dropped network, sleeping laptop) is /// detected in seconds instead of hanging the other side forever. @@ -263,19 +347,21 @@ async fn read_frame(recv: &mut RecvStream) -> anyhow::Result { async fn server_handshake( send: &mut SendStream, recv: &mut RecvStream, - key: &[u8; 32], + pending: Pending, session: u32, ) -> anyhow::Result<()> { let hello = read_frame(recv).await?; - if hello.t != FRAME_CLIENT_HELLO || hello.payload.len() < 2 + NONCE_LEN { + if hello.t != FRAME_CLIENT_HELLO || hello.payload.len() != 2 + NONCE_LEN + ED25519_PUB_LEN { anyhow::bail!("malformed client hello"); } let c_ver = u16::from_be_bytes([hello.payload[0], hello.payload[1]]); if c_ver != PROTOCOL_VERSION { - write_frame(send, FRAME_DENIED, 0, format!("version {c_ver}").as_bytes()).await?; + deny_and_finish(send, &format!("version {c_ver}")).await; anyhow::bail!("version mismatch: {c_ver}"); } let c_nonce = hello.payload[2..2 + NONCE_LEN].to_vec(); + let corr: [u8; 8] = c_nonce[0..8].try_into().expect("nonce is 32 bytes"); + let client_pub = hello.payload[2 + NONCE_LEN..].to_vec(); let s_nonce = random_nonce(); let mut reply = Vec::with_capacity(2 + NONCE_LEN); @@ -283,80 +369,82 @@ async fn server_handshake( reply.extend_from_slice(&s_nonce); write_frame(send, FRAME_SERVER_HELLO, 0, &reply).await?; + // Consult the Go daemon: it owns the allowlist and verifies the + // signature. Register the pending channel BEFORE reading the auth + // frame so the decision can never race the registration. + let (tx, mut rx) = mpsc::unbounded_channel::(); + pending.lock().unwrap().insert(corr, tx); + let consult = send_to_go(&Frame::new( + FRAME_AUTH_REQUEST, + 0, + [ + c_nonce.as_slice(), + s_nonce.as_slice(), + client_pub.as_slice(), + ] + .concat(), + )); + if !consult { + anyhow::bail!("daemon gone"); + } + let auth = read_frame(recv).await?; - if auth.t != FRAME_CLIENT_AUTH || auth.payload.len() != TAG_LEN { - write_frame(send, FRAME_DENIED, 0, b"bad auth frame").await?; + if auth.t != FRAME_CLIENT_AUTH || auth.payload.len() != SIGNATURE_LEN { + deny_and_finish(send, "bad auth frame").await; anyhow::bail!("malformed client auth"); } - let expect = hmac_tag(key, &[b"kit-client", &s_nonce, &c_nonce]); - if auth.payload.as_slice().ct_eq(&expect).unwrap_u8() != 1 { - write_frame(send, FRAME_DENIED, 0, b"bad tag").await?; - anyhow::bail!("pairing tag mismatch"); + if !send_to_go(&Frame::new( + FRAME_AUTH_PAYLOAD, + 0, + [c_nonce.as_slice(), auth.payload.as_slice()].concat(), + )) { + anyhow::bail!("daemon gone"); } - let s_tag = hmac_tag(key, &[b"kit-server", &c_nonce, &s_nonce]); - write_frame(send, FRAME_SERVER_OK, 0, &s_tag).await?; + let decision = tokio::time::timeout(Duration::from_secs(HANDSHAKE_TIMEOUT), rx.recv()).await; + match decision { + // Accept: payload is the 8-byte correlation key + a 0x01 verdict. + Ok(Some(f)) if f.payload.len() >= 9 && f.payload[8] == 0x01 => {} + Ok(Some(f)) => { + let reason = if f.payload.len() > 9 { + String::from_utf8_lossy(&f.payload[9..]).into_owned() + } else { + "not authorized".into() + }; + deny_and_finish(send, &reason).await; + anyhow::bail!("unauthorized client: {reason}"); + } + Ok(None) | Err(_) => { + anyhow::bail!("auth decision timeout"); + } + } // Tell the client which session id to use on this connection. It goes // after the verdict so the client's handshake loop never sees it. + write_frame(send, FRAME_SERVER_OK, 0, &[]).await?; write_frame(send, FRAME_SESSION_ASSIGN, 0, &session.to_be_bytes()).await?; Ok(()) } -async fn client_handshake( - send: &mut SendStream, - recv: &mut RecvStream, - key: &[u8; 32], -) -> anyhow::Result<()> { - let c_nonce = random_nonce(); - let mut hello = Vec::with_capacity(2 + NONCE_LEN); - hello.extend_from_slice(&PROTOCOL_VERSION.to_be_bytes()); - hello.extend_from_slice(&c_nonce); - write_frame(send, FRAME_CLIENT_HELLO, 0, &hello).await?; - - let reply = read_frame(recv).await?; - if reply.t != FRAME_SERVER_HELLO || reply.payload.len() < 2 + NONCE_LEN { - anyhow::bail!("malformed server hello"); - } - let s_ver = u16::from_be_bytes([reply.payload[0], reply.payload[1]]); - if s_ver != PROTOCOL_VERSION { - anyhow::bail!("daemon version mismatch: {s_ver}"); - } - let s_nonce = reply.payload[2..2 + NONCE_LEN].to_vec(); - - let tag = hmac_tag(key, &[b"kit-client", &s_nonce, &c_nonce]); - write_frame(send, FRAME_CLIENT_AUTH, 0, &tag).await?; - - let verdict = read_frame(recv).await?; - match verdict.t { - FRAME_SERVER_OK => { - if verdict.payload.len() != TAG_LEN { - anyhow::bail!("malformed server ok"); - } - let expect = hmac_tag(key, &[b"kit-server", &c_nonce, &s_nonce]); - if verdict.payload.as_slice().ct_eq(&expect).unwrap_u8() != 1 { - anyhow::bail!("daemon failed tag verification"); - } - Ok(()) - } - FRAME_DENIED => { - let reason = String::from_utf8_lossy(&verdict.payload); - anyhow::bail!("daemon rejected the pairing code: {reason}"); - } - other => anyhow::bail!("unexpected handshake frame {other:#04x}"), - } -} - // --------------------------------------------------------------------------- // Serve mode: one endpoint, many sessions // --------------------------------------------------------------------------- type Registry = Arc>>>; -async fn serve(seed_hex: &str) { - let seed = parse_seed(seed_hex); - let key = Arc::new(auth_key(&seed)); - let secret = secret_from_seed(&seed); +/// Consultation replies from the Go daemon, keyed by the handshake's client +/// nonce (8 random bytes — collision-free for practical purposes). The +/// stdin reader routes AUTH_DECISION/PAIR_DECISION frames here; handshake +/// tasks await on the channel. +type Pending = Arc>>>; + +fn send_to_go(f: &Frame) -> bool { + tokio::task::block_in_place(|| write_frame_sync(&mut io::stdout().lock(), f)).is_ok() +} + +async fn serve(flags: &Flags) { + let secret_bytes = parse_seed(&flags.get("secret-hex")); + let secret = secret_from_seed(&secret_bytes); let endpoint = Endpoint::builder(presets::N0) .secret_key(secret) @@ -373,17 +461,28 @@ async fn serve(seed_hex: &str) { let active = Arc::new(AtomicUsize::new(0)); let backoff: BackoffState = Arc::new(Mutex::new(Backoff::default())); let reject_budget = Arc::new(Semaphore::new(REJECT_BUDGET)); + let pending: Pending = Arc::new(Mutex::new(HashMap::new())); // Router: frames arriving on stdin are dispatched to the session named - // in the frame header; unknown ids are dropped. + // in the frame header; auth decisions are routed to the handshake that + // requested them (keyed by client nonce); unknown ids are dropped. { let registry = registry.clone(); + let pending = pending.clone(); tokio::spawn(async move { loop { let frame = tokio::task::block_in_place(|| read_frame_sync(&mut io::stdin().lock())); match frame { Ok(Some(f)) => { + if f.t == FRAME_AUTH_DECISION && f.payload.len() >= 9 { + let mut key = [0u8; 8]; + key.copy_from_slice(&f.payload[0..8]); + if let Some(tx) = pending.lock().unwrap().remove(&key) { + let _ = tx.send(f); + } + continue; + } let tx = registry.lock().unwrap().get(&f.session).cloned(); if let Some(tx) = tx { let _ = tx.send(f); @@ -406,7 +505,7 @@ async fn serve(seed_hex: &str) { }); } - accept_loop(endpoint, key, registry, active, backoff, reject_budget).await; + accept_loop(endpoint, registry, active, backoff, reject_budget, pending).await; } /// The serve accept loop: reserves a session slot per incoming connection @@ -415,11 +514,11 @@ async fn serve(seed_hex: &str) { #[allow(clippy::too_many_arguments)] async fn accept_loop( endpoint: Endpoint, - key: Arc<[u8; 32]>, registry: Registry, active: Arc, backoff: BackoffState, reject_budget: Arc, + pending: Pending, ) { let mut next_id: u32 = 1; while let Some(incoming) = endpoint.accept().await { @@ -442,10 +541,10 @@ async fn accept_loop( tokio::spawn(handle_connection( incoming, id, - key.clone(), registry.clone(), active.clone(), backoff.clone(), + pending.clone(), )); } } @@ -500,6 +599,19 @@ impl Drop for SlotGuard<'_> { } } +/// Removes the correlation entry from the pending map on every exit path, +/// so peers that connect, say hello, and vanish cannot grow the map. +struct PendingGuard<'a> { + pending: &'a Pending, + corr: &'a [u8; 8], +} + +impl Drop for PendingGuard<'_> { + fn drop(&mut self) { + self.pending.lock().unwrap().remove(self.corr); + } +} + /// Politely refuse a peer when the session cap is reached. Fully bounded: /// every wait uses the handshake timeout (an over-cap peer that never opens /// a stream, or stalls at any point, is reaped instead of pinning a @@ -523,10 +635,10 @@ async fn reject_session_full(incoming: Incoming, _permit: tokio::sync::OwnedSema async fn handle_connection( incoming: Incoming, id: u32, - key: Arc<[u8; 32]>, registry: Registry, active: Arc, backoff: BackoffState, + pending: Pending, ) { // The slot was reserved by the accept loop; drop releases it. let _slot = SlotGuard { active: &active }; @@ -576,7 +688,7 @@ async fn handle_connection( let handshake = tokio::time::timeout( Duration::from_secs(HANDSHAKE_TIMEOUT), - server_handshake(&mut send, &mut recv, &key, id), + server_handshake(&mut send, &mut recv, pending, id), ) .await; match handshake { @@ -662,12 +774,21 @@ async fn handle_connection( // Dial mode: one client connection // --------------------------------------------------------------------------- -async fn dial(seed_hex: &str, timeout_secs: u64) { - let seed = parse_seed(seed_hex); +async fn dial_pair(flags: &Flags) { + let seed = parse_seed(&secret_material(flags, "KIT_TUNNEL_PAIR_SEED")); + if seed.len() != 32 { + fail("pairing seed must be 32 bytes"); + } let key = auth_key(&seed); - // The daemon's endpoint id is the public half of the seed-derived key: - // knowledge of the code is what makes the endpoint findable. + // The bootstrap endpoint is derived from the one-time code: knowledge + // of the code is what makes it findable, exactly like protocol v2 — + // but this endpoint exists only for the pairing window. let server_id = secret_from_seed(&seed).public(); + let client_pub = parse_seed(&flags.get("client-pub-hex")); + if client_pub.len() != ED25519_PUB_LEN { + fail("client-pub-hex must be 64 hex chars"); + } + let timeout_secs = flags.timeout(); let endpoint = Endpoint::builder(presets::N0) .alpns(vec![ALPN.to_vec()]) @@ -685,7 +806,7 @@ async fn dial(seed_hex: &str, timeout_secs: u64) { { Ok(Ok(conn)) => conn, Ok(Err(e)) => fail(&format!("connect to daemon: {e}")), - Err(_) => fail("connect to daemon: timed out"), + Err(_) => fail("no daemon is live for this pairing code (wrong code, expired window, or network issue)"), }; let (mut send, mut recv) = match conn.open_bi().await { @@ -693,21 +814,159 @@ async fn dial(seed_hex: &str, timeout_secs: u64) { Err(e) => fail(&format!("open stream: {e}")), }; - let outcome = tokio::time::timeout( - Duration::from_secs(timeout_secs), - client_handshake(&mut send, &mut recv, &key), - ) - .await; - match outcome { - Ok(Ok(())) => {} - Ok(Err(e)) => { - status(&format!("DENIED reason={e}")); + // Prove code knowledge up front so a wrong code never reaches the + // human on the host side. + let c_nonce = random_nonce(); + let tag = hmac_tag(&key, &[PAIR_TAG_ROLE_CLIENT, &c_nonce]); + let mut hello = Vec::with_capacity(2 + NONCE_LEN + ED25519_PUB_LEN + TAG_LEN); + hello.extend_from_slice(&PROTOCOL_VERSION.to_be_bytes()); + hello.extend_from_slice(&c_nonce); + hello.extend_from_slice(&client_pub); + hello.extend_from_slice(&tag); + if write_frame(&mut send, FRAME_PAIR_CLIENT_HELLO, 0, &hello) + .await + .is_err() + { + fail("write pair hello"); + } + + let reply = + tokio::time::timeout(Duration::from_secs(timeout_secs), read_frame(&mut recv)).await; + match reply { + Ok(Ok(f)) + if f.t == FRAME_PAIR_SERVER_OK + && f.payload.len() == NONCE_LEN + TAG_LEN + ED25519_PUB_LEN => + { + let s_nonce = &f.payload[0..NONCE_LEN]; + let expect = hmac_tag(&key, &[PAIR_TAG_ROLE_SERVER, &c_nonce, s_nonce]); + if f.payload[NONCE_LEN..NONCE_LEN + TAG_LEN] + .ct_eq(&expect) + .unwrap_u8() + != 1 + { + fail("daemon failed tag verification"); + } + // The stable endpoint id the client stores for codeless + // reconnection. iroh's QUIC handshake authenticates the daemon + // against it, so dialing it later cannot be hijacked. + let host_id = hex::encode(&f.payload[NONCE_LEN + TAG_LEN..]); + status(&format!("PAIRED host_endpoint_id={host_id}")); + // Hold until the Go side closes the pipe (it saves the host + // entry first), then end. + let _ = tokio::task::block_in_place(|| read_frame_sync(&mut io::stdin().lock())); + } + Ok(Ok(f)) if f.t == FRAME_DENIED => { + let reason = String::from_utf8_lossy(&f.payload); + status(&format!("DENIED reason={reason}")); return; } - Err(_) => { - status("DENIED reason=handshake timeout"); + Ok(Ok(f)) => fail(&format!("unexpected pairing frame {:#04x}", f.t)), + Ok(Err(e)) => fail(&format!("pairing: {e:#}")), + Err(_) => fail("pairing timed out (was the request accepted on the host?)"), + } +} + +async fn dial_host(flags: &Flags) { + let server_bytes = parse_seed(&flags.get("endpoint-id")); + if server_bytes.len() != ED25519_PUB_LEN { + fail("endpoint-id must be 64 hex chars"); + } + let server_id = EndpointId::from_bytes(&server_bytes.try_into().expect("checked above")) + .unwrap_or_else(|e| fail(&format!("bad endpoint id: {e}"))); + let signing_seed = parse_seed(&secret_material(flags, "KIT_TUNNEL_CLIENT_SEED")); + if signing_seed.len() != 32 { + fail("client seed must be 32 bytes"); + } + let signing = SigningKey::from_bytes(&signing_seed.try_into().expect("checked above")); + let timeout_secs = flags.timeout(); + + // Transport identity is ephemeral; the application-level identity is + // the client signing key. The daemon authenticates the signature, and + // iroh authenticates the daemon against the endpoint id we dialed — + // the one pinned at pairing time. + let endpoint = Endpoint::builder(presets::N0) + .secret_key(SecretKey::generate()) + .alpns(vec![ALPN.to_vec()]) + .transport_config(transport_config()) + .bind() + .await + .unwrap_or_else(|e| fail(&format!("endpoint bind: {e}"))); + endpoint.online().await; + + let conn = match tokio::time::timeout( + Duration::from_secs(timeout_secs), + endpoint.connect(server_id, ALPN), + ) + .await + { + Ok(Ok(conn)) => conn, + Ok(Err(e)) => fail(&format!("connect to daemon: {e}")), + Err(_) => fail("could not reach the daemon (network or relay issue)"), + }; + + let (mut send, mut recv) = match conn.open_bi().await { + Ok(pair) => pair, + Err(e) => fail(&format!("open stream: {e}")), + }; + + let c_nonce = random_nonce(); + let mut hello = Vec::with_capacity(2 + NONCE_LEN + ED25519_PUB_LEN); + hello.extend_from_slice(&PROTOCOL_VERSION.to_be_bytes()); + hello.extend_from_slice(&c_nonce); + hello.extend_from_slice(signing.verifying_key().as_bytes()); + if write_frame(&mut send, FRAME_CLIENT_HELLO, 0, &hello) + .await + .is_err() + { + fail("write hello"); + } + + let reply = match tokio::time::timeout(Duration::from_secs(timeout_secs), read_frame(&mut recv)) + .await + { + Ok(Ok(f)) => f, + Ok(Err(e)) => fail(&format!("handshake: {e}")), + Err(_) => fail("handshake timed out"), + }; + if reply.t != FRAME_SERVER_HELLO || reply.payload.len() < 2 + NONCE_LEN { + fail("malformed server hello"); + } + let s_ver = u16::from_be_bytes([reply.payload[0], reply.payload[1]]); + if s_ver != PROTOCOL_VERSION { + fail(&format!("daemon version mismatch: {s_ver}")); + } + let s_nonce = &reply.payload[2..2 + NONCE_LEN]; + + let mut msg = Vec::with_capacity(SIGN_CONTEXT.len() + NONCE_LEN * 2); + msg.extend_from_slice(SIGN_CONTEXT); + msg.extend_from_slice(&c_nonce); + msg.extend_from_slice(s_nonce); + let sig: Signature = signing.sign(&msg); + if write_frame(&mut send, FRAME_CLIENT_AUTH, 0, sig.to_bytes().as_slice()) + .await + .is_err() + { + fail("write auth"); + } + + let verdict = match tokio::time::timeout( + Duration::from_secs(timeout_secs), + read_frame(&mut recv), + ) + .await + { + Ok(Ok(f)) => f, + Ok(Err(e)) => fail(&format!("handshake: {e}")), + Err(_) => fail("handshake timed out"), + }; + match verdict.t { + FRAME_SERVER_OK => {} + FRAME_DENIED => { + let reason = String::from_utf8_lossy(&verdict.payload); + status(&format!("DENIED reason={reason}")); return; } + other => fail(&format!("unexpected handshake frame {other:#04x}")), } // The server assigns our session id right after the handshake; client @@ -727,6 +986,13 @@ async fn dial(seed_hex: &str, timeout_secs: u64) { ]); status(&format!("VERIFIED id={session}")); + relay_client_session(send, recv, session).await; + status("CLOSED"); +} + +/// Bidirectional session relay after a verified client handshake (the +/// session-assignment frame has already been consumed). +async fn relay_client_session(mut send: SendStream, mut recv: RecvStream, session: u32) { // Local stdin -> connection (rewritten to the assigned session id). let up = tokio::spawn(async move { loop { @@ -773,56 +1039,267 @@ async fn dial(seed_hex: &str, timeout_secs: u64) { } } up.abort(); +} + +// --------------------------------------------------------------------------- +// Pairing window: one bootstrap endpoint, at most one pairing +// --------------------------------------------------------------------------- + +/// The host side of the pairing window. Binds the bootstrap endpoint +/// derived from the one-time code, verifies the caller knows the code +/// (so a wrong guess never reaches the human), asks Go to prompt the +/// user, and — on accept — hands the client the daemon's stable endpoint +/// id. The Go side enforces the window timeout; every wait here is also +/// bounded so a stalled peer cannot pin the task. +async fn serve_pair(flags: &Flags) { + let seed = parse_seed(&flags.get("pair-seed-hex")); + let key = Arc::new(auth_key(&seed)); + let secret = secret_from_seed(&seed); + + let endpoint = Endpoint::builder(presets::N0) + .secret_key(secret) + .alpns(vec![ALPN.to_vec()]) + .transport_config(transport_config()) + .bind() + .await + .unwrap_or_else(|e| fail(&format!("endpoint bind: {e}"))); + endpoint.online().await; + + status(&format!("READY_PAIR node_id={}", endpoint.id())); + + let pending: Pending = Arc::new(Mutex::new(HashMap::new())); + { + let pending = pending.clone(); + tokio::spawn(async move { + loop { + let frame = + tokio::task::block_in_place(|| read_frame_sync(&mut io::stdin().lock())); + match frame { + Ok(Some(f)) => { + if f.t == FRAME_PAIR_DECISION && f.payload.len() >= 9 { + let mut key = [0u8; 8]; + key.copy_from_slice(&f.payload[0..8]); + if let Some(tx) = pending.lock().unwrap().remove(&key) { + let _ = tx.send(f); + } + continue; + } + } + Ok(None) => break, // Go closed the window + Err(e) => { + eprintln!("STATUS ERROR msg=local stdin: {e}"); + break; + } + } + } + }); + } + + let Some(incoming) = endpoint.accept().await else { + return; + }; + handle_pair_connection(incoming, key, pending).await; + // Linger briefly so the confirmation frame is delivered and read + // before the process exit tears the QUIC connection down. + tokio::time::sleep(Duration::from_millis(1500)).await; status("CLOSED"); } +async fn handle_pair_connection(incoming: Incoming, key: Arc<[u8; 32]>, pending: Pending) { + let backoff: BackoffState = Arc::new(Mutex::new(Backoff::default())); + let delay = { backoff.lock().unwrap().delay() }; + if delay > Duration::ZERO { + tokio::time::sleep(delay).await; + } + + let conn = match incoming.accept() { + Ok(accepting) => match accepting.await { + Ok(conn) => conn, + Err(e) => { + status(&format!("ERROR msg=connect failed: {e}")); + return; + } + }, + Err(e) => { + status(&format!("ERROR msg=incoming rejected: {e}")); + return; + } + }; + + let opened = + tokio::time::timeout(Duration::from_secs(HANDSHAKE_TIMEOUT), conn.accept_bi()).await; + let (mut send, mut recv) = match opened { + Ok(Ok(pair)) => pair, + Ok(Err(e)) => { + status(&format!("ERROR msg=open stream: {e}")); + return; + } + Err(_) => { + status("DENIED reason=stream open timeout"); + return; + } + }; + + let hello = match tokio::time::timeout( + Duration::from_secs(HANDSHAKE_TIMEOUT), + read_frame(&mut recv), + ) + .await + { + Ok(Ok(f)) => f, + Ok(Err(e)) => { + status(&format!("DENIED reason=read hello: {e}")); + return; + } + Err(_) => { + status("DENIED reason=pairing timeout"); + return; + } + }; + // ver(u16) | c_nonce(32) | client_pub(32) | tag(32) + if hello.t != FRAME_PAIR_CLIENT_HELLO + || hello.payload.len() != 2 + NONCE_LEN + ED25519_PUB_LEN + TAG_LEN + { + backoff.lock().unwrap().record_failure(); + status("DENIED reason=malformed pair hello"); + return; + } + let c_ver = u16::from_be_bytes([hello.payload[0], hello.payload[1]]); + if c_ver != PROTOCOL_VERSION { + backoff.lock().unwrap().record_failure(); + status(&format!("DENIED reason=version {c_ver}")); + return; + } + let c_nonce = hello.payload[2..2 + NONCE_LEN].to_vec(); + let corr: [u8; 8] = c_nonce[0..8].try_into().expect("nonce is 32 bytes"); + let client_pub = hello.payload[2 + NONCE_LEN..2 + NONCE_LEN + ED25519_PUB_LEN].to_vec(); + let tag = &hello.payload[2 + NONCE_LEN + ED25519_PUB_LEN..]; + let expect = hmac_tag(&key, &[PAIR_TAG_ROLE_CLIENT, &c_nonce]); + if tag.ct_eq(&expect).unwrap_u8() != 1 { + backoff.lock().unwrap().record_failure(); + status("DENIED reason=bad pairing tag"); + return; + } + + // The peer knows the code. Ask the human. + let fp = fingerprint(&client_pub); + status(&format!("PAIR_REQUEST fp={fp}")); + let (tx, mut rx) = mpsc::unbounded_channel::(); + pending.lock().unwrap().insert(corr, tx); + let _guard = PendingGuard { + pending: &pending, + corr: &corr, + }; + if !send_to_go(&Frame::new( + FRAME_PAIR_REQUEST, + 0, + [c_nonce.as_slice(), client_pub.as_slice()].concat(), + )) { + return; + } + + // The Go side prompts for up to its own deadline; allow margin. + let decision = tokio::time::timeout(Duration::from_secs(120), rx.recv()).await; + match decision { + // Accept: c_nonce | 0x01 | host_endpoint_id(32) + Ok(Some(f)) if f.payload.len() == 9 + ED25519_PUB_LEN && f.payload[8] == 0x01 => { + let host_id = &f.payload[9..]; + backoff.lock().unwrap().record_success(); + let s_nonce = random_nonce(); + let tag2 = hmac_tag(&key, &[PAIR_TAG_ROLE_SERVER, &c_nonce, &s_nonce]); + let mut ok = Vec::with_capacity(NONCE_LEN + TAG_LEN + ED25519_PUB_LEN); + ok.extend_from_slice(&s_nonce); + ok.extend_from_slice(&tag2); + ok.extend_from_slice(host_id); + if write_frame(&mut send, FRAME_PAIR_SERVER_OK, 0, &ok) + .await + .is_err() + { + return; + } + // Deliver the confirmation as finished data: dropping the send + // side unfinished would reset the stream and the client would + // lose the frame. Hold while the peer drains it. + let _ = send.finish(); + tokio::time::sleep(Duration::from_secs(2)).await; + status("PAIRED"); + } + Ok(Some(f)) => { + let reason = if f.payload.len() > 9 { + String::from_utf8_lossy(&f.payload[9..]).into_owned() + } else { + "rejected on the host".into() + }; + backoff.lock().unwrap().record_failure(); + deny_and_finish(&mut send, &reason).await; + status(&format!("PAIR_DENIED reason={reason}")); + } + Ok(None) | Err(_) => { + backoff.lock().unwrap().record_failure(); + deny_and_finish(&mut send, "pairing window closed").await; + status("PAIR_DENIED reason=window closed"); + } + } +} + // --------------------------------------------------------------------------- // Args / entry // --------------------------------------------------------------------------- -fn main() { - // Tracing is opt-in via RUST_LOG; keep stderr clean for the Go side, - // which only parses "STATUS ..." lines and buffers the rest. - if std::env::var_os("RUST_LOG").is_some() { - let _ = tracing_subscriber::fmt::try_init(); +/// Flat --key value flag store parsed from the argv tail. +struct Flags { + map: HashMap, +} + +impl Flags { + fn get(&self, name: &str) -> String { + self.map.get(name).cloned().unwrap_or_default() } - let args: Vec = std::env::args().collect(); - let mut mode = String::new(); - let mut seed_hex = String::new(); - let mut timeout: u64 = 30; + fn timeout(&self) -> u64 { + self.get("timeout").parse().unwrap_or(30) + } +} - let mut i = 1; +fn parse_flags(args: &[String]) -> Flags { + let mut map = HashMap::new(); + let mut i = 0; while i < args.len() { - match args[i].as_str() { - "serve" | "dial" => mode = args[i].clone(), - "--seed-hex" => { - i += 1; - seed_hex = args.get(i).cloned().unwrap_or_default(); - } - "--timeout" => { - i += 1; - timeout = args.get(i).and_then(|v| v.parse().ok()).unwrap_or(30); + if let Some(name) = args[i].strip_prefix("--") { + if i + 1 < args.len() { + map.insert(name.to_string(), args[i + 1].clone()); + i += 2; + continue; } - other => fail(&format!("unknown argument: {other}")), } i += 1; } + Flags { map } +} + +fn main() { + // Tracing is opt-in via RUST_LOG; keep stderr clean for the Go side, + // which only parses "STATUS ..." lines and buffers the rest. + if std::env::var_os("RUST_LOG").is_some() { + let _ = tracing_subscriber::fmt::try_init(); + } - let runtime = tokio::runtime::Builder::new_multi_thread() + let args: Vec = std::env::args().skip(1).collect(); + let Some(mode) = args.first().cloned() else { + fail("usage: kit-tunnel [--flags]"); + }; + let flags = parse_flags(&args[1..]); + let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() - .unwrap_or_else(|e| fail(&format!("tokio runtime: {e}"))); - - runtime.block_on(async move { - match mode.as_str() { - "serve" => serve(&seed_hex).await, - "dial" => dial(&seed_hex, timeout).await, - other => fail(&format!( - "usage: kit-tunnel serve|dial --seed-hex (got: {other:?})" - )), - } - }); + .expect("tokio runtime"); + match mode.as_str() { + "serve" => rt.block_on(serve(&flags)), + "serve-pair" => rt.block_on(serve_pair(&flags)), + "dial-pair" => rt.block_on(dial_pair(&flags)), + "dial-host" => rt.block_on(dial_host(&flags)), + other => fail(&format!("unknown mode {other}")), + } } #[cfg(test)] @@ -832,13 +1309,14 @@ mod tests { /// Regression test: peers that complete the QUIC handshake but stall /// before authenticating hold session slots only for the pre-auth /// timeout — and until then, the session cap must keep rejecting new - /// peers instead of letting the slot count grow unbounded. + /// peers instead of letting the slot count grow unbounded. Pairs that + /// get as far as the AUTH consultation hang waiting for a decision + /// that the test never sends, which is exactly the stall being tested. #[tokio::test(flavor = "multi_thread")] async fn stalled_pre_auth_peers_are_capped_and_expire() { let secret = SecretKey::generate(); - let key = Arc::new(auth_key(b"test-code-0001")); let endpoint = Endpoint::builder(presets::N0) - .secret_key(secret.clone()) + .secret_key(secret) .alpns(vec![ALPN.to_vec()]) .transport_config(transport_config()) .bind() @@ -850,20 +1328,20 @@ mod tests { let registry: Registry = Arc::new(Mutex::new(HashMap::new())); let active = Arc::new(AtomicUsize::new(0)); let backoff: BackoffState = Arc::new(Mutex::new(Backoff::default())); + let pending: Pending = Arc::new(Mutex::new(HashMap::new())); + let accept_task = tokio::spawn(accept_loop( endpoint, - key.clone(), registry.clone(), active.clone(), backoff.clone(), Arc::new(Semaphore::new(REJECT_BUDGET)), + pending.clone(), )); - // One client endpoint opens MAX_SESSIONS connections that stall - // before authenticating — half without ever opening a stream (the - // accept_bi path) and half with a bare CLIENT_HELLO (the handshake - // read path). Both variants must hold a slot only until the - // pre-auth timeout. + // One client endpoint opens MAX_SESSIONS connections that stall: + // half never open a stream (the accept_bi path), half send a + // CLIENT_HELLO and then go quiet (the auth-consultation path). let client = Endpoint::builder(presets::N0) .alpns(vec![ALPN.to_vec()]) .transport_config(transport_config()) @@ -875,18 +1353,17 @@ mod tests { for i in 0..MAX_SESSIONS { let conn = client.connect(server_id, ALPN).await.expect("connect"); if i % 2 == 0 { - // Stall before any stream: exercises the accept_bi timeout. held_conns.push(conn); continue; } let (mut send, recv) = conn.open_bi().await.expect("open bi"); - let mut hello = Vec::with_capacity(2 + NONCE_LEN); + let mut hello = Vec::with_capacity(2 + NONCE_LEN + ED25519_PUB_LEN); hello.extend_from_slice(&PROTOCOL_VERSION.to_be_bytes()); hello.extend_from_slice(&random_nonce()); + hello.extend_from_slice(&[0u8; ED25519_PUB_LEN]); write_frame(&mut send, FRAME_CLIENT_HELLO, 0, &hello) .await .expect("client hello"); - // Hold the streams open until long after the pre-auth timeout. held_streams.push((send, recv)); } @@ -906,9 +1383,10 @@ mod tests { // The next peer must be refused, not admitted past the cap. let conn = client.connect(server_id, ALPN).await.expect("connect 9th"); let (mut send, mut recv) = conn.open_bi().await.expect("open bi 9th"); - let mut hello = Vec::with_capacity(2 + NONCE_LEN); + let mut hello = Vec::with_capacity(2 + NONCE_LEN + ED25519_PUB_LEN); hello.extend_from_slice(&PROTOCOL_VERSION.to_be_bytes()); hello.extend_from_slice(&random_nonce()); + hello.extend_from_slice(&[0u8; ED25519_PUB_LEN]); write_frame(&mut send, FRAME_CLIENT_HELLO, 0, &hello) .await .expect("client hello 9th"); @@ -927,37 +1405,17 @@ mod tests { // Over-cap peers that NEVER open a stream must also be reaped: the // rejection task is bounded, so their connections close within the - // pre-auth timeout instead of leaking forever. Connect them now and - // assert the reaps after the shared expiry window below. + // pre-auth timeout instead of leaking forever. let mut over_cap = Vec::new(); - let mut refused_at_connect = 0; let over_cap_count = REJECT_BUDGET + 8; for _ in 0..over_cap_count { match client.connect(server_id, ALPN).await { Ok(conn) => over_cap.push(conn), // Beyond-budget peers are dropped as unaccepted Incomings, // which the client observes as a connect refusal. - Err(_) => refused_at_connect += 1, - } - } - - // Budgeted peers that connect silently hold until the pre-auth - // timeout; anything beyond the budget must already be gone. - tokio::time::sleep(Duration::from_secs(3)).await; - let mut closed_now = 0; - for c in &over_cap { - if matches!( - tokio::time::timeout(Duration::ZERO, c.closed()).await, - Ok(_) - ) { - closed_now += 1; + Err(_) => {} } } - assert!( - refused_at_connect + closed_now >= over_cap_count - REJECT_BUDGET, - "expected at least {} immediate refusals, got {refused_at_connect} connect-refused + {closed_now} instant-closed", - over_cap_count - REJECT_BUDGET - ); // After the pre-auth timeout the stalled slots expire and the // cap opens again. @@ -976,5 +1434,7 @@ mod tests { } accept_task.abort(); + drop(held_conns); + drop(held_streams); } } diff --git a/internal/daemon/client.go b/internal/daemon/client.go index 7948df3f..44c2a7c1 100644 --- a/internal/daemon/client.go +++ b/internal/daemon/client.go @@ -1,6 +1,7 @@ package daemon import ( + "bufio" "context" "fmt" "os" @@ -27,13 +28,27 @@ const terminalResetSeq = "\x1b[?1049l\x1b[?25h" + "\x1b[?2004l" + "\x1b['") case strings.Contains(last, "No addressing information available"): - return fmt.Errorf("no daemon is live for this pairing code (it may have expired or already been used)") + return fmt.Errorf("could not resolve the daemon's endpoint (is 'kit daemon' running on the host?)") case strings.Contains(last, "timed out"): return fmt.Errorf("could not reach the daemon (network or relay issue)") } return fmt.Errorf("daemon: %w", err) } + _ = TouchHost(name) stdinFD := os.Stdin.Fd() stdoutFD := os.Stdout.Fd() diff --git a/internal/daemon/identity.go b/internal/daemon/identity.go new file mode 100644 index 00000000..86923566 --- /dev/null +++ b/internal/daemon/identity.go @@ -0,0 +1,123 @@ +package daemon + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "path/filepath" +) + +// ed25519PubLen is the size of an ed25519 public key (and of an iroh +// endpoint id: the endpoint id IS the endpoint's ed25519 public key). +const ed25519PubLen = 32 + +// mustHexDecode decodes a hex string that the store already validated. +func mustHexDecode(s string) []byte { + b, err := hex.DecodeString(s) + if err != nil { + return nil + } + return b +} + +// Stable identities for the pairing model. +// +// The daemon owns a long-lived ed25519 keypair whose seed is also the iroh +// endpoint secret: the endpoint id clients store after pairing IS this +// public key, and iroh's QUIC handshake proves the peer holds it. The +// client owns its own ed25519 keypair used to sign reconnect handshakes; +// the host stores only the public key (an allowlist entry), so revoking a +// client is deleting a line. +// +// Key files hold the 32-byte seed as hex with 0600 permissions. Losing the +// daemon identity changes the endpoint id, which invalidates every stored +// client entry — both sides simply pair again. + +// IdentityPaths resolves the key file locations under ~/.config/kit. +type IdentityPaths struct { + DaemonSeed string // daemon endpoint seed (~/.config/kit/daemon/identity.key) + ClientSeed string // client signing seed (~/.config/kit/remote/identity.key) +} + +func identityPaths() (IdentityPaths, error) { + base, err := os.UserConfigDir() + if err != nil { + return IdentityPaths{}, fmt.Errorf("daemon: config dir: %w", err) + } + return IdentityPaths{ + DaemonSeed: filepath.Join(base, "kit", "daemon", "identity.key"), + ClientSeed: filepath.Join(base, "kit", "remote", "identity.key"), + }, nil +} + +// loadOrCreateSeed returns the 32-byte seed stored at path, generating and +// persisting a fresh one (0600) only when the file does not exist. An +// existing but invalid file is an error, never silently regenerated: for +// the daemon seed that would rotate the endpoint id and orphan every +// paired client. +func loadOrCreateSeed(path string) ([]byte, error) { + if b, err := os.ReadFile(path); err == nil { + if len(b) >= 64 { + seed, derr := hex.DecodeString(string(b)[:64]) + if derr == nil && len(seed) == 32 { + return seed, nil + } + } + return nil, fmt.Errorf("daemon: corrupt identity file %s — fix or remove it (removing the daemon identity rotates the endpoint id and un-pairs every client)", path) + } + seed := make([]byte, 32) + if _, err := rand.Read(seed); err != nil { + return nil, fmt.Errorf("daemon: generate identity: %w", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, fmt.Errorf("daemon: identity dir: %w", err) + } + if err := os.WriteFile(path, []byte(hex.EncodeToString(seed)+"\n"), 0o600); err != nil { + return nil, fmt.Errorf("daemon: write identity: %w", err) + } + return seed, nil +} + +// LoadDaemonIdentity returns the daemon's endpoint seed, creating it on +// first use. The seed doubles as the iroh endpoint secret. +func LoadDaemonIdentity() ([]byte, error) { + paths, err := identityPaths() + if err != nil { + return nil, err + } + return loadOrCreateSeed(paths.DaemonSeed) +} + +// LoadClientIdentity returns the client's signing seed, creating it on +// first use. +func LoadClientIdentity() ([]byte, error) { + paths, err := identityPaths() + if err != nil { + return nil, err + } + return loadOrCreateSeed(paths.ClientSeed) +} + +// ClientKeyPair is the client's signing identity. +type ClientKeyPair struct { + Seed []byte + Priv ed25519.PrivateKey + Pub ed25519.PublicKey + PubHex string // 64 hex chars +} + +// NewClientKeyPair derives the full ed25519 keypair from the stored seed. +func NewClientKeyPair(seed []byte) ClientKeyPair { + priv := ed25519.NewKeyFromSeed(seed) + pub := priv.Public().(ed25519.PublicKey) + return ClientKeyPair{Seed: seed, Priv: priv, Pub: pub, PubHex: hex.EncodeToString(pub)} +} + +// Fingerprint is the short public identity used in prompts and stores: +// the first 16 hex chars of SHA-256 over the raw key bytes. +func Fingerprint(raw []byte) string { + sum := sha256Sum(raw) + return hex.EncodeToString(sum[:])[:16] +} diff --git a/internal/daemon/pair.go b/internal/daemon/pair.go new file mode 100644 index 00000000..b23882f4 --- /dev/null +++ b/internal/daemon/pair.go @@ -0,0 +1,213 @@ +package daemon + +import ( + "bufio" + "context" + "crypto/ed25519" + "encoding/hex" + "fmt" + "os" + "strings" + "time" + + "github.com/charmbracelet/log" + "github.com/charmbracelet/x/term" +) + +// PairWindowOptions controls `kit daemon pair`. Zero values are valid. +type PairWindowOptions struct { + // Code forces a specific pairing code instead of a random one. + // Intended for tests. + Code string + // Prompt overrides the interactive accept/reject decision (tests). + // When nil, the decision is made on the terminal; a non-TTY stdin + // always denies. Implementations should return false when ctx ends. + Prompt func(ctx context.Context, fp string) bool + // Window bounds the pairing window. Zero means the default (10 min). + Window time.Duration +} + +// RunPairWindow opens a one-time pairing window: it derives an ephemeral +// bootstrap endpoint from a fresh code, shows the code, and — when a +// client presents it — asks the user to accept or reject on this +// terminal. On accept the client's public key joins the allowlist and the +// client learns this daemon's endpoint id; the code is then burned. +// +// The window is independent of the main daemon process: pairing writes +// the allowlist to disk, and `kit daemon` (running or not) picks it up on +// the next connection attempt. +func RunPairWindow(ctx context.Context, opts PairWindowOptions) error { + if _, err := FindTunnelBinary(); err != nil { + return err + } + + window := opts.Window + if window <= 0 { + window = pairWindowTime + } + + code := opts.Code + if code == "" { + var err error + code, err = GenerateCode() + if err != nil { + return err + } + } else if _, err := NormalizeCode(code); err != nil { + return err + } + seed, err := SeedFromCode(code) + if err != nil { + return err + } + + daemonSeed, err := LoadDaemonIdentity() + if err != nil { + return err + } + // The endpoint id the client will store is the daemon identity's + // ed25519 public key: iroh endpoint ids ARE ed25519 public keys, and + // the QUIC handshake proves the peer holds the matching secret. + priv := ed25519.NewKeyFromSeed(daemonSeed) + hostEndpointID := hex.EncodeToString(priv.Public().(ed25519.PublicKey)) + + pctx, cancel := context.WithTimeout(ctx, window) + defer cancel() + + fmt.Println() + fmt.Println(" Pair a client with this host") + fmt.Println() + fmt.Printf(" Pairing code: %s\n", FormatCode(code)) + fmt.Printf(" On the client run: kit remote --pair %s\n", code) + fmt.Printf(" This window closes in %s or after one successful pairing.\n", window) + fmt.Println() + + tun, err := StartTunnel(pctx, TunnelOptions{ + Mode: "serve-pair", + Args: []string{"--timeout", "30"}, + Env: []string{"KIT_TUNNEL_PAIR_SEED=" + fmt.Sprintf("%x", seed)}, + }) + if err != nil { + return err + } + defer tun.Close() + + for { + select { + case <-pctx.Done(): + fmt.Println(" Pairing window closed.") + return nil + default: + } + frame, err := ReadFrame(tun.Stdout()) + if err != nil { + // Tunnel ended: window expired (Go ctx killed it) or crash. + if pctx.Err() != nil { + fmt.Println(" Pairing window closed.") + return nil + } + return fmt.Errorf("daemon: pairing tunnel ended: %w", err) + } + switch frame.Type { + case FramePairRequest: + // Payload: c_nonce(32) | client_pub(32). The correlation key + // echoed in the decision is the first 8 bytes of c_nonce. + if len(frame.Payload) != 32+32 { + continue + } + clientPub := frame.Payload[32:] + corr := frame.Payload[0:8] + fp := Fingerprint(clientPub) + + fmt.Printf(" Pairing request from client %s\n", fingerprintShort(fp)) + allowed := opts.prompt(pctx, fp) + if !allowed { + fmt.Println(" Rejected.") + writePairDecision(tun, corr, false, "", hostEndpointID) + continue + } + if _, err := AuthorizeClient(hex.EncodeToString(clientPub)); err != nil { + log.Error("daemon: authorize failed", "error", err) + writePairDecision(tun, corr, false, "host error", hostEndpointID) + continue + } + writePairDecision(tun, corr, true, "", hostEndpointID) + fmt.Println(" Client paired. It can now connect with: kit remote --host ") + fmt.Println() + // One successful pairing burns the code; end the window. A + // short grace lets the client drain the confirmation frame + // before the tunnel teardown closes the connection. + _, _ = tun.WaitAnyStatus(pctx, 10*time.Second, "PAIRED", "PAIR_DENIED", "CLOSED") + time.Sleep(2 * time.Second) + fmt.Fprintf(os.Stderr, "pair window statuses: %s\n", tun.LastStatuses()) + return nil + } + } +} + +// prompt asks on the terminal. Non-interactive contexts always deny: +// pairing is an inherently human decision, and an unattended daemon must +// not approve anything. Returns false when ctx ends (window expired) +// while waiting for the operator. +func (opts PairWindowOptions) prompt(ctx context.Context, fp string) bool { + if opts.Prompt != nil { + return opts.Prompt(ctx, fp) + } + if !term.IsTerminal(os.Stdin.Fd()) { + log.Warn("daemon: pairing request denied — no terminal to confirm on; run 'kit daemon pair' interactively", "fp", fp) + return false + } + fmt.Printf(" Accept? [y/N]: ") + line := make(chan string, 1) + go func() { + reader := bufio.NewReader(os.Stdin) + text, _ := reader.ReadString('\n') + line <- strings.TrimSpace(text) + }() + select { + case answer := <-line: + return promptDecision(ctx, answer) + case <-ctx.Done(): + fmt.Println("\n (window expired) rejected.") + return false + } +} + +// promptDecision resolves a typed answer against the window context. The +// context check runs after the answer is received: Go's select may pick a +// queued "yes" even when the deadline has already fired, and an answer +// that lands at (or after) expiry is a rejection. +func promptDecision(ctx context.Context, answer string) bool { + if ctx.Err() != nil { + return false + } + switch strings.ToLower(answer) { + case "y", "yes": + return true + default: + return false + } +} + +// writeDecision answers the sidecar's pairing consultation. Payload: +// correlation key (8) | verdict (1) | host endpoint id (32, accept only) +// | optional reason (deny only). +func writePairDecision(tun *Tunnel, corr []byte, allow bool, reason, hostEndpointID string) { + out := []byte{} + out = append(out, corr...) + if allow { + out = append(out, 1) + id, err := hex.DecodeString(hostEndpointID) + if err != nil || len(id) != ed25519PubLen { + out = out[:8] + out = append(out, 0) + out = append(out, "host identity error"...) + } else { + out = append(out, id...) + } + } else { + out = append(out, 0) + out = append(out, reason...) + } + _ = WriteFrame(tun.Stdin(), FramePairDecision, 0, out) +} diff --git a/internal/daemon/pair_test.go b/internal/daemon/pair_test.go new file mode 100644 index 00000000..76627269 --- /dev/null +++ b/internal/daemon/pair_test.go @@ -0,0 +1,30 @@ +package daemon + +import ( + "context" + "testing" +) + +// Regression test: an approval answer that lands after the pairing window +// expired must be rejected, even when it was already queued in the input +// channel when the deadline fired. +func TestPromptDecisionAfterWindowExpiry(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if !promptDecision(ctx, "y") { + t.Fatal("live window + y should approve") + } + if !promptDecision(ctx, "yes") { + t.Fatal("live window + yes should approve") + } + cancel() + if promptDecision(ctx, "y") { + t.Fatal("expired window must deny a queued y") + } + if promptDecision(context.Background(), "n") { + t.Fatal("n should deny") + } + if promptDecision(context.Background(), "") { + t.Fatal("empty answer should deny") + } +} diff --git a/internal/daemon/pairing.go b/internal/daemon/pairing.go index 829b9859..1b6bb395 100644 --- a/internal/daemon/pairing.go +++ b/internal/daemon/pairing.go @@ -1,7 +1,8 @@ // Package daemon implements kit's remote-session transport: a daemon mode -// (`kit daemon`) that accepts iroh connections and hosts kit sessions in a -// PTY, and a client mode (`kit --remote CODE`) that attaches a local -// terminal to that remote session. +// (`kit daemon`) that hosts kit sessions for paired clients, and a client +// mode (`kit remote --host `) that attaches a local terminal to that +// remote session. New clients pair via `kit daemon pair` + `kit remote +// --pair `. // // The design keeps all iroh logic inside the kit-tunnel sidecar (Rust, see // contrib/kit-tunnel). The Go side owns policy only: pairing codes, frame @@ -15,6 +16,7 @@ import ( "crypto/subtle" "fmt" "strings" + "time" ) // CodeAlphabet excludes 0/O/1/I to keep codes readable and unambiguous when @@ -30,9 +32,15 @@ const ( // HKDF domain separation. These must match contrib/kit-tunnel/src/main.rs. var ( - hkdfSalt = []byte("kit-remote-v1") - hkdfInfo = []byte("kit-remote tunnel seed") - hkdfAuthMsg = []byte("kit-remote auth") + // hkdfSalt/hkdfInfo/hkdfAuthMsg must match + // contrib/kit-tunnel/src/main.rs (the Rust side is authoritative for + // the pairing-tag roles "kit-pair-client"/"kit-pair-server" — the Go + // side never recomputes them). + hkdfSalt = []byte("kit-remote-v1") + hkdfInfo = []byte("kit-remote tunnel seed") + hkdfAuthMsg = []byte("kit-remote auth") + signContext = []byte("kit-remote-v3-auth") + pairWindowTime = 10 * time.Minute ) // GenerateCode returns a fresh random pairing code of CodeLength characters. diff --git a/internal/daemon/protocol.go b/internal/daemon/protocol.go index 87c1d4a9..53c791d7 100644 --- a/internal/daemon/protocol.go +++ b/internal/daemon/protocol.go @@ -24,6 +24,23 @@ const ( // Tunnel -> daemon session lifecycle (serve side only). FrameSessionOpen FrameType = 0x16 FrameSessionClosed FrameType = 0x17 + + // Pairing-model control frames on the tunnel stdio (v3, protocol v3 in + // the sidecar). They never cross the iroh connection; they are the + // daemon<->sidecar consultation channel that keeps all policy in Go. + // + // Reconnect authentication (main endpoint): + // AUTH_REQUEST sidecar->daemon {c_nonce, s_nonce, client_pub} (8+8+32) + // AUTH_PAYLOAD sidecar->daemon {signature} (64) + // AUTH_DECISION daemon->sidecar {0|1} + // Pairing (bootstrap endpoint): + // PAIR_REQUEST sidecar->daemon {c_nonce, client_pub} (8+32) + // PAIR_DECISION daemon->sidecar {0|1, host_endpoint_id?} (1 or 33) + FrameAuthRequest FrameType = 0x30 + FrameAuthPayload FrameType = 0x31 + FrameAuthDecision FrameType = 0x32 + FramePairRequest FrameType = 0x40 + FramePairDecision FrameType = 0x41 ) const frameHeaderSize = 7 // type byte + u32 session + u16 big-endian length diff --git a/internal/daemon/runtime.go b/internal/daemon/runtime.go index 558ace89..8fe773dc 100644 --- a/internal/daemon/runtime.go +++ b/internal/daemon/runtime.go @@ -26,7 +26,6 @@ const ( // daemonState is the snapshot `kit daemon status` reports. type daemonState struct { PID int `json:"pid"` - Code string `json:"code"` Endpoint string `json:"endpoint,omitempty"` StartedAt time.Time `json:"started_at"` SessionsActive int `json:"sessions_active"` @@ -88,12 +87,11 @@ type daemonRuntime struct { state daemonState } -func newDaemonRuntime(lock *daemonLock, code string) *daemonRuntime { +func newDaemonRuntime(lock *daemonLock) *daemonRuntime { return &daemonRuntime{ lock: lock, state: daemonState{ PID: os.Getpid(), - Code: FormatCode(code), StartedAt: time.Now(), }, } diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 794ca911..ecd35f7c 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -2,6 +2,8 @@ package daemon import ( "context" + "crypto/ed25519" + "encoding/hex" "fmt" "os" "os/exec" @@ -14,37 +16,22 @@ import ( "github.com/creack/pty" ) -// ServeOptions controls the daemon loop. Zero values are valid. -type ServeOptions struct { - // Code forces a specific pairing code instead of a random one. - // Intended for tests. - Code string -} - -// Serve runs the daemon until ctx is cancelled: derive the endpoint from a -// pairing code, then host remote sessions over it. The code stays valid for -// the daemon's lifetime; each verified client gets its own session (its own -// `kit --pick-dir` child in its own PTY) and sessions end independently. -func Serve(ctx context.Context, opts ServeOptions) error { +// Serve runs the daemon until ctx is cancelled: bind the stable endpoint +// derived from the daemon identity, then host remote sessions for paired +// clients. Each client authenticates by signing the handshake with its +// pairing key; the signature is checked against the allowlist written by +// `kit daemon pair`. First-time clients pair through `kit daemon pair`, +// which runs its own short-lived bootstrap endpoint. +func Serve(ctx context.Context) error { if _, err := FindTunnelBinary(); err != nil { return err // fail fast with a clear message instead of per attempt } - code := opts.Code - if code == "" { - var err error - code, err = GenerateCode() - if err != nil { - return err - } - } else if _, err := NormalizeCode(code); err != nil { - return err - } - seed, err := SeedFromCode(code) + seed, err := LoadDaemonIdentity() if err != nil { return err } - seedHex := fmt.Sprintf("%x", seed) + secretHex := hex.EncodeToString(seed) // Single instance per user: the lock is held for the daemon's lifetime // and released automatically on crash, so there is no stale-lock state. @@ -54,18 +41,24 @@ func Serve(ctx context.Context, opts ServeOptions) error { } defer lock.release() defer clearState() - rt := newDaemonRuntime(lock, code) + rt := newDaemonRuntime(lock) - printBanner(code) + fmt.Println() + fmt.Println(" kit daemon") + fmt.Println() // If the tunnel process dies unexpectedly (crash, kill), restart it - // with the same seed: the endpoint id is derived from the code, so the - // same code finds us again. Live sessions do not survive the restart. + // with the same identity: the endpoint id is stable, so paired clients + // find us again. Live sessions do not survive the restart. for { if ctx.Err() != nil { return ctx.Err() } - tun, err := StartTunnel(ctx, "serve", seedHex) + tun, err := StartTunnel(ctx, TunnelOptions{ + Mode: "serve", + Args: []string{"--timeout", "30"}, + Env: []string{"KIT_TUNNEL_SECRET=" + secretHex}, + }) if err != nil { return err } @@ -74,9 +67,11 @@ func Serve(ctx context.Context, opts ServeOptions) error { tun.Close() return fmt.Errorf("daemon: tunnel failed to start: %w", err) } - if nodeID, ok := strings.CutPrefix(ready, "READY node_id="); ok { - rt.setEndpoint(nodeID) - } + nodeID, _ := strings.CutPrefix(ready, "READY node_id=") + rt.setEndpoint(nodeID) + fmt.Printf(" Endpoint: %s\n", shortEndpoint(nodeID)) + fmt.Println(" Waiting for paired clients. Pair a new one with: kit daemon pair") + fmt.Println() err = runSessions(ctx, tun, rt) @@ -87,8 +82,16 @@ func Serve(ctx context.Context, opts ServeOptions) error { if err != nil { return fmt.Errorf("daemon: tunnel ended: %w", err) } - fmt.Println(" Listener restarted — same pairing code, waiting…") + fmt.Println(" Listener restarted — endpoint unchanged, waiting…") + } +} + +// shortEndpoint renders the first bytes of an endpoint id for display. +func shortEndpoint(id string) string { + if len(id) > 16 { + return id[:16] + "…" } + return id } // remoteSession is one connected client and its kit child process. @@ -98,15 +101,24 @@ type remoteSession struct { ptmx *os.File } +// authChallenge is an in-flight reconnect handshake awaiting the client's +// signature. Keyed by the 8-byte correlation key (first bytes of c_nonce). +type authChallenge struct { + clientPub []byte + cNonce []byte + sNonce []byte +} + // sessionTable tracks live sessions. The tunnel's stdout frames are read by // a single goroutine, so map access is confined to it plus teardown paths // guarded by mu. type sessionTable struct { - tunnel *Tunnel - rt *daemonRuntime - mu sync.Mutex - sessions map[uint32]*remoteSession - writeMu sync.Mutex // tunnel stdin is shared by all session pumps + tunnel *Tunnel + rt *daemonRuntime + mu sync.Mutex + sessions map[uint32]*remoteSession + pendingAuths map[[8]byte]authChallenge // confined to the frame loop + writeMu sync.Mutex // tunnel stdin is shared by all session pumps } // writeTo sends one frame to the tunnel stdin. Errors are the caller's to @@ -118,7 +130,12 @@ func (t *sessionTable) writeTo(frame Frame) error { } func runSessions(ctx context.Context, tun *Tunnel, rt *daemonRuntime) error { - table := &sessionTable{tunnel: tun, rt: rt, sessions: make(map[uint32]*remoteSession)} + table := &sessionTable{ + tunnel: tun, + rt: rt, + sessions: make(map[uint32]*remoteSession), + pendingAuths: make(map[[8]byte]authChallenge), + } defer table.teardownAll() // Child exits are noticed by the per-session PTY reader; when a client @@ -130,6 +147,10 @@ func runSessions(ctx context.Context, tun *Tunnel, rt *daemonRuntime) error { return nil // tunnel stream/process ended } switch frame.Type { + case FrameAuthRequest: + table.handleAuthRequest(frame.Payload) + case FrameAuthPayload: + table.handleAuthPayload(frame.Payload) case FrameSessionOpen: table.openSession(frame.Session) case FrameSessionClosed, FrameBye: @@ -153,6 +174,89 @@ func runSessions(ctx context.Context, tun *Tunnel, rt *daemonRuntime) error { } } +// handleAuthRequest stashes the handshake parameters so the signature can +// be verified when the client's AUTH_PAYLOAD arrives. +func (t *sessionTable) handleAuthRequest(payload []byte) { + if len(payload) < 8 { + log.Warn("short auth request frame", "len", len(payload)) + return // nothing to correlate a denial with; drop + } + if len(payload) != 32+32+32 { + log.Warn("malformed auth request", "len", len(payload)) + t.decideAuth(payload[:8], false, "malformed auth request") + return + } + corr := [8]byte(payload[0:8]) + t.pendingAuths[corr] = authChallenge{ + clientPub: payload[64:96], + cNonce: payload[0:32], + sNonce: payload[32:64], + } + log.Info("auth request", "fp", Fingerprint(payload[64:96])) +} + +// handleAuthPayload verifies the client's signature against the allowlist +// and answers the sidecar's consultation. Payload: c_nonce(32) | sig(64); +// the correlation key is the first 8 bytes of c_nonce. +func (t *sessionTable) handleAuthPayload(payload []byte) { + if len(payload) != 32+64 { + log.Warn("malformed auth payload", "len", len(payload)) + return + } + corr := [8]byte(payload[0:8]) + sig := payload[32:] + challenge, ok := t.pendingAuths[corr] + if !ok { + log.Warn("auth payload without request", "corr", hex.EncodeToString(corr[:])) + t.decideAuth(corr[:], false, "unknown handshake") + return + } + // Drop the stashed challenge on every path below: the sidecar gets an + // answer either way, and the map cannot grow under repeated + // request/payload floods. + delete(t.pendingAuths, corr) + fp := Fingerprint(challenge.clientPub) + entry, authorized, err := LookupClient(fp) + if err != nil { + t.decideAuth(corr[:], false, "allowlist error") + return + } + if !authorized { + log.Warn("client not paired", "fp", fp) + t.decideAuth(corr[:], false, "client not paired — run 'kit daemon pair' on the host") + return + } + pub, err := hex.DecodeString(entry.PubKey) + if err != nil || len(pub) != ed25519.PublicKeySize { + t.decideAuth(corr[:], false, "corrupt allowlist entry") + return + } + msg := append([]byte(signContext), challenge.cNonce...) + msg = append(msg, challenge.sNonce...) + if !ed25519.Verify(ed25519.PublicKey(pub), msg, sig) { + log.Warn("bad client signature", "fp", fp) + t.decideAuth(corr[:], false, "bad signature") + return + } + _ = TouchClient(fp) + log.Info("client authorized", "fp", fp) + t.decideAuth(corr[:], true, "") +} + +// decideAuth answers the sidecar's consultation. The payload mirrors what +// the Rust side parses: correlation key (8), verdict byte, optional reason. +func (t *sessionTable) decideAuth(corr []byte, allow bool, reason string) { + out := make([]byte, 0, 8+1+len(reason)) + out = append(out, corr...) + if allow { + out = append(out, 1) + } else { + out = append(out, 0) + } + out = append(out, reason...) + _ = t.writeTo(Frame{Type: FrameAuthDecision, Session: 0, Payload: out}) +} + // openSession spawns a fresh `kit --pick-dir` child for a newly verified // client. A failure to spawn is reported to that client as a BYE; the // daemon and other sessions continue. @@ -277,13 +381,3 @@ func homeDir() string { } return "/" } - -func printBanner(code string) { - fmt.Println() - fmt.Println(" kit daemon") - fmt.Println() - fmt.Printf(" Pairing code: %s\n", FormatCode(code)) - fmt.Println(" Enter this code on the remote machine with: kit --remote " + code) - fmt.Println(" The code stays valid while the daemon runs; multiple sessions allowed.") - fmt.Println() -} diff --git a/internal/daemon/store.go b/internal/daemon/store.go new file mode 100644 index 00000000..971c6126 --- /dev/null +++ b/internal/daemon/store.go @@ -0,0 +1,429 @@ +package daemon + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "time" +) + +// Credential stores for the pairing model. +// +// Both stores are plain JSON files with 0600 permissions under +// ~/.config/kit, written atomically (temp file + rename), matching how kit +// keeps its other local secrets. +// +// - The client keeps a "host book": friendly name -> daemon endpoint id +// plus the host's fingerprint. The endpoint id is the host's ed25519 +// public key; iroh's QUIC handshake authenticates the peer against it, +// so dialing a stored id cannot be hijacked by an impostor endpoint. +// - The host keeps an allowlist of paired clients: fingerprint -> +// ed25519 public key. Reconnecting clients sign the handshake nonce; +// the host verifies against this list. Revoking a client deletes an +// entry — the host stores no secrets. + +func sha256Sum(b []byte) []byte { + sum := sha256.Sum256(b) + return sum[:] +} + +// fingerprintShort is the human-facing form of a fingerprint: abcd…wxyz. +func fingerprintShort(fp string) string { + if len(fp) <= 8 { + return fp + } + return fp[:4] + "…" + fp[len(fp)-4:] +} + +// --------------------------------------------------------------------------- +// Client side: known hosts +// --------------------------------------------------------------------------- + +// HostEntry is one paired daemon, as known on the client. +type HostEntry struct { + Name string `json:"name"` + EndpointID string `json:"endpoint_id"` // 64 hex chars (ed25519 public key) + HostFP string `json:"host_fp"` // Fingerprint(EndpointID) + AddedAt time.Time `json:"added_at"` + LastUsed time.Time `json:"last_used"` +} + +type hostBookFile struct { + Hosts []HostEntry `json:"hosts"` +} + +func hostBookPath() (string, error) { + base, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("daemon: config dir: %w", err) + } + return filepath.Join(base, "kit", "remote", "hosts.json"), nil +} + +func readHostBook() ([]HostEntry, error) { + path, err := hostBookPath() + if err != nil { + return nil, err + } + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("daemon: read host book: %w", err) + } + var book hostBookFile + if err := json.Unmarshal(b, &book); err != nil { + return nil, fmt.Errorf("daemon: parse host book %s: %w", path, err) + } + return book.Hosts, nil +} + +func writeHostBook(hosts []HostEntry) error { + path, err := hostBookPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("daemon: host book dir: %w", err) + } + sort.Slice(hosts, func(i, j int) bool { return hosts[i].Name < hosts[j].Name }) + b, err := json.MarshalIndent(hostBookFile{Hosts: hosts}, "", " ") + if err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".hosts-*") + if err != nil { + return err + } + defer func() { _ = os.Remove(tmp.Name()) }() + if _, err := tmp.Write(append(b, '\n')); 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(tmp.Name(), path) +} + +// SaveHost adds (or replaces) a paired host entry under the given name. +// The endpoint id must be 64 hex chars (an ed25519 public key) so a stored +// entry can never crash the sidecar's dial path later. +func SaveHost(name string, endpointID string) error { + if name == "" { + return fmt.Errorf("daemon: host name must not be empty") + } + if raw, err := hex.DecodeString(endpointID); err != nil || len(raw) != ed25519PubLen { + return fmt.Errorf("daemon: host endpoint id must be 64 hex chars") + } + hosts, err := readHostBook() + if err != nil { + return err + } + entry := HostEntry{ + Name: name, + EndpointID: endpointID, + HostFP: Fingerprint(mustHexDecode(endpointID)), + AddedAt: time.Now(), + LastUsed: time.Now(), + } + replaced := false + for i := range hosts { + if hosts[i].Name == name { + entry.AddedAt = hosts[i].AddedAt + hosts[i] = entry + replaced = true + break + } + } + if !replaced { + hosts = append(hosts, entry) + } + return writeHostBook(hosts) +} + +// GetHost returns the stored entry for name. +func GetHost(name string) (HostEntry, error) { + hosts, err := readHostBook() + if err != nil { + return HostEntry{}, err + } + for _, h := range hosts { + if h.Name == name { + return h, nil + } + } + return HostEntry{}, fmt.Errorf("daemon: no paired host named %q — run 'kit remote --pair ' first", name) +} + +// ListHosts returns all paired hosts, sorted by name. +func ListHosts() ([]HostEntry, error) { + hosts, err := readHostBook() + if err != nil { + return nil, err + } + sort.Slice(hosts, func(i, j int) bool { return hosts[i].Name < hosts[j].Name }) + return hosts, nil +} + +// ForgetHost removes a stored host. Returns an error when unknown. +func ForgetHost(name string) error { + hosts, err := readHostBook() + if err != nil { + return err + } + out := hosts[:0] + found := false + for _, h := range hosts { + if h.Name == name { + found = true + continue + } + out = append(out, h) + } + if !found { + return fmt.Errorf("daemon: no paired host named %q", name) + } + return writeHostBook(out) +} + +// TouchHost updates last_used after a successful connection. +func TouchHost(name string) error { + hosts, err := readHostBook() + if err != nil { + return err + } + for i := range hosts { + if hosts[i].Name == name { + hosts[i].LastUsed = time.Now() + return writeHostBook(hosts) + } + } + return nil +} + +// --------------------------------------------------------------------------- +// Host side: authorized clients +// --------------------------------------------------------------------------- + +// ClientEntry is one paired client, as known on the host. +type ClientEntry struct { + FP string `json:"fp"` // Fingerprint(client public key) + PubKey string `json:"pubkey"` // 64 hex chars (ed25519 public) + Label string `json:"label"` // optional note set at pairing time + AddedAt time.Time `json:"added_at"` + LastSeen time.Time `json:"last_seen"` +} + +type allowlistFile struct { + Clients []ClientEntry `json:"clients"` +} + +func allowlistPath() (string, error) { + base, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("daemon: config dir: %w", err) + } + return filepath.Join(base, "kit", "daemon", "authorized.json"), nil +} + +// withFileLock runs fn while holding an exclusive lock on a stable +// .lock file. Read-modify-write store updates run under it so +// concurrent processes (daemon touches vs pair-command revokes) cannot +// overwrite each other's changes. The lock file is never replaced, so the +// lock itself is stable across the atomic renames of the store file. +func withFileLock(path string, fn func() error) error { + lockPath := path + ".lock" + if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil { + return fmt.Errorf("daemon: store lock dir: %w", err) + } + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return fmt.Errorf("daemon: store lock: %w", err) + } + defer func() { _ = f.Close() }() + if err := lockFileExclusive(f); err != nil { + return fmt.Errorf("daemon: store lock busy: %w", err) + } + defer unlockFile(f) + return fn() +} + +func readAllowlist() ([]ClientEntry, error) { + path, err := allowlistPath() + if err != nil { + return nil, err + } + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("daemon: read allowlist: %w", err) + } + var list allowlistFile + if err := json.Unmarshal(b, &list); err != nil { + return nil, fmt.Errorf("daemon: parse allowlist %s: %w", path, err) + } + return list.Clients, nil +} + +func writeAllowlist(clients []ClientEntry) error { + path, err := allowlistPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("daemon: allowlist dir: %w", err) + } + sort.Slice(clients, func(i, j int) bool { return clients[i].FP < clients[j].FP }) + b, err := json.MarshalIndent(allowlistFile{Clients: clients}, "", " ") + if err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".authorized-*") + if err != nil { + return err + } + defer func() { _ = os.Remove(tmp.Name()) }() + if _, err := tmp.Write(append(b, '\n')); 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(tmp.Name(), path) +} + +// AuthorizeClient stores a freshly paired client's public key. +func AuthorizeClient(pubKeyHex string) (string, error) { + raw, err := hex.DecodeString(pubKeyHex) + if err != nil || len(raw) != ed25519PubLen { + return "", fmt.Errorf("daemon: bad client public key") + } + fp := Fingerprint(raw) + path, err := allowlistPath() + if err != nil { + return "", err + } + err = withFileLock(path, func() error { + clients, err := readAllowlist() + if err != nil { + return err + } + for i := range clients { + if clients[i].FP == fp { + clients[i].LastSeen = time.Now() + return writeAllowlist(clients) + } + } + clients = append(clients, ClientEntry{FP: fp, PubKey: pubKeyHex, AddedAt: time.Now(), LastSeen: time.Now()}) + return writeAllowlist(clients) + }) + return fp, err +} + +// LookupClient verifies a fingerprint is authorized and returns its entry. +func LookupClient(fp string) (ClientEntry, bool, error) { + clients, err := readAllowlist() + if err != nil { + return ClientEntry{}, false, err + } + for _, c := range clients { + if c.FP == fp { + return c, true, nil + } + } + return ClientEntry{}, false, nil +} + +// TouchClient updates last_seen after a successful authenticated handshake. +// Runs under the store lock so a concurrent revoke is never resurrected by +// a stale read-modify-write. +func TouchClient(fp string) error { + path, err := allowlistPath() + if err != nil { + return err + } + return withFileLock(path, func() error { + clients, err := readAllowlist() + if err != nil { + return err + } + for i := range clients { + if clients[i].FP == fp { + clients[i].LastSeen = time.Now() + return writeAllowlist(clients) + } + } + return nil + }) +} + +// ListAuthorized returns all paired clients, sorted by fingerprint. +func ListAuthorized() ([]ClientEntry, error) { + clients, err := readAllowlist() + if err != nil { + return nil, err + } + sort.Slice(clients, func(i, j int) bool { return clients[i].FP < clients[j].FP }) + return clients, nil +} + +// RevokeClient removes an authorized client by fingerprint (or by its +// unique short prefix). Returns the removed entry. +func RevokeClient(fpOrPrefix string) (ClientEntry, error) { + path, err := allowlistPath() + if err != nil { + return ClientEntry{}, err + } + var removed ClientEntry + err = withFileLock(path, func() error { + removed, err = revokeClientLocked(fpOrPrefix) + return err + }) + return removed, err +} + +func revokeClientLocked(fpOrPrefix string) (ClientEntry, error) { + clients, err := readAllowlist() + if err != nil { + return ClientEntry{}, err + } + matches := []ClientEntry{} + for _, c := range clients { + if len(fpOrPrefix) <= len(c.FP) && c.FP[:len(fpOrPrefix)] == fpOrPrefix { + matches = append(matches, c) + } + } + if len(matches) == 0 { + return ClientEntry{}, fmt.Errorf("daemon: no paired client with fingerprint prefix %q — see 'kit daemon pair --list'", fpOrPrefix) + } + if len(matches) > 1 { + return ClientEntry{}, fmt.Errorf("daemon: fingerprint prefix %q matches %d clients — be more specific", fpOrPrefix, len(matches)) + } + out := clients[:0] + for _, c := range clients { + if c.FP != matches[0].FP { + out = append(out, c) + } + } + if err := writeAllowlist(out); err != nil { + return ClientEntry{}, err + } + return matches[0], nil +} diff --git a/internal/daemon/store_test.go b/internal/daemon/store_test.go new file mode 100644 index 00000000..530267b5 --- /dev/null +++ b/internal/daemon/store_test.go @@ -0,0 +1,189 @@ +package daemon + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" + "time" +) + +// isolateConfig points the XDG config dir at a temp dir so tests never +// touch the user's real stores. +func isolateConfig(t *testing.T) { + t.Helper() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) // Windows fallbacks via UserConfigDir +} + +func TestLoadOrCreateSeedPersists(t *testing.T) { + isolateConfig(t) + paths, err := identityPaths() + if err != nil { + t.Fatal(err) + } + seed1, err := LoadDaemonIdentity() + if err != nil { + t.Fatal(err) + } + if len(seed1) != 32 { + t.Fatalf("seed length = %d, want 32", len(seed1)) + } + info, err := os.Stat(paths.DaemonSeed) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Fatalf("identity file mode = %o, want 600", perm) + } + seed2, err := LoadDaemonIdentity() + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(seed1) != hex.EncodeToString(seed2) { + t.Fatal("identity seed changed between loads") + } +} + +func TestClientKeyPairSignVerify(t *testing.T) { + isolateConfig(t) + seed, err := LoadClientIdentity() + if err != nil { + t.Fatal(err) + } + kp := NewClientKeyPair(seed) + msg := []byte("kit-remote-v3-auth" + "nonce-c" + "nonce-s") + sig := ed25519.Sign(kp.Priv, msg) + if !ed25519.Verify(kp.Pub, msg, sig) { + t.Fatal("signature does not verify") + } + if len(kp.PubHex) != 64 { + t.Fatalf("pub hex length = %d, want 64", len(kp.PubHex)) + } + // A tampered message must not verify. + if ed25519.Verify(kp.Pub, []byte("tampered"), sig) { + t.Fatal("tampered message verified") + } +} + +func TestFingerprintMatchesSHA256Prefix(t *testing.T) { + raw := []byte("some client public key bytes") + sum := sha256.Sum256(raw) + want := hex.EncodeToString(sum[:])[:16] + if got := Fingerprint(raw); got != want { + t.Fatalf("Fingerprint = %s, want %s", got, want) + } +} + +func TestHostBookRoundTrip(t *testing.T) { + isolateConfig(t) + if err := SaveHost("zora", "aabbccdd00112233445566778899aabbccddeeff00112233445566778899aabb"); err != nil { + t.Fatal(err) + } + if err := SaveHost("bifrost", "1122334455667788aabbccddeeff0011223344556677889900aabbccddeeff11"); err != nil { + t.Fatal(err) + } + hosts, err := ListHosts() + if err != nil { + t.Fatal(err) + } + if len(hosts) != 2 || hosts[0].Name != "bifrost" || hosts[1].Name != "zora" { + t.Fatalf("unexpected host book: %+v", hosts) + } + got, err := GetHost("zora") + if err != nil { + t.Fatal(err) + } + if got.EndpointID != "aabbccdd00112233445566778899aabbccddeeff00112233445566778899aabb" { + t.Fatalf("endpoint id mismatch: %s", got.EndpointID) + } + wantFP := Fingerprint(mustHexDecode(got.EndpointID)) + if got.HostFP != wantFP { + t.Fatalf("host fp = %s, want %s", got.HostFP, wantFP) + } + // Saving the same name replaces the entry, keeping added_at. + first := got.AddedAt + time.Sleep(2 * time.Millisecond) + if err := SaveHost("zora", "1111ccdd00112233445566778899aabbccddeeff00112233445566778899aabb"); err != nil { + t.Fatal(err) + } + again, _ := GetHost("zora") + if again.EndpointID[:4] != "1111" { + t.Fatal("entry was not replaced") + } + if !again.AddedAt.Equal(first) { + t.Fatal("added_at should be preserved on replace") + } + if err := ForgetHost("zora"); err != nil { + t.Fatal(err) + } + if _, err := GetHost("zora"); err == nil { + t.Fatal("expected unknown-host error after forget") + } + if err := ForgetHost("nope"); err == nil { + t.Fatal("expected error forgetting unknown host") + } +} + +func TestHostBookCorruptFileFails(t *testing.T) { + isolateConfig(t) + path, err := hostBookPath() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := GetHost("zora"); err == nil { + t.Fatal("expected error on corrupt host book") + } +} + +func TestAllowlistAuthorizeLookupRevoke(t *testing.T) { + isolateConfig(t) + pub1 := "aabb" + "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"[4:] + pub2 := "ccdd" + "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"[4:] + fp1, err := AuthorizeClient(pub1) + if err != nil { + t.Fatal(err) + } + if _, err := AuthorizeClient(pub2); err != nil { + t.Fatal(err) + } + entry, ok, err := LookupClient(fp1) + if err != nil || !ok { + t.Fatalf("client not found after authorize: ok=%v err=%v", ok, err) + } + if entry.PubKey != pub1 { + t.Fatalf("stored pubkey mismatch: %s", entry.PubKey) + } + // Short-prefix matching for revoke; ambiguous prefixes are refused. + if _, err := RevokeClient("aa"); err == nil { + t.Fatal("expected ambiguous-prefix error") + } + removed, err := RevokeClient(fp1) + if err != nil { + t.Fatal(err) + } + if removed.FP != fp1 { + t.Fatalf("revoked wrong client: %s", removed.FP) + } + if _, ok, _ := LookupClient(fp1); ok { + t.Fatal("client still authorized after revoke") + } + if _, err := RevokeClient(fp1); err == nil { + t.Fatal("expected error revoking unknown client") + } +} + +func TestSaveHostEmptyNameRejected(t *testing.T) { + isolateConfig(t) + if err := SaveHost("", "aabb"); err == nil { + t.Fatal("expected empty-name error") + } +} diff --git a/internal/daemon/tunnel.go b/internal/daemon/tunnel.go index d9c28fd8..5716679d 100644 --- a/internal/daemon/tunnel.go +++ b/internal/daemon/tunnel.go @@ -65,15 +65,26 @@ func FindTunnelBinary() (string, error) { return "", errors.New("daemon: kit-tunnel sidecar not found; build it with 'task tunnel' or 'cargo build --release' in contrib/kit-tunnel, and place it next to the kit binary (or set KIT_TUNNEL_BIN)") } -// StartTunnel launches the sidecar in serve or dial mode with the given -// hex-encoded seed. Status lines are parsed from stderr; frames flow on -// stdin/stdout. -func StartTunnel(ctx context.Context, mode, seedHex string) (*Tunnel, error) { +// TunnelOptions describes one sidecar invocation. Args carry the mode's +// public flags (e.g. "--timeout", "30"); Env carries key material — argv is +// world-readable via ps, the child environment is not. +type TunnelOptions struct { + Mode string + Args []string // public flags after the mode + Env []string // KEY=VALUE pairs (secret seeds) added to the child env +} + +// StartTunnel launches the sidecar with the given options. Status lines are +// parsed from stderr; frames flow on stdin/stdout. +func StartTunnel(ctx context.Context, opts TunnelOptions) (*Tunnel, error) { bin, err := FindTunnelBinary() if err != nil { return nil, err } - cmd := exec.CommandContext(ctx, bin, mode, "--seed-hex", seedHex, "--timeout", "30") + cmd := exec.CommandContext(ctx, bin, append([]string{opts.Mode}, opts.Args...)...) + if len(opts.Env) > 0 { + cmd.Env = append(os.Environ(), opts.Env...) + } cmd.Stderr = nil // replaced below with our own pipe stdin, err := cmd.StdinPipe() if err != nil { diff --git a/www/pages/advanced/remote-sessions.md b/www/pages/advanced/remote-sessions.md index 7f48e37f..c5764944 100644 --- a/www/pages/advanced/remote-sessions.md +++ b/www/pages/advanced/remote-sessions.md @@ -11,13 +11,26 @@ local terminal just renders it. The transport is [iroh](https://iroh.computer): a direct, end-to-end encrypted QUIC connection that holes through NATs and falls back to relays. +Access is **pairing-based**. A client pairs with the host once — with a +one-time code and an explicit accept/reject on the host's terminal — and +from then on reconnects by name with its own signing key. For normal +reconnects no code is needed, and the host can revoke any client at any +time. (If the host's daemon identity file is deleted, its endpoint id +rotates and every client pairs again.) + ```bash -# On the machine that does the work: +# On the host: start the daemon kit daemon -# Pairing code: A1B2-C3D4 -# On the machine you are sitting at: -kit --remote A1B2C3D4 +# On the host: open a pairing window (shows a one-time code) +kit daemon pair + +# On the client: pair (the host terminal asks you to accept) +kit remote --pair A1B2C3D4 +Save this host as [workstation]: zora + +# On the client: connect — no code needed, ever again +kit remote --host zora ``` On connection the remote peer picks a working directory (the picker starts in @@ -34,30 +47,63 @@ rendering, and session persistence all run on the daemon host. ## Commands -| Command | Purpose | -|---------|---------| -| `kit daemon` | Start the daemon and print the pairing code | -| `kit daemon status` | Show code, endpoint, uptime and active sessions of a running daemon | -| `kit daemon service install` | Install and start a systemd user service | -| `kit daemon service remove` | Stop and uninstall the service | -| `kit --remote CODE` | Attach this terminal to a daemon session | - -Useful daemon flags: `--code ABCD2345` pins a specific pairing code -(hidden, mainly for tests). - -## Multiple sessions - -Each verified client gets its own session with its own working directory -choice. Exiting a session (`/quit`) closes only that client's connection; -detaching with `Ctrl-]` keeps the session running until it is reaped by -its own timeout. One pairing code stays valid for the whole daemon run, so -teammates (or your other machines) can attach while you are working. +| Command | Side | Purpose | +|---------|------|---------| +| `kit daemon` | host | Host sessions for paired clients | +| `kit daemon pair` | host | Open a 10-minute pairing window; confirm requests on this terminal | +| `kit daemon pair --list` | host | List paired clients with fingerprints | +| `kit daemon pair --revoke ` | host | Revoke a paired client | +| `kit daemon status` | host | Endpoint, paired clients, active sessions | +| `kit remote --pair ` | client | Pair with a host and save it under a name | +| `kit remote --host ` | client | Connect to a paired host | +| `kit remote --list` | client | List saved hosts | +| `kit remote --forget ` | client | Forget a saved host | + +`Ctrl-]` detaches the client from the session; `/quit` ends the session and +closes only that client's connection — other sessions are unaffected. + +## How pairing works + +1. `kit daemon pair` generates a fresh one-time code and opens a bootstrap + endpoint for **10 minutes** (or until one client pairs). +2. `kit remote --pair ` proves knowledge of the code and presents the + client's signing public key. +3. The host terminal shows the request (`client fp=379d…8510`) and asks + **Accept? [y/N]** — the default is reject. Requests arriving while no + terminal can confirm (e.g. the service runs headless) are always denied. +4. On accept, the client's public key joins the host's allowlist + (`~/.config/kit/daemon/authorized.json`), and the client stores the + host's endpoint id (`~/.config/kit/remote/hosts.json`). The code is + burned. + +The code itself never grants access: it only makes the pairing window +reachable, and a human still has to approve. Pairing requests that fail the +code check never reach the prompt. + +## How reconnection works + +`kit remote --host ` dials the stored endpoint id and signs the +handshake with the client's private signing key; the host verifies the +signature against its allowlist. iroh's QUIC handshake additionally +authenticates the daemon against the stored endpoint id, so a malicious or +poisoned endpoint cannot impersonate the host. + +## Security notes + +- The client's signing key lives in `~/.config/kit/remote/identity.key` + (0600). The host stores only public keys — there are no shared secrets. +- Deleting the host's `~/.config/kit/daemon/identity.key` changes its + endpoint id; every client must pair again. +- Revocation is immediate and one-sided: `kit daemon pair --revoke ` + (prefix matching works; ambiguous prefixes are refused). +- The daemon holds a per-user lock; a second instance refuses to start. See + `kit daemon status`. ## systemd ```bash kit daemon service install # writes ~/.config/systemd/user/kit.service, enables + starts it -kit daemon status # shows the live pairing code +kit daemon status # endpoint, paired clients, active sessions systemctl --user status kit # manage the service directly kit daemon service remove # stop and uninstall ``` @@ -65,28 +111,19 @@ kit daemon service remove # stop and uninstall `install` captures provider credentials from your current shell (`*_API_KEY`, `*_TOKEN`, `PROVIDER_*`, and similar) into `~/.config/kit/daemon.env`, which the unit loads via `EnvironmentFile`. Edit -that file and run `systemctl --user restart kit` when keys change. - -## Security model - -- The pairing code is 8 characters from a 32-symbol alphabet (~40 bits of - entropy). It stays valid for the daemon's lifetime — treat it like a - password. -- The daemon's endpoint identity is derived from the code: without it, a - peer cannot even find the endpoint. Connections are additionally - authenticated with a mutual HMAC handshake; failed attempts back off - exponentially. -- Session slots are capped, and polite rejections of over-cap peers are - budgeted so connection floods cannot pin daemon resources. -- Only one daemon may run per user (enforced with a `flock`); state lives in - `~/.cache/kit/daemon/`. +that file and run `systemctl --user restart kit` when keys change. The +service runs without a terminal, so pairing requests are denied while it is +the only thing running — pair interactively with `kit daemon pair` (the +allowlist is shared). ## Troubleshooting | Symptom | Cause and fix | |---------|---------------| | `another instance is already running` | A daemon (or service) already holds the lock — see `kit daemon status` | -| `no daemon is live for this pairing code` | The daemon restarted or stopped; get the current code from `kit daemon status` | +| `no daemon is live for this pairing code` | The pairing window expired or a client already paired; open a new one with `kit daemon pair` | +| `the pairing request was rejected on the host` | The request reached the host and was declined; ask the host user to rerun `kit daemon pair` | +| `the host no longer knows this machine` | The client was revoked — pair again | | `could not reach the daemon` | Network or relay issue; check connectivity on both sides | | Session dies with `API key not provided` | The daemon environment is missing provider keys — for the systemd service, edit `~/.config/kit/daemon.env` and restart | -| Detached by accident | Just reconnect with the same code; the daemon is still running and sessions persist on the daemon host | +| Detached by accident | Just reconnect with `kit remote --host `; the daemon keeps running | diff --git a/www/pages/cli/commands.md b/www/pages/cli/commands.md index c0367d80..a4544f22 100644 --- a/www/pages/cli/commands.md +++ b/www/pages/cli/commands.md @@ -296,14 +296,29 @@ end-to-end encrypted iroh connection. All work runs on the daemon host; see [Remote sessions](/advanced/remote-sessions) for the full picture. ```bash -kit daemon # Start daemon, print pairing code (Ctrl+C stops) -kit daemon status # Code, endpoint, uptime, active sessions +# On the host +kit daemon # Host sessions for paired clients (Ctrl+C stops) +kit daemon pair # One-time pairing: show code, confirm on this terminal +kit daemon pair --list # List paired clients (fingerprints) +kit daemon pair --revoke # Revoke a client +kit daemon status # Endpoint, paired clients, active sessions kit daemon service install # Install + start the systemd user service kit daemon service remove # Stop and uninstall the service -kit --remote A1B2C3D4 # Attach to the daemon from another machine + +# On the client (first time — the host terminal asks you to accept) +kit remote --pair A1B2C3D4 # Pair and save the host under a name + +# On the client (any time after) +kit remote --host zora # Attach to the paired host +kit remote --list # List paired hosts +kit remote --forget zora # Forget a saved host ``` -On connection the remote peer picks a working directory and gets a private -session; multiple clients can hold sessions at the same time and `/quit` -closes only that client's connection. `Ctrl-]` detaches without ending the -daemon. Only one daemon may run per user. +Pairing is one-time and human-approved: the code only works while the +`kit daemon pair` window is open, and the host must accept the request on +its terminal. After pairing, the client authenticates with its own signing +key — no code involved — and the host can revoke it at any time. Each +client picks a working directory and gets a private session; multiple +clients can hold sessions at the same time and `/quit` closes only that +client's connection. `Ctrl-]` detaches without ending the daemon. Only one +daemon may run per user. diff --git a/www/pages/cli/flags.md b/www/pages/cli/flags.md index a917f71e..c4c14bd3 100644 --- a/www/pages/cli/flags.md +++ b/www/pages/cli/flags.md @@ -102,12 +102,6 @@ self-defeating. | `--no-skills` | — | `false` | Disable skill loading (auto-discovery and explicit) | | `--no-agents` | — | `false` | Disable named agent discovery (built-ins and [definition files](/advanced/subagents#named-agents)) | -## Remote - -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--remote` | — | — | Attach this terminal to a daemon session using a pairing code (e.g. `kit --remote A1B2C3D4`). Mutually exclusive with prompts and `@file` attachments. See [Remote sessions](/advanced/remote-sessions). | - ## Generation parameters | Flag | Short | Default | Description |