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 | 87 | usage line a wrong-argument refusal does. Outside the CLI a usage |
| 88 | 88 | refusal reads =usage: ssh git@<host> ...= on every surface, |
| 89 | 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 | 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 | 442 | return protocol.ExitUsage |
| 443 | 443 | } |
| 444 | 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", |
| 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) |
| 447 | 453 | return protocol.ExitDenied |
| 448 | 454 | } |
| 449 | 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 | 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 | } |