Commit 35954391f3
35954391f3a289b7150b2c80e5723ad47f45d88e
parent: bc76b6f008
Verified · cmc
cmc <hello@cleberg.net> · 2026-09-28 07:41 UTC
sshd: unregistered-key message names the fingerprint and the real host
Ref #268
Layout: unified · split
CHANGELOG.org
+2
| @@ -87,6 +87,8 @@ for the eighteen commands whose CLI path differs from the registry's |
| 87 | usage line a wrong-argument refusal does. Outside the CLI a usage |
87 | usage line a wrong-argument refusal does. Outside the CLI a usage |
| 88 | refusal reads =usage: ssh git@<host> ...= on every surface, |
88 | refusal reads =usage: ssh git@<host> ...= on every surface, |
| 89 | including the error text of the web UI and the JSON API (#267). |
89 | including the error text of the web UI and the JSON API (#267). |
| |
90 | - An unregistered SSH key is refused with its own fingerprint and the |
| |
91 | real host, and both the web and ssh paths to register (#268). |
| 90 | |
92 | |
| 91 | * v1.36.0 — 2026-09-23 |
93 | * v1.36.0 — 2026-09-23 |
| 92 | |
94 | |
internal/sshd/sshd.go
+8 −2
| @@ -442,8 +442,14 @@ func (s *Server) runAnonymous(ch ssh.Channel, keyB64, cmdline string) int { |
| 442 | return protocol.ExitUsage |
442 | return protocol.ExitUsage |
| 443 | } |
443 | } |
| 444 | if len(argv) == 0 || argv[0] != "register" { |
444 | if len(argv) == 0 || argv[0] != "register" { |
| 445 | fmt.Fprintf(ch.Stderr(), "this key is not registered here. Create an account with:\n ssh <host> register --username <name> %s\n", |
445 | host := s.cfg.SiteHost() |
| 446 | map[string]string{"open": "--email <address>", "invite": "--invite <code>"}[s.cfg.Registration.Mode]) |
446 | fp := ssh.FingerprintSHA256(pub) |
| |
447 | flag := map[string]string{"open": "--email <address>", "invite": "--invite <code>"}[s.cfg.Registration.Mode] |
| |
448 | fmt.Fprintf(ch.Stderr(), |
| |
449 | "this key (%s) is not registered on %s.\n"+ |
| |
450 | "already have an account? add it at https://%s/settings#keys\n"+ |
| |
451 | "new here? ssh git@%s register --username <name> %s\n", |
| |
452 | fp, host, host, host, flag) |
| 447 | return protocol.ExitDenied |
453 | return protocol.ExitDenied |
| 448 | } |
454 | } |
| 449 | return control.RunRegister(s.cfg, s.st, pub, argv, ch, ch.Stderr()) |
455 | return control.RunRegister(s.cfg, s.st, pub, argv, ch, ch.Stderr()) |
internal/sshd/sshd_test.go
+69
| @@ -226,3 +226,72 @@ func TestStopEndsFollow(t *testing.T) { |
| 226 | t.Errorf("stderr %q", stderr.String()) |
226 | t.Errorf("stderr %q", stderr.String()) |
| 227 | } |
227 | } |
| 228 | } |
228 | } |
| |
229 | |
| |
230 | // An unregistered key is told its own fingerprint and the real host, and |
| |
231 | // offered both the web and the ssh path to register. |
| |
232 | func TestUnregisteredKeyMessageNamesFingerprintAndHost(t *testing.T) { |
| |
233 | root := t.TempDir() |
| |
234 | st, err := store.Open(filepath.Join(root, "gitbay.db")) |
| |
235 | if err != nil { |
| |
236 | t.Fatal(err) |
| |
237 | } |
| |
238 | t.Cleanup(func() { st.Close() }) |
| |
239 | if err := st.MigrateUp(); err != nil { |
| |
240 | t.Fatal(err) |
| |
241 | } |
| |
242 | |
| |
243 | cfg := config.Default() |
| |
244 | cfg.Server.Root = root |
| |
245 | cfg.Server.SiteURL = "https://forge.test" |
| |
246 | cfg.Registration.Mode = "open" |
| |
247 | srv, err := New(cfg, st) |
| |
248 | if err != nil { |
| |
249 | t.Fatal(err) |
| |
250 | } |
| |
251 | ln, err := net.Listen("tcp", "127.0.0.1:0") |
| |
252 | if err != nil { |
| |
253 | t.Fatal(err) |
| |
254 | } |
| |
255 | go srv.Serve(ln) |
| |
256 | t.Cleanup(func() { ln.Close() }) |
| |
257 | |
| |
258 | _, priv, err := ed25519.GenerateKey(rand.Reader) |
| |
259 | if err != nil { |
| |
260 | t.Fatal(err) |
| |
261 | } |
| |
262 | signer, err := ssh.NewSignerFromKey(priv) |
| |
263 | if err != nil { |
| |
264 | t.Fatal(err) |
| |
265 | } |
| |
266 | |
| |
267 | client, err := ssh.Dial("tcp", ln.Addr().String(), &ssh.ClientConfig{ |
| |
268 | User: "git", |
| |
269 | Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, |
| |
270 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), |
| |
271 | Timeout: 5 * time.Second, |
| |
272 | }) |
| |
273 | if err != nil { |
| |
274 | t.Fatal(err) |
| |
275 | } |
| |
276 | t.Cleanup(func() { client.Close() }) |
| |
277 | |
| |
278 | sess, err := client.NewSession() |
| |
279 | if err != nil { |
| |
280 | t.Fatal(err) |
| |
281 | } |
| |
282 | defer sess.Close() |
| |
283 | var stderr bytes.Buffer |
| |
284 | sess.Stderr = &stderr |
| |
285 | |
| |
286 | var exit *ssh.ExitError |
| |
287 | if err := sess.Run("whoami"); !errors.As(err, &exit) || exit.ExitStatus() != 4 { |
| |
288 | t.Fatalf("whoami ended with %v, want exit 4", err) |
| |
289 | } |
| |
290 | |
| |
291 | fp := ssh.FingerprintSHA256(signer.PublicKey()) |
| |
292 | for _, want := range []string{fp, "forge.test", "https://forge.test/settings#keys", "ssh git@forge.test register"} { |
| |
293 | if !strings.Contains(stderr.String(), want) { |
| |
294 | t.Errorf("message missing %q:\n%s", want, stderr.String()) |
| |
295 | } |
| |
296 | } |
| |
297 | } |