Commit c0b0f1fca2

c0b0f1fca2fb9ed924070f80118015b0cdee2915

parent: 2965d74d76

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-23 22:23 UTC

M1: SSH control plane and identity

- embedded SSH listener: publickey auth by fingerprint, exec dispatch,
  auto-generated ed25519 host key, scope enforcement in the dispatcher
- POSIX tokenizer for SSH exec strings; rejects unquoted metacharacters;
  round-trip and no-panic fuzz targets
- control command registry with per-command ReadsStdin; whoami, keys
  list/add/remove; JSON envelope with protocol_version
- store: users, ssh keys (epoch bump on change), emails with verified_by
- forged serve, admin user create, admin email verify
- e2e: bare OpenSSH client exercises whoami --json, scope denial,
  duplicate-key message, unknown-key rejection, spaced-arg round trip

Layout: unified · split

cmd/forged/main.go +144 −4
@@ -4,14 +4,33 @@ package main
44
55import (
66 "fmt"
7 "log/slog"
8 "net"
79 "os"
10 "path/filepath"
11 "strconv"
812
913 "github.com/spf13/cobra"
14 "golang.org/x/crypto/ssh"
1015
1116 "github.com/krazywarez/forge/internal/config"
17 "github.com/krazywarez/forge/internal/policy"
18 "github.com/krazywarez/forge/internal/sshd"
1219 "github.com/krazywarez/forge/internal/store"
1320)
1421
22func openStore(cfg config.Config) (*store.Store, error) {
23 s, err := store.Open(filepath.Join(cfg.Server.Root, "forge.db"))
24 if err != nil {
25 return nil, err
26 }
27 if err := s.MigrateUp(); err != nil {
28 s.Close()
29 return nil, err
30 }
31 return s, nil
32}
33
1534var configPath string
1635
1736func main() {
@@ -64,7 +83,29 @@ func serveCmd() *cobra.Command {
6483 Use: "serve",
6584 Short: "run the ssh, http, and git listeners",
6685 RunE: func(cmd *cobra.Command, args []string) error {
67 return fmt.Errorf("not implemented (M1)")
86 cfg, err := config.Load(configPath)
87 if err != nil {
88 return err
89 }
90 st, err := openStore(cfg)
91 if err != nil {
92 return err
93 }
94 defer st.Close()
95
96 if cfg.SSH.Mode != "embedded" {
97 return fmt.Errorf("ssh.mode = %q not implemented (M9)", cfg.SSH.Mode)
98 }
99 srv, err := sshd.New(cfg, st)
100 if err != nil {
101 return err
102 }
103 ln, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(cfg.SSH.Port)))
104 if err != nil {
105 return err
106 }
107 slog.Info("ssh listening", "addr", ln.Addr())
108 return srv.Serve(ln)
68109 },
69110 }
70111}
@@ -109,17 +150,116 @@ func adminCmd() *cobra.Command {
109150 Use: use,
110151 Short: short,
111152 RunE: func(cmd *cobra.Command, args []string) error {
112 return fmt.Errorf("not implemented (M1)")
153 return fmt.Errorf("not implemented")
113154 },
114155 }
115156 }
157 userCmd := &cobra.Command{Use: "user", Short: "manage users"}
158 userCmd.AddCommand(adminUserCreateCmd())
159 emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"}
160 emailCmd.AddCommand(adminEmailVerifyCmd())
116161 admin.AddCommand(
117 notImplemented("user", "create and manage users"),
162 userCmd,
163 emailCmd,
118164 notImplemented("invite", "issue registration invites"),
119 notImplemented("email", "verify user emails"),
120165 notImplemented("backup", "consistent backup: repos first, then database"),
121166 notImplemented("gc", "run git gc across repositories"),
122167 notImplemented("stats", "instance statistics"),
123168 )
124169 return admin
125170}
171
172func adminUserCreateCmd() *cobra.Command {
173 var keyPath, email string
174 var verified, isAdmin bool
175 cmd := &cobra.Command{
176 Use: "create <username>",
177 Short: "create a user (host-local bootstrap; the only path in closed mode)",
178 Args: cobra.ExactArgs(1),
179 RunE: func(cmd *cobra.Command, args []string) error {
180 username := args[0]
181 if err := policy.ValidateOwnerName(username); err != nil {
182 return err
183 }
184 cfg, err := config.Load(configPath)
185 if err != nil {
186 return err
187 }
188 st, err := openStore(cfg)
189 if err != nil {
190 return err
191 }
192 defer st.Close()
193
194 uid, err := st.CreateUser(username, isAdmin)
195 if err != nil {
196 return err
197 }
198 if email != "" {
199 verifiedBy := ""
200 if verified {
201 verifiedBy = "admin"
202 }
203 if err := st.AddEmail(uid, email, verifiedBy, true); err != nil {
204 return err
205 }
206 }
207 if keyPath != "" {
208 raw, err := os.ReadFile(keyPath)
209 if err != nil {
210 return err
211 }
212 pub, _, _, _, err := ssh.ParseAuthorizedKey(raw)
213 if err != nil {
214 return fmt.Errorf("%s: not a public key in authorized_keys format: %w", keyPath, err)
215 }
216 fp := ssh.FingerprintSHA256(pub)
217 if err := st.AddSSHKey(uid, fp, pub.Type(), pub.Marshal(), "full"); err != nil {
218 return err
219 }
220 fmt.Println("key", fp)
221 }
222 fmt.Println("created user", username)
223 return nil
224 },
225 }
226 cmd.Flags().StringVar(&keyPath, "key", "", "path to an SSH public key to register")
227 cmd.Flags().StringVar(&email, "email", "", "primary email address")
228 cmd.Flags().BoolVar(&verified, "verified", false, "mark the email verified (admin assertion)")
229 cmd.Flags().BoolVar(&isAdmin, "admin", false, "grant instance admin")
230 return cmd
231}
232
233func adminEmailVerifyCmd() *cobra.Command {
234 return &cobra.Command{
235 Use: "verify <username> <address>",
236 Short: "mark an email verified by admin assertion",
237 Args: cobra.ExactArgs(2),
238 RunE: func(cmd *cobra.Command, args []string) error {
239 cfg, err := config.Load(configPath)
240 if err != nil {
241 return err
242 }
243 st, err := openStore(cfg)
244 if err != nil {
245 return err
246 }
247 defer st.Close()
248 u, err := st.UserByUsername(args[0])
249 if err != nil {
250 return fmt.Errorf("user %s: %w", args[0], err)
251 }
252 res, err := st.DB.Exec(
253 `UPDATE emails SET verified_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), verified_by = 'admin'
254 WHERE user_id = ? AND address = ?`, u.ID, args[1])
255 if err != nil {
256 return err
257 }
258 if n, _ := res.RowsAffected(); n == 0 {
259 return fmt.Errorf("no address %s on user %s", args[1], args[0])
260 }
261 fmt.Println("verified", args[1])
262 return nil
263 },
264 }
265}
e2e/ssh_test.go added +218
@@ -0,0 +1,218 @@
1// Package e2e drives a real forged with the real ssh and git clients.
2package e2e
3
4import (
5 "encoding/json"
6 "fmt"
7 "net"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12 "testing"
13 "time"
14)
15
16type instance struct {
17 forged string // path to built binary
18 root string
19 config string
20 port int
21 proc *exec.Cmd
22 sshDir string // per-user client keys live here
23}
24
25func buildForged(t *testing.T) string {
26 t.Helper()
27 bin := filepath.Join(t.TempDir(), "forged")
28 cmd := exec.Command("go", "build", "-o", bin, "github.com/krazywarez/forge/cmd/forged")
29 cmd.Dir = ".."
30 if out, err := cmd.CombinedOutput(); err != nil {
31 t.Fatalf("build forged: %v\n%s", err, out)
32 }
33 return bin
34}
35
36func freePort(t *testing.T) int {
37 t.Helper()
38 ln, err := net.Listen("tcp", "127.0.0.1:0")
39 if err != nil {
40 t.Fatal(err)
41 }
42 defer ln.Close()
43 return ln.Addr().(*net.TCPAddr).Port
44}
45
46func startInstance(t *testing.T) *instance {
47 t.Helper()
48 inst := &instance{
49 forged: buildForged(t),
50 root: t.TempDir(),
51 port: freePort(t),
52 sshDir: t.TempDir(),
53 }
54 inst.config = filepath.Join(inst.root, "config.toml")
55 cfg := fmt.Sprintf(`
56[server]
57root = %q
58site_url = "https://forge.test"
59[ssh]
60port = %d
61`, inst.root, inst.port)
62 if err := os.WriteFile(inst.config, []byte(cfg), 0o600); err != nil {
63 t.Fatal(err)
64 }
65
66 inst.proc = exec.Command(inst.forged, "--config", inst.config, "serve")
67 inst.proc.Stderr = os.Stderr
68 if err := inst.proc.Start(); err != nil {
69 t.Fatal(err)
70 }
71 t.Cleanup(func() {
72 inst.proc.Process.Kill()
73 inst.proc.Wait()
74 })
75
76 // Wait for the listener.
77 deadline := time.Now().Add(10 * time.Second)
78 for {
79 conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", inst.port), 200*time.Millisecond)
80 if err == nil {
81 conn.Close()
82 return inst
83 }
84 if time.Now().After(deadline) {
85 t.Fatal("forged did not start listening")
86 }
87 time.Sleep(50 * time.Millisecond)
88 }
89}
90
91// admin runs a forged admin command against the instance's database.
92func (i *instance) admin(t *testing.T, args ...string) string {
93 t.Helper()
94 cmd := exec.Command(i.forged, append([]string{"--config", i.config}, args...)...)
95 out, err := cmd.CombinedOutput()
96 if err != nil {
97 t.Fatalf("forged %v: %v\n%s", args, err, out)
98 }
99 return string(out)
100}
101
102// newKey generates a client keypair and returns the private key path.
103func (i *instance) newKey(t *testing.T, name string) string {
104 t.Helper()
105 priv := filepath.Join(i.sshDir, name)
106 cmd := exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", name, "-f", priv)
107 if out, err := cmd.CombinedOutput(); err != nil {
108 t.Fatalf("ssh-keygen: %v\n%s", err, out)
109 }
110 return priv
111}
112
113// ssh runs the real OpenSSH client against the instance with the given key.
114func (i *instance) ssh(t *testing.T, key string, stdin string, args ...string) (string, string, int) {
115 t.Helper()
116 base := []string{
117 "-p", fmt.Sprint(i.port),
118 "-i", key,
119 "-o", "IdentitiesOnly=yes",
120 "-o", "StrictHostKeyChecking=no",
121 "-o", "UserKnownHostsFile=" + filepath.Join(i.sshDir, "known_hosts"),
122 "-o", "BatchMode=yes",
123 "git@127.0.0.1",
124 }
125 cmd := exec.Command("ssh", append(base, args...)...)
126 if stdin != "" {
127 cmd.Stdin = strings.NewReader(stdin)
128 }
129 var out, errOut strings.Builder
130 cmd.Stdout = &out
131 cmd.Stderr = &errOut
132 err := cmd.Run()
133 code := 0
134 if ee, ok := err.(*exec.ExitError); ok {
135 code = ee.ExitCode()
136 } else if err != nil {
137 t.Fatalf("ssh: %v", err)
138 }
139 return out.String(), errOut.String(), code
140}
141
142func TestControlPlaneOverBareSSH(t *testing.T) {
143 inst := startInstance(t)
144
145 aliceKey := inst.newKey(t, "alice")
146 inst.admin(t, "admin", "user", "create", "alice",
147 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
148
149 // whoami --json from bare OpenSSH.
150 out, errOut, code := inst.ssh(t, aliceKey, "", "whoami", "--json")
151 if code != 0 {
152 t.Fatalf("whoami exit %d, stderr: %s", code, errOut)
153 }
154 var env struct {
155 ProtocolVersion int `json:"protocol_version"`
156 Data struct {
157 Username string `json:"username"`
158 KeyScope string `json:"key_scope"`
159 } `json:"data"`
160 }
161 if err := json.Unmarshal([]byte(out), &env); err != nil {
162 t.Fatalf("whoami output not JSON: %v\n%s", err, out)
163 }
164 if env.Data.Username != "alice" || env.ProtocolVersion != 1 || env.Data.KeyScope != "full" {
165 t.Fatalf("whoami = %+v", env)
166 }
167
168 // Unknown key is refused at auth.
169 strangerKey := inst.newKey(t, "stranger")
170 _, _, code = inst.ssh(t, strangerKey, "", "whoami")
171 if code == 0 {
172 t.Fatal("unknown key was authenticated")
173 }
174
175 // keys add over stdin, then list shows both.
176 secondKey := inst.newKey(t, "alice2")
177 pub, _ := os.ReadFile(secondKey + ".pub")
178 out, errOut, code = inst.ssh(t, aliceKey, string(pub), "keys", "add", "--scope", "git")
179 if code != 0 {
180 t.Fatalf("keys add exit %d, stderr: %s", code, errOut)
181 }
182 out, _, code = inst.ssh(t, aliceKey, "", "keys", "list")
183 if code != 0 || len(strings.Split(strings.TrimSpace(out), "\n")) != 2 {
184 t.Fatalf("keys list exit %d:\n%s", code, out)
185 }
186
187 // The git-scoped key authenticates but is denied control commands.
188 out, errOut, code = inst.ssh(t, secondKey, "", "whoami")
189 if code != 4 {
190 t.Fatalf("git-scoped whoami: exit %d (want 4), stdout %q stderr %q", code, out, errOut)
191 }
192 if !strings.Contains(errOut, "does not allow control commands") {
193 t.Fatalf("scope denial message missing: %q", errOut)
194 }
195
196 // Duplicate key registration: bob cannot claim alice's key, and the
197 // message is the exact spec text, naming no account.
198 bobKey := inst.newKey(t, "bob")
199 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
200 alicePub, _ := os.ReadFile(aliceKey + ".pub")
201 _, errOut, code = inst.ssh(t, bobKey, string(alicePub), "keys", "add")
202 if code != 2 {
203 t.Fatalf("duplicate key add: exit %d, want 2", code)
204 }
205 want := "that key is already registered to another account; remove it there first or use a different key"
206 if !strings.Contains(errOut, want) {
207 t.Fatalf("duplicate key message = %q, want %q", errOut, want)
208 }
209 if strings.Contains(errOut, "alice") {
210 t.Fatalf("duplicate key message leaks account name: %q", errOut)
211 }
212
213 // Arguments with spaces survive the tokenizer round trip.
214 _, errOut, code = inst.ssh(t, aliceKey, "", "keys", "remove", "'no such fingerprint'")
215 if code != 3 {
216 t.Fatalf("keys remove with spaced arg: exit %d (want 3), stderr %q", code, errOut)
217 }
218}
go.mod +1
@@ -5,6 +5,7 @@ go 1.27.0
55require (
66 github.com/BurntSushi/toml v1.6.0
77 github.com/spf13/cobra v1.10.2
8 golang.org/x/crypto v0.55.0
89 modernc.org/sqlite v1.57.0
910)
1011
go.sum +4
@@ -23,12 +23,16 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
2323github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
2424github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
2525go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
26golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
27golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
2628golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
2729golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
2830golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
2931golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
3032golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
3133golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
34golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
35golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
3236golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
3337golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
3438gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
internal/control/control.go added +140
@@ -0,0 +1,140 @@
1// Package control implements the forge control commands executed over SSH.
2// Every command here is reachable from bare OpenSSH: argv in, JSON or plain
3// text on stdout, diagnostics on stderr, exit code out.
4package control
5
6import (
7 "encoding/json"
8 "fmt"
9 "io"
10 "slices"
11
12 "github.com/krazywarez/forge/internal/config"
13 "github.com/krazywarez/forge/internal/protocol"
14 "github.com/krazywarez/forge/internal/store"
15)
16
17type Ctx struct {
18 User store.User
19 Scope string // scope of the key that authenticated this session
20 Store *store.Store
21 Cfg config.Config
22 Stdin io.Reader
23 Stdout io.Writer
24 Stderr io.Writer
25 JSON bool
26}
27
28type Command struct {
29 Path []string // e.g. ["keys", "add"]
30 Summary string
31 ReadsStdin bool
32 Run func(c *Ctx, args []string) int
33}
34
35var registry []Command
36
37func register(cmd Command) { registry = append(registry, cmd) }
38
39// Commands returns the registry, for the bare-ssh reachability test.
40func Commands() []Command { return registry }
41
42// Lookup resolves argv to a command by longest path match, returning the
43// command and the remaining arguments.
44func Lookup(argv []string) (Command, []string, bool) {
45 best := -1
46 var found Command
47 for _, cmd := range registry {
48 if len(cmd.Path) <= len(argv) && slices.Equal(cmd.Path, argv[:len(cmd.Path)]) && len(cmd.Path) > best {
49 best = len(cmd.Path)
50 found = cmd
51 }
52 }
53 if best < 0 {
54 return Command{}, nil, false
55 }
56 return found, argv[best:], true
57}
58
59// Dispatch runs argv for an authenticated session. The dispatcher — not the
60// handlers — enforces key scope: control commands require a full-scope key.
61func Dispatch(c *Ctx, argv []string) int {
62 if len(argv) == 0 {
63 return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help")
64 }
65 cmd, rest, ok := Lookup(argv)
66 if !ok {
67 return c.fail(protocol.ExitUsage, "unknown command %q", argv[0])
68 }
69 if c.Scope != "full" {
70 return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands", c.Scope)
71 }
72 // Strip the global --json flag wherever it appears.
73 args := rest[:0:0]
74 for _, a := range rest {
75 if a == "--json" {
76 c.JSON = true
77 continue
78 }
79 args = append(args, a)
80 }
81 if !cmd.ReadsStdin {
82 c.Stdin = emptyReader{}
83 }
84 return cmd.Run(c, args)
85}
86
87type emptyReader struct{}
88
89func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF }
90
91// emit writes data as the command result: a JSON envelope under --json,
92// otherwise via the plain formatter.
93func (c *Ctx) emit(data any, plain func(w io.Writer)) int {
94 if c.JSON {
95 enc := json.NewEncoder(c.Stdout)
96 enc.SetEscapeHTML(false)
97 if err := enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Data: data}); err != nil {
98 return protocol.ExitFailure
99 }
100 return protocol.ExitOK
101 }
102 plain(c.Stdout)
103 return protocol.ExitOK
104}
105
106func (c *Ctx) fail(code int, format string, args ...any) int {
107 msg := fmt.Sprintf(format, args...)
108 if c.JSON {
109 enc := json.NewEncoder(c.Stdout)
110 enc.SetEscapeHTML(false)
111 enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Error: msg})
112 } else {
113 fmt.Fprintln(c.Stderr, msg)
114 }
115 return code
116}
117
118func init() {
119 register(Command{
120 Path: []string{"help"},
121 Summary: "list available commands",
122 Run: func(c *Ctx, args []string) int {
123 for _, cmd := range registry {
124 fmt.Fprintf(c.Stdout, "%-24s %s\n", joinPath(cmd.Path), cmd.Summary)
125 }
126 return protocol.ExitOK
127 },
128 })
129}
130
131func joinPath(p []string) string {
132 out := ""
133 for i, s := range p {
134 if i > 0 {
135 out += " "
136 }
137 out += s
138 }
139 return out
140}
internal/control/control_test.go added +56
@@ -0,0 +1,56 @@
1package control
2
3import (
4 "strings"
5 "testing"
6
7 "github.com/krazywarez/forge/internal/protocol"
8)
9
10// TestEveryCommandReachableFromBareSSH asserts that each registered command's
11// path, rendered exactly as a user would type it after `ssh <host>`, resolves
12// back to that command through the tokenizer and Lookup. This is the guard
13// that keeps the forge CLI optional.
14func TestEveryCommandReachableFromBareSSH(t *testing.T) {
15 cmds := Commands()
16 if len(cmds) == 0 {
17 t.Fatal("no commands registered")
18 }
19 for _, cmd := range cmds {
20 line := strings.Join(cmd.Path, " ")
21 argv, err := protocol.Tokenize(line)
22 if err != nil {
23 t.Errorf("command %q not tokenizable: %v", line, err)
24 continue
25 }
26 got, rest, ok := Lookup(argv)
27 if !ok {
28 t.Errorf("command %q not found by Lookup", line)
29 continue
30 }
31 if strings.Join(got.Path, " ") != line || len(rest) != 0 {
32 t.Errorf("Lookup(%q) resolved to %q with rest %v", line, strings.Join(got.Path, " "), rest)
33 }
34 if cmd.Run == nil {
35 t.Errorf("command %q has no Run", line)
36 }
37 if cmd.Summary == "" {
38 t.Errorf("command %q has no summary", line)
39 }
40 }
41}
42
43func TestLookupLongestMatch(t *testing.T) {
44 // "keys list" must not resolve to a hypothetical shorter prefix and
45 // unknown commands must not match.
46 if _, _, ok := Lookup([]string{"keys"}); ok {
47 t.Error("bare \"keys\" resolved; group prefixes must not be runnable")
48 }
49 if _, _, ok := Lookup([]string{"nope"}); ok {
50 t.Error("unknown command resolved")
51 }
52 cmd, rest, ok := Lookup([]string{"keys", "list", "--json"})
53 if !ok || strings.Join(cmd.Path, " ") != "keys list" || len(rest) != 1 {
54 t.Errorf("Lookup keys list --json = %v %v %v", cmd.Path, rest, ok)
55 }
56}
internal/control/identity.go added +133
@@ -0,0 +1,133 @@
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7
8 "golang.org/x/crypto/ssh"
9
10 "github.com/krazywarez/forge/internal/protocol"
11 "github.com/krazywarez/forge/internal/store"
12)
13
14func init() {
15 register(Command{
16 Path: []string{"whoami"},
17 Summary: "show the authenticated account",
18 Run: runWhoami,
19 })
20 register(Command{
21 Path: []string{"keys", "list"},
22 Summary: "list registered SSH keys",
23 Run: runKeysList,
24 })
25 register(Command{
26 Path: []string{"keys", "add"},
27 Summary: "register an SSH public key (authorized_keys format on stdin) [--scope full|git]",
28 ReadsStdin: true,
29 Run: runKeysAdd,
30 })
31 register(Command{
32 Path: []string{"keys", "remove"},
33 Summary: "remove an SSH key by fingerprint",
34 Run: runKeysRemove,
35 })
36}
37
38func runWhoami(c *Ctx, args []string) int {
39 if len(args) != 0 {
40 return c.fail(protocol.ExitUsage, "usage: whoami [--json]")
41 }
42 type out struct {
43 Username string `json:"username"`
44 Admin bool `json:"admin"`
45 KeyScope string `json:"key_scope"`
46 }
47 d := out{Username: c.User.Username, Admin: c.User.IsAdmin, KeyScope: c.Scope}
48 return c.emit(d, func(w io.Writer) {
49 fmt.Fprintln(w, d.Username)
50 })
51}
52
53func runKeysList(c *Ctx, args []string) int {
54 if len(args) != 0 {
55 return c.fail(protocol.ExitUsage, "usage: keys list [--json]")
56 }
57 keys, err := c.Store.ListSSHKeys(c.User.ID)
58 if err != nil {
59 return c.fail(protocol.ExitFailure, "listing keys: %v", err)
60 }
61 type out struct {
62 Fingerprint string `json:"fingerprint"`
63 Algo string `json:"algo"`
64 Scope string `json:"scope"`
65 }
66 var ds []out
67 for _, k := range keys {
68 ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope})
69 }
70 return c.emit(ds, func(w io.Writer) {
71 for _, d := range ds {
72 fmt.Fprintf(w, "%s\t%s\t%s\n", d.Fingerprint, d.Algo, d.Scope)
73 }
74 })
75}
76
77func runKeysAdd(c *Ctx, args []string) int {
78 scope := "full"
79 for i := 0; i < len(args); i++ {
80 switch args[i] {
81 case "--scope":
82 if i+1 >= len(args) {
83 return c.fail(protocol.ExitUsage, "--scope requires a value")
84 }
85 scope = args[i+1]
86 i++
87 default:
88 return c.fail(protocol.ExitUsage, "usage: keys add [--scope full|git] < key.pub")
89 }
90 }
91 if scope != "full" && scope != "git" {
92 // deploy:* scopes are granted via repo settings, not self-service.
93 return c.fail(protocol.ExitUsage, "scope must be full or git")
94 }
95 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
96 if err != nil {
97 return c.fail(protocol.ExitFailure, "reading key: %v", err)
98 }
99 pub, _, _, _, err := ssh.ParseAuthorizedKey(raw)
100 if err != nil {
101 return c.fail(protocol.ExitUsage, "not a valid public key in authorized_keys format: %v", err)
102 }
103 fp := ssh.FingerprintSHA256(pub)
104 if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), scope); err != nil {
105 if errors.Is(err, store.ErrDuplicateKey) {
106 return c.fail(protocol.ExitUsage, "%v", err)
107 }
108 return c.fail(protocol.ExitFailure, "adding key: %v", err)
109 }
110 type out struct {
111 Fingerprint string `json:"fingerprint"`
112 Scope string `json:"scope"`
113 }
114 d := out{fp, scope}
115 return c.emit(d, func(w io.Writer) {
116 fmt.Fprintf(w, "added %s (%s)\n", d.Fingerprint, d.Scope)
117 })
118}
119
120func runKeysRemove(c *Ctx, args []string) int {
121 if len(args) != 1 {
122 return c.fail(protocol.ExitUsage, "usage: keys remove <fingerprint>")
123 }
124 if err := c.Store.RemoveSSHKey(c.User.ID, args[0]); err != nil {
125 if errors.Is(err, store.ErrNotFound) {
126 return c.fail(protocol.ExitNotFound, "no key with fingerprint %s on your account", args[0])
127 }
128 return c.fail(protocol.ExitFailure, "removing key: %v", err)
129 }
130 return c.emit(map[string]string{"removed": args[0]}, func(w io.Writer) {
131 fmt.Fprintf(w, "removed %s\n", args[0])
132 })
133}
internal/protocol/tokenize.go added +89
@@ -0,0 +1,89 @@
1package protocol
2
3import (
4 "errors"
5 "fmt"
6 "strings"
7)
8
9// Tokenize splits an SSH exec command string into argv using POSIX
10// shell-word rules: whitespace separates words; single quotes preserve
11// everything literally; double quotes preserve everything except backslash
12// before \, ", or $; a bare backslash escapes the next character. There is
13// no expansion of any kind — no globbing, variables, or substitution. The
14// client's shell has already applied one layer of quoting before the string
15// reaches us.
16func Tokenize(s string) ([]string, error) {
17 var argv []string
18 var cur strings.Builder
19 inWord := false
20
21 i := 0
22 for i < len(s) {
23 c := s[i]
24 switch {
25 case c == ' ' || c == '\t' || c == '\n':
26 if inWord {
27 argv = append(argv, cur.String())
28 cur.Reset()
29 inWord = false
30 }
31 i++
32 case c == '\'':
33 inWord = true
34 end := strings.IndexByte(s[i+1:], '\'')
35 if end < 0 {
36 return nil, errors.New("unterminated single quote")
37 }
38 cur.WriteString(s[i+1 : i+1+end])
39 i += end + 2
40 case c == '"':
41 inWord = true
42 i++
43 closed := false
44 for i < len(s) {
45 c = s[i]
46 if c == '"' {
47 closed = true
48 i++
49 break
50 }
51 if c == '\\' && i+1 < len(s) {
52 switch s[i+1] {
53 case '\\', '"', '$', '`':
54 cur.WriteByte(s[i+1])
55 i += 2
56 continue
57 }
58 }
59 cur.WriteByte(c)
60 i++
61 }
62 if !closed {
63 return nil, errors.New("unterminated double quote")
64 }
65 case c == '\\':
66 if i+1 >= len(s) {
67 return nil, errors.New("trailing backslash")
68 }
69 inWord = true
70 cur.WriteByte(s[i+1])
71 i += 2
72 case c == '$' || c == '`' || c == ';' || c == '&' || c == '|' ||
73 c == '<' || c == '>' || c == '(' || c == ')' || c == '*' ||
74 c == '?' || c == '[' || c == '#' || c == '~':
75 // Unquoted shell metacharacters are rejected outright rather
76 // than passed through: there is no shell here, and silently
77 // treating them as literals would mask client quoting bugs.
78 return nil, fmt.Errorf("unquoted shell metacharacter %q", c)
79 default:
80 inWord = true
81 cur.WriteByte(c)
82 i++
83 }
84 }
85 if inWord {
86 argv = append(argv, cur.String())
87 }
88 return argv, nil
89}
internal/protocol/tokenize_test.go added +94
@@ -0,0 +1,94 @@
1package protocol
2
3import (
4 "reflect"
5 "strings"
6 "testing"
7)
8
9func TestTokenize(t *testing.T) {
10 cases := []struct {
11 in string
12 want []string
13 }{
14 {`whoami --json`, []string{"whoami", "--json"}},
15 {`repo create krz/newthing --private`, []string{"repo", "create", "krz/newthing", "--private"}},
16 {`git-upload-pack '/krz/hutch.git'`, []string{"git-upload-pack", "/krz/hutch.git"}},
17 {`issue create --title 'a b c'`, []string{"issue", "create", "--title", "a b c"}},
18 {`issue create --title "a \"b\" c"`, []string{"issue", "create", "--title", `a "b" c`}},
19 {`a\ b`, []string{"a b"}},
20 {`'it''s'`, []string{"its"}},
21 {`"don't"`, []string{"don't"}},
22 {" spaced \t out ", []string{"spaced", "out"}},
23 {`""`, []string{""}},
24 {``, nil},
25 {`--message "line1\nliteral"`, []string{"--message", `line1\nliteral`}},
26 }
27 for _, tc := range cases {
28 got, err := Tokenize(tc.in)
29 if err != nil {
30 t.Errorf("Tokenize(%q) error: %v", tc.in, err)
31 continue
32 }
33 if !reflect.DeepEqual(got, tc.want) {
34 t.Errorf("Tokenize(%q) = %#v, want %#v", tc.in, got, tc.want)
35 }
36 }
37}
38
39func TestTokenizeRejects(t *testing.T) {
40 bad := []string{
41 `echo $(rm -rf /)`,
42 "`id`",
43 `a; b`,
44 `a | b`,
45 `a > f`,
46 `a & b`,
47 `'unterminated`,
48 `"unterminated`,
49 `trailing\`,
50 `glob *`,
51 `~root`,
52 }
53 for _, in := range bad {
54 if got, err := Tokenize(in); err == nil {
55 t.Errorf("Tokenize(%q) = %#v, want error", in, got)
56 }
57 }
58}
59
60// shellQuote quotes one word the way a POSIX client shell would.
61func shellQuote(w string) string {
62 return "'" + strings.ReplaceAll(w, "'", `'\''`) + "'"
63}
64
65// FuzzTokenizeRoundTrip checks that any argv, single-quoted as a client
66// shell would emit it, tokenizes back to the identical argv.
67func FuzzTokenizeRoundTrip(f *testing.F) {
68 f.Add("whoami", "--json", "")
69 f.Add("issue create", "--title", "a 'quoted' \"title\" with $pecial\\chars")
70 f.Add("répo", "\t", "\n\n")
71 f.Fuzz(func(t *testing.T, a, b, c string) {
72 want := []string{a, b, c}
73 quoted := make([]string, len(want))
74 for i, w := range want {
75 quoted[i] = shellQuote(w)
76 }
77 got, err := Tokenize(strings.Join(quoted, " "))
78 if err != nil {
79 t.Fatalf("Tokenize error on %q: %v", strings.Join(quoted, " "), err)
80 }
81 if !reflect.DeepEqual(got, want) {
82 t.Fatalf("round trip: got %#v, want %#v", got, want)
83 }
84 })
85}
86
87// FuzzTokenizeNoPanic feeds arbitrary bytes; Tokenize must return, never panic.
88func FuzzTokenizeNoPanic(f *testing.F) {
89 f.Add(`repo create 'x`)
90 f.Add(`\\\'\"`)
91 f.Fuzz(func(t *testing.T, s string) {
92 _, _ = Tokenize(s)
93 })
94}
internal/sshd/sshd.go added +209
@@ -0,0 +1,209 @@
1// Package sshd implements the embedded SSH listener: public-key auth against
2// registered keys, then dispatch to git transport or control commands.
3package sshd
4
5import (
6 "crypto/ed25519"
7 "crypto/rand"
8 "encoding/pem"
9 "errors"
10 "fmt"
11 "log/slog"
12 "net"
13 "os"
14 "path/filepath"
15 "strconv"
16 "strings"
17
18 "golang.org/x/crypto/ssh"
19
20 "github.com/krazywarez/forge/internal/config"
21 "github.com/krazywarez/forge/internal/control"
22 "github.com/krazywarez/forge/internal/protocol"
23 "github.com/krazywarez/forge/internal/store"
24)
25
26type Server struct {
27 cfg config.Config
28 st *store.Store
29 sshCfg *ssh.ServerConfig
30}
31
32func New(cfg config.Config, st *store.Store) (*Server, error) {
33 s := &Server{cfg: cfg, st: st}
34
35 sc := &ssh.ServerConfig{
36 PublicKeyCallback: s.authenticate,
37 ServerVersion: "SSH-2.0-forged",
38 }
39 signers, err := loadHostKeys(cfg)
40 if err != nil {
41 return nil, err
42 }
43 for _, sg := range signers {
44 sc.AddHostKey(sg)
45 }
46 s.sshCfg = sc
47 return s, nil
48}
49
50// loadHostKeys loads the configured host keys, or generates an ed25519 key
51// under server.root/ssh/ when none are configured.
52func loadHostKeys(cfg config.Config) ([]ssh.Signer, error) {
53 paths := cfg.SSH.HostKeys
54 if len(paths) == 0 {
55 p := filepath.Join(cfg.Server.Root, "ssh", "host_ed25519")
56 if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) {
57 if err := generateHostKey(p); err != nil {
58 return nil, fmt.Errorf("generating host key: %w", err)
59 }
60 slog.Info("generated ssh host key", "path", p)
61 }
62 paths = []string{p}
63 }
64 var signers []ssh.Signer
65 for _, p := range paths {
66 raw, err := os.ReadFile(p)
67 if err != nil {
68 return nil, fmt.Errorf("host key %s: %w", p, err)
69 }
70 sg, err := ssh.ParsePrivateKey(raw)
71 if err != nil {
72 return nil, fmt.Errorf("host key %s: %w", p, err)
73 }
74 signers = append(signers, sg)
75 }
76 return signers, nil
77}
78
79func generateHostKey(path string) error {
80 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
81 return err
82 }
83 _, priv, err := ed25519.GenerateKey(rand.Reader)
84 if err != nil {
85 return err
86 }
87 block, err := ssh.MarshalPrivateKey(priv, "")
88 if err != nil {
89 return err
90 }
91 return os.WriteFile(path, pem.EncodeToMemory(block), 0o600)
92}
93
94// authenticate resolves the presented key to a registered account. The SSH
95// username is ignored; identity comes from the key alone.
96func (s *Server) authenticate(_ ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Permissions, error) {
97 fp := ssh.FingerprintSHA256(pub)
98 key, err := s.st.SSHKeyByFingerprint(fp)
99 if err != nil {
100 return nil, fmt.Errorf("unknown key %s", fp)
101 }
102 return &ssh.Permissions{Extensions: map[string]string{
103 "user-id": strconv.FormatInt(key.UserID, 10),
104 "key-id": strconv.FormatInt(key.ID, 10),
105 "scope": key.Scope,
106 }}, nil
107}
108
109// Serve accepts connections on ln until it is closed.
110func (s *Server) Serve(ln net.Listener) error {
111 for {
112 conn, err := ln.Accept()
113 if err != nil {
114 return err
115 }
116 go s.handleConn(conn)
117 }
118}
119
120func (s *Server) handleConn(conn net.Conn) {
121 defer conn.Close()
122 sconn, chans, reqs, err := ssh.NewServerConn(conn, s.sshCfg)
123 if err != nil {
124 return
125 }
126 defer sconn.Close()
127 go ssh.DiscardRequests(reqs)
128
129 for newCh := range chans {
130 if newCh.ChannelType() != "session" {
131 newCh.Reject(ssh.UnknownChannelType, "only session channels are supported")
132 continue
133 }
134 ch, chReqs, err := newCh.Accept()
135 if err != nil {
136 continue
137 }
138 go s.handleSession(sconn, ch, chReqs)
139 }
140}
141
142func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-chan *ssh.Request) {
143 defer ch.Close()
144 for req := range reqs {
145 switch req.Type {
146 case "exec":
147 var payload struct{ Command string }
148 if err := ssh.Unmarshal(req.Payload, &payload); err != nil {
149 req.Reply(false, nil)
150 continue
151 }
152 req.Reply(true, nil)
153 code := s.runExec(sconn, ch, payload.Command)
154 sendExit(ch, code)
155 return
156 case "shell":
157 req.Reply(true, nil)
158 fmt.Fprintf(ch, "forge control plane: interactive shells are not available.\nTry: ssh %s help\n", s.cfg.Server.SiteURL)
159 sendExit(ch, protocol.ExitUsage)
160 return
161 case "pty-req", "env":
162 // Harmless; accept and ignore.
163 req.Reply(true, nil)
164 default:
165 req.Reply(false, nil)
166 }
167 }
168}
169
170func sendExit(ch ssh.Channel, code int) {
171 var msg = struct{ Status uint32 }{uint32(code)}
172 ch.SendRequest("exit-status", false, ssh.Marshal(&msg))
173}
174
175func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string) int {
176 ext := sconn.Permissions.Extensions
177 userID, _ := strconv.ParseInt(ext["user-id"], 10, 64)
178 keyID, _ := strconv.ParseInt(ext["key-id"], 10, 64)
179 user, err := s.st.UserByID(userID)
180 if err != nil {
181 fmt.Fprintln(ch.Stderr(), "account no longer exists")
182 return protocol.ExitDenied
183 }
184 _ = s.st.TouchSSHKey(keyID)
185
186 if name, _, ok := strings.Cut(cmdline, " "); ok || name != "" {
187 switch name {
188 case "git-upload-pack", "git-receive-pack", "git-upload-archive":
189 fmt.Fprintln(ch.Stderr(), "git transport not implemented (M2)")
190 return protocol.ExitFailure
191 }
192 }
193
194 argv, err := protocol.Tokenize(cmdline)
195 if err != nil {
196 fmt.Fprintf(ch.Stderr(), "cannot parse command: %v\n", err)
197 return protocol.ExitUsage
198 }
199 ctx := &control.Ctx{
200 User: user,
201 Scope: ext["scope"],
202 Store: s.st,
203 Cfg: s.cfg,
204 Stdin: ch,
205 Stdout: ch,
206 Stderr: ch.Stderr(),
207 }
208 return control.Dispatch(ctx, argv)
209}
internal/store/users.go added +185
@@ -0,0 +1,185 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6 "fmt"
7 "strings"
8)
9
10type User struct {
11 ID int64
12 Username string
13 IsAdmin bool
14}
15
16type SSHKey struct {
17 ID int64
18 UserID int64
19 Fingerprint string
20 Algo string
21 Blob []byte
22 Scope string
23}
24
25// ErrDuplicateKey carries the exact user-facing message from the spec. It
26// deliberately does not name the owning account (enumeration oracle).
27var ErrDuplicateKey = errors.New("that key is already registered to another account; remove it there first or use a different key")
28
29var ErrNotFound = errors.New("not found")
30
31func (s *Store) CreateUser(username string, isAdmin bool) (int64, error) {
32 res, err := s.DB.Exec("INSERT INTO users (username, is_admin) VALUES (?, ?)", username, boolInt(isAdmin))
33 if err != nil {
34 if isUniqueErr(err) {
35 return 0, fmt.Errorf("username %q is taken", username)
36 }
37 return 0, err
38 }
39 return res.LastInsertId()
40}
41
42func (s *Store) UserByUsername(name string) (User, error) {
43 var u User
44 var admin int
45 err := s.DB.QueryRow("SELECT id, username, is_admin FROM users WHERE username = ?", name).
46 Scan(&u.ID, &u.Username, &admin)
47 if errors.Is(err, sql.ErrNoRows) {
48 return u, ErrNotFound
49 }
50 u.IsAdmin = admin != 0
51 return u, err
52}
53
54func (s *Store) UserByID(id int64) (User, error) {
55 var u User
56 var admin int
57 err := s.DB.QueryRow("SELECT id, username, is_admin FROM users WHERE id = ?", id).
58 Scan(&u.ID, &u.Username, &admin)
59 if errors.Is(err, sql.ErrNoRows) {
60 return u, ErrNotFound
61 }
62 u.IsAdmin = admin != 0
63 return u, err
64}
65
66// AddSSHKey registers a key and bumps the key epoch in one transaction.
67func (s *Store) AddSSHKey(userID int64, fingerprint, algo string, blob []byte, scope string) error {
68 tx, err := s.DB.Begin()
69 if err != nil {
70 return err
71 }
72 defer tx.Rollback()
73 if _, err := tx.Exec(
74 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope) VALUES (?, ?, ?, ?, ?)",
75 userID, fingerprint, algo, blob, scope); err != nil {
76 if isUniqueErr(err) {
77 return ErrDuplicateKey
78 }
79 return err
80 }
81 if err := bumpKeyEpoch(tx); err != nil {
82 return err
83 }
84 return tx.Commit()
85}
86
87// RemoveSSHKey removes a key owned by userID and bumps the key epoch.
88func (s *Store) RemoveSSHKey(userID int64, fingerprint string) error {
89 tx, err := s.DB.Begin()
90 if err != nil {
91 return err
92 }
93 defer tx.Rollback()
94 res, err := tx.Exec("DELETE FROM ssh_keys WHERE user_id = ? AND fingerprint = ?", userID, fingerprint)
95 if err != nil {
96 return err
97 }
98 if n, _ := res.RowsAffected(); n == 0 {
99 return ErrNotFound
100 }
101 if err := bumpKeyEpoch(tx); err != nil {
102 return err
103 }
104 return tx.Commit()
105}
106
107func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) {
108 var k SSHKey
109 err := s.DB.QueryRow(
110 "SELECT id, user_id, fingerprint, algo, blob, scope FROM ssh_keys WHERE fingerprint = ?",
111 fingerprint).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope)
112 if errors.Is(err, sql.ErrNoRows) {
113 return k, ErrNotFound
114 }
115 return k, err
116}
117
118func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
119 rows, err := s.DB.Query(
120 "SELECT id, user_id, fingerprint, algo, blob, scope FROM ssh_keys WHERE user_id = ? ORDER BY id",
121 userID)
122 if err != nil {
123 return nil, err
124 }
125 defer rows.Close()
126 var keys []SSHKey
127 for rows.Next() {
128 var k SSHKey
129 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope); err != nil {
130 return nil, err
131 }
132 keys = append(keys, k)
133 }
134 return keys, rows.Err()
135}
136
137// TouchSSHKey records key use; best-effort, callers ignore the error.
138func (s *Store) TouchSSHKey(id int64) error {
139 _, err := s.DB.Exec(
140 "UPDATE ssh_keys SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", id)
141 return err
142}
143
144// AddEmail adds an address; verifiedBy is "" (unverified), "smtp", or "admin".
145func (s *Store) AddEmail(userID int64, address, verifiedBy string, primary bool) error {
146 var vAt, vBy any
147 if verifiedBy != "" {
148 vAt = "now"
149 vBy = verifiedBy
150 }
151 _, err := s.DB.Exec(
152 `INSERT INTO emails (user_id, address, verified_at, verified_by, is_primary)
153 VALUES (?, ?, CASE WHEN ? IS NULL THEN NULL ELSE strftime('%Y-%m-%dT%H:%M:%fZ','now') END, ?, ?)`,
154 userID, address, vAt, vBy, boolInt(primary))
155 if isUniqueErr(err) {
156 return fmt.Errorf("address %q is already in use", address)
157 }
158 return err
159}
160
161func (s *Store) KeyEpoch() (int64, error) {
162 var v int64
163 err := s.DB.QueryRow("SELECT value FROM settings WHERE key = 'key_epoch'").Scan(&v)
164 return v, err
165}
166
167type execer interface {
168 Exec(query string, args ...any) (sql.Result, error)
169}
170
171func bumpKeyEpoch(tx execer) error {
172 _, err := tx.Exec("UPDATE settings SET value = value + 1 WHERE key = 'key_epoch'")
173 return err
174}
175
176func boolInt(b bool) int {
177 if b {
178 return 1
179 }
180 return 0
181}
182
183func isUniqueErr(err error) bool {
184 return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
185}