tuns
created pr with
88.1
cmds
checkout latest patchset:
ssh pr.pico.sh print 88 | git am -3checkout any patchset in a patch request:
ssh pr.pico.sh print 88.[rev] | git am -3add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh pr add 88
Patchset
88.1
feat: use cert pubkey instead of underlying pubkey
Eric Bower
2025-12-13T18:16:15ZSemantic diff summary
1 added,
2 modified,
0 signature changed,
0 removed
across 2 analyzed files
+124
-0
utils/authentication_key_request_test.go
#
| ... | ... | @@ -252,3 +254,125 @@ func TestAuthenticationKeyRequest(t *testing.T) { | |
| 252 | 254 | } | |
| 253 | 255 | } | |
| 254 | 256 | } | |
| 257 | + | ||
| 258 | + | // TestAuthenticationKeyRequestWithCertificate validates that when authenticating | |
| 259 | + | // with an SSH certificate, the certificate (not the underlying public key) is sent | |
| 260 | + | // to the authentication-key-request-url. | |
| 261 | + | func TestAuthenticationKeyRequestWithCertificate(t *testing.T) { | |
| 262 | + | // Generate CA key for signing certificates | |
| 263 | + | caKey, err := rsa.GenerateKey(rand.Reader, 2048) | |
| 264 | + | if err != nil { | |
| 265 | + | t.Fatal(err) | |
| 266 | + | } | |
| 267 | + | caSigner, err := ssh.NewSignerFromKey(caKey) | |
| 268 | + | if err != nil { | |
| 269 | + | t.Fatal(err) | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // Generate user key | |
| 273 | + | userKey, err := rsa.GenerateKey(rand.Reader, 2048) | |
| 274 | + | if err != nil { | |
| 275 | + | t.Fatal(err) | |
| 276 | + | } | |
| 277 | + | userPubKey, err := ssh.NewPublicKey(&userKey.PublicKey) | |
| 278 | + | if err != nil { | |
| 279 | + | t.Fatal(err) | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | // Create a certificate signed by the CA | |
| 283 | + | cert := &ssh.Certificate{ | |
| 284 | + | Key: userPubKey, | |
| 285 | + | Serial: 1, | |
| 286 | + | CertType: ssh.UserCert, | |
| 287 | + | KeyId: "test-user", | |
| 288 | + | ValidPrincipals: []string{"ubuntu"}, | |
| 289 | + | ValidAfter: uint64(time.Now().Add(-time.Hour).Unix()), | |
| 290 | + | ValidBefore: uint64(time.Now().Add(time.Hour).Unix()), | |
| 291 | + | } | |
| 292 | + | err = cert.SignCert(rand.Reader, caSigner) | |
| 293 | + | if err != nil { | |
| 294 | + | t.Fatal(err) | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | // Create a signer that uses the certificate | |
| 298 | + | userSigner, err := ssh.NewSignerFromKey(userKey) | |
| 299 | + | if err != nil { | |
| 300 | + | t.Fatal(err) | |
| 301 | + | } | |
| 302 | + | certSigner, err := ssh.NewCertSigner(cert, userSigner) | |
| 303 | + | if err != nil { | |
| 304 | + | t.Fatal(err) | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | // Give sish a temp directory to generate a server ssh host key | |
| 308 | + | dir, err := os.MkdirTemp("", "sish_keys_cert") | |
| 309 | + | if err != nil { | |
| 310 | + | t.Fatal(err) | |
| 311 | + | } | |
| 312 | + | defer os.RemoveAll(dir) | |
| 313 | + | ||
| 314 | + | viper.Set("private-keys-directory", dir) | |
| 315 | + | viper.Set("authentication", true) | |
| 316 | + | ||
| 317 | + | // Track what key was received by the auth server | |
| 318 | + | var receivedKey string | |
| 319 | + | httpSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 320 | + | body, err := io.ReadAll(r.Body) | |
| 321 | + | if err != nil { | |
| 322 | + | http.Error(w, err.Error(), http.StatusBadRequest) | |
| 323 | + | return | |
| 324 | + | } | |
| 325 | + | var reqBody AuthRequestBody | |
| 326 | + | err = json.Unmarshal(body, &reqBody) | |
| 327 | + | if err != nil { | |
| 328 | + | http.Error(w, err.Error(), http.StatusBadRequest) | |
| 329 | + | return | |
| 330 | + | } | |
| 331 | + | receivedKey = reqBody.PubKey | |
| 332 | + | // Accept the auth | |
| 333 | + | w.WriteHeader(http.StatusOK) | |
| 334 | + | })) | |
| 335 | + | defer httpSrv.Close() | |
| 336 | + | ||
| 337 | + | viper.Set("authentication-key-request-url", httpSrv.URL) | |
| 338 | + | ||
| 339 | + | sshListener, err := net.Listen("tcp", "localhost:0") | |
| 340 | + | if err != nil { | |
| 341 | + | t.Fatal(err) | |
| 342 | + | } | |
| 343 | + | defer sshListener.Close() | |
| 344 | + | ||
| 345 | + | successAuth := make(chan bool) | |
| 346 | + | go HandleSSHConn(sshListener, &successAuth) | |
| 347 | + | ||
| 348 | + | // Connect with the certificate | |
| 349 | + | clientConfig := &ssh.ClientConfig{ | |
| 350 | + | Auth: []ssh.AuthMethod{ | |
| 351 | + | ssh.PublicKeys(certSigner), | |
| 352 | + | }, | |
| 353 | + | HostKeyCallback: ssh.InsecureIgnoreHostKey(), | |
| 354 | + | User: "ubuntu", | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | client, err := ssh.Dial("tcp", sshListener.Addr().String(), clientConfig) | |
| 358 | + | if err != nil { | |
| 359 | + | t.Fatalf("ssh client connection failed: %v", err) | |
| 360 | + | } | |
| 361 | + | client.Close() | |
| 362 | + | ||
| 363 | + | didAuth := <-successAuth | |
| 364 | + | if !didAuth { | |
| 365 | + | t.Error("Expected auth to succeed") | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | // Verify that the received key is a certificate (starts with ssh-rsa-cert-v01@openssh.com or similar) | |
| 369 | + | if !strings.Contains(receivedKey, "-cert-") { | |
| 370 | + | t.Errorf("Expected certificate to be sent to auth URL, got: %s", receivedKey[:min(100, len(receivedKey))]) | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | // Verify it's not just the plain public key | |
| 374 | + | plainPubKey := string(ssh.MarshalAuthorizedKey(userPubKey)) | |
| 375 | + | if strings.TrimSpace(receivedKey) == strings.TrimSpace(plainPubKey) { | |
| 376 | + | t.Error("Expected certificate to be sent, but received the underlying public key instead") | |
| 377 | + | } | |
| 378 | + | } |
+8
-1
utils/utils.go
#
| ... | ... | @@ -515,7 +515,14 @@ func GetSSHConfig() *ssh.ServerConfig { | |
| 515 | 515 | // Allow validation of public keys via a sub-request to another service | |
| 516 | 516 | authUrl := viper.GetString("authentication-key-request-url") | |
| 517 | 517 | if authUrl != "" { | |
| 518 | - | validKey, extensionsInfo, err := checkAuthenticationKeyRequest(authUrl, authKey, c.RemoteAddr(), c.User()) | |
| 518 | + | // If the key is an SSH certificate, send the certificate instead of the underlying public key | |
| 519 | + | authKeyToSend := authKey | |
| 520 | + | if cert, ok := key.(*ssh.Certificate); ok { | |
| 521 | + | certKey := ssh.MarshalAuthorizedKey(cert) | |
| 522 | + | authKeyToSend = certKey[:len(certKey)-1] | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | validKey, extensionsInfo, err := checkAuthenticationKeyRequest(authUrl, authKeyToSend, c.RemoteAddr(), c.User()) | |
| 519 | 526 | if err != nil { | |
| 520 | 527 | slog.Error("error calling authentication key url", slog.String("authURL", authUrl), slog.Any("error", err)) | |
| 521 | 528 | } |