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
8787 usage line a wrong-argument refusal does. Outside the CLI a usage
8888 refusal reads =usage: ssh git@<host> ...= on every surface,
8989 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).
9092
9193* v1.36.0 — 2026-09-23
9294
internal/sshd/sshd.go +8 −2
@@ -442,8 +442,14 @@ func (s *Server) runAnonymous(ch ssh.Channel, keyB64, cmdline string) int {
442442 return protocol.ExitUsage
443443 }
444444 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",
446 map[string]string{"open": "--email <address>", "invite": "--invite <code>"}[s.cfg.Registration.Mode])
445 host := s.cfg.SiteHost()
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)
447453 return protocol.ExitDenied
448454 }
449455 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) {
226226 t.Errorf("stderr %q", stderr.String())
227227 }
228228}
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.
232func 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}