tenseleyflow/shithub / 9f965ab

Browse files

Wire shithubd ssh-authkeys + ssh-shell with fail-closed empty stdout on miss/error

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
9f965ab58c5e2d92357021c6f0a35899a799a70d
Parents
3f863a2
Tree
6803e42

2 changed files

StatusFile+-
M cmd/shithubd/root.go 1 2
A cmd/shithubd/ssh.go 169 0
cmd/shithubd/root.gomodified
@@ -34,8 +34,7 @@ func init() {
3434
 	// Stubs for subcommands implemented in later sprints. They surface in
3535
 	// `--help` so the operator-facing interface is discoverable from day one.
3636
 	rootCmd.AddCommand(stubCmd("worker", "Run background workers", "S14"))
37
-	rootCmd.AddCommand(stubCmd("ssh-authkeys", "AuthorizedKeysCommand handler", "S07"))
38
-	rootCmd.AddCommand(stubCmd("ssh-shell", "Forced SSH shell dispatcher", "S07/S13"))
37
+	// ssh-authkeys and ssh-shell are registered in cmd/shithubd/ssh.go.
3938
 	rootCmd.AddCommand(storageCmd)
4039
 	rootCmd.AddCommand(stubCmd("hook", "Git hook entrypoint", "S14"))
4140
 	rootCmd.AddCommand(adminCmd)
cmd/shithubd/ssh.goadded
@@ -0,0 +1,169 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package main
4
+
5
+import (
6
+	"context"
7
+	"fmt"
8
+	"net/netip"
9
+	"os"
10
+	"strings"
11
+	"time"
12
+
13
+	"github.com/spf13/cobra"
14
+
15
+	"github.com/tenseleyFlow/shithub/internal/infra/config"
16
+	"github.com/tenseleyFlow/shithub/internal/infra/db"
17
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
18
+)
19
+
20
+// sshAuthkeysCmd implements sshd's AuthorizedKeysCommand contract:
21
+//
22
+//   - On a known fingerprint, write a single authorized_keys line on stdout
23
+//     with a forced command and restrictive options.
24
+//   - On an unknown fingerprint OR any error, write nothing and exit 0.
25
+//     sshd uses STDOUT as the auth answer; non-zero exit is a config error,
26
+//     not a deny. Failing closed is the right model: better to deny a
27
+//     legitimate connection than accidentally authorize the wrong user.
28
+//
29
+// Latency is critical — every SSH connection waits on this. The pool is
30
+// sized small (max 4 conns) to bound startup cost and tail-latency.
31
+var sshAuthkeysCmd = &cobra.Command{
32
+	Use:    "ssh-authkeys <fingerprint>",
33
+	Short:  "AuthorizedKeysCommand handler for sshd",
34
+	Args:   cobra.ExactArgs(1),
35
+	Hidden: true, // not for direct human use
36
+	RunE: func(cmd *cobra.Command, args []string) error {
37
+		// Fail-closed wrapper: anything below that returns an error or
38
+		// panics writes nothing to stdout. The exit code stays 0.
39
+		defer func() {
40
+			_ = recover()
41
+		}()
42
+		fp := strings.TrimSpace(args[0])
43
+		if !isWellFormedFingerprint(fp) {
44
+			return nil
45
+		}
46
+
47
+		cfg, err := config.Load(nil)
48
+		if err != nil || cfg.DB.URL == "" {
49
+			return nil
50
+		}
51
+
52
+		ctx, cancel := context.WithTimeout(cmd.Context(), 1500*time.Millisecond)
53
+		defer cancel()
54
+
55
+		pool, err := db.Open(ctx, db.Config{
56
+			URL: cfg.DB.URL, MaxConns: 4, MinConns: 0,
57
+			ConnectTimeout: 750 * time.Millisecond,
58
+		})
59
+		if err != nil {
60
+			return nil
61
+		}
62
+		defer pool.Close()
63
+
64
+		q := usersdb.New()
65
+		row, err := q.GetUserSSHKeyByFingerprint(ctx, pool, fp)
66
+		if err != nil {
67
+			// pgx.ErrNoRows or any other error → silently empty.
68
+			return nil
69
+		}
70
+
71
+		_, _ = fmt.Fprintln(cmd.OutOrStdout(), authorizedKeysLine(row))
72
+
73
+		// Best-effort last-used update. 500ms cap; any error is dropped.
74
+		updateCtx, updateCancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
75
+		defer updateCancel()
76
+		_ = q.TouchSSHKeyLastUsed(updateCtx, pool, usersdb.TouchSSHKeyLastUsedParams{
77
+			ID:         row.ID,
78
+			LastUsedIp: clientAddrFromEnv(),
79
+		})
80
+		return nil
81
+	},
82
+}
83
+
84
+// sshShellCmd is the placeholder for the forced-command target. S13 swaps
85
+// this for the real git-over-SSH dispatcher; for S07 we just log the
86
+// inbound command and exit non-zero with a friendly message so an
87
+// operator (or test) can confirm the wiring works end-to-end.
88
+var sshShellCmd = &cobra.Command{
89
+	Use:    "ssh-shell <user_id>",
90
+	Short:  "Forced-command target invoked by sshd via AuthorizedKeysCommand",
91
+	Args:   cobra.ExactArgs(1),
92
+	Hidden: true,
93
+	RunE: func(cmd *cobra.Command, args []string) error {
94
+		userID := args[0]
95
+		original := os.Getenv("SSH_ORIGINAL_COMMAND")
96
+		// Log to stderr so it's captured by sshd's session log without
97
+		// polluting the (silent-on-empty) stdout contract.
98
+		_, _ = fmt.Fprintf(cmd.ErrOrStderr(),
99
+			"shithubd ssh-shell: user_id=%s original_command=%q (git-over-SSH lands in S13)\n",
100
+			userID, original)
101
+		return fmt.Errorf("git over SSH not enabled yet")
102
+	},
103
+}
104
+
105
+// authorizedKeysLine builds the single line sshd consumes. The forced
106
+// command runs `shithubd ssh-shell <user_id>`; the option set strips
107
+// every interactive affordance.
108
+func authorizedKeysLine(row usersdb.UserSshKey) string {
109
+	binary := os.Args[0]
110
+	// Quote-escape only the binary path; user_id is a digit string so it
111
+	// can never contain shell metacharacters.
112
+	cmd := fmt.Sprintf(`%s ssh-shell %d`, binary, row.UserID)
113
+	options := strings.Join([]string{
114
+		fmt.Sprintf(`command="%s"`, cmd),
115
+		"no-port-forwarding",
116
+		"no-X11-forwarding",
117
+		"no-agent-forwarding",
118
+		"no-pty",
119
+	}, ",")
120
+	return options + " " + row.PublicKey
121
+}
122
+
123
+// clientAddrFromEnv extracts the connecting client's address from
124
+// $SSH_CONNECTION (sshd sets it to "<client> <cport> <server> <sport>").
125
+// Returns nil when unavailable, which sqlc encodes as a SQL NULL.
126
+func clientAddrFromEnv() *netip.Addr {
127
+	conn := os.Getenv("SSH_CONNECTION")
128
+	if conn == "" {
129
+		return nil
130
+	}
131
+	parts := strings.Fields(conn)
132
+	if len(parts) < 1 {
133
+		return nil
134
+	}
135
+	addr, err := netip.ParseAddr(parts[0])
136
+	if err != nil {
137
+		return nil
138
+	}
139
+	return &addr
140
+}
141
+
142
+// isWellFormedFingerprint accepts only the canonical SHA256:<b64> shape
143
+// our codebase emits. Defense against an attacker passing crafted strings
144
+// to influence the SQL plan.
145
+func isWellFormedFingerprint(s string) bool {
146
+	if !strings.HasPrefix(s, "SHA256:") {
147
+		return false
148
+	}
149
+	rest := s[len("SHA256:"):]
150
+	if len(rest) < 30 || len(rest) > 80 {
151
+		return false
152
+	}
153
+	for _, r := range rest {
154
+		switch {
155
+		case r >= 'A' && r <= 'Z',
156
+			r >= 'a' && r <= 'z',
157
+			r >= '0' && r <= '9',
158
+			r == '+', r == '/', r == '=':
159
+		default:
160
+			return false
161
+		}
162
+	}
163
+	return true
164
+}
165
+
166
+func init() {
167
+	rootCmd.AddCommand(sshAuthkeysCmd)
168
+	rootCmd.AddCommand(sshShellCmd)
169
+}