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
4 4
5import ( 5import (
6 "fmt" 6 "fmt"
7 "log/slog"
8 "net"
7 "os" 9 "os"
10 "path/filepath"
11 "strconv"
8 12
9 "github.com/spf13/cobra" 13 "github.com/spf13/cobra"
14 "golang.org/x/crypto/ssh"
10 15
11 "github.com/krazywarez/forge/internal/config" 16 "github.com/krazywarez/forge/internal/config"
17 "github.com/krazywarez/forge/internal/policy"
18 "github.com/krazywarez/forge/internal/sshd"
12 "github.com/krazywarez/forge/internal/store" 19 "github.com/krazywarez/forge/internal/store"
13) 20)
14 21
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
15var configPath string 34var configPath string
16 35
17func main() { 36func main() {
@@ -64,7 +83,29 @@ func serveCmd() *cobra.Command {
64 Use: "serve", 83 Use: "serve",
65 Short: "run the ssh, http, and git listeners", 84 Short: "run the ssh, http, and git listeners",
66 RunE: func(cmd *cobra.Command, args []string) error { 85 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)
68 }, 109 },
69 } 110 }
70} 111}
@@ -109,17 +150,116 @@ func adminCmd() *cobra.Command {
109 Use: use, 150 Use: use,
110 Short: short, 151 Short: short,
111 RunE: func(cmd *cobra.Command, args []string) error { 152 RunE: func(cmd *cobra.Command, args []string) error {
112 return fmt.Errorf("not implemented (M1)") 153 return fmt.Errorf("not implemented")
113 }, 154 },
114 } 155 }
115 } 156 }
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())
116 admin.AddCommand( 161 admin.AddCommand(
117 notImplemented("user", "create and manage users"), 162 userCmd,
163 emailCmd,
118 notImplemented("invite", "issue registration invites"), 164 notImplemented("invite", "issue registration invites"),
119 notImplemented("email", "verify user emails"),
120 notImplemented("backup", "consistent backup: repos first, then database"), 165 notImplemented("backup", "consistent backup: repos first, then database"),
121 notImplemented("gc", "run git gc across repositories"), 166 notImplemented("gc", "run git gc across repositories"),
122 notImplemented("stats", "instance statistics"), 167 notImplemented("stats", "instance statistics"),
123 ) 168 )
124 return admin 169 return admin
125} 170}
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
5require ( 5require (
6 github.com/BurntSushi/toml v1.6.0 6 github.com/BurntSushi/toml v1.6.0
7 github.com/spf13/cobra v1.10.2 7 github.com/spf13/cobra v1.10.2
8 golang.org/x/crypto v0.55.0
8 modernc.org/sqlite v1.57.0 9 modernc.org/sqlite v1.57.0
9) 10)
10 11
go.sum +4
@@ -23,12 +23,16 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
23github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 23github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
24github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 24github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
25go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 25go.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=
26golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= 28golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
27golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= 29golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
28golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= 30golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
29golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 31golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
30golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= 32golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
31golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 33golang.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=
32golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= 36golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
33golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= 37golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
34gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 38gopkg.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}