ssh/agent: preserve constraint extensions when adding keys

The client Add method only serialized the lifetime and confirm
constraints and silently dropped AddedKey.ConstraintExtensions before
sending the SSH_AGENTC_ADD_IDENTITY request. As a result the remote
agent always received the key with no extension constraints, regardless
of what the caller requested.

Applications that add a key believing custom constraint extensions
(such as restrict-destination-v00@openssh.com) would be enforced
instead loaded a completely unrestricted key into the agent. For
example, an administrator forwarding their agent into an untrusted jump
host and trying to limit the forwarded key with restrict-destination
never had that restriction reach the agent: any user or compromised
process on that host could make the agent sign arbitrary challenges.

Serialize each entry in key.ConstraintExtensions as an
agentConstrainExtension constraint so the constraints reach the agent,
and add a round-trip regression test that verifies the extensions
survive client serialization and server parsing.

This issue was found during a security audit by NCC Group Cryptography
Services, sponsored by Teleport.

Updates CVE-2026-39832
Updates golang/go#79435

Change-Id: I14c5583b106cbf0d282d2ba01e000e0f586f08c7
Reviewed-on: https://go-review.googlesource.com/c/crypto/+/778640
Reviewed-by: Neal Patel <neal@golang.org>
Reviewed-by: Neal Patel <nealpatel@google.com>
Reviewed-by: Keith Randall <khr@google.com>
Reviewed-by: David Chase <drchase@google.com>
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/ssh/agent/client.go b/ssh/agent/client.go
index b357e18..2fc2aa9 100644
--- a/ssh/agent/client.go
+++ b/ssh/agent/client.go
@@ -663,6 +663,13 @@
 		constraints = append(constraints, agentConstrainConfirm)
 	}
 
+	for _, ext := range key.ConstraintExtensions {
+		constraints = append(constraints, ssh.Marshal(constrainExtensionAgentMsg{
+			ExtensionName:    ext.ExtensionName,
+			ExtensionDetails: ext.ExtensionDetails,
+		})...)
+	}
+
 	cert := key.Certificate
 	if cert == nil {
 		return c.insertKey(key.PrivateKey, key.Comment, constraints)
diff --git a/ssh/agent/client_test.go b/ssh/agent/client_test.go
index 2ca92a4..567c0d9 100644
--- a/ssh/agent/client_test.go
+++ b/ssh/agent/client_test.go
@@ -594,3 +594,63 @@
 		t.Fatal("should have gotten agent extension failure")
 	}
 }
+
+// capturingAgent records the AddedKey observed on the server side of the
+// agent protocol. It forwards to a keyring with constraint fields stripped,
+// so the keyring's own rejection of unsupported constraints does not
+// interfere with what this test is measuring: the client-side wire
+// serialization of ConstraintExtensions.
+type capturingAgent struct {
+	Agent
+	lastAdd AddedKey
+}
+
+func newCapturingAgent() *capturingAgent {
+	return &capturingAgent{Agent: NewKeyring()}
+}
+
+func (a *capturingAgent) Add(key AddedKey) error {
+	a.lastAdd = key
+	stripped := key
+	stripped.ConstraintExtensions = nil
+	stripped.ConfirmBeforeUse = false
+	return a.Agent.Add(stripped)
+}
+
+// TestAddConstraintExtensionsWireFormat verifies that client.Add serializes
+// ConstraintExtensions into the SSH_AGENTC_ADD_IDENTITY payload and the
+// server deserializes them back into the AddedKey delivered to the backend.
+// Regressions in the client marshal loop (missing, swapped fields, wrong
+// framing) would be invisible to a keyring-based rejection test, which
+// signals only "extensions were present", not "the right ones arrived".
+func TestAddConstraintExtensionsWireFormat(t *testing.T) {
+	capturing := newCapturingAgent()
+	client, cleanup := startAgent(t, capturing)
+	defer cleanup()
+
+	constraints := []ConstraintExtension{
+		{ExtensionName: "ext-one@example.com", ExtensionDetails: []byte("details-one")},
+		{ExtensionName: "ext-two@example.com", ExtensionDetails: []byte("\x00\x01\x02\xff")},
+	}
+
+	if err := client.Add(AddedKey{
+		PrivateKey:           testPrivateKeys["rsa"],
+		Comment:              "wire-format-test",
+		ConstraintExtensions: constraints,
+	}); err != nil {
+		t.Fatalf("client.Add: %v", err)
+	}
+
+	got := capturing.lastAdd.ConstraintExtensions
+	if len(got) != len(constraints) {
+		t.Fatalf("server received %d extensions, want %d", len(got), len(constraints))
+	}
+	for i, want := range constraints {
+		if got[i].ExtensionName != want.ExtensionName {
+			t.Errorf("extension[%d] name: got %q, want %q", i, got[i].ExtensionName, want.ExtensionName)
+		}
+		if !bytes.Equal(got[i].ExtensionDetails, want.ExtensionDetails) {
+			t.Errorf("extension[%d] details: got %x, want %x", i, got[i].ExtensionDetails, want.ExtensionDetails)
+		}
+	}
+}