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) + } + } +}