From fc01f303ea34b7475919e922d7ae2bd55e41ef7d Mon Sep 17 00:00:00 2001 From: "Dylan B. Mikus" Date: Sun, 2 Aug 2026 23:01:03 +0000 Subject: [PATCH 01/14] Define no-relay SSH contracts --- Makefile | 8 +- go/cmd/amika/plumbing.go | 27 +++++ go/cmd/amikad/main.go | 17 ++++ go/internal/amikad/command.go | 138 ++++++++++++++++++++++++++ go/internal/amikad/norelay/handler.go | 78 +++++++++++++++ go/internal/amikad/sshd/contracts.go | 38 +++++++ go/internal/amikad/state/contracts.go | 31 ++++++ go/internal/apiclient/ssh_session.go | 23 +++++ go/internal/ssh/session.go | 74 ++++++++++++++ 9 files changed, 432 insertions(+), 2 deletions(-) create mode 100644 go/cmd/amika/plumbing.go create mode 100644 go/cmd/amikad/main.go create mode 100644 go/internal/amikad/command.go create mode 100644 go/internal/amikad/norelay/handler.go create mode 100644 go/internal/amikad/sshd/contracts.go create mode 100644 go/internal/amikad/state/contracts.go create mode 100644 go/internal/apiclient/ssh_session.go create mode 100644 go/internal/ssh/session.go diff --git a/Makefile b/Makefile index a2f8c896..10c06f95 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: goenv build build-cli build-server build-amikalog build-akfs clean test test-unit test-integration test-contract test-e2e test-e2e-api test-expensive test-all coverage vet fmt fmtcheck lint shellcheck ci setup +.PHONY: goenv build build-cli build-server build-amikad build-amikalog build-akfs clean test test-unit test-integration test-contract test-e2e test-e2e-api test-expensive test-all coverage vet fmt fmtcheck lint shellcheck ci setup GO_DIR = go UNIT_PACKAGES = $$(go -C $(GO_DIR) list ./... | grep -Ev '/test/(integration|contract)($$|/)') @@ -10,7 +10,7 @@ export GOTMPDIR := $(CURDIR)/.gotmp goenv: mkdir -p "$(GOCACHE)" "$(GOTMPDIR)" -build: build-cli build-server build-amikalog build-akfs +build: build-cli build-server build-amikad build-amikalog build-akfs build-cli: goenv mkdir -p dist @@ -20,6 +20,10 @@ build-server: goenv mkdir -p dist go -C $(GO_DIR) build -o $(CURDIR)/dist/amika-server ./cmd/amika-server +build-amikad: goenv + mkdir -p dist + go -C $(GO_DIR) build -o $(CURDIR)/dist/amikad ./cmd/amikad + build-amikalog: goenv mkdir -p dist go -C $(GO_DIR) build -o $(CURDIR)/dist/amikalog ./cmd/amikalog diff --git a/go/cmd/amika/plumbing.go b/go/cmd/amika/plumbing.go new file mode 100644 index 00000000..60f557e3 --- /dev/null +++ b/go/cmd/amika/plumbing.go @@ -0,0 +1,27 @@ +package main + +import ( + "github.com/gofixpoint/amika/go/internal/ssh" + "github.com/spf13/cobra" +) + +var plumbingCmd = &cobra.Command{ + Use: "plumbing", + Short: "Internal machine-facing commands", + Hidden: true, +} + +var sshStdioProxyCmd = &cobra.Command{ + Use: "ssh-stdio-proxy ", + Short: "Proxy standard IO to one SSH transport", + Hidden: true, + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, _ []string) error { + return ssh.ErrSessionTransportNotImplemented + }, +} + +func init() { + rootCmd.AddCommand(plumbingCmd) + plumbingCmd.AddCommand(sshStdioProxyCmd) +} diff --git a/go/cmd/amikad/main.go b/go/cmd/amikad/main.go new file mode 100644 index 00000000..0de00f05 --- /dev/null +++ b/go/cmd/amikad/main.go @@ -0,0 +1,17 @@ +// Package main runs the Amika sandbox daemon. +package main + +import ( + "fmt" + "os" + + "github.com/gofixpoint/amika/go/internal/amikad" +) + +func main() { + cmd := amikad.NewCommand(amikad.UnimplementedOperations{}) + if err := cmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/go/internal/amikad/command.go b/go/internal/amikad/command.go new file mode 100644 index 00000000..0e33f136 --- /dev/null +++ b/go/internal/amikad/command.go @@ -0,0 +1,138 @@ +// Package amikad defines the sandbox daemon command surface. +package amikad + +import ( + "context" + "errors" + "io" + + "github.com/spf13/cobra" +) + +// DefaultPort is the single reserved HTTP port used by amikad. +const DefaultPort = 60999 + +// ErrNotImplemented marks fail-closed daemon operations whose implementation +// has not landed yet. +var ErrNotImplemented = errors.New("amikad operation is not implemented") + +// ServeOptions selects the daemon transports and listen port. +type ServeOptions struct { + Port int + BetaNoRelay bool +} + +// Operations is the command layer's sandbox-interior boundary. +type Operations interface { + SetupSSHD(context.Context) error + ShowHostKey(context.Context, io.Writer) error + SetAuthorizedKeys(context.Context, io.Reader) error + SetConnectToken(context.Context, io.Reader) error + Serve(context.Context, ServeOptions) error +} + +// UnimplementedOperations is a fail-closed Operations implementation. It does +// not read command input, write output, mutate files, or open listeners. +type UnimplementedOperations struct{} + +// SetupSSHD returns ErrNotImplemented without changing sshd state. +func (UnimplementedOperations) SetupSSHD(context.Context) error { + return ErrNotImplemented +} + +// ShowHostKey returns ErrNotImplemented without writing key material. +func (UnimplementedOperations) ShowHostKey(context.Context, io.Writer) error { + return ErrNotImplemented +} + +// SetAuthorizedKeys returns ErrNotImplemented without reading or writing keys. +func (UnimplementedOperations) SetAuthorizedKeys(context.Context, io.Reader) error { + return ErrNotImplemented +} + +// SetConnectToken returns ErrNotImplemented without reading or writing a token. +func (UnimplementedOperations) SetConnectToken(context.Context, io.Reader) error { + return ErrNotImplemented +} + +// Serve returns ErrNotImplemented without opening a listener. +func (UnimplementedOperations) Serve(context.Context, ServeOptions) error { + return ErrNotImplemented +} + +// NewCommand builds the amikad command tree around an injectable Operations +// boundary. +func NewCommand(operations Operations) *cobra.Command { + root := &cobra.Command{ + Use: "amikad", + Short: "Run the Amika sandbox daemon", + SilenceUsage: true, + SilenceErrors: true, + } + + setup := &cobra.Command{Use: "setup", Short: "Configure sandbox services"} + setup.AddCommand(&cobra.Command{ + Use: "sshd", + Short: "Configure loopback-only OpenSSH", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return operations.SetupSSHD(cmd.Context()) + }, + }) + + hostKey := &cobra.Command{Use: "host-key", Short: "Manage the SSH host key"} + hostKey.AddCommand(&cobra.Command{ + Use: "show", + Short: "Print the SSH host public key", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return operations.ShowHostKey(cmd.Context(), cmd.OutOrStdout()) + }, + }) + + authorizedKeys := &cobra.Command{ + Use: "authorized-keys", + Short: "Manage authorized SSH public keys", + } + authorizedKeys.AddCommand(&cobra.Command{ + Use: "set", + Short: "Replace authorized keys from standard input", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return operations.SetAuthorizedKeys(cmd.Context(), cmd.InOrStdin()) + }, + }) + + connectToken := &cobra.Command{ + Use: "connect-token", + Short: "Manage the no-relay connection token", + } + connectToken.AddCommand(&cobra.Command{ + Use: "set", + Short: "Replace the connection token from standard input", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return operations.SetConnectToken(cmd.Context(), cmd.InOrStdin()) + }, + }) + + serveOptions := ServeOptions{Port: DefaultPort} + serve := &cobra.Command{ + Use: "serve", + Short: "Serve the amikad HTTP API", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return operations.Serve(cmd.Context(), serveOptions) + }, + } + serve.Flags().IntVar(&serveOptions.Port, "port", DefaultPort, "HTTP listen port") + serve.Flags().BoolVar( + &serveOptions.BetaNoRelay, + "beta-no-relay", + false, + "enable the unstable no-relay WebSocket SSH transport", + ) + + root.AddCommand(setup, hostKey, authorizedKeys, connectToken, serve) + return root +} diff --git a/go/internal/amikad/norelay/handler.go b/go/internal/amikad/norelay/handler.go new file mode 100644 index 00000000..6c9e7bf2 --- /dev/null +++ b/go/internal/amikad/norelay/handler.go @@ -0,0 +1,78 @@ +// Package norelay serves authenticated WebSocket streams to loopback sshd. +package norelay + +import ( + "context" + "errors" + "io" + "net/http" +) + +// SSHSessionsPath is the no-relay WebSocket upgrade route. +const SSHSessionsPath = "/v1/ssh-sessions" + +// ErrNotImplemented marks the fail-closed no-relay handler. +var ErrNotImplemented = errors.New("no-relay SSH bridge is not implemented") + +// Stream is one bidirectional byte stream. +type Stream interface { + io.Reader + io.Writer + io.Closer +} + +// TokenVerifier authenticates a bearer token without exposing stored token +// material to the HTTP layer. +type TokenVerifier interface { + Verify(string) bool +} + +// Upgrader upgrades an authenticated HTTP request into a binary stream. +type Upgrader interface { + Upgrade(http.ResponseWriter, *http.Request) (Stream, error) +} + +// Dialer opens the loopback sshd stream after authentication and upgrade. +type Dialer interface { + DialContext(context.Context, string, string) (Stream, error) +} + +// Logger records metadata-only bridge events. +type Logger interface { + Info(string, ...any) + Error(string, ...any) +} + +// Config contains bounded bridge settings. +type Config struct { + MaxConnections int + SSHDAddress string +} + +// Dependencies are the handler's security-sensitive boundaries. +type Dependencies struct { + Verifier TokenVerifier + Upgrader Upgrader + Dialer Dialer + Logger Logger +} + +// Handler is a fail-closed placeholder for the no-relay route. +type Handler struct { + config Config + deps Dependencies +} + +// NewHandler creates a no-relay handler without opening any listener. +func NewHandler(config Config, deps Dependencies) *Handler { + return &Handler{config: config, deps: deps} +} + +// ServeHTTP rejects requests until authentication, capacity accounting, and +// bounded byte copying are implemented. +func (h *Handler) ServeHTTP(w http.ResponseWriter, _ *http.Request) { + _ = h.config + _ = h.deps + w.Header().Set("Cache-Control", "no-store") + http.Error(w, ErrNotImplemented.Error(), http.StatusServiceUnavailable) +} diff --git a/go/internal/amikad/sshd/contracts.go b/go/internal/amikad/sshd/contracts.go new file mode 100644 index 00000000..ff696f2a --- /dev/null +++ b/go/internal/amikad/sshd/contracts.go @@ -0,0 +1,38 @@ +// Package sshd owns loopback-only OpenSSH configuration and supervision. +package sshd + +import ( + "context" + "errors" + "io" +) + +// ErrNotImplemented marks the fail-closed sshd stub. +var ErrNotImplemented = errors.New("amikad sshd manager is not implemented") + +// Manager controls the daemon-owned OpenSSH instance. +type Manager interface { + Setup(context.Context) error + ShowHostKey(context.Context, io.Writer) error + SetAuthorizedKeys(context.Context, io.Reader) error + Serve(context.Context) error +} + +// UnimplementedManager rejects every operation without file or process changes. +type UnimplementedManager struct{} + +// Setup returns ErrNotImplemented without changing configuration. +func (UnimplementedManager) Setup(context.Context) error { return ErrNotImplemented } + +// ShowHostKey returns ErrNotImplemented without writing output. +func (UnimplementedManager) ShowHostKey(context.Context, io.Writer) error { + return ErrNotImplemented +} + +// SetAuthorizedKeys returns ErrNotImplemented without reading input. +func (UnimplementedManager) SetAuthorizedKeys(context.Context, io.Reader) error { + return ErrNotImplemented +} + +// Serve returns ErrNotImplemented without starting sshd. +func (UnimplementedManager) Serve(context.Context) error { return ErrNotImplemented } diff --git a/go/internal/amikad/state/contracts.go b/go/internal/amikad/state/contracts.go new file mode 100644 index 00000000..73ad7728 --- /dev/null +++ b/go/internal/amikad/state/contracts.go @@ -0,0 +1,31 @@ +// Package state owns amikad's sensitive files and scrub manifest. +package state + +import ( + "context" + "errors" + "io" + "io/fs" +) + +// ErrNotImplemented marks the fail-closed state stub. +var ErrNotImplemented = errors.New("amikad state store is not implemented") + +// SensitiveStore atomically writes sensitive data and registers its path in +// the injected-path scrub manifest as one operation. +type SensitiveStore interface { + WriteAndRegister(context.Context, string, io.Reader, fs.FileMode) error +} + +// UnimplementedStore rejects writes without reading input or touching disk. +type UnimplementedStore struct{} + +// WriteAndRegister returns ErrNotImplemented without mutating state. +func (UnimplementedStore) WriteAndRegister( + context.Context, + string, + io.Reader, + fs.FileMode, +) error { + return ErrNotImplemented +} diff --git a/go/internal/apiclient/ssh_session.go b/go/internal/apiclient/ssh_session.go new file mode 100644 index 00000000..e25ce3f6 --- /dev/null +++ b/go/internal/apiclient/ssh_session.go @@ -0,0 +1,23 @@ +package apiclient + +import "errors" + +// ErrSSHSessionNotImplemented marks the fail-closed direct-session client stub. +var ErrSSHSessionNotImplemented = errors.New("SSH session API is not implemented") + +// SSHSession is the transport descriptor returned for one SSH dial. +type SSHSession struct { + SessionID string `json:"session_id"` + Transport string `json:"transport"` + ConnectURL string `json:"connect_url"` + ConnectCredential string `json:"connect_credential"` + SandboxID string `json:"sandbox_id"` + SSHUser string `json:"ssh_user"` + HostPublicKey string `json:"host_public_key"` +} + +// CreateSSHSession fails closed until the v2 API contract is implemented. +func (c *Client) CreateSSHSession(_ string) (*SSHSession, error) { + _ = c + return nil, ErrSSHSessionNotImplemented +} diff --git a/go/internal/ssh/session.go b/go/internal/ssh/session.go new file mode 100644 index 00000000..1dfcb63c --- /dev/null +++ b/go/internal/ssh/session.go @@ -0,0 +1,74 @@ +package ssh + +import ( + "context" + "errors" + "io" + + "github.com/gofixpoint/amika/go/internal/apiclient" +) + +// ErrSessionTransportNotImplemented marks the fail-closed v2 SSH client stub. +var ErrSessionTransportNotImplemented = errors.New("SSH session transport is not implemented") + +// SandboxAlias identifies the immutable sandbox id parsed from a v2 host alias. +type SandboxAlias struct { + Name string + ID string +} + +// SessionConfig describes the strict wildcard SSH configuration. +type SessionConfig struct { + IdentityFile string + KnownHostsFile string + ProxyCommand string +} + +// SessionCreator creates a fresh transport descriptor for each SSH dial. +type SessionCreator interface { + CreateSSHSession(string) (*apiclient.SSHSession, error) +} + +// Stream is the binary WebSocket abstraction used by the stdio proxy. +type Stream interface { + io.Reader + io.Writer + io.Closer +} + +// SessionDialer opens a binary stream with a credential sent outside argv. +type SessionDialer interface { + Dial(context.Context, string, string) (Stream, error) +} + +// BuildSessionAlias fails closed until the v2 alias rules are implemented. +func BuildSessionAlias(_, _ string) (string, error) { + return "", ErrSessionTransportNotImplemented +} + +// ParseSessionAlias fails closed until right-to-left alias parsing is implemented. +func ParseSessionAlias(_ string) (SandboxAlias, error) { + return SandboxAlias{}, ErrSessionTransportNotImplemented +} + +// RenderSessionConfig fails closed until strict host-key configuration lands. +func RenderSessionConfig(_ SessionConfig) (string, error) { + return "", ErrSessionTransportNotImplemented +} + +// KnownHostLine fails closed until alias-keyed host pinning lands. +func KnownHostLine(_, _ string) (string, error) { + return "", ErrSessionTransportNotImplemented +} + +// ProxySession fails before creating a session, dialing, or copying standard IO. +func ProxySession( + _ context.Context, + _ SessionCreator, + _ SessionDialer, + _ string, + _ io.Reader, + _ io.Writer, +) error { + return ErrSessionTransportNotImplemented +} From 650b433454b87079a7ef1e7cd0bad9825f2a97fb Mon Sep 17 00:00:00 2001 From: "Dylan B. Mikus" Date: Sun, 2 Aug 2026 23:04:24 +0000 Subject: [PATCH 02/14] Specify no-relay SSH vertical slice --- go/internal/amikad/command_test.go | 45 +++++++ go/internal/amikad/norelay/handler_test.go | 119 +++++++++++++++++++ go/internal/amikad/state/contracts.go | 22 ++++ go/internal/amikad/state/contracts_test.go | 46 ++++++++ go/internal/apiclient/ssh_session_test.go | 48 ++++++++ go/internal/ssh/session_test.go | 129 +++++++++++++++++++++ 6 files changed, 409 insertions(+) create mode 100644 go/internal/amikad/command_test.go create mode 100644 go/internal/amikad/norelay/handler_test.go create mode 100644 go/internal/amikad/state/contracts_test.go create mode 100644 go/internal/apiclient/ssh_session_test.go create mode 100644 go/internal/ssh/session_test.go diff --git a/go/internal/amikad/command_test.go b/go/internal/amikad/command_test.go new file mode 100644 index 00000000..ff7ef701 --- /dev/null +++ b/go/internal/amikad/command_test.go @@ -0,0 +1,45 @@ +package amikad + +import ( + "errors" + "io" + "testing" +) + +type panicReader struct{} + +func (panicReader) Read([]byte) (int, error) { + panic("fail-closed stub read secret input") +} + +func TestCommandTopology(t *testing.T) { + cmd := NewCommand(UnimplementedOperations{}) + for _, path := range [][]string{ + {"setup", "sshd"}, + {"host-key", "show"}, + {"authorized-keys", "set"}, + {"connect-token", "set"}, + {"serve"}, + } { + found, _, err := cmd.Find(path) + if err != nil { + t.Fatalf("find %v: %v", path, err) + } + if found == cmd { + t.Fatalf("path %v resolved to root command", path) + } + } +} + +func TestUnimplementedConnectTokenDoesNotReadSecret(t *testing.T) { + cmd := NewCommand(UnimplementedOperations{}) + cmd.SetArgs([]string{"connect-token", "set"}) + cmd.SetIn(panicReader{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + err := cmd.Execute() + if !errors.Is(err, ErrNotImplemented) { + t.Fatalf("execute error = %v, want ErrNotImplemented", err) + } +} diff --git a/go/internal/amikad/norelay/handler_test.go b/go/internal/amikad/norelay/handler_test.go new file mode 100644 index 00000000..df4c4f7f --- /dev/null +++ b/go/internal/amikad/norelay/handler_test.go @@ -0,0 +1,119 @@ +package norelay + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" +) + +type fakeVerifier struct { + want string + calls int +} + +func (v *fakeVerifier) Verify(token string) bool { + v.calls++ + return token == v.want +} + +type fakeStream struct { + read *bytes.Reader + writes bytes.Buffer +} + +func newFakeStream(input string) *fakeStream { + return &fakeStream{read: bytes.NewReader([]byte(input))} +} + +func (s *fakeStream) Read(p []byte) (int, error) { return s.read.Read(p) } +func (s *fakeStream) Write(p []byte) (int, error) { return s.writes.Write(p) } +func (s *fakeStream) Close() error { return nil } + +type fakeUpgrader struct { + stream Stream + calls int +} + +func (u *fakeUpgrader) Upgrade(http.ResponseWriter, *http.Request) (Stream, error) { + u.calls++ + return u.stream, nil +} + +type fakeDialer struct { + stream Stream + calls int + network string + address string +} + +func (d *fakeDialer) DialContext(_ context.Context, network, address string) (Stream, error) { + d.calls++ + d.network = network + d.address = address + return d.stream, nil +} + +type discardLogger struct{} + +func (discardLogger) Info(string, ...any) {} +func (discardLogger) Error(string, ...any) {} + +func TestHandlerRejectsMissingTokenBeforeUpgradeOrDial(t *testing.T) { + verifier := &fakeVerifier{want: "secret-token"} + upgrader := &fakeUpgrader{stream: newFakeStream("")} + dialer := &fakeDialer{stream: newFakeStream("")} + handler := NewHandler(Config{MaxConnections: 64, SSHDAddress: "127.0.0.1:22"}, Dependencies{ + Verifier: verifier, + Upgrader: upgrader, + Dialer: dialer, + Logger: discardLogger{}, + }) + + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, SSHSessionsPath, nil) + handler.ServeHTTP(response, request) + + if response.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", response.Code, http.StatusUnauthorized) + } + if upgrader.calls != 0 || dialer.calls != 0 { + t.Fatalf("unauthorized request reached upgrader=%d dialer=%d", upgrader.calls, dialer.calls) + } +} + +func TestHandlerBridgesAuthenticatedBytesToLoopbackSSHD(t *testing.T) { + websocket := newFakeStream("ssh-from-client") + loopback := newFakeStream("ssh-from-server") + verifier := &fakeVerifier{want: "secret-token"} + upgrader := &fakeUpgrader{stream: websocket} + dialer := &fakeDialer{stream: loopback} + handler := NewHandler(Config{MaxConnections: 64, SSHDAddress: "127.0.0.1:22"}, Dependencies{ + Verifier: verifier, + Upgrader: upgrader, + Dialer: dialer, + Logger: discardLogger{}, + }) + + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, SSHSessionsPath, nil) + request.Header.Set("Authorization", "Bearer secret-token") + handler.ServeHTTP(response, request) + + if verifier.calls != 1 { + t.Fatalf("verifier calls = %d, want 1", verifier.calls) + } + if upgrader.calls != 1 || dialer.calls != 1 { + t.Fatalf("authenticated request reached upgrader=%d dialer=%d, want 1 each", upgrader.calls, dialer.calls) + } + if dialer.network != "tcp" || dialer.address != "127.0.0.1:22" { + t.Fatalf("dial = %s %s, want tcp 127.0.0.1:22", dialer.network, dialer.address) + } + if got := loopback.writes.String(); got != "ssh-from-client" { + t.Fatalf("loopback received %q, want client bytes", got) + } + if got := websocket.writes.String(); got != "ssh-from-server" { + t.Fatalf("websocket received %q, want server bytes", got) + } +} diff --git a/go/internal/amikad/state/contracts.go b/go/internal/amikad/state/contracts.go index 73ad7728..8539b3b8 100644 --- a/go/internal/amikad/state/contracts.go +++ b/go/internal/amikad/state/contracts.go @@ -17,6 +17,28 @@ type SensitiveStore interface { WriteAndRegister(context.Context, string, io.Reader, fs.FileMode) error } +// Store is the manifest-backed sensitive writer. Its implementation remains +// fail-closed until atomic file and manifest replacement land. +type Store struct { + manifestPath string +} + +// NewStore creates a sensitive writer for an explicit manifest path. +func NewStore(manifestPath string) *Store { + return &Store{manifestPath: manifestPath} +} + +// WriteAndRegister returns ErrNotImplemented without reading input or touching disk. +func (s *Store) WriteAndRegister( + context.Context, + string, + io.Reader, + fs.FileMode, +) error { + _ = s.manifestPath + return ErrNotImplemented +} + // UnimplementedStore rejects writes without reading input or touching disk. type UnimplementedStore struct{} diff --git a/go/internal/amikad/state/contracts_test.go b/go/internal/amikad/state/contracts_test.go new file mode 100644 index 00000000..04232727 --- /dev/null +++ b/go/internal/amikad/state/contracts_test.go @@ -0,0 +1,46 @@ +package state + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestStoreWritesSensitiveFileAndRegistersAbsolutePath(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, "injected-paths.json") + tokenPath := filepath.Join(dir, "connect-token") + store := NewStore(manifestPath) + + if err := store.WriteAndRegister( + context.Background(), + tokenPath, + strings.NewReader("connect-token"), + 0o600, + ); err != nil { + t.Fatalf("WriteAndRegister: %v", err) + } + + info, err := os.Stat(tokenPath) + if err != nil { + t.Fatalf("stat token: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("token mode = %o, want 600", got) + } + + manifest, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatalf("read manifest: %v", err) + } + var paths []string + if err := json.Unmarshal(manifest, &paths); err != nil { + t.Fatalf("decode manifest: %v", err) + } + if len(paths) != 1 || paths[0] != tokenPath { + t.Fatalf("manifest paths = %#v, want [%q]", paths, tokenPath) + } +} diff --git a/go/internal/apiclient/ssh_session_test.go b/go/internal/apiclient/ssh_session_test.go new file mode 100644 index 00000000..5edf8e19 --- /dev/null +++ b/go/internal/apiclient/ssh_session_test.go @@ -0,0 +1,48 @@ +package apiclient + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestCreateSSHSessionPostsForEveryDial(t *testing.T) { + var calls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + if r.URL.Path != "/api/v0beta1/sandboxes/sbx_123/ssh-sessions" { + t.Errorf("path = %s", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer api-token" { + t.Errorf("Authorization = %q", got) + } + _ = json.NewEncoder(w).Encode(SSHSession{ + SessionID: "sshs_1", + Transport: "direct_ws", + ConnectURL: "wss://sandbox.example/v1/ssh-sessions", + ConnectCredential: "connect-token", + SandboxID: "sbx_123", + SSHUser: "amika", + HostPublicKey: "ssh-ed25519 AAAAtest", + }) + })) + defer server.Close() + + client := NewClient(server.URL, "api-token") + for range 2 { + session, err := client.CreateSSHSession("sbx_123") + if err != nil { + t.Fatalf("CreateSSHSession: %v", err) + } + if session.SessionID != "sshs_1" || session.Transport != "direct_ws" { + t.Fatalf("session = %#v", session) + } + } + if calls != 2 { + t.Fatalf("API calls = %d, want 2", calls) + } +} diff --git a/go/internal/ssh/session_test.go b/go/internal/ssh/session_test.go new file mode 100644 index 00000000..4a4a0276 --- /dev/null +++ b/go/internal/ssh/session_test.go @@ -0,0 +1,129 @@ +package ssh + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/gofixpoint/amika/go/internal/apiclient" +) + +func TestParseSessionAliasUsesRightmostSandboxID(t *testing.T) { + parsed, err := ParseSessionAlias("my.team.sbx-123.amika") + if err != nil { + t.Fatalf("ParseSessionAlias: %v", err) + } + if parsed.Name != "my.team" || parsed.ID != "sbx-123" { + t.Fatalf("parsed = %#v", parsed) + } +} + +func TestRenderSessionConfigPinsHostKeysAndFetchesEveryDial(t *testing.T) { + config, err := RenderSessionConfig(SessionConfig{ + IdentityFile: "/home/user/.ssh/amika_id_ed25519", + KnownHostsFile: "/home/user/.ssh/amika_known_hosts", + ProxyCommand: "amika plumbing ssh-stdio-proxy %h", + }) + if err != nil { + t.Fatalf("RenderSessionConfig: %v", err) + } + for _, line := range []string{ + "Host *.amika", + "User amika", + "IdentityFile /home/user/.ssh/amika_id_ed25519", + "IdentitiesOnly yes", + "StrictHostKeyChecking yes", + "UserKnownHostsFile /home/user/.ssh/amika_known_hosts", + "ProxyCommand amika plumbing ssh-stdio-proxy %h", + "ServerAliveInterval 15", + } { + if !strings.Contains(config, line) { + t.Errorf("config missing %q:\n%s", line, config) + } + } +} + +func TestKnownHostLinePinsTheAliasToTheExactHostKey(t *testing.T) { + line, err := KnownHostLine( + "my.team.sbx-123.amika", + "ssh-ed25519 AAAAtest host-comment", + ) + if err != nil { + t.Fatalf("KnownHostLine: %v", err) + } + if line != "my.team.sbx-123.amika ssh-ed25519 AAAAtest\n" { + t.Fatalf("known-host line = %q", line) + } +} + +type fakeCreator struct { + calls int +} + +func (c *fakeCreator) CreateSSHSession(name string) (*apiclient.SSHSession, error) { + c.calls++ + return &apiclient.SSHSession{ + SessionID: "sshs_1", + Transport: "direct_ws", + ConnectURL: "wss://sandbox.example/v1/ssh-sessions", + ConnectCredential: "connect-token", + SandboxID: name, + SSHUser: "amika", + HostPublicKey: "ssh-ed25519 AAAAtest", + }, nil +} + +type proxyStream struct { + read *bytes.Reader + writes bytes.Buffer +} + +func (s *proxyStream) Read(p []byte) (int, error) { return s.read.Read(p) } +func (s *proxyStream) Write(p []byte) (int, error) { return s.writes.Write(p) } +func (s *proxyStream) Close() error { return nil } + +type fakeSessionDialer struct { + stream *proxyStream + url string + credential string + calls int +} + +func (d *fakeSessionDialer) Dial(_ context.Context, url, credential string) (Stream, error) { + d.calls++ + d.url = url + d.credential = credential + return d.stream, nil +} + +func TestProxySessionCreatesSessionAndCopiesBytes(t *testing.T) { + creator := &fakeCreator{} + stream := &proxyStream{read: bytes.NewReader([]byte("from-sshd"))} + dialer := &fakeSessionDialer{stream: stream} + var stdout bytes.Buffer + + err := ProxySession( + context.Background(), + creator, + dialer, + "sbx_123", + strings.NewReader("from-openssh"), + &stdout, + ) + if err != nil { + t.Fatalf("ProxySession: %v", err) + } + if creator.calls != 1 || dialer.calls != 1 { + t.Fatalf("creator calls = %d, dialer calls = %d", creator.calls, dialer.calls) + } + if dialer.url != "wss://sandbox.example/v1/ssh-sessions" || dialer.credential != "connect-token" { + t.Fatalf("dial = %q credential %q", dialer.url, dialer.credential) + } + if got := stream.writes.String(); got != "from-openssh" { + t.Fatalf("stream received %q", got) + } + if got := stdout.String(); got != "from-sshd" { + t.Fatalf("stdout received %q", got) + } +} From dac6b6d8c9387b3e51a146c2122f655efa98084d Mon Sep 17 00:00:00 2001 From: "Dylan B. Mikus" Date: Sun, 2 Aug 2026 23:11:55 +0000 Subject: [PATCH 03/14] Tighten no-relay SSH contracts --- go/internal/amikad/norelay/handler.go | 15 +- go/internal/amikad/norelay/handler_test.go | 3 +- go/internal/amikad/state/contracts.go | 25 ++- go/internal/amikad/state/contracts_test.go | 240 +++++++++++++++++++-- go/internal/apiclient/ssh_session.go | 26 ++- go/internal/apiclient/ssh_session_test.go | 39 +++- go/internal/ssh/session.go | 17 ++ go/internal/ssh/session_test.go | 33 +++ 8 files changed, 366 insertions(+), 32 deletions(-) diff --git a/go/internal/amikad/norelay/handler.go b/go/internal/amikad/norelay/handler.go index 6c9e7bf2..11dd0d85 100644 --- a/go/internal/amikad/norelay/handler.go +++ b/go/internal/amikad/norelay/handler.go @@ -37,10 +37,19 @@ type Dialer interface { DialContext(context.Context, string, string) (Stream, error) } -// Logger records metadata-only bridge events. +// Event is the complete metadata-only log shape. It cannot carry request +// headers, credentials, or SSH payload bytes. +type Event struct { + SessionID string + Outcome string + CloseReason string + BytesFromClient int64 + BytesFromSSHD int64 +} + +// Logger records typed metadata-only bridge events. type Logger interface { - Info(string, ...any) - Error(string, ...any) + Record(Event) } // Config contains bounded bridge settings. diff --git a/go/internal/amikad/norelay/handler_test.go b/go/internal/amikad/norelay/handler_test.go index df4c4f7f..916ffa24 100644 --- a/go/internal/amikad/norelay/handler_test.go +++ b/go/internal/amikad/norelay/handler_test.go @@ -57,8 +57,7 @@ func (d *fakeDialer) DialContext(_ context.Context, network, address string) (St type discardLogger struct{} -func (discardLogger) Info(string, ...any) {} -func (discardLogger) Error(string, ...any) {} +func (discardLogger) Record(Event) {} func TestHandlerRejectsMissingTokenBeforeUpgradeOrDial(t *testing.T) { verifier := &fakeVerifier{want: "secret-token"} diff --git a/go/internal/amikad/state/contracts.go b/go/internal/amikad/state/contracts.go index 8539b3b8..f7cb6d5a 100644 --- a/go/internal/amikad/state/contracts.go +++ b/go/internal/amikad/state/contracts.go @@ -11,21 +11,37 @@ import ( // ErrNotImplemented marks the fail-closed state stub. var ErrNotImplemented = errors.New("amikad state store is not implemented") -// SensitiveStore atomically writes sensitive data and registers its path in -// the injected-path scrub manifest as one operation. +// ErrInvalidPath marks a sensitive target that is not an absolute clean path. +var ErrInvalidPath = errors.New("invalid sensitive file path") + +// ErrSymlinkPath marks a sensitive target that resolves through a symlink. +var ErrSymlinkPath = errors.New("sensitive file path contains a symlink") + +// SensitiveStore first registers an absolute path by atomically replacing the +// scrub manifest, then atomically replaces the sensitive file. A stale manifest +// entry is safe; an unregistered sensitive file is not. type SensitiveStore interface { WriteAndRegister(context.Context, string, io.Reader, fs.FileMode) error } +// FileSystem is the minimum filesystem boundary needed for ordered, atomic +// manifest and sensitive-file replacement. +type FileSystem interface { + ReadFile(string) ([]byte, error) + Lstat(string) (fs.FileInfo, error) + WriteFileAtomic(string, []byte, fs.FileMode) error +} + // Store is the manifest-backed sensitive writer. Its implementation remains // fail-closed until atomic file and manifest replacement land. type Store struct { manifestPath string + files FileSystem } // NewStore creates a sensitive writer for an explicit manifest path. -func NewStore(manifestPath string) *Store { - return &Store{manifestPath: manifestPath} +func NewStore(manifestPath string, files FileSystem) *Store { + return &Store{manifestPath: manifestPath, files: files} } // WriteAndRegister returns ErrNotImplemented without reading input or touching disk. @@ -36,6 +52,7 @@ func (s *Store) WriteAndRegister( fs.FileMode, ) error { _ = s.manifestPath + _ = s.files return ErrNotImplemented } diff --git a/go/internal/amikad/state/contracts_test.go b/go/internal/amikad/state/contracts_test.go index 04232727..ad6b83ff 100644 --- a/go/internal/amikad/state/contracts_test.go +++ b/go/internal/amikad/state/contracts_test.go @@ -3,17 +3,92 @@ package state import ( "context" "encoding/json" - "os" + "errors" + "io/fs" "path/filepath" "strings" + "sync" "testing" + "time" ) -func TestStoreWritesSensitiveFileAndRegistersAbsolutePath(t *testing.T) { +type writeCall struct { + path string + data []byte + mode fs.FileMode +} + +type memoryFiles struct { + mu sync.Mutex + contents map[string][]byte + modes map[string]fs.FileMode + symlinks map[string]bool + failWrites map[string]error + writes []writeCall +} + +func newMemoryFiles() *memoryFiles { + return &memoryFiles{ + contents: make(map[string][]byte), + modes: make(map[string]fs.FileMode), + symlinks: make(map[string]bool), + failWrites: make(map[string]error), + } +} + +func (f *memoryFiles) ReadFile(path string) ([]byte, error) { + f.mu.Lock() + defer f.mu.Unlock() + data, ok := f.contents[path] + if !ok { + return nil, fs.ErrNotExist + } + return append([]byte(nil), data...), nil +} + +func (f *memoryFiles) Lstat(path string) (fs.FileInfo, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.symlinks[path] { + return fakeFileInfo{mode: fs.ModeSymlink}, nil + } + data, ok := f.contents[path] + if !ok { + return nil, fs.ErrNotExist + } + return fakeFileInfo{size: int64(len(data)), mode: f.modes[path]}, nil +} + +func (f *memoryFiles) WriteFileAtomic(path string, data []byte, mode fs.FileMode) error { + f.mu.Lock() + defer f.mu.Unlock() + f.writes = append(f.writes, writeCall{path: path, data: append([]byte(nil), data...), mode: mode}) + if err := f.failWrites[path]; err != nil { + return err + } + f.contents[path] = append([]byte(nil), data...) + f.modes[path] = mode + return nil +} + +type fakeFileInfo struct { + size int64 + mode fs.FileMode +} + +func (fakeFileInfo) Name() string { return "test" } +func (i fakeFileInfo) Size() int64 { return i.size } +func (i fakeFileInfo) Mode() fs.FileMode { return i.mode } +func (fakeFileInfo) ModTime() time.Time { return time.Time{} } +func (fakeFileInfo) IsDir() bool { return false } +func (fakeFileInfo) Sys() any { return nil } + +func TestStoreRegistersBeforeWritingSensitiveFile(t *testing.T) { dir := t.TempDir() manifestPath := filepath.Join(dir, "injected-paths.json") tokenPath := filepath.Join(dir, "connect-token") - store := NewStore(manifestPath) + files := newMemoryFiles() + store := NewStore(manifestPath, files) if err := store.WriteAndRegister( context.Background(), @@ -23,24 +98,159 @@ func TestStoreWritesSensitiveFileAndRegistersAbsolutePath(t *testing.T) { ); err != nil { t.Fatalf("WriteAndRegister: %v", err) } + if len(files.writes) != 2 { + t.Fatalf("writes = %#v, want manifest then sensitive file", files.writes) + } + if files.writes[0].path != manifestPath || files.writes[1].path != tokenPath { + t.Fatalf("write order = %q then %q", files.writes[0].path, files.writes[1].path) + } + if files.writes[1].mode != 0o600 { + t.Fatalf("token mode = %o, want 600", files.writes[1].mode) + } + assertManifestPaths(t, files.writes[0].data, []string{tokenPath}) +} - info, err := os.Stat(tokenPath) - if err != nil { - t.Fatalf("stat token: %v", err) +func TestStoreDoesNotWriteSecretWhenManifestWriteFails(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, "injected-paths.json") + tokenPath := filepath.Join(dir, "connect-token") + manifestErr := errors.New("manifest write failed") + files := newMemoryFiles() + files.failWrites[manifestPath] = manifestErr + store := NewStore(manifestPath, files) + + err := store.WriteAndRegister(context.Background(), tokenPath, strings.NewReader("token"), 0o600) + if !errors.Is(err, manifestErr) { + t.Fatalf("error = %v, want manifest failure", err) } - if got := info.Mode().Perm(); got != 0o600 { - t.Fatalf("token mode = %o, want 600", got) + for _, call := range files.writes { + if call.path == tokenPath { + t.Fatalf("secret write occurred after manifest failure") + } } +} - manifest, err := os.ReadFile(manifestPath) - if err != nil { - t.Fatalf("read manifest: %v", err) +func TestStoreLeavesRegistrationWhenSensitiveWriteFails(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, "injected-paths.json") + tokenPath := filepath.Join(dir, "connect-token") + fileErr := errors.New("file write failed") + files := newMemoryFiles() + files.failWrites[tokenPath] = fileErr + store := NewStore(manifestPath, files) + + err := store.WriteAndRegister(context.Background(), tokenPath, strings.NewReader("token"), 0o600) + if !errors.Is(err, fileErr) { + t.Fatalf("error = %v, want file failure", err) + } + assertManifestPaths(t, files.contents[manifestPath], []string{tokenPath}) +} + +func TestStorePreservesExistingManifestEntries(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, "injected-paths.json") + oldPath := filepath.Join(dir, "old-secret") + newPath := filepath.Join(dir, "new-secret") + files := newMemoryFiles() + files.contents[manifestPath], _ = json.Marshal([]string{oldPath}) + store := NewStore(manifestPath, files) + + if err := store.WriteAndRegister(context.Background(), newPath, strings.NewReader("new"), 0o600); err != nil { + t.Fatalf("WriteAndRegister: %v", err) } - var paths []string - if err := json.Unmarshal(manifest, &paths); err != nil { + assertManifestPaths(t, files.contents[manifestPath], []string{oldPath, newPath}) +} + +func TestStoreRejectsUnsafePaths(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, "injected-paths.json") + + t.Run("relative", func(t *testing.T) { + files := newMemoryFiles() + store := NewStore(manifestPath, files) + err := store.WriteAndRegister(context.Background(), "relative/token", strings.NewReader("token"), 0o600) + if !errors.Is(err, ErrInvalidPath) { + t.Fatalf("error = %v, want ErrInvalidPath", err) + } + if len(files.writes) != 0 { + t.Fatalf("unsafe path caused writes: %#v", files.writes) + } + }) + + t.Run("symlink", func(t *testing.T) { + files := newMemoryFiles() + tokenPath := filepath.Join(dir, "token-link") + files.symlinks[tokenPath] = true + store := NewStore(manifestPath, files) + err := store.WriteAndRegister(context.Background(), tokenPath, strings.NewReader("token"), 0o600) + if !errors.Is(err, ErrSymlinkPath) { + t.Fatalf("error = %v, want ErrSymlinkPath", err) + } + if len(files.writes) != 0 { + t.Fatalf("symlink path caused writes: %#v", files.writes) + } + }) +} + +func TestStoreSerializesConcurrentManifestUpdates(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, "injected-paths.json") + files := newMemoryFiles() + store := NewStore(manifestPath, files) + paths := []string{ + filepath.Join(dir, "secret-a"), + filepath.Join(dir, "secret-b"), + filepath.Join(dir, "secret-c"), + } + + var wg sync.WaitGroup + errs := make(chan error, len(paths)) + for _, path := range paths { + wg.Add(1) + go func() { + defer wg.Done() + errs <- store.WriteAndRegister(context.Background(), path, strings.NewReader("secret"), 0o600) + }() + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("concurrent WriteAndRegister: %v", err) + } + } + assertManifestPathSet(t, files.contents[manifestPath], paths) +} + +func assertManifestPaths(t *testing.T, data []byte, want []string) { + t.Helper() + var got []string + if err := json.Unmarshal(data, &got); err != nil { t.Fatalf("decode manifest: %v", err) } - if len(paths) != 1 || paths[0] != tokenPath { - t.Fatalf("manifest paths = %#v, want [%q]", paths, tokenPath) + if len(got) != len(want) { + t.Fatalf("manifest = %#v, want %#v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("manifest = %#v, want %#v", got, want) + } + } +} + +func assertManifestPathSet(t *testing.T, data []byte, want []string) { + t.Helper() + var got []string + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("decode manifest: %v", err) + } + seen := make(map[string]bool, len(got)) + for _, path := range got { + seen[path] = true + } + for _, path := range want { + if !seen[path] { + t.Fatalf("manifest %v missing %q", got, path) + } } } diff --git a/go/internal/apiclient/ssh_session.go b/go/internal/apiclient/ssh_session.go index e25ce3f6..14b30c97 100644 --- a/go/internal/apiclient/ssh_session.go +++ b/go/internal/apiclient/ssh_session.go @@ -5,15 +5,27 @@ import "errors" // ErrSSHSessionNotImplemented marks the fail-closed direct-session client stub. var ErrSSHSessionNotImplemented = errors.New("SSH session API is not implemented") +// SSHSessionTransport selects how the stdio proxy reaches the sandbox. +type SSHSessionTransport string + +// SSHSessionTransportDirectWS is the provider-exposed no-relay transport. +const SSHSessionTransportDirectWS SSHSessionTransport = "direct_ws" + // SSHSession is the transport descriptor returned for one SSH dial. type SSHSession struct { - SessionID string `json:"session_id"` - Transport string `json:"transport"` - ConnectURL string `json:"connect_url"` - ConnectCredential string `json:"connect_credential"` - SandboxID string `json:"sandbox_id"` - SSHUser string `json:"ssh_user"` - HostPublicKey string `json:"host_public_key"` + SessionID string `json:"session_id"` + Transport SSHSessionTransport `json:"transport"` + ConnectURL string `json:"connect_url"` + ConnectCredential string `json:"connect_credential"` + SandboxID string `json:"sandbox_id"` + SSHUser string `json:"ssh_user"` + HostPublicKey string `json:"host_public_key"` +} + +// Validate fails closed until descriptor validation is implemented. +func (s *SSHSession) Validate(_ string) error { + _ = s + return ErrSSHSessionNotImplemented } // CreateSSHSession fails closed until the v2 API contract is implemented. diff --git a/go/internal/apiclient/ssh_session_test.go b/go/internal/apiclient/ssh_session_test.go index 5edf8e19..7575030a 100644 --- a/go/internal/apiclient/ssh_session_test.go +++ b/go/internal/apiclient/ssh_session_test.go @@ -22,7 +22,7 @@ func TestCreateSSHSessionPostsForEveryDial(t *testing.T) { } _ = json.NewEncoder(w).Encode(SSHSession{ SessionID: "sshs_1", - Transport: "direct_ws", + Transport: SSHSessionTransportDirectWS, ConnectURL: "wss://sandbox.example/v1/ssh-sessions", ConnectCredential: "connect-token", SandboxID: "sbx_123", @@ -46,3 +46,40 @@ func TestCreateSSHSessionPostsForEveryDial(t *testing.T) { t.Fatalf("API calls = %d, want 2", calls) } } + +func TestSSHSessionValidateAcceptsOnlyMatchingDirectWSSession(t *testing.T) { + valid := SSHSession{ + SessionID: "sshs_1", + Transport: SSHSessionTransportDirectWS, + ConnectURL: "wss://sandbox.example/v1/ssh-sessions", + ConnectCredential: "connect-token", + SandboxID: "sbx_123", + SSHUser: "amika", + HostPublicKey: "ssh-ed25519 AAAAtest", + } + if err := valid.Validate("sbx_123"); err != nil { + t.Fatalf("Validate valid session: %v", err) + } + + tests := map[string]SSHSession{ + "unknown transport": func() SSHSession { s := valid; s.Transport = "other"; return s }(), + "wrong sandbox": func() SSHSession { s := valid; s.SandboxID = "sbx_other"; return s }(), + "non-wss URL": func() SSHSession { s := valid; s.ConnectURL = "https://sandbox.example/v1/ssh-sessions"; return s }(), + "URL credential": func() SSHSession { + s := valid + s.ConnectURL = "wss://user:secret@sandbox.example/v1/ssh-sessions" + return s + }(), + "wrong URL path": func() SSHSession { s := valid; s.ConnectURL = "wss://sandbox.example/other"; return s }(), + "empty credential": func() SSHSession { s := valid; s.ConnectCredential = ""; return s }(), + "invalid host key": func() SSHSession { s := valid; s.HostPublicKey = "ssh-rsa AAAA"; return s }(), + "host key with newline": func() SSHSession { s := valid; s.HostPublicKey = "ssh-ed25519 AAAA\nInjected"; return s }(), + } + for name, session := range tests { + t.Run(name, func(t *testing.T) { + if err := session.Validate("sbx_123"); err == nil { + t.Fatal("Validate returned nil") + } + }) + } +} diff --git a/go/internal/ssh/session.go b/go/internal/ssh/session.go index 1dfcb63c..338e8bc1 100644 --- a/go/internal/ssh/session.go +++ b/go/internal/ssh/session.go @@ -41,6 +41,12 @@ type SessionDialer interface { Dial(context.Context, string, string) (Stream, error) } +// HostKeyPinStore atomically creates or verifies an alias-keyed known-host pin. +// An existing different key must fail closed. +type HostKeyPinStore interface { + Pin(string, string) error +} + // BuildSessionAlias fails closed until the v2 alias rules are implemented. func BuildSessionAlias(_, _ string) (string, error) { return "", ErrSessionTransportNotImplemented @@ -61,6 +67,17 @@ func KnownHostLine(_, _ string) (string, error) { return "", ErrSessionTransportNotImplemented } +// PrepareSessionHost fetches and validates a descriptor, then pins its host key +// before OpenSSH is started. +func PrepareSessionHost( + _ SessionCreator, + _ HostKeyPinStore, + _ string, + _ string, +) (*apiclient.SSHSession, error) { + return nil, ErrSessionTransportNotImplemented +} + // ProxySession fails before creating a session, dialing, or copying standard IO. func ProxySession( _ context.Context, diff --git a/go/internal/ssh/session_test.go b/go/internal/ssh/session_test.go index 4a4a0276..c40a63b8 100644 --- a/go/internal/ssh/session_test.go +++ b/go/internal/ssh/session_test.go @@ -61,6 +61,39 @@ type fakeCreator struct { calls int } +type fakePinStore struct { + alias string + key string + calls int +} + +func (s *fakePinStore) Pin(alias, key string) error { + s.calls++ + s.alias = alias + s.key = key + return nil +} + +func TestPrepareSessionHostPinsAPIHostKeyBeforeOpenSSH(t *testing.T) { + creator := &fakeCreator{} + pins := &fakePinStore{} + session, err := PrepareSessionHost( + creator, + pins, + "sbx_123", + "my.team.sbx-123.amika", + ) + if err != nil { + t.Fatalf("PrepareSessionHost: %v", err) + } + if session.SandboxID != "sbx_123" || creator.calls != 1 { + t.Fatalf("session = %#v, creator calls = %d", session, creator.calls) + } + if pins.calls != 1 || pins.alias != "my.team.sbx-123.amika" || pins.key != "ssh-ed25519 AAAAtest" { + t.Fatalf("pin calls = %d alias = %q key = %q", pins.calls, pins.alias, pins.key) + } +} + func (c *fakeCreator) CreateSSHSession(name string) (*apiclient.SSHSession, error) { c.calls++ return &apiclient.SSHSession{ From 329275be8ec3b6be959fae5b2dfa97e10486a794 Mon Sep 17 00:00:00 2001 From: Dylan Mikus Date: Mon, 3 Aug 2026 00:09:59 -0400 Subject: [PATCH 04/14] amika annotations: amend this commit with changes --- go/internal/amikad/command.go | 24 ++++++++++++++---- go/internal/amikad/command_test.go | 26 +++++++++++++++++++ go/internal/amikad/norelay/handler.go | 14 +++++------ go/internal/amikad/norelay/handler_test.go | 12 +++++---- go/internal/amikad/state/contracts.go | 29 +++++++++++++++------- go/internal/amikad/state/contracts_test.go | 12 +++++++++ 6 files changed, 91 insertions(+), 26 deletions(-) diff --git a/go/internal/amikad/command.go b/go/internal/amikad/command.go index 0e33f136..f574dc24 100644 --- a/go/internal/amikad/command.go +++ b/go/internal/amikad/command.go @@ -22,9 +22,15 @@ type ServeOptions struct { BetaNoRelay bool } +// SetupSSHDOptions controls whether managed setup may replace an existing +// user-defined sshd configuration. +type SetupSSHDOptions struct { + ForceOverwrite bool +} + // Operations is the command layer's sandbox-interior boundary. type Operations interface { - SetupSSHD(context.Context) error + SetupSSHD(context.Context, SetupSSHDOptions) error ShowHostKey(context.Context, io.Writer) error SetAuthorizedKeys(context.Context, io.Reader) error SetConnectToken(context.Context, io.Reader) error @@ -36,7 +42,7 @@ type Operations interface { type UnimplementedOperations struct{} // SetupSSHD returns ErrNotImplemented without changing sshd state. -func (UnimplementedOperations) SetupSSHD(context.Context) error { +func (UnimplementedOperations) SetupSSHD(context.Context, SetupSSHDOptions) error { return ErrNotImplemented } @@ -71,14 +77,22 @@ func NewCommand(operations Operations) *cobra.Command { } setup := &cobra.Command{Use: "setup", Short: "Configure sandbox services"} - setup.AddCommand(&cobra.Command{ + setupSSHDOptions := SetupSSHDOptions{} + setupSSHD := &cobra.Command{ Use: "sshd", Short: "Configure loopback-only OpenSSH", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - return operations.SetupSSHD(cmd.Context()) + return operations.SetupSSHD(cmd.Context(), setupSSHDOptions) }, - }) + } + setupSSHD.Flags().BoolVar( + &setupSSHDOptions.ForceOverwrite, + "force-overwrite", + false, + "replace an existing user-defined sshd configuration", + ) + setup.AddCommand(setupSSHD) hostKey := &cobra.Command{Use: "host-key", Short: "Manage the SSH host key"} hostKey.AddCommand(&cobra.Command{ diff --git a/go/internal/amikad/command_test.go b/go/internal/amikad/command_test.go index ff7ef701..7ade6a21 100644 --- a/go/internal/amikad/command_test.go +++ b/go/internal/amikad/command_test.go @@ -1,6 +1,7 @@ package amikad import ( + "context" "errors" "io" "testing" @@ -12,6 +13,16 @@ func (panicReader) Read([]byte) (int, error) { panic("fail-closed stub read secret input") } +type recordingOperations struct { + UnimplementedOperations + setupOptions SetupSSHDOptions +} + +func (o *recordingOperations) SetupSSHD(_ context.Context, options SetupSSHDOptions) error { + o.setupOptions = options + return nil +} + func TestCommandTopology(t *testing.T) { cmd := NewCommand(UnimplementedOperations{}) for _, path := range [][]string{ @@ -43,3 +54,18 @@ func TestUnimplementedConnectTokenDoesNotReadSecret(t *testing.T) { t.Fatalf("execute error = %v, want ErrNotImplemented", err) } } + +func TestSetupSSHDForceOverwriteFlag(t *testing.T) { + operations := &recordingOperations{} + cmd := NewCommand(operations) + cmd.SetArgs([]string{"setup", "sshd", "--force-overwrite"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + if !operations.setupOptions.ForceOverwrite { + t.Fatal("ForceOverwrite = false, want true") + } +} diff --git a/go/internal/amikad/norelay/handler.go b/go/internal/amikad/norelay/handler.go index 11dd0d85..9d74a75a 100644 --- a/go/internal/amikad/norelay/handler.go +++ b/go/internal/amikad/norelay/handler.go @@ -5,6 +5,7 @@ import ( "context" "errors" "io" + "log/slog" "net/http" ) @@ -33,8 +34,12 @@ type Upgrader interface { } // Dialer opens the loopback sshd stream after authentication and upgrade. +// The context carries the request cancellation and deadline, network must be +// "tcp", and address is the configured loopback sshd endpoint. A successful +// call returns the connected bidirectional stream; a failure returns an error +// and no usable stream. type Dialer interface { - DialContext(context.Context, string, string) (Stream, error) + DialContext(ctx context.Context, network, address string) (Stream, error) } // Event is the complete metadata-only log shape. It cannot carry request @@ -47,11 +52,6 @@ type Event struct { BytesFromSSHD int64 } -// Logger records typed metadata-only bridge events. -type Logger interface { - Record(Event) -} - // Config contains bounded bridge settings. type Config struct { MaxConnections int @@ -63,7 +63,7 @@ type Dependencies struct { Verifier TokenVerifier Upgrader Upgrader Dialer Dialer - Logger Logger + Logger *slog.Logger } // Handler is a fail-closed placeholder for the no-relay route. diff --git a/go/internal/amikad/norelay/handler_test.go b/go/internal/amikad/norelay/handler_test.go index 916ffa24..9a28eb96 100644 --- a/go/internal/amikad/norelay/handler_test.go +++ b/go/internal/amikad/norelay/handler_test.go @@ -3,6 +3,8 @@ package norelay import ( "bytes" "context" + "io" + "log/slog" "net/http" "net/http/httptest" "testing" @@ -55,9 +57,9 @@ func (d *fakeDialer) DialContext(_ context.Context, network, address string) (St return d.stream, nil } -type discardLogger struct{} - -func (discardLogger) Record(Event) {} +func testLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} func TestHandlerRejectsMissingTokenBeforeUpgradeOrDial(t *testing.T) { verifier := &fakeVerifier{want: "secret-token"} @@ -67,7 +69,7 @@ func TestHandlerRejectsMissingTokenBeforeUpgradeOrDial(t *testing.T) { Verifier: verifier, Upgrader: upgrader, Dialer: dialer, - Logger: discardLogger{}, + Logger: testLogger(), }) response := httptest.NewRecorder() @@ -92,7 +94,7 @@ func TestHandlerBridgesAuthenticatedBytesToLoopbackSSHD(t *testing.T) { Verifier: verifier, Upgrader: upgrader, Dialer: dialer, - Logger: discardLogger{}, + Logger: testLogger(), }) response := httptest.NewRecorder() diff --git a/go/internal/amikad/state/contracts.go b/go/internal/amikad/state/contracts.go index f7cb6d5a..305a1109 100644 --- a/go/internal/amikad/state/contracts.go +++ b/go/internal/amikad/state/contracts.go @@ -21,7 +21,10 @@ var ErrSymlinkPath = errors.New("sensitive file path contains a symlink") // scrub manifest, then atomically replaces the sensitive file. A stale manifest // entry is safe; an unregistered sensitive file is not. type SensitiveStore interface { - WriteAndRegister(context.Context, string, io.Reader, fs.FileMode) error + // WriteAndRegister uses ctx for cancellation, registers absolutePath in the + // scrub manifest, reads the replacement contents from contents, and installs + // the sensitive file with mode after the registration is durable. + WriteAndRegister(ctx context.Context, absolutePath string, contents io.Reader, mode fs.FileMode) error } // FileSystem is the minimum filesystem boundary needed for ordered, atomic @@ -46,11 +49,15 @@ func NewStore(manifestPath string, files FileSystem) *Store { // WriteAndRegister returns ErrNotImplemented without reading input or touching disk. func (s *Store) WriteAndRegister( - context.Context, - string, - io.Reader, - fs.FileMode, + ctx context.Context, + absolutePath string, + contents io.Reader, + mode fs.FileMode, ) error { + _ = ctx + _ = absolutePath + _ = contents + _ = mode _ = s.manifestPath _ = s.files return ErrNotImplemented @@ -61,10 +68,14 @@ type UnimplementedStore struct{} // WriteAndRegister returns ErrNotImplemented without mutating state. func (UnimplementedStore) WriteAndRegister( - context.Context, - string, - io.Reader, - fs.FileMode, + ctx context.Context, + absolutePath string, + contents io.Reader, + mode fs.FileMode, ) error { + _ = ctx + _ = absolutePath + _ = contents + _ = mode return ErrNotImplemented } diff --git a/go/internal/amikad/state/contracts_test.go b/go/internal/amikad/state/contracts_test.go index ad6b83ff..e596e2a0 100644 --- a/go/internal/amikad/state/contracts_test.go +++ b/go/internal/amikad/state/contracts_test.go @@ -165,6 +165,18 @@ func TestStoreRejectsUnsafePaths(t *testing.T) { dir := t.TempDir() manifestPath := filepath.Join(dir, "injected-paths.json") + t.Run("manifest itself", func(t *testing.T) { + files := newMemoryFiles() + store := NewStore(manifestPath, files) + err := store.WriteAndRegister(context.Background(), manifestPath, strings.NewReader("replacement"), 0o600) + if !errors.Is(err, ErrInvalidPath) { + t.Fatalf("error = %v, want ErrInvalidPath", err) + } + if len(files.writes) != 0 { + t.Fatalf("manifest overwrite caused writes: %#v", files.writes) + } + }) + t.Run("relative", func(t *testing.T) { files := newMemoryFiles() store := NewStore(manifestPath, files) From d4bb156a80c0a20c41267f85874dd58f7a9499ec Mon Sep 17 00:00:00 2001 From: "Dylan B. Mikus" Date: Mon, 3 Aug 2026 05:09:35 +0000 Subject: [PATCH 05/14] Implement local no-relay SSH path Add the production amikad state, OpenSSH, token, and bounded WebSocket bridge implementations. Wire strict host pinning, per-dial session creation, the hidden stdio proxy, and the beta sshv2/key commands into the CLI. --- go/cmd/amika/plumbing.go | 12 +- go/cmd/amika/sandbox/command.go | 2 + go/cmd/amika/sandbox/sandbox_ssh_v2.go | 75 ++++ go/cmd/amika/secrets.go | 4 + go/cmd/amika/ssh_keygen.go | 74 ++++ go/cmd/amikad/main.go | 2 +- go/go.mod | 5 +- go/go.sum | 12 +- go/internal/amikad/norelay/handler.go | 206 +++++++++-- go/internal/amikad/norelay/handler_test.go | 98 +++++ go/internal/amikad/norelay/net_dialer.go | 17 + go/internal/amikad/norelay/token.go | 70 ++++ go/internal/amikad/norelay/token_test.go | 87 +++++ go/internal/amikad/norelay/websocket.go | 25 ++ go/internal/amikad/norelay/websocket_test.go | 82 +++++ go/internal/amikad/operations.go | 192 ++++++++++ go/internal/amikad/operations_test.go | 115 ++++++ go/internal/amikad/sshd/contracts.go | 38 -- go/internal/amikad/sshd/manager.go | 362 +++++++++++++++++++ go/internal/amikad/sshd/manager_test.go | 205 +++++++++++ go/internal/amikad/state/contracts.go | 140 +++++-- go/internal/amikad/state/contracts_test.go | 38 ++ go/internal/amikad/state/os_files.go | 15 + go/internal/amikad/state/os_files_linux.go | 108 ++++++ go/internal/amikad/state/os_files_other.go | 22 ++ go/internal/apiclient/ssh_session.go | 90 ++++- go/internal/apiclient/ssh_session_test.go | 13 +- go/internal/basedir/basedir.go | 22 ++ go/internal/basedir/basedir_test.go | 6 + go/internal/filelock/filelock.go | 43 +++ go/internal/filelock/lock_unix.go | 21 ++ go/internal/filelock/lock_windows.go | 30 ++ go/internal/ssh/config.go | 40 +- go/internal/ssh/identity.go | 92 +++++ go/internal/ssh/identity_test.go | 29 ++ go/internal/ssh/known_hosts.go | 99 +++++ go/internal/ssh/known_hosts_test.go | 33 ++ go/internal/ssh/session.go | 208 +++++++++-- go/internal/ssh/session_test.go | 45 ++- go/internal/ssh/ssh.go | 18 + go/internal/ssh/websocket_dialer_test.go | 57 +++ go/internal/wsstream/stream.go | 84 +++++ 42 files changed, 2790 insertions(+), 146 deletions(-) create mode 100644 go/cmd/amika/sandbox/sandbox_ssh_v2.go create mode 100644 go/cmd/amika/ssh_keygen.go create mode 100644 go/internal/amikad/norelay/net_dialer.go create mode 100644 go/internal/amikad/norelay/token.go create mode 100644 go/internal/amikad/norelay/token_test.go create mode 100644 go/internal/amikad/norelay/websocket.go create mode 100644 go/internal/amikad/norelay/websocket_test.go create mode 100644 go/internal/amikad/operations.go create mode 100644 go/internal/amikad/operations_test.go delete mode 100644 go/internal/amikad/sshd/contracts.go create mode 100644 go/internal/amikad/sshd/manager.go create mode 100644 go/internal/amikad/sshd/manager_test.go create mode 100644 go/internal/amikad/state/os_files.go create mode 100644 go/internal/amikad/state/os_files_linux.go create mode 100644 go/internal/amikad/state/os_files_other.go create mode 100644 go/internal/filelock/filelock.go create mode 100644 go/internal/filelock/lock_unix.go create mode 100644 go/internal/filelock/lock_windows.go create mode 100644 go/internal/ssh/identity.go create mode 100644 go/internal/ssh/identity_test.go create mode 100644 go/internal/ssh/known_hosts.go create mode 100644 go/internal/ssh/known_hosts_test.go create mode 100644 go/internal/ssh/websocket_dialer_test.go create mode 100644 go/internal/wsstream/stream.go diff --git a/go/cmd/amika/plumbing.go b/go/cmd/amika/plumbing.go index 60f557e3..71155eae 100644 --- a/go/cmd/amika/plumbing.go +++ b/go/cmd/amika/plumbing.go @@ -1,6 +1,7 @@ package main import ( + "github.com/gofixpoint/amika/go/internal/runmode" "github.com/gofixpoint/amika/go/internal/ssh" "github.com/spf13/cobra" ) @@ -16,8 +17,15 @@ var sshStdioProxyCmd = &cobra.Command{ Short: "Proxy standard IO to one SSH transport", Hidden: true, Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, _ []string) error { - return ssh.ErrSessionTransportNotImplemented + RunE: func(cmd *cobra.Command, args []string) error { + return ssh.ProxySession( + cmd.Context(), + runmode.NewRemoteClient(), + ssh.WebSocketDialer{}, + args[0], + cmd.InOrStdin(), + cmd.OutOrStdout(), + ) }, } diff --git a/go/cmd/amika/sandbox/command.go b/go/cmd/amika/sandbox/command.go index d9b71f87..b92d8bf6 100644 --- a/go/cmd/amika/sandbox/command.go +++ b/go/cmd/amika/sandbox/command.go @@ -24,6 +24,7 @@ func New() *cobra.Command { sandboxCmd.AddCommand(sandboxListCmd) sandboxCmd.AddCommand(sandboxConnectCmd) sandboxCmd.AddCommand(sandboxSSHCmd) + sandboxCmd.AddCommand(sandboxSSHV2Cmd) sandboxCmd.AddCommand(sandboxCodeCmd) sandboxCmd.AddCommand(sandboxAgentSendCmd) @@ -65,6 +66,7 @@ func New() *cobra.Command { sandboxSSHCmd.Flags().BoolP("t", "t", false, "Force pseudo-terminal allocation (like ssh -t)") sandboxSSHCmd.Flags().Bool("revoke", false, "Revoke SSH access for the sandbox") sandboxSSHCmd.Flags().Bool("print", false, "Print the SSH connection string instead of connecting") + sandboxSSHV2Cmd.Flags().BoolP("t", "t", false, "Force pseudo-terminal allocation (like ssh -t)") sandboxCodeCmd.Flags().String("editor", "cursor", "Editor or agent to open: \"cursor\", \"claude\", or \"codex\"") sandboxCodeCmd.Flags().String("path", "", "Override the remote path to open (absolute, or relative to the sandbox workspace root)") sandboxAgentSendCmd.Flags().Bool("no-wait", false, "Send the instruction and return immediately without waiting for a response") diff --git a/go/cmd/amika/sandbox/sandbox_ssh_v2.go b/go/cmd/amika/sandbox/sandbox_ssh_v2.go new file mode 100644 index 00000000..27e826c5 --- /dev/null +++ b/go/cmd/amika/sandbox/sandbox_ssh_v2.go @@ -0,0 +1,75 @@ +package sandboxcmd + +import ( + "fmt" + "os" + + "github.com/gofixpoint/amika/go/internal/basedir" + "github.com/gofixpoint/amika/go/internal/output" + "github.com/gofixpoint/amika/go/internal/runmode" + "github.com/gofixpoint/amika/go/internal/ssh" + "github.com/spf13/cobra" +) + +var sandboxSSHV2Cmd = &cobra.Command{ + Use: "sshv2 [flags] [-- ...]", + Short: "SSH through the beta direct WebSocket transport", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if runmode.Resolve(cmd) == runmode.Local { + return fmt.Errorf("direct WebSocket SSH requires a remote sandbox") + } + if err := runmode.RequireAuth(runmode.Remote, runmode.DefaultAuthChecker); err != nil { + return err + } + if err := output.RejectFlag(cmd); err != nil { + return err + } + target, err := getRemoteTarget(cmd) + if err != nil { + return err + } + client, err := getRemoteClient(target) + if err != nil { + return err + } + sandbox, err := client.GetSandbox(args[0]) + if err != nil { + return err + } + alias, err := ssh.BuildSessionAlias(sandbox.Name, sandbox.ID) + if err != nil { + return err + } + paths := basedir.New("") + identityFile, err := paths.SSHIdentityFile() + if err != nil { + return err + } + identityInfo, err := os.Stat(identityFile) + if err != nil || !identityInfo.Mode().IsRegular() || identityInfo.Mode().Perm() != 0o600 { + return fmt.Errorf("SSH identity is missing or unsafe; run \"amika secret ssh-keygen\"") + } + knownHostsFile, err := paths.SSHKnownHostsFile() + if err != nil { + return err + } + if err := ssh.ConfigureSession(paths, ssh.SessionConfig{ + IdentityFile: identityFile, + KnownHostsFile: knownHostsFile, + ProxyCommand: "amika plumbing ssh-stdio-proxy %h", + }); err != nil { + return err + } + if _, err := ssh.PrepareSessionHost( + client, + ssh.FileHostKeyPinStore{Path: knownHostsFile}, + sandbox.ID, + alias, + ); err != nil { + return err + } + forcePTY, _ := cmd.Flags().GetBool("t") + return ssh.ExecSessionSSH(alias, forcePTY, args[1:]) + }, +} diff --git a/go/cmd/amika/secrets.go b/go/cmd/amika/secrets.go index 191dc182..b0f945ea 100644 --- a/go/cmd/amika/secrets.go +++ b/go/cmd/amika/secrets.go @@ -1011,12 +1011,16 @@ func init() { rootCmd.AddCommand(secretCmd) secretCmd.AddCommand(newSecretExtractCmd()) secretCmd.AddCommand(newSecretPushCmd()) + secretCmd.AddCommand(newSSHKeygenCmd()) addProviderCommands(secretCmd, claudeProvider, false) addProviderCommands(secretCmd, codexProvider, false) rootCmd.AddCommand(secretsAliasCmd) secretsAliasCmd.AddCommand(newSecretExtractCmd()) secretsAliasCmd.AddCommand(newSecretPushCmd()) + sshKeygenAlias := newSSHKeygenCmd() + sshKeygenAlias.Hidden = true + secretsAliasCmd.AddCommand(sshKeygenAlias) addProviderCommands(secretsAliasCmd, claudeProvider, true) addProviderCommands(secretsAliasCmd, codexProvider, true) } diff --git a/go/cmd/amika/ssh_keygen.go b/go/cmd/amika/ssh_keygen.go new file mode 100644 index 00000000..22a6a105 --- /dev/null +++ b/go/cmd/amika/ssh_keygen.go @@ -0,0 +1,74 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/gofixpoint/amika/go/internal/apiclient" + "github.com/gofixpoint/amika/go/internal/basedir" + "github.com/gofixpoint/amika/go/internal/output" + "github.com/gofixpoint/amika/go/internal/runmode" + "github.com/gofixpoint/amika/go/internal/ssh" + "github.com/spf13/cobra" +) + +func newSSHKeygenCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "ssh-keygen", + Short: "Create or import a user-owned SSH key", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := runmode.RequireAuth(runmode.Remote, runmode.DefaultAuthChecker); err != nil { + return err + } + format, err := output.FormatFrom(cmd) + if err != nil { + return err + } + name, _ := cmd.Flags().GetString("name") + if strings.TrimSpace(name) == "" { + return fmt.Errorf("--name must not be empty") + } + paths := basedir.New("") + identityPath, err := paths.SSHIdentityFile() + if err != nil { + return err + } + importPath, _ := cmd.Flags().GetString("import") + var publicKey string + if importPath == "" { + publicKey, err = ssh.GenerateIdentity(identityPath) + } else { + identityPath, publicKey, err = ssh.ImportIdentity(importPath) + } + if err != nil { + return err + } + knownHostsPath, err := paths.SSHKnownHostsFile() + if err != nil { + return err + } + if err := ssh.ConfigureSession(paths, ssh.SessionConfig{ + IdentityFile: identityPath, + KnownHostsFile: knownHostsPath, + ProxyCommand: "amika plumbing ssh-stdio-proxy %h", + }); err != nil { + return err + } + summary, err := runmode.NewRemoteClient().CreateSSHPublicKey( + apiclient.CreateSSHPublicKeyRequest{Name: name, PublicKey: publicKey}, + ) + if err != nil { + return err + } + if format.IsJSON() { + return format.JSON(cmd.OutOrStdout(), summary) + } + fmt.Fprintf(cmd.OutOrStdout(), "SSH public key %q uploaded; private key remains at %s.\n", summary.Name, identityPath) + return nil + }, + } + cmd.Flags().String("import", "", "Import an existing .pub file instead of generating a key") + cmd.Flags().String("name", "default", "Name for the uploaded public key") + return cmd +} diff --git a/go/cmd/amikad/main.go b/go/cmd/amikad/main.go index 0de00f05..96c915c4 100644 --- a/go/cmd/amikad/main.go +++ b/go/cmd/amikad/main.go @@ -9,7 +9,7 @@ import ( ) func main() { - cmd := amikad.NewCommand(amikad.UnimplementedOperations{}) + cmd := amikad.NewCommand(amikad.NewProductionOperations()) if err := cmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) diff --git a/go/go.mod b/go/go.mod index 9f7fb2e1..4236d609 100644 --- a/go/go.mod +++ b/go/go.mod @@ -4,14 +4,17 @@ go 1.25.3 require ( github.com/BurntSushi/toml v1.6.0 + github.com/coder/websocket v1.8.15 github.com/danielgtaylor/huma/v2 v2.37.2 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 + golang.org/x/crypto v0.53.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect ) diff --git a/go/go.sum b/go/go.sum index 54563994..148e42dd 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,5 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/danielgtaylor/huma/v2 v2.37.2 h1:Nf9vjy2sxBJFaupPlthXL/Hy2+LurfVbaKHmCMEI7xE= github.com/danielgtaylor/huma/v2 v2.37.2/go.mod h1:95S04G/lExFRYlBkKaBaZm9lVmxRmqX9f2CgoOZ11AM= @@ -28,8 +30,14 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/go/internal/amikad/norelay/handler.go b/go/internal/amikad/norelay/handler.go index 9d74a75a..67238159 100644 --- a/go/internal/amikad/norelay/handler.go +++ b/go/internal/amikad/norelay/handler.go @@ -3,17 +3,21 @@ package norelay import ( "context" + "crypto/rand" + "encoding/hex" "errors" "io" "log/slog" "net/http" + "strings" + "sync" + "sync/atomic" ) // SSHSessionsPath is the no-relay WebSocket upgrade route. const SSHSessionsPath = "/v1/ssh-sessions" -// ErrNotImplemented marks the fail-closed no-relay handler. -var ErrNotImplemented = errors.New("no-relay SSH bridge is not implemented") +const copyBufferBytes = 32 * 1024 // Stream is one bidirectional byte stream. type Stream interface { @@ -42,16 +46,6 @@ type Dialer interface { DialContext(ctx context.Context, network, address string) (Stream, error) } -// Event is the complete metadata-only log shape. It cannot carry request -// headers, credentials, or SSH payload bytes. -type Event struct { - SessionID string - Outcome string - CloseReason string - BytesFromClient int64 - BytesFromSSHD int64 -} - // Config contains bounded bridge settings. type Config struct { MaxConnections int @@ -68,20 +62,190 @@ type Dependencies struct { // Handler is a fail-closed placeholder for the no-relay route. type Handler struct { - config Config - deps Dependencies + config Config + deps Dependencies + slots chan struct{} + active atomic.Int64 + streamsMu sync.Mutex + streams map[Stream]struct{} } // NewHandler creates a no-relay handler without opening any listener. func NewHandler(config Config, deps Dependencies) *Handler { - return &Handler{config: config, deps: deps} + if config.MaxConnections <= 0 { + config.MaxConnections = 64 + } + if config.SSHDAddress == "" { + config.SSHDAddress = "127.0.0.1:22" + } + if deps.Logger == nil { + deps.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + } + return &Handler{ + config: config, + deps: deps, + slots: make(chan struct{}, config.MaxConnections), + streams: make(map[Stream]struct{}), + } } -// ServeHTTP rejects requests until authentication, capacity accounting, and -// bounded byte copying are implemented. -func (h *Handler) ServeHTTP(w http.ResponseWriter, _ *http.Request) { - _ = h.config - _ = h.deps +// ActiveConnections returns the number of upgraded bridge sessions currently +// holding a capacity slot. +func (h *Handler) ActiveConnections() int64 { return h.active.Load() } + +// Close cancels all active bridge I/O during daemon shutdown. +func (h *Handler) Close() { + h.streamsMu.Lock() + streams := make([]Stream, 0, len(h.streams)) + for stream := range h.streams { + streams = append(streams, stream) + } + h.streamsMu.Unlock() + for _, stream := range streams { + _ = stream.Close() + } +} + +// ServeHTTP authenticates, enforces capacity, and bridges one opaque stream. +func (h *Handler) ServeHTTP(w http.ResponseWriter, request *http.Request) { w.Header().Set("Cache-Control", "no-store") - http.Error(w, ErrNotImplemented.Error(), http.StatusServiceUnavailable) + if request.URL.Path != SSHSessionsPath { + http.NotFound(w, request) + return + } + if request.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if h.deps.Verifier == nil || h.deps.Upgrader == nil || h.deps.Dialer == nil { + h.log("ssh_bridge_refused", slog.String("reason", "not_configured")) + http.Error(w, "service unavailable", http.StatusServiceUnavailable) + return + } + + token, ok := requestBearerToken(request) + if !ok || !h.deps.Verifier.Verify(token) { + h.log("ssh_bridge_refused", slog.String("reason", "unauthorized")) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + select { + case h.slots <- struct{}{}: + h.active.Add(1) + defer func() { + <-h.slots + h.active.Add(-1) + }() + default: + h.log("ssh_bridge_refused", slog.String("reason", "capacity")) + http.Error(w, "capacity exceeded", http.StatusTooManyRequests) + return + } + + websocketStream, err := h.deps.Upgrader.Upgrade(w, request) + if err != nil { + h.log("ssh_bridge_refused", slog.String("reason", "upgrade_failed")) + return + } + h.track(websocketStream) + defer h.untrack(websocketStream) + defer websocketStream.Close() + + sshdStream, err := h.deps.Dialer.DialContext( + request.Context(), + "tcp", + h.config.SSHDAddress, + ) + if err != nil { + h.log("ssh_bridge_refused", slog.String("reason", "sshd_unreachable")) + return + } + h.track(sshdStream) + defer h.untrack(sshdStream) + defer sshdStream.Close() + + sessionID := newSessionID() + h.log("ssh_bridge_open", slog.String("session_id", sessionID)) + fromClient, fromSSHD, closeReason := bridge(websocketStream, sshdStream) + h.log( + "ssh_bridge_close", + slog.String("session_id", sessionID), + slog.String("reason", closeReason), + slog.Int64("bytes_from_client", fromClient), + slog.Int64("bytes_from_sshd", fromSSHD), + ) +} + +func requestBearerToken(request *http.Request) (string, bool) { + values := request.Header.Values("Authorization") + if len(values) != 1 { + return "", false + } + scheme, token, found := strings.Cut(values[0], " ") + if !found || !strings.EqualFold(scheme, "Bearer") || token == "" || strings.ContainsAny(token, " \t\r\n") { + return "", false + } + return token, true +} + +type copyResult struct { + name string + bytes int64 + err error +} + +func bridge(websocketStream, sshdStream Stream) (fromClient, fromSSHD int64, closeReason string) { + results := make(chan copyResult, 2) + go copyStream(results, "client", sshdStream, websocketStream) + go copyStream(results, "sshd", websocketStream, sshdStream) + + first := <-results + _ = websocketStream.Close() + _ = sshdStream.Close() + second := <-results + + for _, result := range []copyResult{first, second} { + switch result.name { + case "client": + fromClient = result.bytes + case "sshd": + fromSSHD = result.bytes + } + } + if first.err != nil && !errors.Is(first.err, io.EOF) { + return fromClient, fromSSHD, first.name + "_copy_error" + } + return fromClient, fromSSHD, first.name + "_closed" +} + +func copyStream(results chan<- copyResult, name string, destination io.Writer, source io.Reader) { + buffer := make([]byte, copyBufferBytes) + written, err := io.CopyBuffer(destination, source, buffer) + results <- copyResult{name: name, bytes: written, err: err} +} + +func newSessionID() string { + var random [16]byte + if _, err := rand.Read(random[:]); err != nil { + return "unavailable" + } + return "sshs_" + hex.EncodeToString(random[:]) +} + +func (h *Handler) log(message string, attributes ...any) { + h.deps.Logger.Info(message, attributes...) +} + +func (h *Handler) track(stream Stream) { + h.streamsMu.Lock() + h.streams[stream] = struct{}{} + h.streamsMu.Unlock() +} + +func (h *Handler) untrack(stream Stream) { + h.streamsMu.Lock() + delete(h.streams, stream) + h.streamsMu.Unlock() } diff --git a/go/internal/amikad/norelay/handler_test.go b/go/internal/amikad/norelay/handler_test.go index 9a28eb96..dc118a28 100644 --- a/go/internal/amikad/norelay/handler_test.go +++ b/go/internal/amikad/norelay/handler_test.go @@ -7,7 +7,10 @@ import ( "log/slog" "net/http" "net/http/httptest" + "strings" + "sync" "testing" + "time" ) type fakeVerifier struct { @@ -118,3 +121,98 @@ func TestHandlerBridgesAuthenticatedBytesToLoopbackSSHD(t *testing.T) { t.Fatalf("websocket received %q, want server bytes", got) } } + +type blockingStream struct { + closed chan struct{} + once sync.Once +} + +func newBlockingStream() *blockingStream { return &blockingStream{closed: make(chan struct{})} } +func (s *blockingStream) Read([]byte) (int, error) { + <-s.closed + return 0, io.EOF +} +func (s *blockingStream) Write(buffer []byte) (int, error) { return len(buffer), nil } +func (s *blockingStream) Close() error { + s.once.Do(func() { close(s.closed) }) + return nil +} + +func TestHandlerEnforcesCapacityBeforeUpgrade(t *testing.T) { + websocket := newBlockingStream() + loopback := newBlockingStream() + handler := NewHandler(Config{MaxConnections: 1}, Dependencies{ + Verifier: &fakeVerifier{want: "secret-token"}, + Upgrader: &fakeUpgrader{stream: websocket}, + Dialer: &fakeDialer{stream: loopback}, + Logger: testLogger(), + }) + firstDone := make(chan struct{}) + go func() { + defer close(firstDone) + request := httptest.NewRequest(http.MethodGet, SSHSessionsPath, nil) + request.Header.Set("Authorization", "Bearer secret-token") + handler.ServeHTTP(httptest.NewRecorder(), request) + }() + deadline := time.Now().Add(time.Second) + for handler.ActiveConnections() != 1 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if handler.ActiveConnections() != 1 { + t.Fatal("first connection did not acquire capacity") + } + + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, SSHSessionsPath, nil) + request.Header.Set("Authorization", "Bearer secret-token") + handler.ServeHTTP(response, request) + if response.Code != http.StatusTooManyRequests { + t.Fatalf("status = %d, want %d", response.Code, http.StatusTooManyRequests) + } + websocket.Close() + loopback.Close() + <-firstDone +} + +func TestHandlerNeverLogsAuthorizationMaterial(t *testing.T) { + secret := "secret-authorization-material" + var logs bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&logs, nil)) + handler := NewHandler(Config{}, Dependencies{ + Verifier: &fakeVerifier{want: "different"}, + Upgrader: &fakeUpgrader{stream: newFakeStream("")}, + Dialer: &fakeDialer{stream: newFakeStream("")}, + Logger: logger, + }) + request := httptest.NewRequest(http.MethodGet, SSHSessionsPath, nil) + request.Header.Set("Authorization", "Bearer "+secret) + handler.ServeHTTP(httptest.NewRecorder(), request) + if strings.Contains(logs.String(), secret) || strings.Contains(logs.String(), "Authorization") { + t.Fatalf("logs exposed credentials: %s", logs.String()) + } +} + +func TestHandlerRejectsMalformedAuthorizationBeforeUpgrade(t *testing.T) { + for _, header := range []string{ + "secret-token", + "Bearer", + "Bearer secret-token trailing", + "Basic secret-token", + } { + upgrader := &fakeUpgrader{stream: newFakeStream("")} + dialer := &fakeDialer{stream: newFakeStream("")} + handler := NewHandler(Config{}, Dependencies{ + Verifier: &fakeVerifier{want: "secret-token"}, + Upgrader: upgrader, + Dialer: dialer, + Logger: testLogger(), + }) + request := httptest.NewRequest(http.MethodGet, SSHSessionsPath, nil) + request.Header.Set("Authorization", header) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized || upgrader.calls != 0 || dialer.calls != 0 { + t.Fatalf("header %q reached status=%d upgrade=%d dial=%d", header, response.Code, upgrader.calls, dialer.calls) + } + } +} diff --git a/go/internal/amikad/norelay/net_dialer.go b/go/internal/amikad/norelay/net_dialer.go new file mode 100644 index 00000000..3db6cf34 --- /dev/null +++ b/go/internal/amikad/norelay/net_dialer.go @@ -0,0 +1,17 @@ +package norelay + +import ( + "context" + "net" +) + +// NetDialer opens the configured loopback TCP stream with the standard +// library network dialer. +type NetDialer struct { + dialer net.Dialer +} + +// DialContext opens one cancellable network stream. +func (d *NetDialer) DialContext(ctx context.Context, network, address string) (Stream, error) { + return d.dialer.DialContext(ctx, network, address) +} diff --git a/go/internal/amikad/norelay/token.go b/go/internal/amikad/norelay/token.go new file mode 100644 index 00000000..06c06533 --- /dev/null +++ b/go/internal/amikad/norelay/token.go @@ -0,0 +1,70 @@ +package norelay + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "io/fs" +) + +// TokenBytes is the decoded size of a no-relay connect token. +const TokenBytes = 32 + +const encodedTokenBytes = 43 + +// TokenFileReader reads the current token for every upgrade so rotation takes +// effect without restarting the long-running server process. +type TokenFileReader interface { + ReadFile(string) ([]byte, error) + Lstat(string) (fs.FileInfo, error) +} + +// FileTokenVerifier compares a canonical base64url token from disk in constant +// time. It fails closed when the file is absent, linked, malformed, or not +// owner-only. +type FileTokenVerifier struct { + path string + files TokenFileReader +} + +// NewFileTokenVerifier creates a verifier for one explicit token file. +func NewFileTokenVerifier(path string, files TokenFileReader) *FileTokenVerifier { + return &FileTokenVerifier{path: path, files: files} +} + +// Verify authenticates candidate against the current token file. +func (v *FileTokenVerifier) Verify(candidate string) bool { + stored, ok := v.currentToken() + if !ok || !IsCanonicalToken(candidate) { + return false + } + storedDigest := sha256.Sum256(stored) + candidateDigest := sha256.Sum256([]byte(candidate)) + return subtle.ConstantTimeCompare(storedDigest[:], candidateDigest[:]) == 1 +} + +// Ready reports whether the current token file is safe and canonical without +// returning its contents. +func (v *FileTokenVerifier) Ready() bool { + _, ok := v.currentToken() + return ok +} + +func (v *FileTokenVerifier) currentToken() ([]byte, bool) { + info, err := v.files.Lstat(v.path) + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 || info.Size() != encodedTokenBytes { + return nil, false + } + stored, err := v.files.ReadFile(v.path) + if err != nil || !IsCanonicalToken(string(stored)) { + return nil, false + } + return stored, true +} + +// IsCanonicalToken reports whether token is exactly 32 random bytes encoded +// with unpadded URL-safe base64. +func IsCanonicalToken(token string) bool { + decoded, err := base64.RawURLEncoding.DecodeString(token) + return err == nil && len(decoded) == TokenBytes && base64.RawURLEncoding.EncodeToString(decoded) == token +} diff --git a/go/internal/amikad/norelay/token_test.go b/go/internal/amikad/norelay/token_test.go new file mode 100644 index 00000000..8c602d51 --- /dev/null +++ b/go/internal/amikad/norelay/token_test.go @@ -0,0 +1,87 @@ +package norelay + +import ( + "encoding/base64" + "io/fs" + "testing" + "time" +) + +type tokenFiles struct { + data map[string][]byte + mode fs.FileMode + reads int +} + +func (f *tokenFiles) ReadFile(path string) ([]byte, error) { + f.reads++ + data, ok := f.data[path] + if !ok { + return nil, fs.ErrNotExist + } + return append([]byte(nil), data...), nil +} + +func TestFileTokenVerifierRejectsWrongSizeBeforeReading(t *testing.T) { + path := "/var/lib/amikad/connect-token" + files := &tokenFiles{data: map[string][]byte{path: make([]byte, 1<<20)}, mode: 0o600} + verifier := NewFileTokenVerifier(path, files) + if verifier.Ready() { + t.Fatal("oversized token file was ready") + } + if files.reads != 0 { + t.Fatalf("oversized token file was read %d times", files.reads) + } +} + +func (f *tokenFiles) Lstat(path string) (fs.FileInfo, error) { + data, ok := f.data[path] + if !ok { + return nil, fs.ErrNotExist + } + return tokenFileInfo{size: int64(len(data)), mode: f.mode}, nil +} + +type tokenFileInfo struct { + size int64 + mode fs.FileMode +} + +func (tokenFileInfo) Name() string { return "connect-token" } +func (i tokenFileInfo) Size() int64 { return i.size } +func (i tokenFileInfo) Mode() fs.FileMode { return i.mode } +func (tokenFileInfo) ModTime() time.Time { return time.Time{} } +func (tokenFileInfo) IsDir() bool { return false } +func (tokenFileInfo) Sys() any { return nil } + +func TestFileTokenVerifierRejectsUnsafeTokensAndObservesRotation(t *testing.T) { + path := "/var/lib/amikad/connect-token" + first := base64.RawURLEncoding.EncodeToString(make([]byte, TokenBytes)) + secondBytes := make([]byte, TokenBytes) + secondBytes[0] = 1 + second := base64.RawURLEncoding.EncodeToString(secondBytes) + files := &tokenFiles{data: map[string][]byte{path: []byte(first)}, mode: 0o600} + verifier := NewFileTokenVerifier(path, files) + + if !verifier.Verify(first) { + t.Fatal("current token was rejected") + } + for _, candidate := range []string{"", first + "=", "not-base64", second} { + if verifier.Verify(candidate) { + t.Fatalf("unsafe candidate %q was accepted", candidate) + } + } + + files.data[path] = []byte(second) + if verifier.Verify(first) || !verifier.Verify(second) { + t.Fatal("verifier did not observe token rotation") + } + files.mode = 0o644 + if verifier.Verify(second) { + t.Fatal("world-readable token file was accepted") + } + files.mode = fs.ModeSymlink | 0o777 + if verifier.Verify(second) { + t.Fatal("symlink token file was accepted") + } +} diff --git a/go/internal/amikad/norelay/websocket.go b/go/internal/amikad/norelay/websocket.go new file mode 100644 index 00000000..e0b5eb58 --- /dev/null +++ b/go/internal/amikad/norelay/websocket.go @@ -0,0 +1,25 @@ +package norelay + +import ( + "net/http" + + "github.com/coder/websocket" + "github.com/gofixpoint/amika/go/internal/wsstream" +) + +const maxWebSocketMessageBytes = 64 * 1024 + +// WebSocketUpgrader accepts binary, uncompressed WebSocket streams with a +// bounded per-message read limit. +type WebSocketUpgrader struct{} + +// Upgrade performs the RFC 6455 handshake and returns a byte-stream adapter. +func (WebSocketUpgrader) Upgrade(w http.ResponseWriter, request *http.Request) (Stream, error) { + connection, err := websocket.Accept(w, request, &websocket.AcceptOptions{ + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + return nil, err + } + return wsstream.New(request.Context(), connection, maxWebSocketMessageBytes), nil +} diff --git a/go/internal/amikad/norelay/websocket_test.go b/go/internal/amikad/norelay/websocket_test.go new file mode 100644 index 00000000..54796b37 --- /dev/null +++ b/go/internal/amikad/norelay/websocket_test.go @@ -0,0 +1,82 @@ +package norelay + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/coder/websocket" +) + +func TestWebSocketUpgraderCarriesBinaryBytesWithoutCompression(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + stream, err := (WebSocketUpgrader{}).Upgrade(w, request) + if err != nil { + return + } + defer stream.Close() + buffer := make([]byte, 32) + read, err := stream.Read(buffer) + if err != nil { + return + } + _, _ = stream.Write(buffer[:read]) + })) + defer server.Close() + + connectURL := "ws" + strings.TrimPrefix(server.URL, "http") + connection, response, err := websocket.Dial(context.Background(), connectURL, &websocket.DialOptions{ + CompressionMode: websocket.CompressionNoContextTakeover, + }) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer connection.CloseNow() + if extension := response.Header.Get("Sec-WebSocket-Extensions"); extension != "" { + t.Fatalf("compression extension negotiated: %q", extension) + } + + want := []byte("opaque-ssh-bytes") + if err := connection.Write(context.Background(), websocket.MessageBinary, want); err != nil { + t.Fatalf("write: %v", err) + } + messageType, got, err := connection.Read(context.Background()) + if err != nil { + t.Fatalf("read: %v", err) + } + if messageType != websocket.MessageBinary || string(got) != string(want) { + t.Fatalf("message = type %v body %q, want binary %q", messageType, got, want) + } +} + +func TestWebSocketUpgraderRejectsOversizedMessages(t *testing.T) { + result := make(chan error, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + stream, err := (WebSocketUpgrader{}).Upgrade(w, request) + if err != nil { + result <- err + return + } + defer stream.Close() + _, err = io.Copy(io.Discard, stream) + result <- err + })) + defer server.Close() + + connectURL := "ws" + strings.TrimPrefix(server.URL, "http") + connection, _, err := websocket.Dial(context.Background(), connectURL, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + payload := make([]byte, maxWebSocketMessageBytes+1) + if err := connection.Write(context.Background(), websocket.MessageBinary, payload); err != nil { + t.Fatalf("write oversized message: %v", err) + } + connection.CloseNow() + if err := <-result; !strings.Contains(err.Error(), "message too big") { + t.Fatalf("server read error = %v, want message too big", err) + } +} diff --git a/go/internal/amikad/operations.go b/go/internal/amikad/operations.go new file mode 100644 index 00000000..95a8da78 --- /dev/null +++ b/go/internal/amikad/operations.go @@ -0,0 +1,192 @@ +package amikad + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/gofixpoint/amika/go/internal/amikad/norelay" + "github.com/gofixpoint/amika/go/internal/amikad/sshd" + "github.com/gofixpoint/amika/go/internal/amikad/state" +) + +const ( + defaultManifestPath = "/var/lib/amikad/injected-paths.json" + defaultTokenPath = "/var/lib/amikad/connect-token" +) + +// ErrNoServeMode marks a serve invocation with no explicitly enabled +// transport. +var ErrNoServeMode = errors.New("no amikad serve mode enabled") + +// ErrUnsafeConnectToken marks missing, malformed, or unsafe token state. +var ErrUnsafeConnectToken = errors.New("connect token is missing or unsafe") + +// SSHDManager is the daemon operation boundary implemented by package sshd. +type SSHDManager interface { + Setup(context.Context, sshd.SetupOptions) error + ShowHostKey(context.Context, io.Writer) error + SetAuthorizedKeys(context.Context, io.Reader) error + Serve(context.Context) error +} + +// DaemonOperations implements the amikad command surface. +type DaemonOperations struct { + sshd SSHDManager + store state.SensitiveStore + verifier *norelay.FileTokenVerifier + tokenPath string + logger *slog.Logger +} + +// NewDaemonOperations creates a command implementation around explicit +// security-sensitive dependencies. +func NewDaemonOperations( + sshdManager SSHDManager, + store state.SensitiveStore, + verifier *norelay.FileTokenVerifier, + tokenPath string, + logger *slog.Logger, +) *DaemonOperations { + return &DaemonOperations{ + sshd: sshdManager, store: store, verifier: verifier, tokenPath: tokenPath, logger: logger, + } +} + +// NewProductionOperations wires amikad to the host filesystem and image +// OpenSSH binaries. +func NewProductionOperations() *DaemonOperations { + files := state.OSFiles{} + store := state.NewStore(defaultManifestPath, files) + manager := sshd.NewManager( + sshd.DefaultPaths(), + store, + files, + sshd.ExecKeyGenerator{}, + sshd.ExecProcessRunner{}, + ) + logger := slog.New(slog.NewJSONHandler(os.Stderr, nil)) + return NewDaemonOperations( + manager, + store, + norelay.NewFileTokenVerifier(defaultTokenPath, files), + defaultTokenPath, + logger, + ) +} + +// SetupSSHD installs the managed OpenSSH policy. +func (o *DaemonOperations) SetupSSHD(ctx context.Context, options SetupSSHDOptions) error { + return o.sshd.Setup(ctx, sshd.SetupOptions{ForceOverwrite: options.ForceOverwrite}) +} + +// ShowHostKey prints only the canonical public host key. +func (o *DaemonOperations) ShowHostKey(ctx context.Context, output io.Writer) error { + return o.sshd.ShowHostKey(ctx, output) +} + +// SetAuthorizedKeys validates and replaces the complete authorized key set. +func (o *DaemonOperations) SetAuthorizedKeys(ctx context.Context, input io.Reader) error { + return o.sshd.SetAuthorizedKeys(ctx, input) +} + +// SetConnectToken validates canonical base64url input and stores it without a +// trailing newline through the scrub-registered sensitive store. +func (o *DaemonOperations) SetConnectToken(ctx context.Context, input io.Reader) error { + contents, err := io.ReadAll(io.LimitReader(input, 129)) + if err != nil { + return err + } + if len(contents) > 128 { + return ErrUnsafeConnectToken + } + token := strings.TrimSuffix(string(contents), "\n") + token = strings.TrimSuffix(token, "\r") + if !norelay.IsCanonicalToken(token) { + return ErrUnsafeConnectToken + } + return o.store.WriteAndRegister(ctx, o.tokenPath, strings.NewReader(token), 0o600) +} + +// Serve runs the no-relay HTTP bridge and the loopback OpenSSH child until +// either fails or ctx is cancelled. +func (o *DaemonOperations) Serve(ctx context.Context, options ServeOptions) error { + if !options.BetaNoRelay { + return ErrNoServeMode + } + if options.Port < 1 || options.Port > 65535 { + return fmt.Errorf("invalid HTTP listen port %d", options.Port) + } + if !o.verifier.Ready() { + return ErrUnsafeConnectToken + } + + handler := norelay.NewHandler( + norelay.Config{MaxConnections: 64, SSHDAddress: "127.0.0.1:22"}, + norelay.Dependencies{ + Verifier: o.verifier, + Upgrader: norelay.WebSocketUpgrader{}, + Dialer: &norelay.NetDialer{}, + Logger: o.logger, + }, + ) + mux := http.NewServeMux() + mux.Handle(norelay.SSHSessionsPath, handler) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + }) + mux.HandleFunc("/v1/status", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(struct { + Mode string `json:"mode"` + Port int `json:"port"` + ActiveConnections int64 `json:"active_connections"` + }{Mode: "beta_no_relay", Port: options.Port, ActiveConnections: handler.ActiveConnections()}) + }) + + server := &http.Server{ + Addr: ":" + strconv.Itoa(options.Port), + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + IdleTimeout: 2 * time.Minute, + MaxHeaderBytes: 16 * 1024, + } + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + type componentResult struct { + component string + err error + } + results := make(chan componentResult, 2) + go func() { results <- componentResult{component: "sshd", err: o.sshd.Serve(runCtx)} }() + go func() { results <- componentResult{component: "http", err: server.ListenAndServe()} }() + + first := <-results + cancel() + handler.Close() + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = server.Shutdown(shutdownCtx) + shutdownCancel() + select { + case <-results: + case <-time.After(5 * time.Second): + } + if ctx.Err() != nil { + return ctx.Err() + } + if first.err == nil || errors.Is(first.err, http.ErrServerClosed) { + return fmt.Errorf("%s stopped unexpectedly", first.component) + } + return fmt.Errorf("%s failed: %w", first.component, first.err) +} diff --git a/go/internal/amikad/operations_test.go b/go/internal/amikad/operations_test.go new file mode 100644 index 00000000..ce0ee29a --- /dev/null +++ b/go/internal/amikad/operations_test.go @@ -0,0 +1,115 @@ +package amikad + +import ( + "context" + "encoding/base64" + "errors" + "io" + "io/fs" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gofixpoint/amika/go/internal/amikad/norelay" + "github.com/gofixpoint/amika/go/internal/amikad/sshd" + "github.com/gofixpoint/amika/go/internal/amikad/state" +) + +type fakeSSHDManager struct { + setupOptions sshd.SetupOptions + serveCalls int +} + +func (m *fakeSSHDManager) Setup(_ context.Context, options sshd.SetupOptions) error { + m.setupOptions = options + return nil +} +func (*fakeSSHDManager) ShowHostKey(context.Context, io.Writer) error { return nil } +func (*fakeSSHDManager) SetAuthorizedKeys(context.Context, io.Reader) error { return nil } +func (m *fakeSSHDManager) Serve(context.Context) error { + m.serveCalls++ + return nil +} + +func testOperations(t *testing.T) (*DaemonOperations, string, *fakeSSHDManager) { + t.Helper() + directory := t.TempDir() + files := state.OSFiles{} + manifestPath := filepath.Join(directory, "injected-paths.json") + tokenPath := filepath.Join(directory, "connect-token") + store := state.NewStore(manifestPath, files) + manager := &fakeSSHDManager{} + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + return NewDaemonOperations( + manager, + store, + norelay.NewFileTokenVerifier(tokenPath, files), + tokenPath, + logger, + ), tokenPath, manager +} + +func TestSetConnectTokenStoresCanonicalOwnerOnlyValue(t *testing.T) { + operations, tokenPath, _ := testOperations(t) + token := base64.RawURLEncoding.EncodeToString(make([]byte, norelay.TokenBytes)) + if err := operations.SetConnectToken(context.Background(), strings.NewReader(token+"\n")); err != nil { + t.Fatalf("SetConnectToken: %v", err) + } + contents, err := os.ReadFile(tokenPath) + if err != nil { + t.Fatalf("read token: %v", err) + } + if string(contents) != token { + t.Fatalf("stored token = %q, want canonical token", contents) + } + info, err := os.Stat(tokenPath) + if err != nil { + t.Fatalf("stat token: %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("token mode = %o, want 600", info.Mode().Perm()) + } + if !operations.verifier.Verify(token) { + t.Fatal("stored token did not authenticate") + } +} + +func TestSetConnectTokenRejectsMalformedInputWithoutLeakingIt(t *testing.T) { + operations, tokenPath, _ := testOperations(t) + secret := "secret-that-must-not-appear-in-errors" + err := operations.SetConnectToken(context.Background(), strings.NewReader(secret)) + if !errors.Is(err, ErrUnsafeConnectToken) { + t.Fatalf("error = %v, want ErrUnsafeConnectToken", err) + } + if strings.Contains(err.Error(), secret) { + t.Fatalf("error leaked token: %v", err) + } + if _, err := os.Stat(tokenPath); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("invalid token created file: %v", err) + } +} + +func TestServeFailsClosedBeforeStartingSSHD(t *testing.T) { + operations, _, manager := testOperations(t) + if err := operations.Serve(context.Background(), ServeOptions{Port: DefaultPort}); !errors.Is(err, ErrNoServeMode) { + t.Fatalf("serve without mode error = %v", err) + } + if err := operations.Serve(context.Background(), ServeOptions{Port: DefaultPort, BetaNoRelay: true}); !errors.Is(err, ErrUnsafeConnectToken) { + t.Fatalf("serve without token error = %v", err) + } + if manager.serveCalls != 0 { + t.Fatalf("unsafe serve started sshd %d times", manager.serveCalls) + } +} + +func TestSetupSSHDForwardsOverwriteAuthorization(t *testing.T) { + operations, _, manager := testOperations(t) + if err := operations.SetupSSHD(context.Background(), SetupSSHDOptions{ForceOverwrite: true}); err != nil { + t.Fatalf("SetupSSHD: %v", err) + } + if !manager.setupOptions.ForceOverwrite { + t.Fatal("force-overwrite authorization was not forwarded") + } +} diff --git a/go/internal/amikad/sshd/contracts.go b/go/internal/amikad/sshd/contracts.go deleted file mode 100644 index ff696f2a..00000000 --- a/go/internal/amikad/sshd/contracts.go +++ /dev/null @@ -1,38 +0,0 @@ -// Package sshd owns loopback-only OpenSSH configuration and supervision. -package sshd - -import ( - "context" - "errors" - "io" -) - -// ErrNotImplemented marks the fail-closed sshd stub. -var ErrNotImplemented = errors.New("amikad sshd manager is not implemented") - -// Manager controls the daemon-owned OpenSSH instance. -type Manager interface { - Setup(context.Context) error - ShowHostKey(context.Context, io.Writer) error - SetAuthorizedKeys(context.Context, io.Reader) error - Serve(context.Context) error -} - -// UnimplementedManager rejects every operation without file or process changes. -type UnimplementedManager struct{} - -// Setup returns ErrNotImplemented without changing configuration. -func (UnimplementedManager) Setup(context.Context) error { return ErrNotImplemented } - -// ShowHostKey returns ErrNotImplemented without writing output. -func (UnimplementedManager) ShowHostKey(context.Context, io.Writer) error { - return ErrNotImplemented -} - -// SetAuthorizedKeys returns ErrNotImplemented without reading input. -func (UnimplementedManager) SetAuthorizedKeys(context.Context, io.Reader) error { - return ErrNotImplemented -} - -// Serve returns ErrNotImplemented without starting sshd. -func (UnimplementedManager) Serve(context.Context) error { return ErrNotImplemented } diff --git a/go/internal/amikad/sshd/manager.go b/go/internal/amikad/sshd/manager.go new file mode 100644 index 00000000..d5e81b08 --- /dev/null +++ b/go/internal/amikad/sshd/manager.go @@ -0,0 +1,362 @@ +// Package sshd owns loopback-only OpenSSH configuration and supervision. +package sshd + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/gofixpoint/amika/go/internal/amikad/state" + "golang.org/x/crypto/ssh" +) + +// ErrExistingConfiguration indicates setup would replace user-defined SSH +// state without explicit authorization. +var ErrExistingConfiguration = errors.New("existing sshd configuration requires --force-overwrite") + +// ErrInvalidPublicKey indicates malformed or option-bearing authorized-key +// input. +var ErrInvalidPublicKey = errors.New("invalid SSH public key") + +// Paths contains every filesystem location owned by the managed sshd. +type Paths struct { + Config string + HostPrivateKey string + HostPublicKey string + AuthorizedKeys string + PID string +} + +// DefaultPaths returns the production paths owned by amikad. +func DefaultPaths() Paths { + return Paths{ + Config: "/var/lib/amikad/sshd_config", + HostPrivateKey: "/var/lib/amikad/ssh_host_ed25519_key", + HostPublicKey: "/var/lib/amikad/ssh_host_ed25519_key.pub", + AuthorizedKeys: "/home/amika/.ssh/authorized_keys", + PID: "/var/lib/amikad/sshd.pid", + } +} + +// SetupOptions controls replacement of existing non-managed state. +type SetupOptions struct { + ForceOverwrite bool +} + +// KeyGenerator creates an Ed25519 OpenSSH host keypair at privatePath and +// privatePath+".pub". +type KeyGenerator interface { + Generate(ctx context.Context, privatePath string) error +} + +// ProcessRunner runs the foreground OpenSSH daemon until ctx is cancelled or +// the process exits. +type ProcessRunner interface { + Run(ctx context.Context, name string, args ...string) error +} + +// AtomicFiles supplies the filesystem operations needed by Manager. +type AtomicFiles interface { + ReadFile(string) ([]byte, error) + Lstat(string) (fs.FileInfo, error) + WriteFileAtomic(string, []byte, fs.FileMode) error +} + +// Manager configures and runs one loopback-only OpenSSH daemon. +type Manager struct { + paths Paths + store state.SensitiveStore + files AtomicFiles + keygen KeyGenerator + processes ProcessRunner +} + +// NewManager creates a manager with explicit, testable boundaries. +func NewManager( + paths Paths, + store state.SensitiveStore, + files AtomicFiles, + keygen KeyGenerator, + processes ProcessRunner, +) *Manager { + return &Manager{paths: paths, store: store, files: files, keygen: keygen, processes: processes} +} + +// Setup installs the managed policy and creates a host key only when absent. +func (m *Manager) Setup(ctx context.Context, options SetupOptions) error { + if err := validatePaths(m.paths); err != nil { + return err + } + for _, directory := range []string{ + filepath.Dir(m.paths.Config), + filepath.Dir(m.paths.AuthorizedKeys), + } { + if err := os.MkdirAll(directory, 0o700); err != nil { + return err + } + } + + desiredConfig := []byte(RenderConfig(m.paths)) + existingConfig, err := m.files.ReadFile(m.paths.Config) + switch { + case err == nil && bytes.Equal(existingConfig, desiredConfig): + case err == nil && !options.ForceOverwrite: + return ErrExistingConfiguration + case err != nil && !errors.Is(err, fs.ErrNotExist): + return err + default: + if err := m.files.WriteFileAtomic(m.paths.Config, desiredConfig, 0o600); err != nil { + return err + } + } + + privateExists, err := pathExists(m.files, m.paths.HostPrivateKey) + if err != nil { + return err + } + publicExists, err := pathExists(m.files, m.paths.HostPublicKey) + if err != nil { + return err + } + if privateExists && publicExists && validateHostKeyPair(m.files, m.paths) { + return nil + } + if (privateExists || publicExists) && !options.ForceOverwrite { + return ErrExistingConfiguration + } + for _, path := range []string{m.paths.HostPrivateKey, m.paths.HostPublicKey} { + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + } + return m.generateHostKey(ctx) +} + +// ShowHostKey writes the canonical Ed25519 public host key and no private +// material. +func (m *Manager) ShowHostKey(ctx context.Context, output io.Writer) error { + if err := ctx.Err(); err != nil { + return err + } + contents, err := m.files.ReadFile(m.paths.HostPublicKey) + if err != nil { + return err + } + key, err := canonicalPublicKey(string(contents), map[string]bool{"ssh-ed25519": true}) + if err != nil { + return err + } + _, err = io.WriteString(output, key+"\n") + return err +} + +// SetAuthorizedKeys validates and atomically replaces the complete authorized +// key set through the scrub-registered sensitive store. +func (m *Manager) SetAuthorizedKeys(ctx context.Context, input io.Reader) error { + if err := os.MkdirAll(filepath.Dir(m.paths.AuthorizedKeys), 0o700); err != nil { + return err + } + contents, err := io.ReadAll(io.LimitReader(input, (1<<20)+1)) + if err != nil { + return err + } + if len(contents) > 1<<20 { + return state.ErrSensitiveFileTooLarge + } + + allowedTypes := map[string]bool{ + "ecdsa-sha2-nistp256": true, + "sk-ecdsa-sha2-nistp256@openssh.com": true, + "sk-ssh-ed25519@openssh.com": true, + "ssh-ed25519": true, + "ssh-rsa": true, + } + keys := make([]string, 0) + seen := make(map[string]struct{}) + for _, line := range strings.Split(string(contents), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + key, err := canonicalPublicKey(line, allowedTypes) + if err != nil { + return err + } + if _, duplicate := seen[key]; !duplicate { + seen[key] = struct{}{} + keys = append(keys, key) + } + } + if len(keys) == 0 { + return ErrInvalidPublicKey + } + return m.store.WriteAndRegister( + ctx, + m.paths.AuthorizedKeys, + strings.NewReader(strings.Join(keys, "\n")+"\n"), + 0o600, + ) +} + +// Serve runs sshd in the foreground with only the managed configuration. +func (m *Manager) Serve(ctx context.Context) error { + return m.processes.Run(ctx, "sshd", "-D", "-e", "-f", m.paths.Config) +} + +func (m *Manager) generateHostKey(ctx context.Context) error { + temporaryDirectory, err := os.MkdirTemp(filepath.Dir(m.paths.HostPrivateKey), ".amikad-keygen-*") + if err != nil { + return err + } + defer os.RemoveAll(temporaryDirectory) + temporaryPrivate := filepath.Join(temporaryDirectory, "host-key") + for _, temporary := range []struct { + path string + mode fs.FileMode + }{ + {path: temporaryPrivate, mode: 0o600}, + {path: temporaryPrivate + ".pub", mode: 0o644}, + } { + if err := m.store.WriteAndRegister(ctx, temporary.path, strings.NewReader(""), temporary.mode); err != nil { + return err + } + if err := os.Remove(temporary.path); err != nil { + return err + } + } + if err := m.keygen.Generate(ctx, temporaryPrivate); err != nil { + return err + } + privateKey, err := os.ReadFile(temporaryPrivate) + if err != nil { + return err + } + publicKey, err := os.ReadFile(temporaryPrivate + ".pub") + if err != nil { + return err + } + if _, err := canonicalPublicKey(string(publicKey), map[string]bool{"ssh-ed25519": true}); err != nil { + return err + } + if err := m.store.WriteAndRegister(ctx, m.paths.HostPrivateKey, bytes.NewReader(privateKey), 0o600); err != nil { + return err + } + return m.store.WriteAndRegister(ctx, m.paths.HostPublicKey, bytes.NewReader(publicKey), 0o644) +} + +func canonicalPublicKey(line string, allowedTypes map[string]bool) (string, error) { + key, _, options, rest, err := ssh.ParseAuthorizedKey([]byte(line)) + if err != nil || len(options) != 0 || strings.TrimSpace(string(rest)) != "" || !allowedTypes[key.Type()] { + return "", ErrInvalidPublicKey + } + return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(key))), nil +} + +func pathExists(files AtomicFiles, path string) (bool, error) { + _, err := files.Lstat(path) + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +func validateHostKeyPair(files AtomicFiles, paths Paths) bool { + privateInfo, err := files.Lstat(paths.HostPrivateKey) + if err != nil || !privateInfo.Mode().IsRegular() || privateInfo.Mode().Perm() != 0o600 { + return false + } + publicInfo, err := files.Lstat(paths.HostPublicKey) + if err != nil || !publicInfo.Mode().IsRegular() || publicInfo.Mode().Perm()&0o022 != 0 { + return false + } + privateBytes, err := files.ReadFile(paths.HostPrivateKey) + if err != nil { + return false + } + signer, err := ssh.ParsePrivateKey(privateBytes) + if err != nil || signer.PublicKey().Type() != ssh.KeyAlgoED25519 { + return false + } + publicBytes, err := files.ReadFile(paths.HostPublicKey) + if err != nil { + return false + } + publicKey, _, options, rest, err := ssh.ParseAuthorizedKey(publicBytes) + if err != nil || len(options) != 0 || strings.TrimSpace(string(rest)) != "" || publicKey.Type() != ssh.KeyAlgoED25519 { + return false + } + return bytes.Equal(signer.PublicKey().Marshal(), publicKey.Marshal()) +} + +func validatePaths(paths Paths) error { + for _, path := range []string{ + paths.Config, + paths.HostPrivateKey, + paths.HostPublicKey, + paths.AuthorizedKeys, + paths.PID, + } { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return fmt.Errorf("invalid sshd path: %w", state.ErrInvalidPath) + } + } + return nil +} + +// RenderConfig returns the complete loopback-only sshd policy. +func RenderConfig(paths Paths) string { + return fmt.Sprintf(`Port 22 +ListenAddress 127.0.0.1 +Protocol 2 +HostKey %s +PidFile %s +AuthorizedKeysFile %s +AllowUsers amika +AuthenticationMethods publickey +PubkeyAuthentication yes +PasswordAuthentication no +KbdInteractiveAuthentication no +ChallengeResponseAuthentication no +PermitRootLogin no +PermitEmptyPasswords no +AllowAgentForwarding no +AllowTcpForwarding local +GatewayPorts no +X11Forwarding no +PermitTunnel no +PermitUserEnvironment no +UsePAM no +Subsystem sftp internal-sftp +`, paths.HostPrivateKey, paths.PID, paths.AuthorizedKeys) +} + +// ExecKeyGenerator invokes the image-provided OpenSSH key generator. +type ExecKeyGenerator struct{} + +// Generate creates a passwordless Ed25519 keypair. +func (ExecKeyGenerator) Generate(ctx context.Context, privatePath string) error { + command := exec.CommandContext(ctx, "ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", privatePath) + command.Stderr = os.Stderr + return command.Run() +} + +// ExecProcessRunner runs foreground processes with daemon output on stderr. +type ExecProcessRunner struct{} + +// Run executes one process without a shell or environment interpolation. +func (ExecProcessRunner) Run(ctx context.Context, name string, args ...string) error { + command := exec.CommandContext(ctx, name, args...) + command.Stdout = os.Stderr + command.Stderr = os.Stderr + return command.Run() +} diff --git a/go/internal/amikad/sshd/manager_test.go b/go/internal/amikad/sshd/manager_test.go new file mode 100644 index 00000000..0e49a360 --- /dev/null +++ b/go/internal/amikad/sshd/manager_test.go @@ -0,0 +1,205 @@ +package sshd + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gofixpoint/amika/go/internal/amikad/state" + "golang.org/x/crypto/ssh" +) + +type fakeKeyGenerator struct{ calls int } + +func (g *fakeKeyGenerator) Generate(_ context.Context, privatePath string) error { + g.calls++ + public, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return err + } + privateBlock, err := ssh.MarshalPrivateKey(private, "amikad test host key") + if err != nil { + return err + } + if err := os.WriteFile(privatePath, pem.EncodeToMemory(privateBlock), 0o600); err != nil { + return err + } + publicKey, err := ssh.NewPublicKey(public) + if err != nil { + return err + } + return os.WriteFile(privatePath+".pub", ssh.MarshalAuthorizedKey(publicKey), 0o644) +} + +type fakeProcessRunner struct { + name string + args []string +} + +func (r *fakeProcessRunner) Run(_ context.Context, name string, args ...string) error { + r.name = name + r.args = append([]string(nil), args...) + return nil +} + +func testManager(t *testing.T) (*Manager, Paths, *fakeKeyGenerator, *fakeProcessRunner) { + t.Helper() + directory := t.TempDir() + paths := Paths{ + Config: filepath.Join(directory, "state", "sshd_config"), + HostPrivateKey: filepath.Join(directory, "state", "ssh_host_ed25519_key"), + HostPublicKey: filepath.Join(directory, "state", "ssh_host_ed25519_key.pub"), + AuthorizedKeys: filepath.Join(directory, "home", ".ssh", "authorized_keys"), + PID: filepath.Join(directory, "state", "sshd.pid"), + } + files := state.OSFiles{} + store := state.NewStore(filepath.Join(directory, "injected-paths.json"), files) + keygen := &fakeKeyGenerator{} + processes := &fakeProcessRunner{} + return NewManager(paths, store, files, keygen, processes), paths, keygen, processes +} + +func TestSetupCreatesLoopbackPolicyAndIsIdempotent(t *testing.T) { + manager, paths, keygen, _ := testManager(t) + if err := manager.Setup(context.Background(), SetupOptions{}); err != nil { + t.Fatalf("Setup: %v", err) + } + if err := manager.Setup(context.Background(), SetupOptions{}); err != nil { + t.Fatalf("idempotent Setup: %v", err) + } + if keygen.calls != 1 { + t.Fatalf("keygen calls = %d, want 1", keygen.calls) + } + config, err := os.ReadFile(paths.Config) + if err != nil { + t.Fatalf("read config: %v", err) + } + for _, directive := range []string{ + "ListenAddress 127.0.0.1", + "AuthenticationMethods publickey", + "PasswordAuthentication no", + "KbdInteractiveAuthentication no", + "PermitRootLogin no", + "AllowAgentForwarding no", + "AllowTcpForwarding local", + "GatewayPorts no", + "X11Forwarding no", + "PermitTunnel no", + } { + if !strings.Contains(string(config), directive+"\n") { + t.Fatalf("config missing %q:\n%s", directive, config) + } + } + privateInfo, err := os.Stat(paths.HostPrivateKey) + if err != nil { + t.Fatalf("stat private key: %v", err) + } + if privateInfo.Mode().Perm() != 0o600 { + t.Fatalf("private key mode = %o, want 600", privateInfo.Mode().Perm()) + } +} + +func TestSetupRefusesExistingUserConfigurationUnlessForced(t *testing.T) { + manager, paths, _, _ := testManager(t) + if err := os.MkdirAll(filepath.Dir(paths.Config), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paths.Config, []byte("user-defined\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := manager.Setup(context.Background(), SetupOptions{}); !errors.Is(err, ErrExistingConfiguration) { + t.Fatalf("Setup error = %v, want ErrExistingConfiguration", err) + } + if err := manager.Setup(context.Background(), SetupOptions{ForceOverwrite: true}); err != nil { + t.Fatalf("forced Setup: %v", err) + } + config, _ := os.ReadFile(paths.Config) + if string(config) != RenderConfig(paths) { + t.Fatalf("forced config = %q, want managed policy", config) + } +} + +func TestSetupRefusesInvalidExistingHostKeyUnlessForced(t *testing.T) { + manager, paths, keygen, _ := testManager(t) + if err := os.MkdirAll(filepath.Dir(paths.HostPrivateKey), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paths.Config, []byte(RenderConfig(paths)), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paths.HostPrivateKey, []byte("invalid"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paths.HostPublicKey, []byte("invalid"), 0o644); err != nil { + t.Fatal(err) + } + if err := manager.Setup(context.Background(), SetupOptions{}); !errors.Is(err, ErrExistingConfiguration) { + t.Fatalf("Setup error = %v, want ErrExistingConfiguration", err) + } + if err := manager.Setup(context.Background(), SetupOptions{ForceOverwrite: true}); err != nil { + t.Fatalf("forced Setup: %v", err) + } + if keygen.calls != 1 { + t.Fatalf("keygen calls = %d, want 1", keygen.calls) + } +} + +func TestAuthorizedKeysRejectsOptionsAndWritesCanonicalKeys(t *testing.T) { + manager, paths, _, _ := testManager(t) + key := newTestAuthorizedKey(t) + optionBearing := "command=\"touch /tmp/pwned\" " + key + if err := manager.SetAuthorizedKeys(context.Background(), strings.NewReader(optionBearing)); !errors.Is(err, ErrInvalidPublicKey) { + t.Fatalf("option-bearing key error = %v, want ErrInvalidPublicKey", err) + } + input := key + " comment\n" + key + " duplicate\n" + if err := manager.SetAuthorizedKeys(context.Background(), strings.NewReader(input)); err != nil { + t.Fatalf("SetAuthorizedKeys: %v", err) + } + got, err := os.ReadFile(paths.AuthorizedKeys) + if err != nil { + t.Fatalf("read authorized keys: %v", err) + } + want := key + "\n" + if string(got) != want { + t.Fatalf("authorized keys = %q, want %q", got, want) + } +} + +func newTestAuthorizedKey(t *testing.T) string { + t.Helper() + public, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + key, err := ssh.NewPublicKey(public) + if err != nil { + t.Fatal(err) + } + return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(key))) +} + +func TestShowHostKeyAndServeUseOnlyPublicManagedState(t *testing.T) { + manager, _, _, processes := testManager(t) + if err := manager.Setup(context.Background(), SetupOptions{}); err != nil { + t.Fatalf("Setup: %v", err) + } + var output strings.Builder + if err := manager.ShowHostKey(context.Background(), &output); err != nil { + t.Fatalf("ShowHostKey: %v", err) + } + if !strings.HasPrefix(output.String(), "ssh-ed25519 ") || strings.Contains(output.String(), "PRIVATE") { + t.Fatalf("unsafe host-key output %q", output.String()) + } + if err := manager.Serve(context.Background()); err != nil { + t.Fatalf("Serve: %v", err) + } + if processes.name != "sshd" || strings.Join(processes.args, " ") != "-D -e -f "+manager.paths.Config { + t.Fatalf("process = %q %#v", processes.name, processes.args) + } +} diff --git a/go/internal/amikad/state/contracts.go b/go/internal/amikad/state/contracts.go index 305a1109..1d7df188 100644 --- a/go/internal/amikad/state/contracts.go +++ b/go/internal/amikad/state/contracts.go @@ -3,20 +3,30 @@ package state import ( "context" + "encoding/json" "errors" "io" "io/fs" + "path/filepath" + "slices" + "sync" ) -// ErrNotImplemented marks the fail-closed state stub. -var ErrNotImplemented = errors.New("amikad state store is not implemented") - // ErrInvalidPath marks a sensitive target that is not an absolute clean path. var ErrInvalidPath = errors.New("invalid sensitive file path") // ErrSymlinkPath marks a sensitive target that resolves through a symlink. var ErrSymlinkPath = errors.New("sensitive file path contains a symlink") +// ErrInvalidManifest marks a scrub manifest that cannot be safely extended. +var ErrInvalidManifest = errors.New("invalid injected-paths manifest") + +// ErrSensitiveFileTooLarge marks input that exceeds the bounded in-memory +// secret size accepted by the atomic writer. +var ErrSensitiveFileTooLarge = errors.New("sensitive file exceeds size limit") + +const maxSensitiveFileBytes = 1 << 20 + // SensitiveStore first registers an absolute path by atomically replacing the // scrub manifest, then atomically replaces the sensitive file. A stale manifest // entry is safe; an unregistered sensitive file is not. @@ -33,6 +43,7 @@ type FileSystem interface { ReadFile(string) ([]byte, error) Lstat(string) (fs.FileInfo, error) WriteFileAtomic(string, []byte, fs.FileMode) error + WithLock(context.Context, string, func() error) error } // Store is the manifest-backed sensitive writer. Its implementation remains @@ -40,6 +51,7 @@ type FileSystem interface { type Store struct { manifestPath string files FileSystem + mu sync.Mutex } // NewStore creates a sensitive writer for an explicit manifest path. @@ -47,35 +59,113 @@ func NewStore(manifestPath string, files FileSystem) *Store { return &Store{manifestPath: manifestPath, files: files} } -// WriteAndRegister returns ErrNotImplemented without reading input or touching disk. +// WriteAndRegister registers the target before atomically replacing it. func (s *Store) WriteAndRegister( ctx context.Context, absolutePath string, contents io.Reader, mode fs.FileMode, ) error { - _ = ctx - _ = absolutePath - _ = contents - _ = mode - _ = s.manifestPath - _ = s.files - return ErrNotImplemented + if err := ctx.Err(); err != nil { + return err + } + if !isCleanAbsolutePath(absolutePath) || absolutePath == s.manifestPath { + return ErrInvalidPath + } + if mode != mode.Perm() { + return ErrInvalidPath + } + + data, err := io.ReadAll(io.LimitReader(contents, maxSensitiveFileBytes+1)) + if err != nil { + return err + } + if len(data) > maxSensitiveFileBytes { + return ErrSensitiveFileTooLarge + } + + s.mu.Lock() + defer s.mu.Unlock() + + if err := ctx.Err(); err != nil { + return err + } + if err := rejectSymlinkComponents(s.files, s.manifestPath); err != nil { + return err + } + if err := rejectSymlinkComponents(s.files, absolutePath); err != nil { + return err + } + + return s.files.WithLock(ctx, s.manifestPath+".lock", func() error { + paths, err := readManifest(s.files, s.manifestPath) + if err != nil { + return err + } + if !slices.Contains(paths, absolutePath) { + paths = append(paths, absolutePath) + } + manifest, err := json.Marshal(paths) + if err != nil { + return errors.Join(ErrInvalidManifest, err) + } + manifest = append(manifest, '\n') + + if err := s.files.WriteFileAtomic(s.manifestPath, manifest, 0o600); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + return s.files.WriteFileAtomic(absolutePath, data, mode) + }) } -// UnimplementedStore rejects writes without reading input or touching disk. -type UnimplementedStore struct{} +func readManifest(files FileSystem, manifestPath string) ([]string, error) { + data, err := files.ReadFile(manifestPath) + if errors.Is(err, fs.ErrNotExist) { + return []string{}, nil + } + if err != nil { + return nil, err + } -// WriteAndRegister returns ErrNotImplemented without mutating state. -func (UnimplementedStore) WriteAndRegister( - ctx context.Context, - absolutePath string, - contents io.Reader, - mode fs.FileMode, -) error { - _ = ctx - _ = absolutePath - _ = contents - _ = mode - return ErrNotImplemented + var paths []string + if err := json.Unmarshal(data, &paths); err != nil || paths == nil { + return nil, errors.Join(ErrInvalidManifest, err) + } + seen := make(map[string]struct{}, len(paths)) + for _, path := range paths { + if !isCleanAbsolutePath(path) || path == manifestPath { + return nil, ErrInvalidManifest + } + if _, duplicate := seen[path]; duplicate { + return nil, ErrInvalidManifest + } + seen[path] = struct{}{} + } + return paths, nil +} + +func isCleanAbsolutePath(path string) bool { + return filepath.IsAbs(path) && filepath.Clean(path) == path +} + +func rejectSymlinkComponents(files FileSystem, path string) error { + if !isCleanAbsolutePath(path) { + return ErrInvalidPath + } + for current := path; ; current = filepath.Dir(current) { + info, err := files.Lstat(current) + if err == nil && info.Mode()&fs.ModeSymlink != 0 { + return ErrSymlinkPath + } + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + parent := filepath.Dir(current) + if parent == current { + return nil + } + } } diff --git a/go/internal/amikad/state/contracts_test.go b/go/internal/amikad/state/contracts_test.go index e596e2a0..a363c562 100644 --- a/go/internal/amikad/state/contracts_test.go +++ b/go/internal/amikad/state/contracts_test.go @@ -4,7 +4,9 @@ import ( "context" "encoding/json" "errors" + "fmt" "io/fs" + "os" "path/filepath" "strings" "sync" @@ -71,6 +73,10 @@ func (f *memoryFiles) WriteFileAtomic(path string, data []byte, mode fs.FileMode return nil } +func (f *memoryFiles) WithLock(_ context.Context, _ string, operation func() error) error { + return operation() +} + type fakeFileInfo struct { size int64 mode fs.FileMode @@ -234,6 +240,38 @@ func TestStoreSerializesConcurrentManifestUpdates(t *testing.T) { assertManifestPathSet(t, files.contents[manifestPath], paths) } +func TestOSStoresSerializeManifestAcrossInstances(t *testing.T) { + directory := t.TempDir() + manifestPath := filepath.Join(directory, "injected-paths.json") + files := OSFiles{} + stores := []*Store{NewStore(manifestPath, files), NewStore(manifestPath, files)} + paths := make([]string, 40) + var wg sync.WaitGroup + errs := make(chan error, len(paths)) + for index := range paths { + paths[index] = filepath.Join(directory, fmt.Sprintf("secret-%d", index)) + wg.Add(1) + go func(index int) { + defer wg.Done() + errs <- stores[index%len(stores)].WriteAndRegister( + context.Background(), paths[index], strings.NewReader("secret"), 0o600, + ) + }(index) + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("WriteAndRegister: %v", err) + } + } + manifest, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + assertManifestPathSet(t, manifest, paths) +} + func assertManifestPaths(t *testing.T, data []byte, want []string) { t.Helper() var got []string diff --git a/go/internal/amikad/state/os_files.go b/go/internal/amikad/state/os_files.go new file mode 100644 index 00000000..44349050 --- /dev/null +++ b/go/internal/amikad/state/os_files.go @@ -0,0 +1,15 @@ +package state + +import ( + "io/fs" + "os" +) + +// OSFiles atomically replaces files on the host filesystem. +type OSFiles struct{} + +// ReadFile reads a complete file. +func (OSFiles) ReadFile(path string) ([]byte, error) { return os.ReadFile(path) } + +// Lstat reads metadata without following the final symlink. +func (OSFiles) Lstat(path string) (fs.FileInfo, error) { return os.Lstat(path) } diff --git a/go/internal/amikad/state/os_files_linux.go b/go/internal/amikad/state/os_files_linux.go new file mode 100644 index 00000000..7635415c --- /dev/null +++ b/go/internal/amikad/state/os_files_linux.go @@ -0,0 +1,108 @@ +//go:build linux + +package state + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "syscall" + "time" +) + +// WriteFileAtomic writes and renames relative to a no-symlink directory file +// descriptor, preventing path-component swaps between validation and commit. +func (OSFiles) WriteFileAtomic(path string, data []byte, mode fs.FileMode) (returnErr error) { + directoryFD, err := openDirectoryNoSymlinks(filepath.Dir(path)) + if err != nil { + return err + } + defer syscall.Close(directoryFD) + + var random [12]byte + if _, err := rand.Read(random[:]); err != nil { + return err + } + temporaryName := ".amikad-atomic-" + hex.EncodeToString(random[:]) + temporaryFD, err := syscall.Openat( + directoryFD, + temporaryName, + syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL|syscall.O_NOFOLLOW, + uint32(mode.Perm()), + ) + if err != nil { + return err + } + temporary := os.NewFile(uintptr(temporaryFD), temporaryName) + defer func() { + _ = syscall.Unlinkat(directoryFD, temporaryName) + }() + if _, err := temporary.Write(data); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Chmod(mode); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := syscall.Renameat(directoryFD, temporaryName, directoryFD, filepath.Base(path)); err != nil { + return err + } + return syscall.Fsync(directoryFD) +} + +// WithLock serializes a complete manifest transaction across amikad processes. +func (OSFiles) WithLock(ctx context.Context, path string, operation func() error) error { + lock, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return err + } + defer lock.Close() + for { + err = syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err == nil { + break + } + if !errors.Is(err, syscall.EWOULDBLOCK) && !errors.Is(err, syscall.EAGAIN) { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(10 * time.Millisecond): + } + } + defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) + return operation() +} + +func openDirectoryNoSymlinks(path string) (int, error) { + current, err := syscall.Open("/", syscall.O_RDONLY|syscall.O_DIRECTORY, 0) + if err != nil { + return -1, err + } + for _, component := range strings.Split(strings.TrimPrefix(filepath.Clean(path), "/"), "/") { + if component == "" { + continue + } + next, err := syscall.Openat(current, component, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW, 0) + _ = syscall.Close(current) + if err != nil { + return -1, err + } + current = next + } + return current, nil +} diff --git a/go/internal/amikad/state/os_files_other.go b/go/internal/amikad/state/os_files_other.go new file mode 100644 index 00000000..f7b4fb69 --- /dev/null +++ b/go/internal/amikad/state/os_files_other.go @@ -0,0 +1,22 @@ +//go:build !linux + +package state + +import ( + "context" + "errors" + "io/fs" +) + +var errSecureAtomicWritesUnsupported = errors.New("amikad sensitive writes require Linux") + +// WriteFileAtomic fails closed where fd-relative no-symlink writes are not +// implemented. +func (OSFiles) WriteFileAtomic(string, []byte, fs.FileMode) error { + return errSecureAtomicWritesUnsupported +} + +// WithLock fails closed where interprocess manifest locking is not implemented. +func (OSFiles) WithLock(context.Context, string, func() error) error { + return errSecureAtomicWritesUnsupported +} diff --git a/go/internal/apiclient/ssh_session.go b/go/internal/apiclient/ssh_session.go index 14b30c97..96117d1a 100644 --- a/go/internal/apiclient/ssh_session.go +++ b/go/internal/apiclient/ssh_session.go @@ -1,9 +1,15 @@ package apiclient -import "errors" +import ( + "encoding/base64" + "errors" + "fmt" + "net/url" + "strings" +) -// ErrSSHSessionNotImplemented marks the fail-closed direct-session client stub. -var ErrSSHSessionNotImplemented = errors.New("SSH session API is not implemented") +// ErrInvalidSSHSession marks an unsafe or internally inconsistent descriptor. +var ErrInvalidSSHSession = errors.New("invalid SSH session descriptor") // SSHSessionTransport selects how the stdio proxy reaches the sandbox. type SSHSessionTransport string @@ -22,14 +28,76 @@ type SSHSession struct { HostPublicKey string `json:"host_public_key"` } -// Validate fails closed until descriptor validation is implemented. -func (s *SSHSession) Validate(_ string) error { - _ = s - return ErrSSHSessionNotImplemented +// CreateSSHPublicKeyRequest uploads one user-owned public key. +type CreateSSHPublicKeyRequest struct { + Name string `json:"name"` + PublicKey string `json:"public_key"` } -// CreateSSHSession fails closed until the v2 API contract is implemented. -func (c *Client) CreateSSHSession(_ string) (*SSHSession, error) { - _ = c - return nil, ErrSSHSessionNotImplemented +// SSHPublicKeySummary is the non-secret key metadata returned by the API. +type SSHPublicKeySummary struct { + ID string `json:"id"` + Name string `json:"name"` + PublicKey string `json:"public_key"` + Scope string `json:"scope"` +} + +// Validate checks every field used by OpenSSH or the WebSocket dialer. +func (s *SSHSession) Validate(expectedSandboxID string) error { + if s.SessionID == "" || len(s.SessionID) > 128 || !strings.HasPrefix(s.SessionID, "sshs_") { + return ErrInvalidSSHSession + } + if s.Transport != SSHSessionTransportDirectWS || s.SandboxID != expectedSandboxID || s.SSHUser != "amika" { + return ErrInvalidSSHSession + } + if !isCanonicalConnectToken(s.ConnectCredential) || canonicalEd25519Key(s.HostPublicKey) == "" { + return ErrInvalidSSHSession + } + connectURL, err := url.Parse(s.ConnectURL) + if err != nil || connectURL.Scheme != "wss" || connectURL.Host == "" || connectURL.User != nil || connectURL.Fragment != "" { + return ErrInvalidSSHSession + } + if !strings.HasSuffix(connectURL.EscapedPath(), "/v1/ssh-sessions") { + return ErrInvalidSSHSession + } + return nil +} + +// CreateSSHSession creates and validates a fresh descriptor for one dial. +func (c *Client) CreateSSHSession(sandboxID string) (*SSHSession, error) { + var result SSHSession + path := apiBasePath + "/sandboxes/" + url.PathEscape(sandboxID) + "/ssh-sessions" + if err := c.doJSON("POST", path, nil, &result); err != nil { + return nil, fmt.Errorf("remote create SSH session: %w", err) + } + if err := result.Validate(sandboxID); err != nil { + return nil, err + } + return &result, nil +} + +// CreateSSHPublicKey stores a user-scoped SSH public key. +func (c *Client) CreateSSHPublicKey(request CreateSSHPublicKeyRequest) (*SSHPublicKeySummary, error) { + var result SSHPublicKeySummary + if err := c.doJSON("POST", apiBasePath+"/secrets/ssh-public-keys", request, &result); err != nil { + return nil, fmt.Errorf("remote create SSH public key: %w", err) + } + return &result, nil +} + +func isCanonicalConnectToken(value string) bool { + decoded, err := base64.RawURLEncoding.DecodeString(value) + return err == nil && len(decoded) == 32 && base64.RawURLEncoding.EncodeToString(decoded) == value +} + +func canonicalEd25519Key(value string) string { + fields := strings.Fields(value) + if len(fields) < 2 || fields[0] != "ssh-ed25519" || strings.ContainsAny(value, "\r\n") { + return "" + } + decoded, err := base64.StdEncoding.DecodeString(fields[1]) + if err != nil || len(decoded) < 16 { + return "" + } + return fields[0] + " " + fields[1] } diff --git a/go/internal/apiclient/ssh_session_test.go b/go/internal/apiclient/ssh_session_test.go index 7575030a..49e9b4a5 100644 --- a/go/internal/apiclient/ssh_session_test.go +++ b/go/internal/apiclient/ssh_session_test.go @@ -1,6 +1,7 @@ package apiclient import ( + "encoding/base64" "encoding/json" "net/http" "net/http/httptest" @@ -8,6 +9,8 @@ import ( ) func TestCreateSSHSessionPostsForEveryDial(t *testing.T) { + token := base64.RawURLEncoding.EncodeToString(make([]byte, 32)) + key := base64.StdEncoding.EncodeToString([]byte("valid fake ed25519 public key material")) var calls int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ @@ -24,10 +27,10 @@ func TestCreateSSHSessionPostsForEveryDial(t *testing.T) { SessionID: "sshs_1", Transport: SSHSessionTransportDirectWS, ConnectURL: "wss://sandbox.example/v1/ssh-sessions", - ConnectCredential: "connect-token", + ConnectCredential: token, SandboxID: "sbx_123", SSHUser: "amika", - HostPublicKey: "ssh-ed25519 AAAAtest", + HostPublicKey: "ssh-ed25519 " + key, }) })) defer server.Close() @@ -48,14 +51,16 @@ func TestCreateSSHSessionPostsForEveryDial(t *testing.T) { } func TestSSHSessionValidateAcceptsOnlyMatchingDirectWSSession(t *testing.T) { + token := base64.RawURLEncoding.EncodeToString(make([]byte, 32)) + key := base64.StdEncoding.EncodeToString([]byte("valid fake ed25519 public key material")) valid := SSHSession{ SessionID: "sshs_1", Transport: SSHSessionTransportDirectWS, ConnectURL: "wss://sandbox.example/v1/ssh-sessions", - ConnectCredential: "connect-token", + ConnectCredential: token, SandboxID: "sbx_123", SSHUser: "amika", - HostPublicKey: "ssh-ed25519 AAAAtest", + HostPublicKey: "ssh-ed25519 " + key, } if err := valid.Validate("sbx_123"); err != nil { t.Fatalf("Validate valid session: %v", err) diff --git a/go/internal/basedir/basedir.go b/go/internal/basedir/basedir.go index 1d60034c..8ac9dc0d 100644 --- a/go/internal/basedir/basedir.go +++ b/go/internal/basedir/basedir.go @@ -23,6 +23,8 @@ const ( sshDirName = ".ssh" sshConfigFile = "config" sshAmikaConfigFile = "amika.conf" + sshKnownHostsFile = "amika_known_hosts" + sshIdentityFile = "amika_id_ed25519" claudeDirName = ".claude" claudeSettingsFile = "settings.json" codexDirName = ".codex" @@ -61,6 +63,8 @@ type Paths interface { SSHDir() (string, error) SSHConfigFile() (string, error) SSHAmikaConfigFile() (string, error) + SSHKnownHostsFile() (string, error) + SSHIdentityFile() (string, error) ClaudeSettingsFile() (string, error) CodexConfigFile() (string, error) @@ -281,6 +285,24 @@ func (p *xdgPaths) SSHAmikaConfigFile() (string, error) { return filepath.Join(dir, sshAmikaConfigFile), nil } +// SSHKnownHostsFile returns the dedicated strict host-key pin file. +func (p *xdgPaths) SSHKnownHostsFile() (string, error) { + dir, err := p.SSHDir() + if err != nil { + return "", err + } + return filepath.Join(dir, sshKnownHostsFile), nil +} + +// SSHIdentityFile returns the default user-owned Ed25519 private key path. +func (p *xdgPaths) SSHIdentityFile() (string, error) { + dir, err := p.SSHDir() + if err != nil { + return "", err + } + return filepath.Join(dir, sshIdentityFile), nil +} + // ClaudeSettingsFile returns the Claude Desktop / Claude Code settings file at // ~/.claude/settings.json, where SSH environments live under `sshConfigs`. func (p *xdgPaths) ClaudeSettingsFile() (string, error) { diff --git a/go/internal/basedir/basedir_test.go b/go/internal/basedir/basedir_test.go index 02e14832..52766c30 100644 --- a/go/internal/basedir/basedir_test.go +++ b/go/internal/basedir/basedir_test.go @@ -96,6 +96,12 @@ func TestPaths_SSHPaths(t *testing.T) { if got, _ := p.SSHAmikaConfigFile(); got != filepath.Join(home, ".ssh", "amika.conf") { t.Fatalf("SSHAmikaConfigFile = %q", got) } + if got, _ := p.SSHKnownHostsFile(); got != filepath.Join(home, ".ssh", "amika_known_hosts") { + t.Fatalf("SSHKnownHostsFile = %q", got) + } + if got, _ := p.SSHIdentityFile(); got != filepath.Join(home, ".ssh", "amika_id_ed25519") { + t.Fatalf("SSHIdentityFile = %q", got) + } } func TestSSHAmikaConfigName(t *testing.T) { diff --git a/go/internal/filelock/filelock.go b/go/internal/filelock/filelock.go new file mode 100644 index 00000000..0e0ea7cc --- /dev/null +++ b/go/internal/filelock/filelock.go @@ -0,0 +1,43 @@ +// Package filelock provides cancellable cross-process advisory file locks. +package filelock + +import ( + "context" + "errors" + "os" + "time" +) + +// Lock is one held exclusive advisory lock. +type Lock struct { + file *os.File +} + +// Acquire waits until path can be locked or ctx is cancelled. +func Acquire(ctx context.Context, path string) (*Lock, error) { + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, err + } + for { + locked, err := tryLock(file) + if err != nil { + _ = file.Close() + return nil, err + } + if locked { + return &Lock{file: file}, nil + } + select { + case <-ctx.Done(): + _ = file.Close() + return nil, ctx.Err() + case <-time.After(10 * time.Millisecond): + } + } +} + +// Close unlocks and closes the lock file. +func (l *Lock) Close() error { + return errors.Join(unlock(l.file), l.file.Close()) +} diff --git a/go/internal/filelock/lock_unix.go b/go/internal/filelock/lock_unix.go new file mode 100644 index 00000000..f9443416 --- /dev/null +++ b/go/internal/filelock/lock_unix.go @@ -0,0 +1,21 @@ +//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris + +package filelock + +import ( + "errors" + "os" + "syscall" +) + +func tryLock(file *os.File) (bool, error) { + err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) { + return false, nil + } + return err == nil, err +} + +func unlock(file *os.File) error { + return syscall.Flock(int(file.Fd()), syscall.LOCK_UN) +} diff --git a/go/internal/filelock/lock_windows.go b/go/internal/filelock/lock_windows.go new file mode 100644 index 00000000..9ed6d73a --- /dev/null +++ b/go/internal/filelock/lock_windows.go @@ -0,0 +1,30 @@ +//go:build windows + +package filelock + +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) + +func tryLock(file *os.File) (bool, error) { + overlapped := &windows.Overlapped{} + err := windows.LockFileEx( + windows.Handle(file.Fd()), + windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, + 0, + 1, + 0, + overlapped, + ) + if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return false, nil + } + return err == nil, err +} + +func unlock(file *os.File) error { + return windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, &windows.Overlapped{}) +} diff --git a/go/internal/ssh/config.go b/go/internal/ssh/config.go index 6cf320b1..c0661f10 100644 --- a/go/internal/ssh/config.go +++ b/go/internal/ssh/config.go @@ -35,7 +35,8 @@ type HostEntry struct { // HostsState is the source of truth from which ~/.ssh/amika.conf is rendered. type HostsState struct { - Hosts []HostEntry `json:"hosts"` + Hosts []HostEntry `json:"hosts"` + SessionConfig *SessionConfig `json:"session_config,omitempty"` } // Alias returns the stable SSH host alias for a sandbox id. Cursor keys its @@ -218,6 +219,13 @@ func Render(state HostsState) string { } b.WriteString(" StrictHostKeyChecking accept-new\n") } + if state.SessionConfig != nil { + block, err := RenderSessionConfig(*state.SessionConfig) + if err == nil { + b.WriteString("\n# No-relay WebSocket SSH aliases.\n") + b.WriteString(block) + } + } return b.String() } @@ -247,6 +255,11 @@ func LoadState(paths basedir.Paths) (HostsState, error) { // SaveState writes the SSH hosts state atomically with owner-only permissions. func SaveState(paths basedir.Paths, state HostsState) error { + if state.SessionConfig != nil { + if _, err := RenderSessionConfig(*state.SessionConfig); err != nil { + return err + } + } path, err := paths.SSHHostsStateFile() if err != nil { return err @@ -260,6 +273,11 @@ func SaveState(paths basedir.Paths, state HostsState) error { // WriteAmikaConfig renders the state to ~/.ssh/amika.conf atomically. func WriteAmikaConfig(paths basedir.Paths, state HostsState) error { + if state.SessionConfig != nil { + if _, err := RenderSessionConfig(*state.SessionConfig); err != nil { + return err + } + } path, err := paths.SSHAmikaConfigFile() if err != nil { return err @@ -267,6 +285,26 @@ func WriteAmikaConfig(paths basedir.Paths, state HostsState) error { return writeFileAtomic(path, []byte(Render(state)), 0o600) } +// ConfigureSession persists and renders the strict wildcard session block +// without disturbing legacy provider-native host entries. +func ConfigureSession(paths basedir.Paths, config SessionConfig) error { + if _, err := RenderSessionConfig(config); err != nil { + return err + } + state, err := LoadState(paths) + if err != nil { + return err + } + state.SessionConfig = &config + if err := SaveState(paths, state); err != nil { + return err + } + if err := WriteAmikaConfig(paths, state); err != nil { + return err + } + return EnsureInclude(paths) +} + // EnsureInclude makes sure ~/.ssh/config pulls in the managed amika.conf via an // Include directive near the top (Include must precede Host blocks to take // effect, since ssh resolves options first-match-wins). It is idempotent and diff --git a/go/internal/ssh/identity.go b/go/internal/ssh/identity.go new file mode 100644 index 00000000..f24776e8 --- /dev/null +++ b/go/internal/ssh/identity.go @@ -0,0 +1,92 @@ +package ssh + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + cryptossh "golang.org/x/crypto/ssh" +) + +// GenerateIdentity creates or validates an unencrypted user-owned Ed25519 +// keypair and returns its canonical public key. +func GenerateIdentity(privatePath string) (string, error) { + publicPath := privatePath + ".pub" + privateData, privateErr := os.ReadFile(privatePath) + publicData, publicErr := os.ReadFile(publicPath) + if privateErr == nil || publicErr == nil { + if privateErr != nil || publicErr != nil { + return "", fmt.Errorf("incomplete SSH identity pair") + } + privateInfo, err := os.Stat(privatePath) + if err != nil || privateInfo.Mode().Perm() != 0o600 { + return "", fmt.Errorf("SSH private key must have mode 0600") + } + signer, err := cryptossh.ParsePrivateKey(privateData) + if err != nil || signer.PublicKey().Type() != cryptossh.KeyAlgoED25519 { + return "", fmt.Errorf("existing SSH private key is not a valid Ed25519 key") + } + canonical, err := canonicalHostPublicKey(string(publicData)) + if err != nil || !bytes.Equal(signer.PublicKey().Marshal(), mustParsePublicKey(canonical).Marshal()) { + return "", fmt.Errorf("existing SSH keypair does not match") + } + return canonical, nil + } + if !errors.Is(privateErr, os.ErrNotExist) || !errors.Is(publicErr, os.ErrNotExist) { + return "", errors.Join(privateErr, publicErr) + } + + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return "", err + } + privateBlock, err := cryptossh.MarshalPrivateKey(privateKey, "amika SSH identity") + if err != nil { + return "", err + } + publicKey, err := cryptossh.NewPublicKey(privateKey.Public()) + if err != nil { + return "", err + } + canonical := strings.TrimSpace(string(cryptossh.MarshalAuthorizedKey(publicKey))) + if err := writeFileAtomic(privatePath, pem.EncodeToMemory(privateBlock), 0o600); err != nil { + return "", err + } + if err := writeFileAtomic(publicPath, []byte(canonical+"\n"), 0o644); err != nil { + return "", err + } + return canonical, nil +} + +// ImportIdentity validates a public key and its conventional matching private +// key path without copying private material. +func ImportIdentity(publicPath string) (identityPath, canonicalPublicKey string, err error) { + if filepath.Ext(publicPath) != ".pub" { + return "", "", fmt.Errorf("imported public key path must end in .pub") + } + publicData, err := os.ReadFile(publicPath) + if err != nil { + return "", "", err + } + canonical, err := canonicalHostPublicKey(string(publicData)) + if err != nil { + return "", "", err + } + privatePath := strings.TrimSuffix(publicPath, ".pub") + privateInfo, err := os.Stat(privatePath) + if err != nil || !privateInfo.Mode().IsRegular() || privateInfo.Mode().Perm()&0o077 != 0 { + return "", "", fmt.Errorf("matching private key is missing or not owner-only") + } + return privatePath, canonical, nil +} + +func mustParsePublicKey(value string) cryptossh.PublicKey { + key, _, _, _, _ := cryptossh.ParseAuthorizedKey([]byte(value)) + return key +} diff --git a/go/internal/ssh/identity_test.go b/go/internal/ssh/identity_test.go new file mode 100644 index 00000000..3d9ef5bd --- /dev/null +++ b/go/internal/ssh/identity_test.go @@ -0,0 +1,29 @@ +package ssh + +import ( + "os" + "path/filepath" + "testing" +) + +func TestGenerateIdentityIsOwnerOnlyAndIdempotent(t *testing.T) { + privatePath := filepath.Join(t.TempDir(), ".ssh", "amika_id_ed25519") + first, err := GenerateIdentity(privatePath) + if err != nil { + t.Fatalf("GenerateIdentity: %v", err) + } + second, err := GenerateIdentity(privatePath) + if err != nil { + t.Fatalf("idempotent GenerateIdentity: %v", err) + } + if first != second { + t.Fatal("idempotent generation changed public key") + } + info, err := os.Stat(privatePath) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("private mode = %o, want 600", info.Mode().Perm()) + } +} diff --git a/go/internal/ssh/known_hosts.go b/go/internal/ssh/known_hosts.go new file mode 100644 index 00000000..7a4d3d1a --- /dev/null +++ b/go/internal/ssh/known_hosts.go @@ -0,0 +1,99 @@ +package ssh + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/gofixpoint/amika/go/internal/filelock" +) + +// FileHostKeyPinStore atomically maintains the dedicated alias-keyed known +// hosts file. +type FileHostKeyPinStore struct { + Path string +} + +// Pin adds a new alias pin, accepts an identical existing pin, and refuses a +// changed key. +func (s FileHostKeyPinStore) Pin(alias, hostPublicKey string) error { + line, err := KnownHostLine(alias, hostPublicKey) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(s.Path), 0o700); err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + lock, err := filelock.Acquire(ctx, s.Path+".lock") + if err != nil { + return err + } + defer lock.Close() + + pins, err := readPins(s.Path) + if err != nil { + return err + } + canonical := strings.TrimSuffix(line, "\n") + if existing, found := pins[alias]; found { + if existing != canonical { + return ErrHostKeyMismatch + } + return nil + } + pins[alias] = canonical + aliases := make([]string, 0, len(pins)) + for pinnedAlias := range pins { + aliases = append(aliases, pinnedAlias) + } + sort.Strings(aliases) + var output strings.Builder + for _, pinnedAlias := range aliases { + output.WriteString(pins[pinnedAlias]) + output.WriteByte('\n') + } + return writeFileAtomic(s.Path, []byte(output.String()), 0o600) +} + +func readPins(path string) (map[string]string, error) { + pins := make(map[string]string) + file, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return pins, nil + } + if err != nil { + return nil, err + } + defer file.Close() + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) != 3 { + return nil, fmt.Errorf("invalid managed known-host line") + } + canonical, err := KnownHostLine(fields[0], fields[1]+" "+fields[2]) + if err != nil { + return nil, err + } + if _, duplicate := pins[fields[0]]; duplicate { + return nil, fmt.Errorf("duplicate managed known-host alias") + } + pins[fields[0]] = strings.TrimSuffix(canonical, "\n") + } + if err := scanner.Err(); err != nil { + return nil, err + } + return pins, nil +} diff --git a/go/internal/ssh/known_hosts_test.go b/go/internal/ssh/known_hosts_test.go new file mode 100644 index 00000000..c0ab5943 --- /dev/null +++ b/go/internal/ssh/known_hosts_test.go @@ -0,0 +1,33 @@ +package ssh + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFileHostKeyPinStoreRefusesChangedKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "amika_known_hosts") + store := FileHostKeyPinStore{Path: path} + first := testHostKey(t) + second := testHostKey(t) + alias := "team.sbx_1.amika" + if err := store.Pin(alias, first); err != nil { + t.Fatalf("first Pin: %v", err) + } + if err := store.Pin(alias, first); err != nil { + t.Fatalf("idempotent Pin: %v", err) + } + if err := store.Pin(alias, second); !errors.Is(err, ErrHostKeyMismatch) { + t.Fatalf("changed Pin error = %v", err) + } + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(contents), second) || string(contents) != alias+" "+first+"\n" { + t.Fatalf("known hosts changed after mismatch: %q", contents) + } +} diff --git a/go/internal/ssh/session.go b/go/internal/ssh/session.go index 338e8bc1..717dfe74 100644 --- a/go/internal/ssh/session.go +++ b/go/internal/ssh/session.go @@ -3,13 +3,31 @@ package ssh import ( "context" "errors" + "fmt" "io" + "net/http" + "path/filepath" + "regexp" + "strings" + "time" + "github.com/coder/websocket" "github.com/gofixpoint/amika/go/internal/apiclient" + "github.com/gofixpoint/amika/go/internal/wsstream" + cryptossh "golang.org/x/crypto/ssh" ) -// ErrSessionTransportNotImplemented marks the fail-closed v2 SSH client stub. -var ErrSessionTransportNotImplemented = errors.New("SSH session transport is not implemented") +const proxyCopyBufferBytes = 32 * 1024 + +var safeAliasPart = regexp.MustCompile(`^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$`) + +// ErrInvalidSessionAlias marks a host value that is not a safe v2 Amika +// alias. +var ErrInvalidSessionAlias = errors.New("invalid Amika SSH host alias") + +// ErrHostKeyMismatch marks a descriptor whose immutable identity does not +// match the requested host. +var ErrHostKeyMismatch = errors.New("SSH host identity mismatch") // SandboxAlias identifies the immutable sandbox id parsed from a v2 host alias. type SandboxAlias struct { @@ -47,45 +65,177 @@ type HostKeyPinStore interface { Pin(string, string) error } -// BuildSessionAlias fails closed until the v2 alias rules are implemented. -func BuildSessionAlias(_, _ string) (string, error) { - return "", ErrSessionTransportNotImplemented +// BuildSessionAlias combines a safe human name and immutable sandbox id. +func BuildSessionAlias(name, id string) (string, error) { + if name == "" || id == "" || !safeAliasPart.MatchString(name) || !safeAliasPart.MatchString(id) || strings.Contains(id, ".") { + return "", ErrInvalidSessionAlias + } + alias := name + "." + id + ".amika" + if len(alias) > 253 { + return "", ErrInvalidSessionAlias + } + return alias, nil } -// ParseSessionAlias fails closed until right-to-left alias parsing is implemented. -func ParseSessionAlias(_ string) (SandboxAlias, error) { - return SandboxAlias{}, ErrSessionTransportNotImplemented +// ParseSessionAlias splits from the right so dotted sandbox names remain +// intact. +func ParseSessionAlias(alias string) (SandboxAlias, error) { + if len(alias) > 253 || !strings.HasSuffix(alias, ".amika") || strings.ContainsAny(alias, "\r\n\t *?![]\\") { + return SandboxAlias{}, ErrInvalidSessionAlias + } + withoutSuffix := strings.TrimSuffix(alias, ".amika") + separator := strings.LastIndexByte(withoutSuffix, '.') + if separator <= 0 || separator == len(withoutSuffix)-1 { + return SandboxAlias{}, ErrInvalidSessionAlias + } + parsed := SandboxAlias{Name: withoutSuffix[:separator], ID: withoutSuffix[separator+1:]} + if !safeAliasPart.MatchString(parsed.Name) || !safeAliasPart.MatchString(parsed.ID) || strings.Contains(parsed.ID, ".") { + return SandboxAlias{}, ErrInvalidSessionAlias + } + return parsed, nil } -// RenderSessionConfig fails closed until strict host-key configuration lands. -func RenderSessionConfig(_ SessionConfig) (string, error) { - return "", ErrSessionTransportNotImplemented +// RenderSessionConfig renders the strict wildcard block used only by v2 +// aliases. +func RenderSessionConfig(config SessionConfig) (string, error) { + if !safeConfigPath(config.IdentityFile) || !safeConfigPath(config.KnownHostsFile) || config.ProxyCommand != "amika plumbing ssh-stdio-proxy %h" { + return "", ErrInvalidSessionAlias + } + return fmt.Sprintf(`Host *.amika + User amika + IdentityFile %s + IdentitiesOnly yes + StrictHostKeyChecking yes + UserKnownHostsFile %s + ProxyCommand %s + ServerAliveInterval 15 + ServerAliveCountMax 3 +`, config.IdentityFile, config.KnownHostsFile, config.ProxyCommand), nil } -// KnownHostLine fails closed until alias-keyed host pinning lands. -func KnownHostLine(_, _ string) (string, error) { - return "", ErrSessionTransportNotImplemented +// KnownHostLine returns one canonical alias-keyed Ed25519 pin. +func KnownHostLine(alias, hostPublicKey string) (string, error) { + if _, err := ParseSessionAlias(alias); err != nil { + return "", err + } + canonical, err := canonicalHostPublicKey(hostPublicKey) + if err != nil { + return "", err + } + return alias + " " + canonical + "\n", nil } -// PrepareSessionHost fetches and validates a descriptor, then pins its host key -// before OpenSSH is started. +// PrepareSessionHost fetches a descriptor and pins its key before OpenSSH is +// launched. func PrepareSessionHost( - _ SessionCreator, - _ HostKeyPinStore, - _ string, - _ string, + creator SessionCreator, + pins HostKeyPinStore, + sandboxID string, + alias string, ) (*apiclient.SSHSession, error) { - return nil, ErrSessionTransportNotImplemented + parsed, err := ParseSessionAlias(alias) + if err != nil || parsed.ID != sandboxID { + return nil, ErrHostKeyMismatch + } + session, err := creator.CreateSSHSession(sandboxID) + if err != nil { + return nil, err + } + if err := session.Validate(sandboxID); err != nil { + return nil, err + } + canonical, err := canonicalHostPublicKey(session.HostPublicKey) + if err != nil { + return nil, err + } + if err := pins.Pin(alias, canonical); err != nil { + return nil, err + } + return session, nil } -// ProxySession fails before creating a session, dialing, or copying standard IO. +// ProxySession creates a fresh descriptor, dials it with header credentials, +// and copies opaque bytes between OpenSSH standard I/O and the WebSocket. func ProxySession( - _ context.Context, - _ SessionCreator, - _ SessionDialer, - _ string, - _ io.Reader, - _ io.Writer, + ctx context.Context, + creator SessionCreator, + dialer SessionDialer, + alias string, + stdin io.Reader, + stdout io.Writer, ) error { - return ErrSessionTransportNotImplemented + parsed, err := ParseSessionAlias(alias) + if err != nil { + return err + } + session, err := creator.CreateSSHSession(parsed.ID) + if err != nil { + return err + } + if err := session.Validate(parsed.ID); err != nil { + return err + } + stream, err := dialer.Dial(ctx, session.ConnectURL, session.ConnectCredential) + if err != nil { + return errors.New("failed to connect direct SSH transport") + } + defer stream.Close() + + type result struct{ err error } + results := make(chan result, 2) + go func() { + buffer := make([]byte, proxyCopyBufferBytes) + _, copyErr := io.CopyBuffer(stream, stdin, buffer) + results <- result{err: copyErr} + }() + go func() { + buffer := make([]byte, proxyCopyBufferBytes) + _, copyErr := io.CopyBuffer(stdout, stream, buffer) + results <- result{err: copyErr} + }() + first := <-results + _ = stream.Close() + second := <-results + if first.err != nil && !errors.Is(first.err, io.EOF) { + return errors.New("direct SSH transport closed unexpectedly") + } + if second.err != nil && !errors.Is(second.err, io.EOF) { + return errors.New("direct SSH transport closed unexpectedly") + } + return nil +} + +// WebSocketDialer sends the connect credential only in the Authorization +// header and disables compression for opaque SSH ciphertext. +type WebSocketDialer struct { + HTTPClient *http.Client +} + +// Dial opens one bounded binary WebSocket stream. +func (d WebSocketDialer) Dial(ctx context.Context, connectURL, credential string) (Stream, error) { + header := make(http.Header) + header.Set("Authorization", "Bearer "+credential) + dialCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + connection, _, err := websocket.Dial(dialCtx, connectURL, &websocket.DialOptions{ + HTTPClient: d.HTTPClient, + HTTPHeader: header, + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + return nil, errors.New("WebSocket SSH handshake failed") + } + return wsstream.New(ctx, connection, 64*1024), nil +} + +func safeConfigPath(path string) bool { + return filepath.IsAbs(path) && filepath.Clean(path) == path && !strings.ContainsAny(path, "\r\n\t ") +} + +func canonicalHostPublicKey(value string) (string, error) { + key, _, options, rest, err := cryptossh.ParseAuthorizedKey([]byte(value)) + if err != nil || len(options) != 0 || strings.TrimSpace(string(rest)) != "" || key.Type() != cryptossh.KeyAlgoED25519 { + return "", ErrHostKeyMismatch + } + return strings.TrimSpace(string(cryptossh.MarshalAuthorizedKey(key))), nil } diff --git a/go/internal/ssh/session_test.go b/go/internal/ssh/session_test.go index c40a63b8..fc0f574a 100644 --- a/go/internal/ssh/session_test.go +++ b/go/internal/ssh/session_test.go @@ -3,10 +3,14 @@ package ssh import ( "bytes" "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" "strings" "testing" "github.com/gofixpoint/amika/go/internal/apiclient" + cryptossh "golang.org/x/crypto/ssh" ) func TestParseSessionAliasUsesRightmostSandboxID(t *testing.T) { @@ -45,20 +49,22 @@ func TestRenderSessionConfigPinsHostKeysAndFetchesEveryDial(t *testing.T) { } func TestKnownHostLinePinsTheAliasToTheExactHostKey(t *testing.T) { + hostKey := testHostKey(t) line, err := KnownHostLine( "my.team.sbx-123.amika", - "ssh-ed25519 AAAAtest host-comment", + hostKey+" host-comment", ) if err != nil { t.Fatalf("KnownHostLine: %v", err) } - if line != "my.team.sbx-123.amika ssh-ed25519 AAAAtest\n" { + if line != "my.team.sbx-123.amika "+hostKey+"\n" { t.Fatalf("known-host line = %q", line) } } type fakeCreator struct { - calls int + calls int + hostKey string } type fakePinStore struct { @@ -75,13 +81,13 @@ func (s *fakePinStore) Pin(alias, key string) error { } func TestPrepareSessionHostPinsAPIHostKeyBeforeOpenSSH(t *testing.T) { - creator := &fakeCreator{} + creator := &fakeCreator{hostKey: testHostKey(t)} pins := &fakePinStore{} session, err := PrepareSessionHost( creator, pins, "sbx_123", - "my.team.sbx-123.amika", + "my.team.sbx_123.amika", ) if err != nil { t.Fatalf("PrepareSessionHost: %v", err) @@ -89,7 +95,7 @@ func TestPrepareSessionHostPinsAPIHostKeyBeforeOpenSSH(t *testing.T) { if session.SandboxID != "sbx_123" || creator.calls != 1 { t.Fatalf("session = %#v, creator calls = %d", session, creator.calls) } - if pins.calls != 1 || pins.alias != "my.team.sbx-123.amika" || pins.key != "ssh-ed25519 AAAAtest" { + if pins.calls != 1 || pins.alias != "my.team.sbx_123.amika" || pins.key != creator.hostKey { t.Fatalf("pin calls = %d alias = %q key = %q", pins.calls, pins.alias, pins.key) } } @@ -100,10 +106,10 @@ func (c *fakeCreator) CreateSSHSession(name string) (*apiclient.SSHSession, erro SessionID: "sshs_1", Transport: "direct_ws", ConnectURL: "wss://sandbox.example/v1/ssh-sessions", - ConnectCredential: "connect-token", + ConnectCredential: testConnectToken(), SandboxID: name, SSHUser: "amika", - HostPublicKey: "ssh-ed25519 AAAAtest", + HostPublicKey: c.hostKey, }, nil } @@ -131,7 +137,7 @@ func (d *fakeSessionDialer) Dial(_ context.Context, url, credential string) (Str } func TestProxySessionCreatesSessionAndCopiesBytes(t *testing.T) { - creator := &fakeCreator{} + creator := &fakeCreator{hostKey: testHostKey(t)} stream := &proxyStream{read: bytes.NewReader([]byte("from-sshd"))} dialer := &fakeSessionDialer{stream: stream} var stdout bytes.Buffer @@ -140,7 +146,7 @@ func TestProxySessionCreatesSessionAndCopiesBytes(t *testing.T) { context.Background(), creator, dialer, - "sbx_123", + "my.team.sbx_123.amika", strings.NewReader("from-openssh"), &stdout, ) @@ -150,7 +156,7 @@ func TestProxySessionCreatesSessionAndCopiesBytes(t *testing.T) { if creator.calls != 1 || dialer.calls != 1 { t.Fatalf("creator calls = %d, dialer calls = %d", creator.calls, dialer.calls) } - if dialer.url != "wss://sandbox.example/v1/ssh-sessions" || dialer.credential != "connect-token" { + if dialer.url != "wss://sandbox.example/v1/ssh-sessions" || dialer.credential != testConnectToken() { t.Fatalf("dial = %q credential %q", dialer.url, dialer.credential) } if got := stream.writes.String(); got != "from-openssh" { @@ -160,3 +166,20 @@ func TestProxySessionCreatesSessionAndCopiesBytes(t *testing.T) { t.Fatalf("stdout received %q", got) } } + +func testConnectToken() string { + return base64.RawURLEncoding.EncodeToString(make([]byte, 32)) +} + +func testHostKey(t *testing.T) string { + t.Helper() + public, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + key, err := cryptossh.NewPublicKey(public) + if err != nil { + t.Fatal(err) + } + return strings.TrimSpace(string(cryptossh.MarshalAuthorizedKey(key))) +} diff --git a/go/internal/ssh/ssh.go b/go/internal/ssh/ssh.go index b8394822..eaede3ca 100644 --- a/go/internal/ssh/ssh.go +++ b/go/internal/ssh/ssh.go @@ -75,3 +75,21 @@ func DispatchSSH(client *apiclient.Client, name string, extraArgs []string, stdo cmd.Stderr = stderr return cmd.Run() } + +// ExecSessionSSH replaces the current process with OpenSSH targeting a strict +// v2 alias whose ProxyCommand fetches a fresh session per dial. +func ExecSessionSSH(alias string, forcePTY bool, extraArgs []string) error { + if _, err := ParseSessionAlias(alias); err != nil { + return err + } + sshArgs := []string{alias} + if forcePTY { + sshArgs = append([]string{"-t"}, sshArgs...) + } + sshArgs = append(sshArgs, extraArgs...) + sshBin, err := exec.LookPath("ssh") + if err != nil { + return fmt.Errorf("ssh not found: %w", err) + } + return syscall.Exec(sshBin, append([]string{"ssh"}, sshArgs...), os.Environ()) +} diff --git a/go/internal/ssh/websocket_dialer_test.go b/go/internal/ssh/websocket_dialer_test.go new file mode 100644 index 00000000..fb630790 --- /dev/null +++ b/go/internal/ssh/websocket_dialer_test.go @@ -0,0 +1,57 @@ +package ssh + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/coder/websocket" +) + +func TestWebSocketDialerSendsCredentialOnlyInHeader(t *testing.T) { + credential := testConnectToken() + seen := make(chan *http.Request, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + seen <- request.Clone(context.Background()) + connection, err := websocket.Accept(w, request, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled}) + if err != nil { + return + } + defer connection.CloseNow() + messageType, data, err := connection.Read(context.Background()) + if err == nil { + _ = connection.Write(context.Background(), messageType, data) + } + })) + defer server.Close() + + connectURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/v1/ssh-sessions" + stream, err := (WebSocketDialer{}).Dial(context.Background(), connectURL, credential) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer stream.Close() + if _, err := stream.Write([]byte("ssh")); err != nil { + t.Fatalf("stream write: %v", err) + } + got, err := io.ReadAll(io.LimitReader(stream, 3)) + if err != nil { + t.Fatalf("stream read: %v", err) + } + if string(got) != "ssh" { + t.Fatalf("echo = %q", got) + } + request := <-seen + if request.Header.Get("Authorization") != "Bearer "+credential { + t.Fatalf("Authorization = %q", request.Header.Get("Authorization")) + } + if strings.Contains(request.URL.String(), credential) { + t.Fatalf("credential leaked into URL %q", request.URL) + } + if extension := request.Header.Get("Sec-WebSocket-Extensions"); extension != "" { + t.Fatalf("compression requested: %q", extension) + } +} diff --git a/go/internal/wsstream/stream.go b/go/internal/wsstream/stream.go new file mode 100644 index 00000000..ac787be9 --- /dev/null +++ b/go/internal/wsstream/stream.go @@ -0,0 +1,84 @@ +// Package wsstream adapts bounded binary WebSocket messages to an opaque byte +// stream for SSH tunneling. +package wsstream + +import ( + "context" + "errors" + "io" + "sync" + + "github.com/coder/websocket" +) + +// Stream is a concurrent-reader/writer binary WebSocket byte stream. +type Stream struct { + connection *websocket.Conn + ctx context.Context + cancel context.CancelFunc + maxMessage int + readMu sync.Mutex + reader io.Reader + closeOnce sync.Once +} + +// New creates a stream with a bounded per-message read and write size. +func New(parent context.Context, connection *websocket.Conn, maxMessage int) *Stream { + ctx, cancel := context.WithCancel(parent) + connection.SetReadLimit(int64(maxMessage)) + return &Stream{ + connection: connection, + ctx: ctx, + cancel: cancel, + maxMessage: maxMessage, + } +} + +// Read joins consecutive binary messages into one byte stream. +func (s *Stream) Read(buffer []byte) (int, error) { + s.readMu.Lock() + defer s.readMu.Unlock() + for { + if s.reader == nil { + messageType, reader, err := s.connection.Reader(s.ctx) + if err != nil { + return 0, err + } + if messageType != websocket.MessageBinary { + _ = s.connection.Close(websocket.StatusUnsupportedData, "binary messages required") + return 0, errors.New("non-binary WebSocket message") + } + s.reader = reader + } + read, err := s.reader.Read(buffer) + if errors.Is(err, io.EOF) { + s.reader = nil + if read != 0 { + return read, nil + } + continue + } + return read, err + } +} + +// Write sends one bounded binary message. +func (s *Stream) Write(buffer []byte) (int, error) { + if len(buffer) > s.maxMessage { + return 0, websocket.ErrMessageTooBig + } + if err := s.connection.Write(s.ctx, websocket.MessageBinary, buffer); err != nil { + return 0, err + } + return len(buffer), nil +} + +// Close cancels active I/O and closes the underlying socket immediately. +func (s *Stream) Close() error { + var closeErr error + s.closeOnce.Do(func() { + s.cancel() + closeErr = s.connection.CloseNow() + }) + return closeErr +} From 6279f28eccce5183ca3f1738ba3e830805b08594 Mon Sep 17 00:00:00 2001 From: "Dylan B. Mikus" Date: Mon, 3 Aug 2026 05:31:14 +0000 Subject: [PATCH 06/14] Package amikad for sandbox images Add daemon release metadata and installer support, bake the SSH\ndependencies into the base image, and support health-checked detached\nstartup for lifecycle provisioning. --- go/internal/amikad/background_unix.go | 12 +++ go/internal/amikad/background_windows.go | 7 ++ go/internal/amikad/command.go | 9 +++ go/internal/amikad/command_test.go | 21 ++++++ go/internal/amikad/operations.go | 84 +++++++++++++++++++++ go/internal/amikad/operations_test.go | 24 ++++++ go/internal/buildmeta/buildmeta.go | 3 + go/internal/sandbox/presets/base/Dockerfile | 6 ++ install.sh | 12 ++- 9 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 go/internal/amikad/background_unix.go create mode 100644 go/internal/amikad/background_windows.go diff --git a/go/internal/amikad/background_unix.go b/go/internal/amikad/background_unix.go new file mode 100644 index 00000000..85bf2504 --- /dev/null +++ b/go/internal/amikad/background_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package amikad + +import ( + "os/exec" + "syscall" +) + +func configureBackgroundProcess(command *exec.Cmd) { + command.SysProcAttr = &syscall.SysProcAttr{Setsid: true} +} diff --git a/go/internal/amikad/background_windows.go b/go/internal/amikad/background_windows.go new file mode 100644 index 00000000..70935c32 --- /dev/null +++ b/go/internal/amikad/background_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package amikad + +import "os/exec" + +func configureBackgroundProcess(_ *exec.Cmd) {} diff --git a/go/internal/amikad/command.go b/go/internal/amikad/command.go index f574dc24..b939b372 100644 --- a/go/internal/amikad/command.go +++ b/go/internal/amikad/command.go @@ -6,6 +6,7 @@ import ( "errors" "io" + "github.com/gofixpoint/amika/go/internal/buildmeta" "github.com/spf13/cobra" ) @@ -20,6 +21,7 @@ var ErrNotImplemented = errors.New("amikad operation is not implemented") type ServeOptions struct { Port int BetaNoRelay bool + Background bool } // SetupSSHDOptions controls whether managed setup may replace an existing @@ -74,6 +76,7 @@ func NewCommand(operations Operations) *cobra.Command { Short: "Run the Amika sandbox daemon", SilenceUsage: true, SilenceErrors: true, + Version: buildmeta.New("amikad", buildmeta.AmikadVersion).String(), } setup := &cobra.Command{Use: "setup", Short: "Configure sandbox services"} @@ -146,6 +149,12 @@ func NewCommand(operations Operations) *cobra.Command { false, "enable the unstable no-relay WebSocket SSH transport", ) + serve.Flags().BoolVar( + &serveOptions.Background, + "bg", + false, + "start in the background and wait until healthy", + ) root.AddCommand(setup, hostKey, authorizedKeys, connectToken, serve) return root diff --git a/go/internal/amikad/command_test.go b/go/internal/amikad/command_test.go index 7ade6a21..e64e05fd 100644 --- a/go/internal/amikad/command_test.go +++ b/go/internal/amikad/command_test.go @@ -16,6 +16,7 @@ func (panicReader) Read([]byte) (int, error) { type recordingOperations struct { UnimplementedOperations setupOptions SetupSSHDOptions + serveOptions ServeOptions } func (o *recordingOperations) SetupSSHD(_ context.Context, options SetupSSHDOptions) error { @@ -23,6 +24,11 @@ func (o *recordingOperations) SetupSSHD(_ context.Context, options SetupSSHDOpti return nil } +func (o *recordingOperations) Serve(_ context.Context, options ServeOptions) error { + o.serveOptions = options + return nil +} + func TestCommandTopology(t *testing.T) { cmd := NewCommand(UnimplementedOperations{}) for _, path := range [][]string{ @@ -69,3 +75,18 @@ func TestSetupSSHDForceOverwriteFlag(t *testing.T) { t.Fatal("ForceOverwrite = false, want true") } } + +func TestServeBackgroundFlag(t *testing.T) { + operations := &recordingOperations{} + cmd := NewCommand(operations) + cmd.SetArgs([]string{"serve", "--beta-no-relay", "--bg", "--port", "61000"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + if !operations.serveOptions.Background || !operations.serveOptions.BetaNoRelay || operations.serveOptions.Port != 61000 { + t.Fatalf("serve options = %+v", operations.serveOptions) + } +} diff --git a/go/internal/amikad/operations.go b/go/internal/amikad/operations.go index 95a8da78..bef41f04 100644 --- a/go/internal/amikad/operations.go +++ b/go/internal/amikad/operations.go @@ -9,6 +9,7 @@ import ( "log/slog" "net/http" "os" + "os/exec" "strconv" "strings" "time" @@ -21,6 +22,7 @@ import ( const ( defaultManifestPath = "/var/lib/amikad/injected-paths.json" defaultTokenPath = "/var/lib/amikad/connect-token" + defaultServeLogPath = "/var/log/amikad/no-relay.log" ) // ErrNoServeMode marks a serve invocation with no explicitly enabled @@ -128,6 +130,9 @@ func (o *DaemonOperations) Serve(ctx context.Context, options ServeOptions) erro if !o.verifier.Ready() { return ErrUnsafeConnectToken } + if options.Background { + return o.serveBackground(ctx, options) + } handler := norelay.NewHandler( norelay.Config{MaxConnections: 64, SSHDAddress: "127.0.0.1:22"}, @@ -190,3 +195,82 @@ func (o *DaemonOperations) Serve(ctx context.Context, options ServeOptions) erro } return fmt.Errorf("%s failed: %w", first.component, first.err) } + +func (o *DaemonOperations) serveBackground(ctx context.Context, options ServeOptions) error { + if serveReady(ctx, options.Port) { + return nil + } + if err := os.MkdirAll("/var/log/amikad", 0o755); err != nil { + return fmt.Errorf("prepare daemon log directory: %w", err) + } + logFile, err := os.OpenFile(defaultServeLogPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("open daemon log: %w", err) + } + defer logFile.Close() + executable, err := os.Executable() + if err != nil { + return fmt.Errorf("locate amikad executable: %w", err) + } + args := []string{"serve", "--port", strconv.Itoa(options.Port)} + if options.BetaNoRelay { + args = append(args, "--beta-no-relay") + } + command := exec.Command(executable, args...) + command.Stdout = logFile + command.Stderr = logFile + configureBackgroundProcess(command) + if err := command.Start(); err != nil { + return fmt.Errorf("start background amikad: %w", err) + } + if err := command.Process.Release(); err != nil { + return fmt.Errorf("release background amikad: %w", err) + } + + deadline := time.NewTimer(5 * time.Second) + defer deadline.Stop() + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-deadline.C: + return errors.New("background amikad did not become healthy") + case <-ticker.C: + if serveReady(ctx, options.Port) { + return nil + } + } + } +} + +func serveReady(ctx context.Context, port int) bool { + requestCtx, cancel := context.WithTimeout(ctx, 250*time.Millisecond) + defer cancel() + request, err := http.NewRequestWithContext( + requestCtx, + http.MethodGet, + "http://127.0.0.1:"+strconv.Itoa(port)+"/v1/status", + nil, + ) + if err != nil { + return false + } + response, err := http.DefaultClient.Do(request) + if err != nil { + return false + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return false + } + var status struct { + Mode string `json:"mode"` + Port int `json:"port"` + } + if err := json.NewDecoder(io.LimitReader(response.Body, 4096)).Decode(&status); err != nil { + return false + } + return status.Mode == "beta_no_relay" && status.Port == port +} diff --git a/go/internal/amikad/operations_test.go b/go/internal/amikad/operations_test.go index ce0ee29a..024b8bf7 100644 --- a/go/internal/amikad/operations_test.go +++ b/go/internal/amikad/operations_test.go @@ -4,9 +4,12 @@ import ( "context" "encoding/base64" "errors" + "fmt" "io" "io/fs" "log/slog" + "net" + "net/http" "os" "path/filepath" "strings" @@ -113,3 +116,24 @@ func TestSetupSSHDForwardsOverwriteAuthorization(t *testing.T) { t.Fatal("force-overwrite authorization was not forwarded") } } + +func TestServeReadyRequiresNoRelayStatusOnExactPort(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := listener.Addr().(*net.TCPAddr).Port + server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/v1/status" { + http.NotFound(w, request) + return + } + _, _ = io.WriteString(w, fmt.Sprintf(`{"mode":"beta_no_relay","port":%d}`, port)) + })} + go func() { _ = server.Serve(listener) }() + t.Cleanup(func() { _ = server.Close() }) + + if !serveReady(context.Background(), port) { + t.Fatal("no-relay status was not considered ready") + } +} diff --git a/go/internal/buildmeta/buildmeta.go b/go/internal/buildmeta/buildmeta.go index 2cc22e81..910c010b 100644 --- a/go/internal/buildmeta/buildmeta.go +++ b/go/internal/buildmeta/buildmeta.go @@ -10,12 +10,15 @@ import ( var ( amikaVersionValue = "dev" amikaServerVersionValue = "dev" + amikadVersionValue = "dev" amikalogVersionValue = "dev" // AmikaVersion is the parsed semantic version for the amika CLI. AmikaVersion = MustParseSemVer(amikaVersionValue) // AmikaServerVersion is the parsed semantic version for the amika-server binary. AmikaServerVersion = MustParseSemVer(amikaServerVersionValue) + // AmikadVersion is the parsed semantic version for the sandbox daemon. + AmikadVersion = MustParseSemVer(amikadVersionValue) // AmikalogVersion is the parsed semantic version for the amikalog CLI. AmikalogVersion = MustParseSemVer(amikalogVersionValue) // Commit is the full git SHA for the build. diff --git a/go/internal/sandbox/presets/base/Dockerfile b/go/internal/sandbox/presets/base/Dockerfile index d0c50c9f..1480abba 100644 --- a/go/internal/sandbox/presets/base/Dockerfile +++ b/go/internal/sandbox/presets/base/Dockerfile @@ -19,6 +19,7 @@ RUN apt-get update && apt-get install -y \ nano \ tmux \ ncurses-term \ + openssh-server \ && rm -rf /var/lib/apt/lists/* # Install Ghostty terminfo so SSH sessions from Ghostty render correctly. @@ -59,10 +60,14 @@ RUN mkdir -p -m 755 /etc/apt/keyrings \ # piped curl failure would exit 0 and silently skip the install. ARG AMIKA_VERSION=0.13.0 ARG AMIKALOG_VERSION=0.2.0 +ARG AMIKAD_VERSION=0.1.0 RUN curl -fsSL "https://raw.githubusercontent.com/gofixpoint/amika/amika%40v${AMIKA_VERSION}/install.sh" -o /tmp/amika-install.sh \ && sh /tmp/amika-install.sh --install-version "${AMIKA_VERSION}" \ && sh /tmp/amika-install.sh --component amikalog --install-version "${AMIKALOG_VERSION}" \ && rm /tmp/amika-install.sh +RUN curl -fsSL "https://raw.githubusercontent.com/gofixpoint/amika/amikad%40v${AMIKAD_VERSION}/install.sh" -o /tmp/amikad-install.sh \ + && sh /tmp/amikad-install.sh --component amikad --install-version "${AMIKAD_VERSION}" \ + && rm /tmp/amikad-install.sh # Default no-op user-facing hooks; callers can shadow them via # --setup-script / --start-script or by uploading their own. @@ -99,6 +104,7 @@ RUN chmod +x /usr/lib/amikad/pre-setup.sh /usr/lib/amikad/post-setup.sh /usr/lib # Create user with home directory. RUN useradd -m -s /usr/bin/zsh amika +RUN install -d -m 0700 -o amika -g amika /home/amika/.ssh RUN usermod -aG sudo amika # Let amika user run sudo without a password. RUN echo "amika ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers diff --git a/install.sh b/install.sh index 9c23dd54..9212e2ff 100755 --- a/install.sh +++ b/install.sh @@ -5,6 +5,7 @@ INSTALL_DIR="${AMIKA_INSTALL_DIR:-/usr/local/bin}" GITHUB_REPO="gofixpoint/amika" DEFAULT_VERSION="0.13.0" DEFAULT_AMIKALOG_VERSION="0.2.0" +DEFAULT_AMIKAD_VERSION="0.1.0" COMPONENT="amika" INSTALL_VERSION="" DRY_RUN=false @@ -14,15 +15,15 @@ usage() { install.sh — install an amika release binary Downloads a specific release binary from GitHub and installs it. Installs the -amika CLI by default, or the separately-versioned amikalog CLI with --component. +amika CLI by default, or a separately-versioned component with --component. Usage: sh install.sh [--help] [--component NAME] [--install-version VERSION] [--dry-run] Flags: - --component Component to install: amika (default) or amikalog + --component Component to install: amika (default), amikad, or amikalog --install-version Install a specific version - (amika default: ${DEFAULT_VERSION}; amikalog default: ${DEFAULT_AMIKALOG_VERSION}) + (amika: ${DEFAULT_VERSION}; amikad: ${DEFAULT_AMIKAD_VERSION}; amikalog: ${DEFAULT_AMIKALOG_VERSION}) --dry-run Show what would be done without downloading or installing Environment variables: @@ -32,6 +33,7 @@ Examples: curl -fsSL https://raw.githubusercontent.com/gofixpoint/amika/main/install.sh | sh sh install.sh --install-version 0.1.0-rc.1 sh install.sh --component amikalog --install-version 0.2.0 + sh install.sh --component amikad --install-version 0.1.0 AMIKA_INSTALL_DIR=~/.local/bin sh install.sh EOF } @@ -125,9 +127,10 @@ detect_platform() { set_install_version() { case "$COMPONENT" in amika) ;; + amikad) ;; amikalog) ;; *) - echo "Error: unsupported component: $COMPONENT (want amika or amikalog)" >&2 + echo "Error: unsupported component: $COMPONENT (want amika, amikad, or amikalog)" >&2 exit 1 ;; esac @@ -135,6 +138,7 @@ set_install_version() { if [ -z "$INSTALL_VERSION" ]; then case "$COMPONENT" in amika) INSTALL_VERSION="$DEFAULT_VERSION" ;; + amikad) INSTALL_VERSION="$DEFAULT_AMIKAD_VERSION" ;; amikalog) INSTALL_VERSION="$DEFAULT_AMIKALOG_VERSION" ;; esac fi From b5652138fc28504ab0532bd1ec3fd5fa5027fbb5 Mon Sep 17 00:00:00 2001 From: "Dylan B. Mikus" Date: Mon, 3 Aug 2026 05:41:18 +0000 Subject: [PATCH 07/14] Harden SSH key handling Generate host keys in memory so temporary paths never enter the scrub\nmanifest, verify imported keypairs match, validate API host keys with the\nOpenSSH parser, and include session duration in bridge logs. --- go/internal/amikad/norelay/handler.go | 3 + go/internal/amikad/operations.go | 2 +- go/internal/amikad/sshd/manager.go | 78 +++++++++++------------ go/internal/amikad/sshd/manager_test.go | 31 ++++++--- go/internal/apiclient/ssh_session.go | 12 ++-- go/internal/apiclient/ssh_session_test.go | 21 ++++-- go/internal/ssh/identity.go | 11 ++++ go/internal/ssh/identity_test.go | 28 ++++++++ 8 files changed, 126 insertions(+), 60 deletions(-) diff --git a/go/internal/amikad/norelay/handler.go b/go/internal/amikad/norelay/handler.go index 67238159..803b54c7 100644 --- a/go/internal/amikad/norelay/handler.go +++ b/go/internal/amikad/norelay/handler.go @@ -12,6 +12,7 @@ import ( "strings" "sync" "sync/atomic" + "time" ) // SSHSessionsPath is the no-relay WebSocket upgrade route. @@ -167,12 +168,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, request *http.Request) { defer sshdStream.Close() sessionID := newSessionID() + startedAt := time.Now() h.log("ssh_bridge_open", slog.String("session_id", sessionID)) fromClient, fromSSHD, closeReason := bridge(websocketStream, sshdStream) h.log( "ssh_bridge_close", slog.String("session_id", sessionID), slog.String("reason", closeReason), + slog.Int64("duration_ms", time.Since(startedAt).Milliseconds()), slog.Int64("bytes_from_client", fromClient), slog.Int64("bytes_from_sshd", fromSSHD), ) diff --git a/go/internal/amikad/operations.go b/go/internal/amikad/operations.go index bef41f04..8370decb 100644 --- a/go/internal/amikad/operations.go +++ b/go/internal/amikad/operations.go @@ -72,7 +72,7 @@ func NewProductionOperations() *DaemonOperations { sshd.DefaultPaths(), store, files, - sshd.ExecKeyGenerator{}, + sshd.Ed25519KeyGenerator{}, sshd.ExecProcessRunner{}, ) logger := slog.New(slog.NewJSONHandler(os.Stderr, nil)) diff --git a/go/internal/amikad/sshd/manager.go b/go/internal/amikad/sshd/manager.go index d5e81b08..d4b978e0 100644 --- a/go/internal/amikad/sshd/manager.go +++ b/go/internal/amikad/sshd/manager.go @@ -4,6 +4,9 @@ package sshd import ( "bytes" "context" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" "errors" "fmt" "io" @@ -50,10 +53,16 @@ type SetupOptions struct { ForceOverwrite bool } -// KeyGenerator creates an Ed25519 OpenSSH host keypair at privatePath and -// privatePath+".pub". +// GeneratedHostKey holds one in-memory Ed25519 OpenSSH host keypair. +type GeneratedHostKey struct { + Private []byte + Public []byte +} + +// KeyGenerator creates an Ed25519 OpenSSH host keypair without staging it on +// disk. Only the final scrub-registered paths ever contain private material. type KeyGenerator interface { - Generate(ctx context.Context, privatePath string) error + Generate(context.Context) (GeneratedHostKey, error) } // ProcessRunner runs the foreground OpenSSH daemon until ctx is cancelled or @@ -211,44 +220,17 @@ func (m *Manager) Serve(ctx context.Context) error { } func (m *Manager) generateHostKey(ctx context.Context) error { - temporaryDirectory, err := os.MkdirTemp(filepath.Dir(m.paths.HostPrivateKey), ".amikad-keygen-*") - if err != nil { - return err - } - defer os.RemoveAll(temporaryDirectory) - temporaryPrivate := filepath.Join(temporaryDirectory, "host-key") - for _, temporary := range []struct { - path string - mode fs.FileMode - }{ - {path: temporaryPrivate, mode: 0o600}, - {path: temporaryPrivate + ".pub", mode: 0o644}, - } { - if err := m.store.WriteAndRegister(ctx, temporary.path, strings.NewReader(""), temporary.mode); err != nil { - return err - } - if err := os.Remove(temporary.path); err != nil { - return err - } - } - if err := m.keygen.Generate(ctx, temporaryPrivate); err != nil { - return err - } - privateKey, err := os.ReadFile(temporaryPrivate) + generated, err := m.keygen.Generate(ctx) if err != nil { return err } - publicKey, err := os.ReadFile(temporaryPrivate + ".pub") - if err != nil { - return err - } - if _, err := canonicalPublicKey(string(publicKey), map[string]bool{"ssh-ed25519": true}); err != nil { + if _, err := canonicalPublicKey(string(generated.Public), map[string]bool{"ssh-ed25519": true}); err != nil { return err } - if err := m.store.WriteAndRegister(ctx, m.paths.HostPrivateKey, bytes.NewReader(privateKey), 0o600); err != nil { + if err := m.store.WriteAndRegister(ctx, m.paths.HostPrivateKey, bytes.NewReader(generated.Private), 0o600); err != nil { return err } - return m.store.WriteAndRegister(ctx, m.paths.HostPublicKey, bytes.NewReader(publicKey), 0o644) + return m.store.WriteAndRegister(ctx, m.paths.HostPublicKey, bytes.NewReader(generated.Public), 0o644) } func canonicalPublicKey(line string, allowedTypes map[string]bool) (string, error) { @@ -340,14 +322,30 @@ Subsystem sftp internal-sftp `, paths.HostPrivateKey, paths.PID, paths.AuthorizedKeys) } -// ExecKeyGenerator invokes the image-provided OpenSSH key generator. -type ExecKeyGenerator struct{} +// Ed25519KeyGenerator creates a passwordless OpenSSH keypair in memory. +type Ed25519KeyGenerator struct{} // Generate creates a passwordless Ed25519 keypair. -func (ExecKeyGenerator) Generate(ctx context.Context, privatePath string) error { - command := exec.CommandContext(ctx, "ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", privatePath) - command.Stderr = os.Stderr - return command.Run() +func (Ed25519KeyGenerator) Generate(ctx context.Context) (GeneratedHostKey, error) { + if err := ctx.Err(); err != nil { + return GeneratedHostKey{}, err + } + public, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return GeneratedHostKey{}, err + } + privateBlock, err := ssh.MarshalPrivateKey(private, "amikad SSH host key") + if err != nil { + return GeneratedHostKey{}, err + } + publicKey, err := ssh.NewPublicKey(public) + if err != nil { + return GeneratedHostKey{}, err + } + return GeneratedHostKey{ + Private: pem.EncodeToMemory(privateBlock), + Public: ssh.MarshalAuthorizedKey(publicKey), + }, nil } // ExecProcessRunner runs foreground processes with daemon output on stderr. diff --git a/go/internal/amikad/sshd/manager_test.go b/go/internal/amikad/sshd/manager_test.go index 0e49a360..d88f8a32 100644 --- a/go/internal/amikad/sshd/manager_test.go +++ b/go/internal/amikad/sshd/manager_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/ed25519" "crypto/rand" + "encoding/json" "encoding/pem" "errors" "os" @@ -17,24 +18,24 @@ import ( type fakeKeyGenerator struct{ calls int } -func (g *fakeKeyGenerator) Generate(_ context.Context, privatePath string) error { +func (g *fakeKeyGenerator) Generate(_ context.Context) (GeneratedHostKey, error) { g.calls++ public, private, err := ed25519.GenerateKey(rand.Reader) if err != nil { - return err + return GeneratedHostKey{}, err } privateBlock, err := ssh.MarshalPrivateKey(private, "amikad test host key") if err != nil { - return err - } - if err := os.WriteFile(privatePath, pem.EncodeToMemory(privateBlock), 0o600); err != nil { - return err + return GeneratedHostKey{}, err } publicKey, err := ssh.NewPublicKey(public) if err != nil { - return err + return GeneratedHostKey{}, err } - return os.WriteFile(privatePath+".pub", ssh.MarshalAuthorizedKey(publicKey), 0o644) + return GeneratedHostKey{ + Private: pem.EncodeToMemory(privateBlock), + Public: ssh.MarshalAuthorizedKey(publicKey), + }, nil } type fakeProcessRunner struct { @@ -103,6 +104,20 @@ func TestSetupCreatesLoopbackPolicyAndIsIdempotent(t *testing.T) { if privateInfo.Mode().Perm() != 0o600 { t.Fatalf("private key mode = %o, want 600", privateInfo.Mode().Perm()) } + manifestPath := filepath.Join(filepath.Dir(filepath.Dir(paths.Config)), "injected-paths.json") + manifestBytes, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + var manifest []string + if err := json.Unmarshal(manifestBytes, &manifest); err != nil { + t.Fatal(err) + } + for _, path := range manifest { + if strings.Contains(path, ".amikad-keygen-") { + t.Fatalf("temporary host key escaped into scrub manifest: %q", path) + } + } } func TestSetupRefusesExistingUserConfigurationUnlessForced(t *testing.T) { diff --git a/go/internal/apiclient/ssh_session.go b/go/internal/apiclient/ssh_session.go index 96117d1a..9a8db842 100644 --- a/go/internal/apiclient/ssh_session.go +++ b/go/internal/apiclient/ssh_session.go @@ -6,6 +6,8 @@ import ( "fmt" "net/url" "strings" + + cryptossh "golang.org/x/crypto/ssh" ) // ErrInvalidSSHSession marks an unsafe or internally inconsistent descriptor. @@ -91,13 +93,9 @@ func isCanonicalConnectToken(value string) bool { } func canonicalEd25519Key(value string) string { - fields := strings.Fields(value) - if len(fields) < 2 || fields[0] != "ssh-ed25519" || strings.ContainsAny(value, "\r\n") { - return "" - } - decoded, err := base64.StdEncoding.DecodeString(fields[1]) - if err != nil || len(decoded) < 16 { + key, _, options, rest, err := cryptossh.ParseAuthorizedKey([]byte(value)) + if err != nil || len(options) != 0 || strings.TrimSpace(string(rest)) != "" || key.Type() != cryptossh.KeyAlgoED25519 { return "" } - return fields[0] + " " + fields[1] + return strings.TrimSpace(string(cryptossh.MarshalAuthorizedKey(key))) } diff --git a/go/internal/apiclient/ssh_session_test.go b/go/internal/apiclient/ssh_session_test.go index 49e9b4a5..be33667c 100644 --- a/go/internal/apiclient/ssh_session_test.go +++ b/go/internal/apiclient/ssh_session_test.go @@ -1,16 +1,29 @@ package apiclient import ( + "crypto/ed25519" "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "testing" + + cryptossh "golang.org/x/crypto/ssh" ) +func testEd25519PublicKey(t *testing.T) string { + t.Helper() + privateKey := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)) + publicKey, err := cryptossh.NewPublicKey(privateKey.Public()) + if err != nil { + t.Fatal(err) + } + return string(cryptossh.MarshalAuthorizedKey(publicKey)) +} + func TestCreateSSHSessionPostsForEveryDial(t *testing.T) { token := base64.RawURLEncoding.EncodeToString(make([]byte, 32)) - key := base64.StdEncoding.EncodeToString([]byte("valid fake ed25519 public key material")) + key := testEd25519PublicKey(t) var calls int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ @@ -30,7 +43,7 @@ func TestCreateSSHSessionPostsForEveryDial(t *testing.T) { ConnectCredential: token, SandboxID: "sbx_123", SSHUser: "amika", - HostPublicKey: "ssh-ed25519 " + key, + HostPublicKey: key, }) })) defer server.Close() @@ -52,7 +65,7 @@ func TestCreateSSHSessionPostsForEveryDial(t *testing.T) { func TestSSHSessionValidateAcceptsOnlyMatchingDirectWSSession(t *testing.T) { token := base64.RawURLEncoding.EncodeToString(make([]byte, 32)) - key := base64.StdEncoding.EncodeToString([]byte("valid fake ed25519 public key material")) + key := testEd25519PublicKey(t) valid := SSHSession{ SessionID: "sshs_1", Transport: SSHSessionTransportDirectWS, @@ -60,7 +73,7 @@ func TestSSHSessionValidateAcceptsOnlyMatchingDirectWSSession(t *testing.T) { ConnectCredential: token, SandboxID: "sbx_123", SSHUser: "amika", - HostPublicKey: "ssh-ed25519 " + key, + HostPublicKey: key, } if err := valid.Validate("sbx_123"); err != nil { t.Fatalf("Validate valid session: %v", err) diff --git a/go/internal/ssh/identity.go b/go/internal/ssh/identity.go index f24776e8..7fefb913 100644 --- a/go/internal/ssh/identity.go +++ b/go/internal/ssh/identity.go @@ -83,6 +83,17 @@ func ImportIdentity(publicPath string) (identityPath, canonicalPublicKey string, if err != nil || !privateInfo.Mode().IsRegular() || privateInfo.Mode().Perm()&0o077 != 0 { return "", "", fmt.Errorf("matching private key is missing or not owner-only") } + privateData, err := os.ReadFile(privatePath) + if err != nil { + return "", "", err + } + signer, err := cryptossh.ParsePrivateKey(privateData) + if err != nil || signer.PublicKey().Type() != cryptossh.KeyAlgoED25519 { + return "", "", fmt.Errorf("matching private key is not an unencrypted Ed25519 key") + } + if !bytes.Equal(signer.PublicKey().Marshal(), mustParsePublicKey(canonical).Marshal()) { + return "", "", fmt.Errorf("imported SSH keypair does not match") + } return privatePath, canonical, nil } diff --git a/go/internal/ssh/identity_test.go b/go/internal/ssh/identity_test.go index 3d9ef5bd..f3fa8add 100644 --- a/go/internal/ssh/identity_test.go +++ b/go/internal/ssh/identity_test.go @@ -27,3 +27,31 @@ func TestGenerateIdentityIsOwnerOnlyAndIdempotent(t *testing.T) { t.Fatalf("private mode = %o, want 600", info.Mode().Perm()) } } + +func TestImportIdentityRequiresMatchingPrivateKey(t *testing.T) { + directory := t.TempDir() + firstPath := filepath.Join(directory, "first") + firstPublic, err := GenerateIdentity(firstPath) + if err != nil { + t.Fatal(err) + } + secondPath := filepath.Join(directory, "second") + secondPublic, err := GenerateIdentity(secondPath) + if err != nil { + t.Fatal(err) + } + + identityPath, imported, err := ImportIdentity(firstPath + ".pub") + if err != nil { + t.Fatalf("ImportIdentity: %v", err) + } + if identityPath != firstPath || imported != firstPublic { + t.Fatalf("import = %q, %q", identityPath, imported) + } + if err := os.WriteFile(firstPath+".pub", []byte(secondPublic+"\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, _, err := ImportIdentity(firstPath + ".pub"); err == nil { + t.Fatal("ImportIdentity accepted a mismatched keypair") + } +} From f33661740a4a0b39e42fb89ef9e4c0237862cac7 Mon Sep 17 00:00:00 2001 From: "Dylan B. Mikus" Date: Mon, 3 Aug 2026 05:41:52 +0000 Subject: [PATCH 08/14] Build amikad into base images Compile the daemon from an immutable reviewed commit in a discarded\nGo builder stage so sandbox images do not depend on an unreleased binary\nor retain the build toolchain. --- go/internal/sandbox/presets/base/Dockerfile | 14 ++++++++++---- go/internal/sandbox/presets_test.go | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/go/internal/sandbox/presets/base/Dockerfile b/go/internal/sandbox/presets/base/Dockerfile index 1480abba..cae7afee 100644 --- a/go/internal/sandbox/presets/base/Dockerfile +++ b/go/internal/sandbox/presets/base/Dockerfile @@ -1,7 +1,17 @@ # Locally, this image is called "amika/base" +FROM golang:1.25.3-bookworm AS amikad-builder + +# Build from an immutable reviewed commit until the first standalone amikad +# release exists. The final image receives only the binary, not the toolchain. +ARG AMIKAD_SOURCE_REF=b5652138fc28504ab0532bd1ec3fd5fa5027fbb5 +ENV GOBIN=/out +RUN go install "github.com/gofixpoint/amika/go/cmd/amikad@${AMIKAD_SOURCE_REF}" + FROM ubuntu:24.04 +COPY --from=amikad-builder /out/amikad /usr/local/bin/amikad + ENV DEBIAN_FRONTEND=noninteractive ENV LANG=C.UTF-8 @@ -60,14 +70,10 @@ RUN mkdir -p -m 755 /etc/apt/keyrings \ # piped curl failure would exit 0 and silently skip the install. ARG AMIKA_VERSION=0.13.0 ARG AMIKALOG_VERSION=0.2.0 -ARG AMIKAD_VERSION=0.1.0 RUN curl -fsSL "https://raw.githubusercontent.com/gofixpoint/amika/amika%40v${AMIKA_VERSION}/install.sh" -o /tmp/amika-install.sh \ && sh /tmp/amika-install.sh --install-version "${AMIKA_VERSION}" \ && sh /tmp/amika-install.sh --component amikalog --install-version "${AMIKALOG_VERSION}" \ && rm /tmp/amika-install.sh -RUN curl -fsSL "https://raw.githubusercontent.com/gofixpoint/amika/amikad%40v${AMIKAD_VERSION}/install.sh" -o /tmp/amikad-install.sh \ - && sh /tmp/amikad-install.sh --component amikad --install-version "${AMIKAD_VERSION}" \ - && rm /tmp/amikad-install.sh # Default no-op user-facing hooks; callers can shadow them via # --setup-script / --start-script or by uploading their own. diff --git a/go/internal/sandbox/presets_test.go b/go/internal/sandbox/presets_test.go index be493305..1264039c 100644 --- a/go/internal/sandbox/presets_test.go +++ b/go/internal/sandbox/presets_test.go @@ -88,6 +88,24 @@ func TestGetPresetDockerfile_BaseCreatesAmikaAndAmikadDirectories(t *testing.T) } } +func TestGetPresetDockerfile_BaseBuildsPinnedAmikad(t *testing.T) { + data, err := GetPresetDockerfile("base") + if err != nil { + t.Fatal(err) + } + contents := string(data) + for _, want := range []string{ + "FROM golang:1.25.3-bookworm AS amikad-builder", + "ARG AMIKAD_SOURCE_REF=b5652138fc28504ab0532bd1ec3fd5fa5027fbb5", + "go install \"github.com/gofixpoint/amika/go/cmd/amikad@${AMIKAD_SOURCE_REF}\"", + "COPY --from=amikad-builder /out/amikad /usr/local/bin/amikad", + } { + if !strings.Contains(contents, want) { + t.Fatalf("base Dockerfile missing pinned amikad build %q", want) + } + } +} + func TestGetPresetDockerfile_BaseChownsUserManagedAmikaDirectories(t *testing.T) { data, err := GetPresetDockerfile("base") if err != nil { From 05c6df1b4023dded1d59c9f6c7044be87ef393e4 Mon Sep 17 00:00:00 2001 From: "Dylan B. Mikus" Date: Mon, 3 Aug 2026 06:05:45 +0000 Subject: [PATCH 09/14] Fix no-relay SSH runtime lifecycle --- go/cmd/amika/sandbox/sandbox_ssh_v2.go | 36 ++++++--- go/cmd/amikad/main.go | 12 ++- go/internal/amikad/sshd/manager.go | 87 ++++++++++++++++----- go/internal/amikad/sshd/manager_test.go | 20 +++-- go/internal/apiclient/ssh_session.go | 2 +- go/internal/apiclient/ssh_session_test.go | 5 ++ go/internal/sandbox/presets/base/Dockerfile | 5 ++ go/internal/ssh/session.go | 12 ++- go/internal/ssh/session_test.go | 36 ++++++++- go/internal/wsstream/stream.go | 4 +- 10 files changed, 172 insertions(+), 47 deletions(-) diff --git a/go/cmd/amika/sandbox/sandbox_ssh_v2.go b/go/cmd/amika/sandbox/sandbox_ssh_v2.go index 27e826c5..d32def5e 100644 --- a/go/cmd/amika/sandbox/sandbox_ssh_v2.go +++ b/go/cmd/amika/sandbox/sandbox_ssh_v2.go @@ -42,28 +42,38 @@ var sandboxSSHV2Cmd = &cobra.Command{ return err } paths := basedir.New("") - identityFile, err := paths.SSHIdentityFile() + state, err := ssh.LoadState(paths) if err != nil { return err } - identityInfo, err := os.Stat(identityFile) - if err != nil || !identityInfo.Mode().IsRegular() || identityInfo.Mode().Perm() != 0o600 { - return fmt.Errorf("SSH identity is missing or unsafe; run \"amika secret ssh-keygen\"") + var sessionConfig ssh.SessionConfig + if state.SessionConfig != nil { + sessionConfig = *state.SessionConfig + } else { + identityFile, err := paths.SSHIdentityFile() + if err != nil { + return err + } + knownHostsFile, err := paths.SSHKnownHostsFile() + if err != nil { + return err + } + sessionConfig = ssh.SessionConfig{ + IdentityFile: identityFile, + KnownHostsFile: knownHostsFile, + ProxyCommand: "amika plumbing ssh-stdio-proxy %h", + } } - knownHostsFile, err := paths.SSHKnownHostsFile() - if err != nil { - return err + identityInfo, err := os.Stat(sessionConfig.IdentityFile) + if err != nil || !identityInfo.Mode().IsRegular() || identityInfo.Mode().Perm()&0o077 != 0 { + return fmt.Errorf("SSH identity is missing or unsafe; run \"amika secret ssh-keygen\"") } - if err := ssh.ConfigureSession(paths, ssh.SessionConfig{ - IdentityFile: identityFile, - KnownHostsFile: knownHostsFile, - ProxyCommand: "amika plumbing ssh-stdio-proxy %h", - }); err != nil { + if err := ssh.ConfigureSession(paths, sessionConfig); err != nil { return err } if _, err := ssh.PrepareSessionHost( client, - ssh.FileHostKeyPinStore{Path: knownHostsFile}, + ssh.FileHostKeyPinStore{Path: sessionConfig.KnownHostsFile}, sandbox.ID, alias, ); err != nil { diff --git a/go/cmd/amikad/main.go b/go/cmd/amikad/main.go index 96c915c4..060428c4 100644 --- a/go/cmd/amikad/main.go +++ b/go/cmd/amikad/main.go @@ -2,15 +2,25 @@ package main import ( + "context" + "errors" "fmt" "os" + "os/signal" + "syscall" "github.com/gofixpoint/amika/go/internal/amikad" ) func main() { + ctx, stop := signal.NotifyContext( + context.Background(), + os.Interrupt, + syscall.SIGTERM, + ) + defer stop() cmd := amikad.NewCommand(amikad.NewProductionOperations()) - if err := cmd.Execute(); err != nil { + if err := cmd.ExecuteContext(ctx); err != nil && !errors.Is(err, context.Canceled) { fmt.Fprintln(os.Stderr, err) os.Exit(1) } diff --git a/go/internal/amikad/sshd/manager.go b/go/internal/amikad/sshd/manager.go index d4b978e0..7085c470 100644 --- a/go/internal/amikad/sshd/manager.go +++ b/go/internal/amikad/sshd/manager.go @@ -13,7 +13,9 @@ import ( "io/fs" "os" "os/exec" + "os/user" "path/filepath" + "strconv" "strings" "github.com/gofixpoint/amika/go/internal/amikad/state" @@ -30,21 +32,32 @@ var ErrInvalidPublicKey = errors.New("invalid SSH public key") // Paths contains every filesystem location owned by the managed sshd. type Paths struct { - Config string - HostPrivateKey string - HostPublicKey string - AuthorizedKeys string - PID string + Config string + HostPrivateKey string + HostPublicKey string + AuthorizedKeys string + PID string + RuntimeDirectory string + AuthorizedKeysUID int + AuthorizedKeysGID int } // DefaultPaths returns the production paths owned by amikad. func DefaultPaths() Paths { + uid, gid := -1, -1 + if account, err := user.Lookup("amika"); err == nil { + uid, _ = strconv.Atoi(account.Uid) + gid, _ = strconv.Atoi(account.Gid) + } return Paths{ - Config: "/var/lib/amikad/sshd_config", - HostPrivateKey: "/var/lib/amikad/ssh_host_ed25519_key", - HostPublicKey: "/var/lib/amikad/ssh_host_ed25519_key.pub", - AuthorizedKeys: "/home/amika/.ssh/authorized_keys", - PID: "/var/lib/amikad/sshd.pid", + Config: "/var/lib/amikad/sshd_config", + HostPrivateKey: "/var/lib/amikad/ssh_host_ed25519_key", + HostPublicKey: "/var/lib/amikad/ssh_host_ed25519_key.pub", + AuthorizedKeys: "/home/amika/.ssh/authorized_keys", + PID: "/var/lib/amikad/sshd.pid", + RuntimeDirectory: "/run/sshd", + AuthorizedKeysUID: uid, + AuthorizedKeysGID: gid, } } @@ -103,13 +116,17 @@ func (m *Manager) Setup(ctx context.Context, options SetupOptions) error { if err := validatePaths(m.paths); err != nil { return err } - for _, directory := range []string{ - filepath.Dir(m.paths.Config), - filepath.Dir(m.paths.AuthorizedKeys), - } { - if err := os.MkdirAll(directory, 0o700); err != nil { - return err - } + if err := os.MkdirAll(filepath.Dir(m.paths.Config), 0o700); err != nil { + return err + } + if err := m.prepareAuthorizedKeysDirectory(); err != nil { + return err + } + if err := os.MkdirAll(m.paths.RuntimeDirectory, 0o755); err != nil { + return err + } + if err := os.Chmod(m.paths.RuntimeDirectory, 0o755); err != nil { + return err } desiredConfig := []byte(RenderConfig(m.paths)) @@ -169,7 +186,7 @@ func (m *Manager) ShowHostKey(ctx context.Context, output io.Writer) error { // SetAuthorizedKeys validates and atomically replaces the complete authorized // key set through the scrub-registered sensitive store. func (m *Manager) SetAuthorizedKeys(ctx context.Context, input io.Reader) error { - if err := os.MkdirAll(filepath.Dir(m.paths.AuthorizedKeys), 0o700); err != nil { + if err := m.prepareAuthorizedKeysDirectory(); err != nil { return err } contents, err := io.ReadAll(io.LimitReader(input, (1<<20)+1)) @@ -206,11 +223,33 @@ func (m *Manager) SetAuthorizedKeys(ctx context.Context, input io.Reader) error if len(keys) == 0 { return ErrInvalidPublicKey } - return m.store.WriteAndRegister( + if err := m.store.WriteAndRegister( ctx, m.paths.AuthorizedKeys, strings.NewReader(strings.Join(keys, "\n")+"\n"), 0o600, + ); err != nil { + return err + } + return os.Chown( + m.paths.AuthorizedKeys, + m.paths.AuthorizedKeysUID, + m.paths.AuthorizedKeysGID, + ) +} + +func (m *Manager) prepareAuthorizedKeysDirectory() error { + directory := filepath.Dir(m.paths.AuthorizedKeys) + if err := os.MkdirAll(directory, 0o700); err != nil { + return err + } + if err := os.Chmod(directory, 0o700); err != nil { + return err + } + return os.Chown( + directory, + m.paths.AuthorizedKeysUID, + m.paths.AuthorizedKeysGID, ) } @@ -287,11 +326,15 @@ func validatePaths(paths Paths) error { paths.HostPublicKey, paths.AuthorizedKeys, paths.PID, + paths.RuntimeDirectory, } { if !filepath.IsAbs(path) || filepath.Clean(path) != path { return fmt.Errorf("invalid sshd path: %w", state.ErrInvalidPath) } } + if paths.AuthorizedKeysUID < 0 || paths.AuthorizedKeysGID < 0 { + return fmt.Errorf("invalid authorized-keys owner: %w", state.ErrInvalidPath) + } return nil } @@ -353,7 +396,11 @@ type ExecProcessRunner struct{} // Run executes one process without a shell or environment interpolation. func (ExecProcessRunner) Run(ctx context.Context, name string, args ...string) error { - command := exec.CommandContext(ctx, name, args...) + resolved, err := exec.LookPath(name) + if err != nil { + return err + } + command := exec.CommandContext(ctx, resolved, args...) command.Stdout = os.Stderr command.Stderr = os.Stderr return command.Run() diff --git a/go/internal/amikad/sshd/manager_test.go b/go/internal/amikad/sshd/manager_test.go index d88f8a32..2d4200c7 100644 --- a/go/internal/amikad/sshd/manager_test.go +++ b/go/internal/amikad/sshd/manager_test.go @@ -53,11 +53,14 @@ func testManager(t *testing.T) (*Manager, Paths, *fakeKeyGenerator, *fakeProcess t.Helper() directory := t.TempDir() paths := Paths{ - Config: filepath.Join(directory, "state", "sshd_config"), - HostPrivateKey: filepath.Join(directory, "state", "ssh_host_ed25519_key"), - HostPublicKey: filepath.Join(directory, "state", "ssh_host_ed25519_key.pub"), - AuthorizedKeys: filepath.Join(directory, "home", ".ssh", "authorized_keys"), - PID: filepath.Join(directory, "state", "sshd.pid"), + Config: filepath.Join(directory, "state", "sshd_config"), + HostPrivateKey: filepath.Join(directory, "state", "ssh_host_ed25519_key"), + HostPublicKey: filepath.Join(directory, "state", "ssh_host_ed25519_key.pub"), + AuthorizedKeys: filepath.Join(directory, "home", ".ssh", "authorized_keys"), + PID: filepath.Join(directory, "state", "sshd.pid"), + RuntimeDirectory: filepath.Join(directory, "run", "sshd"), + AuthorizedKeysUID: os.Getuid(), + AuthorizedKeysGID: os.Getgid(), } files := state.OSFiles{} store := state.NewStore(filepath.Join(directory, "injected-paths.json"), files) @@ -104,6 +107,13 @@ func TestSetupCreatesLoopbackPolicyAndIsIdempotent(t *testing.T) { if privateInfo.Mode().Perm() != 0o600 { t.Fatalf("private key mode = %o, want 600", privateInfo.Mode().Perm()) } + runtimeInfo, err := os.Stat(paths.RuntimeDirectory) + if err != nil { + t.Fatalf("stat runtime directory: %v", err) + } + if runtimeInfo.Mode().Perm() != 0o755 { + t.Fatalf("runtime directory mode = %o, want 755", runtimeInfo.Mode().Perm()) + } manifestPath := filepath.Join(filepath.Dir(filepath.Dir(paths.Config)), "injected-paths.json") manifestBytes, err := os.ReadFile(manifestPath) if err != nil { diff --git a/go/internal/apiclient/ssh_session.go b/go/internal/apiclient/ssh_session.go index 9a8db842..613cf24e 100644 --- a/go/internal/apiclient/ssh_session.go +++ b/go/internal/apiclient/ssh_session.go @@ -56,7 +56,7 @@ func (s *SSHSession) Validate(expectedSandboxID string) error { return ErrInvalidSSHSession } connectURL, err := url.Parse(s.ConnectURL) - if err != nil || connectURL.Scheme != "wss" || connectURL.Host == "" || connectURL.User != nil || connectURL.Fragment != "" { + if err != nil || connectURL.Scheme != "wss" || connectURL.Host == "" || connectURL.User != nil || connectURL.RawQuery != "" || connectURL.Fragment != "" { return ErrInvalidSSHSession } if !strings.HasSuffix(connectURL.EscapedPath(), "/v1/ssh-sessions") { diff --git a/go/internal/apiclient/ssh_session_test.go b/go/internal/apiclient/ssh_session_test.go index be33667c..7c03ac14 100644 --- a/go/internal/apiclient/ssh_session_test.go +++ b/go/internal/apiclient/ssh_session_test.go @@ -88,6 +88,11 @@ func TestSSHSessionValidateAcceptsOnlyMatchingDirectWSSession(t *testing.T) { s.ConnectURL = "wss://user:secret@sandbox.example/v1/ssh-sessions" return s }(), + "URL query": func() SSHSession { + s := valid + s.ConnectURL = "wss://sandbox.example/v1/ssh-sessions?token=secret" + return s + }(), "wrong URL path": func() SSHSession { s := valid; s.ConnectURL = "wss://sandbox.example/other"; return s }(), "empty credential": func() SSHSession { s := valid; s.ConnectCredential = ""; return s }(), "invalid host key": func() SSHSession { s := valid; s.HostPublicKey = "ssh-rsa AAAA"; return s }(), diff --git a/go/internal/sandbox/presets/base/Dockerfile b/go/internal/sandbox/presets/base/Dockerfile index cae7afee..73e58933 100644 --- a/go/internal/sandbox/presets/base/Dockerfile +++ b/go/internal/sandbox/presets/base/Dockerfile @@ -110,6 +110,11 @@ RUN chmod +x /usr/lib/amikad/pre-setup.sh /usr/lib/amikad/post-setup.sh /usr/lib # Create user with home directory. RUN useradd -m -s /usr/bin/zsh amika +# OpenSSH refuses every login, including public-key auth, for a shadow-locked +# account. Give the account a discarded random password hash so it is unlocked +# without creating a usable password; the managed sshd also disables all +# password and keyboard-interactive authentication. +RUN usermod --password '$6$pdjtCb0QP/guq5xn$iwYNhGlawvdfJqqAeGINjgtq.2yFgcFCvRItN.ly3astbptigtMioqI/opqrgmEdHdDQ.sg0/LOWW5vyp9tHz0' amika RUN install -d -m 0700 -o amika -g amika /home/amika/.ssh RUN usermod -aG sudo amika # Let amika user run sudo without a password. diff --git a/go/internal/ssh/session.go b/go/internal/ssh/session.go index 717dfe74..5811187d 100644 --- a/go/internal/ssh/session.go +++ b/go/internal/ssh/session.go @@ -196,15 +196,23 @@ func ProxySession( first := <-results _ = stream.Close() second := <-results - if first.err != nil && !errors.Is(first.err, io.EOF) { + if !expectedStreamClose(first.err) { return errors.New("direct SSH transport closed unexpectedly") } - if second.err != nil && !errors.Is(second.err, io.EOF) { + if !expectedStreamClose(second.err) { return errors.New("direct SSH transport closed unexpectedly") } return nil } +func expectedStreamClose(err error) bool { + if err == nil || errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) { + return true + } + status := websocket.CloseStatus(err) + return status == websocket.StatusNormalClosure || status == websocket.StatusGoingAway +} + // WebSocketDialer sends the connect credential only in the Authorization // header and disables compression for opaque SSH ciphertext. type WebSocketDialer struct { diff --git a/go/internal/ssh/session_test.go b/go/internal/ssh/session_test.go index fc0f574a..b9868050 100644 --- a/go/internal/ssh/session_test.go +++ b/go/internal/ssh/session_test.go @@ -6,9 +6,12 @@ import ( "crypto/ed25519" "crypto/rand" "encoding/base64" + "errors" + "io" "strings" "testing" + "github.com/coder/websocket" "github.com/gofixpoint/amika/go/internal/apiclient" cryptossh "golang.org/x/crypto/ssh" ) @@ -114,11 +117,18 @@ func (c *fakeCreator) CreateSSHSession(name string) (*apiclient.SSHSession, erro } type proxyStream struct { - read *bytes.Reader - writes bytes.Buffer + read *bytes.Reader + readErr error + writes bytes.Buffer } -func (s *proxyStream) Read(p []byte) (int, error) { return s.read.Read(p) } +func (s *proxyStream) Read(p []byte) (int, error) { + read, err := s.read.Read(p) + if errors.Is(err, io.EOF) && s.readErr != nil { + return read, s.readErr + } + return read, err +} func (s *proxyStream) Write(p []byte) (int, error) { return s.writes.Write(p) } func (s *proxyStream) Close() error { return nil } @@ -167,6 +177,26 @@ func TestProxySessionCreatesSessionAndCopiesBytes(t *testing.T) { } } +func TestProxySessionAcceptsNormalWebSocketClosure(t *testing.T) { + creator := &fakeCreator{hostKey: testHostKey(t)} + stream := &proxyStream{ + read: bytes.NewReader(nil), + readErr: websocket.CloseError{Code: websocket.StatusNormalClosure}, + } + dialer := &fakeSessionDialer{stream: stream} + + if err := ProxySession( + context.Background(), + creator, + dialer, + "my.team.sbx_123.amika", + strings.NewReader(""), + io.Discard, + ); err != nil { + t.Fatalf("ProxySession normal close: %v", err) + } +} + func testConnectToken() string { return base64.RawURLEncoding.EncodeToString(make([]byte, 32)) } diff --git a/go/internal/wsstream/stream.go b/go/internal/wsstream/stream.go index ac787be9..d1d7b39c 100644 --- a/go/internal/wsstream/stream.go +++ b/go/internal/wsstream/stream.go @@ -73,12 +73,12 @@ func (s *Stream) Write(buffer []byte) (int, error) { return len(buffer), nil } -// Close cancels active I/O and closes the underlying socket immediately. +// Close cancels active I/O and performs a normal WebSocket close handshake. func (s *Stream) Close() error { var closeErr error s.closeOnce.Do(func() { + closeErr = s.connection.Close(websocket.StatusNormalClosure, "") s.cancel() - closeErr = s.connection.CloseNow() }) return closeErr } From f864496081e7bcb001706eedcb7167f04c8d4306 Mon Sep 17 00:00:00 2001 From: "Dylan B. Mikus" Date: Mon, 3 Aug 2026 06:06:41 +0000 Subject: [PATCH 10/14] Pin fixed amikad in sandbox images --- go/internal/sandbox/presets/base/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go/internal/sandbox/presets/base/Dockerfile b/go/internal/sandbox/presets/base/Dockerfile index 73e58933..215cb862 100644 --- a/go/internal/sandbox/presets/base/Dockerfile +++ b/go/internal/sandbox/presets/base/Dockerfile @@ -4,7 +4,7 @@ FROM golang:1.25.3-bookworm AS amikad-builder # Build from an immutable reviewed commit until the first standalone amikad # release exists. The final image receives only the binary, not the toolchain. -ARG AMIKAD_SOURCE_REF=b5652138fc28504ab0532bd1ec3fd5fa5027fbb5 +ARG AMIKAD_SOURCE_REF=05c6df1b4023dded1d59c9f6c7044be87ef393e4 ENV GOBIN=/out RUN go install "github.com/gofixpoint/amika/go/cmd/amikad@${AMIKAD_SOURCE_REF}" From f448546576924376230c434818cee82f4bb8e4a5 Mon Sep 17 00:00:00 2001 From: "Dylan B. Mikus" Date: Mon, 3 Aug 2026 06:19:12 +0000 Subject: [PATCH 11/14] Harden authorized key ownership --- go/internal/amikad/sshd/manager.go | 20 ++++++++++++---- go/internal/amikad/sshd/manager_test.go | 32 +++++++++++++++++++++++++ go/internal/sandbox/presets_test.go | 5 +++- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/go/internal/amikad/sshd/manager.go b/go/internal/amikad/sshd/manager.go index 7085c470..58b4520e 100644 --- a/go/internal/amikad/sshd/manager.go +++ b/go/internal/amikad/sshd/manager.go @@ -231,7 +231,7 @@ func (m *Manager) SetAuthorizedKeys(ctx context.Context, input io.Reader) error ); err != nil { return err } - return os.Chown( + return os.Lchown( m.paths.AuthorizedKeys, m.paths.AuthorizedKeysUID, m.paths.AuthorizedKeysGID, @@ -240,13 +240,23 @@ func (m *Manager) SetAuthorizedKeys(ctx context.Context, input io.Reader) error func (m *Manager) prepareAuthorizedKeysDirectory() error { directory := filepath.Dir(m.paths.AuthorizedKeys) - if err := os.MkdirAll(directory, 0o700); err != nil { - return err + info, err := m.files.Lstat(directory) + if errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("authorized-keys directory missing: %w", state.ErrInvalidPath) } - if err := os.Chmod(directory, 0o700); err != nil { + if err != nil { return err } - return os.Chown( + if info.Mode()&fs.ModeSymlink != 0 { + return state.ErrSymlinkPath + } + if !info.IsDir() || info.Mode().Perm() != 0o700 { + return fmt.Errorf("unsafe authorized-keys directory: %w", state.ErrInvalidPath) + } + // Lchown cannot follow a final-component symlink if the sandbox user + // races this check. A replacement real directory can only be assigned to + // that same unprivileged user and is never chmodded by root here. + return os.Lchown( directory, m.paths.AuthorizedKeysUID, m.paths.AuthorizedKeysGID, diff --git a/go/internal/amikad/sshd/manager_test.go b/go/internal/amikad/sshd/manager_test.go index 2d4200c7..e3239516 100644 --- a/go/internal/amikad/sshd/manager_test.go +++ b/go/internal/amikad/sshd/manager_test.go @@ -62,6 +62,12 @@ func testManager(t *testing.T) (*Manager, Paths, *fakeKeyGenerator, *fakeProcess AuthorizedKeysUID: os.Getuid(), AuthorizedKeysGID: os.Getgid(), } + if err := os.MkdirAll(filepath.Dir(paths.AuthorizedKeys), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Chmod(filepath.Dir(paths.AuthorizedKeys), 0o700); err != nil { + t.Fatal(err) + } files := state.OSFiles{} store := state.NewStore(filepath.Join(directory, "injected-paths.json"), files) keygen := &fakeKeyGenerator{} @@ -69,6 +75,32 @@ func testManager(t *testing.T) (*Manager, Paths, *fakeKeyGenerator, *fakeProcess return NewManager(paths, store, files, keygen, processes), paths, keygen, processes } +func TestSetupRejectsSymlinkedAuthorizedKeysDirectoryWithoutChangingTarget(t *testing.T) { + manager, paths, _, _ := testManager(t) + directory := filepath.Dir(paths.AuthorizedKeys) + if err := os.Remove(directory); err != nil { + t.Fatal(err) + } + target := filepath.Join(t.TempDir(), "target") + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, directory); err != nil { + t.Fatal(err) + } + + if err := manager.Setup(context.Background(), SetupOptions{}); !errors.Is(err, state.ErrSymlinkPath) { + t.Fatalf("Setup error = %v, want ErrSymlinkPath", err) + } + info, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o755 { + t.Fatalf("symlink target mode = %o, want unchanged 755", info.Mode().Perm()) + } +} + func TestSetupCreatesLoopbackPolicyAndIsIdempotent(t *testing.T) { manager, paths, keygen, _ := testManager(t) if err := manager.Setup(context.Background(), SetupOptions{}); err != nil { diff --git a/go/internal/sandbox/presets_test.go b/go/internal/sandbox/presets_test.go index 1264039c..7f2c97cc 100644 --- a/go/internal/sandbox/presets_test.go +++ b/go/internal/sandbox/presets_test.go @@ -3,6 +3,7 @@ package sandbox import ( "os" "path/filepath" + "regexp" "strings" "testing" ) @@ -96,7 +97,6 @@ func TestGetPresetDockerfile_BaseBuildsPinnedAmikad(t *testing.T) { contents := string(data) for _, want := range []string{ "FROM golang:1.25.3-bookworm AS amikad-builder", - "ARG AMIKAD_SOURCE_REF=b5652138fc28504ab0532bd1ec3fd5fa5027fbb5", "go install \"github.com/gofixpoint/amika/go/cmd/amikad@${AMIKAD_SOURCE_REF}\"", "COPY --from=amikad-builder /out/amikad /usr/local/bin/amikad", } { @@ -104,6 +104,9 @@ func TestGetPresetDockerfile_BaseBuildsPinnedAmikad(t *testing.T) { t.Fatalf("base Dockerfile missing pinned amikad build %q", want) } } + if !regexp.MustCompile(`(?m)^ARG AMIKAD_SOURCE_REF=[0-9a-f]{40}$`).MatchString(contents) { + t.Fatal("base Dockerfile must pin amikad to a full source commit") + } } func TestGetPresetDockerfile_BaseChownsUserManagedAmikaDirectories(t *testing.T) { From 862cbbd1eb9bfed47e3e5434225948c3e50c6a15 Mon Sep 17 00:00:00 2001 From: "Dylan B. Mikus" Date: Mon, 3 Aug 2026 06:21:04 +0000 Subject: [PATCH 12/14] Pin reviewed amikad in base image --- go/internal/sandbox/presets/base/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go/internal/sandbox/presets/base/Dockerfile b/go/internal/sandbox/presets/base/Dockerfile index 215cb862..3ba8d95f 100644 --- a/go/internal/sandbox/presets/base/Dockerfile +++ b/go/internal/sandbox/presets/base/Dockerfile @@ -4,7 +4,7 @@ FROM golang:1.25.3-bookworm AS amikad-builder # Build from an immutable reviewed commit until the first standalone amikad # release exists. The final image receives only the binary, not the toolchain. -ARG AMIKAD_SOURCE_REF=05c6df1b4023dded1d59c9f6c7044be87ef393e4 +ARG AMIKAD_SOURCE_REF=f448546576924376230c434818cee82f4bb8e4a5 ENV GOBIN=/out RUN go install "github.com/gofixpoint/amika/go/cmd/amikad@${AMIKAD_SOURCE_REF}" From 3fde13c92ff8c3e43a393138459945f7b641cb09 Mon Sep 17 00:00:00 2001 From: "Dylan B. Mikus" Date: Mon, 3 Aug 2026 06:24:58 +0000 Subject: [PATCH 13/14] Close authorized key ownership race --- go/internal/amikad/sshd/manager.go | 13 ++--- go/internal/amikad/state/contracts.go | 44 +++++++++++++++++ go/internal/amikad/state/contracts_test.go | 57 ++++++++++++++++++++-- go/internal/amikad/state/os_files_linux.go | 30 +++++++++++- go/internal/amikad/state/os_files_other.go | 6 +++ 5 files changed, 136 insertions(+), 14 deletions(-) diff --git a/go/internal/amikad/sshd/manager.go b/go/internal/amikad/sshd/manager.go index 58b4520e..0998248e 100644 --- a/go/internal/amikad/sshd/manager.go +++ b/go/internal/amikad/sshd/manager.go @@ -223,18 +223,15 @@ func (m *Manager) SetAuthorizedKeys(ctx context.Context, input io.Reader) error if len(keys) == 0 { return ErrInvalidPublicKey } - if err := m.store.WriteAndRegister( + return m.store.WriteAndRegisterOwned( ctx, m.paths.AuthorizedKeys, strings.NewReader(strings.Join(keys, "\n")+"\n"), 0o600, - ); err != nil { - return err - } - return os.Lchown( - m.paths.AuthorizedKeys, - m.paths.AuthorizedKeysUID, - m.paths.AuthorizedKeysGID, + state.Ownership{ + UID: m.paths.AuthorizedKeysUID, + GID: m.paths.AuthorizedKeysGID, + }, ) } diff --git a/go/internal/amikad/state/contracts.go b/go/internal/amikad/state/contracts.go index 1d7df188..a075fb51 100644 --- a/go/internal/amikad/state/contracts.go +++ b/go/internal/amikad/state/contracts.go @@ -35,6 +35,21 @@ type SensitiveStore interface { // scrub manifest, reads the replacement contents from contents, and installs // the sensitive file with mode after the registration is durable. WriteAndRegister(ctx context.Context, absolutePath string, contents io.Reader, mode fs.FileMode) error + // WriteAndRegisterOwned performs the same ordered replacement while assigning + // ownership through the temporary file descriptor before the final rename. + WriteAndRegisterOwned( + ctx context.Context, + absolutePath string, + contents io.Reader, + mode fs.FileMode, + ownership Ownership, + ) error +} + +// Ownership is the numeric owner applied to a sensitive file before install. +type Ownership struct { + UID int + GID int } // FileSystem is the minimum filesystem boundary needed for ordered, atomic @@ -43,6 +58,7 @@ type FileSystem interface { ReadFile(string) ([]byte, error) Lstat(string) (fs.FileInfo, error) WriteFileAtomic(string, []byte, fs.FileMode) error + WriteFileAtomicOwned(string, []byte, fs.FileMode, Ownership) error WithLock(context.Context, string, func() error) error } @@ -65,6 +81,31 @@ func (s *Store) WriteAndRegister( absolutePath string, contents io.Reader, mode fs.FileMode, +) error { + return s.writeAndRegister(ctx, absolutePath, contents, mode, nil) +} + +// WriteAndRegisterOwned registers and replaces a sensitive file while setting +// ownership on the still-private temporary file descriptor. +func (s *Store) WriteAndRegisterOwned( + ctx context.Context, + absolutePath string, + contents io.Reader, + mode fs.FileMode, + ownership Ownership, +) error { + if ownership.UID < -1 || ownership.GID < -1 { + return ErrInvalidPath + } + return s.writeAndRegister(ctx, absolutePath, contents, mode, &ownership) +} + +func (s *Store) writeAndRegister( + ctx context.Context, + absolutePath string, + contents io.Reader, + mode fs.FileMode, + ownership *Ownership, ) error { if err := ctx.Err(); err != nil { return err @@ -117,6 +158,9 @@ func (s *Store) WriteAndRegister( if err := ctx.Err(); err != nil { return err } + if ownership != nil { + return s.files.WriteFileAtomicOwned(absolutePath, data, mode, *ownership) + } return s.files.WriteFileAtomic(absolutePath, data, mode) }) } diff --git a/go/internal/amikad/state/contracts_test.go b/go/internal/amikad/state/contracts_test.go index a363c562..2c07d0c3 100644 --- a/go/internal/amikad/state/contracts_test.go +++ b/go/internal/amikad/state/contracts_test.go @@ -15,9 +15,10 @@ import ( ) type writeCall struct { - path string - data []byte - mode fs.FileMode + path string + data []byte + mode fs.FileMode + ownership *Ownership } type memoryFiles struct { @@ -62,9 +63,32 @@ func (f *memoryFiles) Lstat(path string) (fs.FileInfo, error) { } func (f *memoryFiles) WriteFileAtomic(path string, data []byte, mode fs.FileMode) error { + return f.writeFileAtomic(path, data, mode, nil) +} + +func (f *memoryFiles) WriteFileAtomicOwned( + path string, + data []byte, + mode fs.FileMode, + ownership Ownership, +) error { + return f.writeFileAtomic(path, data, mode, &ownership) +} + +func (f *memoryFiles) writeFileAtomic( + path string, + data []byte, + mode fs.FileMode, + ownership *Ownership, +) error { f.mu.Lock() defer f.mu.Unlock() - f.writes = append(f.writes, writeCall{path: path, data: append([]byte(nil), data...), mode: mode}) + f.writes = append(f.writes, writeCall{ + path: path, + data: append([]byte(nil), data...), + mode: mode, + ownership: ownership, + }) if err := f.failWrites[path]; err != nil { return err } @@ -73,6 +97,31 @@ func (f *memoryFiles) WriteFileAtomic(path string, data []byte, mode fs.FileMode return nil } +func TestStoreAppliesOwnershipAsPartOfSensitiveReplacement(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, "injected-paths.json") + tokenPath := filepath.Join(dir, "authorized_keys") + files := newMemoryFiles() + store := NewStore(manifestPath, files) + ownership := Ownership{UID: 123, GID: 456} + + if err := store.WriteAndRegisterOwned( + context.Background(), + tokenPath, + strings.NewReader("ssh-ed25519 key\n"), + 0o600, + ownership, + ); err != nil { + t.Fatalf("WriteAndRegisterOwned: %v", err) + } + if len(files.writes) != 2 || files.writes[0].ownership != nil { + t.Fatalf("writes = %#v, want unowned manifest then owned sensitive file", files.writes) + } + if files.writes[1].ownership == nil || *files.writes[1].ownership != ownership { + t.Fatalf("sensitive ownership = %#v, want %#v", files.writes[1].ownership, ownership) + } +} + func (f *memoryFiles) WithLock(_ context.Context, _ string, operation func() error) error { return operation() } diff --git a/go/internal/amikad/state/os_files_linux.go b/go/internal/amikad/state/os_files_linux.go index 7635415c..9c4abba1 100644 --- a/go/internal/amikad/state/os_files_linux.go +++ b/go/internal/amikad/state/os_files_linux.go @@ -18,6 +18,26 @@ import ( // WriteFileAtomic writes and renames relative to a no-symlink directory file // descriptor, preventing path-component swaps between validation and commit. func (OSFiles) WriteFileAtomic(path string, data []byte, mode fs.FileMode) (returnErr error) { + return writeFileAtomic(path, data, mode, nil) +} + +// WriteFileAtomicOwned assigns ownership through the temporary file descriptor +// before installing it, so no attacker-controlled pathname is chowned. +func (OSFiles) WriteFileAtomicOwned( + path string, + data []byte, + mode fs.FileMode, + ownership Ownership, +) error { + return writeFileAtomic(path, data, mode, &ownership) +} + +func writeFileAtomic( + path string, + data []byte, + mode fs.FileMode, + ownership *Ownership, +) (returnErr error) { directoryFD, err := openDirectoryNoSymlinks(filepath.Dir(path)) if err != nil { return err @@ -46,11 +66,17 @@ func (OSFiles) WriteFileAtomic(path string, data []byte, mode fs.FileMode) (retu _ = temporary.Close() return err } - if err := temporary.Sync(); err != nil { + if err := temporary.Chmod(mode); err != nil { _ = temporary.Close() return err } - if err := temporary.Chmod(mode); err != nil { + if ownership != nil { + if err := temporary.Chown(ownership.UID, ownership.GID); err != nil { + _ = temporary.Close() + return err + } + } + if err := temporary.Sync(); err != nil { _ = temporary.Close() return err } diff --git a/go/internal/amikad/state/os_files_other.go b/go/internal/amikad/state/os_files_other.go index f7b4fb69..0e438c54 100644 --- a/go/internal/amikad/state/os_files_other.go +++ b/go/internal/amikad/state/os_files_other.go @@ -16,6 +16,12 @@ func (OSFiles) WriteFileAtomic(string, []byte, fs.FileMode) error { return errSecureAtomicWritesUnsupported } +// WriteFileAtomicOwned fails closed where secure fd-relative writes are not +// implemented. +func (OSFiles) WriteFileAtomicOwned(string, []byte, fs.FileMode, Ownership) error { + return errSecureAtomicWritesUnsupported +} + // WithLock fails closed where interprocess manifest locking is not implemented. func (OSFiles) WithLock(context.Context, string, func() error) error { return errSecureAtomicWritesUnsupported From 4777f5c6b8ad8a1a727f2557057e2c189b189e40 Mon Sep 17 00:00:00 2001 From: "Dylan B. Mikus" Date: Mon, 3 Aug 2026 06:25:44 +0000 Subject: [PATCH 14/14] Repin reviewed amikad source --- go/internal/sandbox/presets/base/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go/internal/sandbox/presets/base/Dockerfile b/go/internal/sandbox/presets/base/Dockerfile index 3ba8d95f..caa10420 100644 --- a/go/internal/sandbox/presets/base/Dockerfile +++ b/go/internal/sandbox/presets/base/Dockerfile @@ -4,7 +4,7 @@ FROM golang:1.25.3-bookworm AS amikad-builder # Build from an immutable reviewed commit until the first standalone amikad # release exists. The final image receives only the binary, not the toolchain. -ARG AMIKAD_SOURCE_REF=f448546576924376230c434818cee82f4bb8e4a5 +ARG AMIKAD_SOURCE_REF=3fde13c92ff8c3e43a393138459945f7b641cb09 ENV GOBIN=/out RUN go install "github.com/gofixpoint/amika/go/cmd/amikad@${AMIKAD_SOURCE_REF}"