Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45460e0797 | ||
|
|
d37c95e27d | ||
|
|
e2ffffe738 | ||
|
|
60e158ad30 | ||
|
|
1b77d23dc8 | ||
|
|
3872a2bc69 | ||
|
|
9f72eccdf6 | ||
|
|
8f405a4109 | ||
|
|
bb41b3dbe6 | ||
|
|
e04e721997 | ||
|
|
b315afd69a | ||
|
|
d4a85f4749 | ||
|
|
6f39c5226a | ||
|
|
4c4d20b72c | ||
|
|
e3e62d9601 | ||
|
|
5adb68b3e6 | ||
|
|
9beb694f97 | ||
|
|
e5306b2f3e | ||
|
|
d2fe592df1 | ||
|
|
d59570d1a3 | ||
|
|
4aab0d9d6a |
@@ -3,9 +3,9 @@ module golang.org/x/crypto
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
golang.org/x/net v0.54.0 // tagx:ignore
|
||||
golang.org/x/sys v0.45.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/net v0.55.0 // tagx:ignore
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/term v0.44.0
|
||||
)
|
||||
|
||||
require golang.org/x/text v0.37.0 // indirect
|
||||
require golang.org/x/text v0.38.0 // indirect
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
|
||||
@@ -80,6 +80,10 @@ func pbDecrypterFor(algorithm pkix.AlgorithmIdentifier, password []byte) (cipher
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if params.Iterations < 0 || params.Iterations > maxIterations {
|
||||
return nil, 0, NotImplementedError("iteration count is invalid or too high")
|
||||
}
|
||||
|
||||
key := cipherType.deriveKey(params.Salt, password, params.Iterations)
|
||||
iv := cipherType.deriveIV(params.Salt, password, params.Iterations)
|
||||
|
||||
|
||||
@@ -13,6 +13,46 @@ import (
|
||||
|
||||
var sha1WithTripleDES = asn1.ObjectIdentifier([]int{1, 2, 840, 113549, 1, 12, 1, 3})
|
||||
|
||||
func TestPbDecrypterForIterationLimit(t *testing.T) {
|
||||
pass, _ := bmpString("Sesame open")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
iterations int
|
||||
wantErr bool
|
||||
}{
|
||||
{"at limit", maxIterations, false},
|
||||
{"over limit", maxIterations + 1, true},
|
||||
{"negative", -1, true},
|
||||
{"max int", 1<<31 - 1, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
params, _ := asn1.Marshal(pbeParams{
|
||||
Salt: []byte{1, 2, 3, 4, 5, 6, 7, 8},
|
||||
Iterations: tt.iterations,
|
||||
})
|
||||
alg := pkix.AlgorithmIdentifier{
|
||||
Algorithm: sha1WithTripleDES,
|
||||
Parameters: asn1.RawValue{
|
||||
FullBytes: params,
|
||||
},
|
||||
}
|
||||
_, _, err := pbDecrypterFor(alg, pass)
|
||||
if tt.wantErr {
|
||||
if _, ok := err.(NotImplementedError); !ok {
|
||||
t.Errorf("iterations=%d: got %v, want NotImplementedError", tt.iterations, err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("iterations=%d: unexpected error: %v", tt.iterations, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPbDecrypterFor(t *testing.T) {
|
||||
params, _ := asn1.Marshal(pbeParams{
|
||||
Salt: []byte{1, 2, 3, 4, 5, 6, 7, 8},
|
||||
|
||||
@@ -27,11 +27,19 @@ var (
|
||||
oidSHA1 = asn1.ObjectIdentifier([]int{1, 3, 14, 3, 2, 26})
|
||||
)
|
||||
|
||||
// maxIterations is a safety limit to prevent CPU exhaustion from
|
||||
// crafted PKCS#12 files with unreasonable iteration counts.
|
||||
const maxIterations = 1 << 20 // ~1 million
|
||||
|
||||
func verifyMac(macData *macData, message, password []byte) error {
|
||||
if !macData.Mac.Algorithm.Algorithm.Equal(oidSHA1) {
|
||||
return NotImplementedError("unknown digest algorithm: " + macData.Mac.Algorithm.Algorithm.String())
|
||||
}
|
||||
|
||||
if macData.Iterations < 0 || macData.Iterations > maxIterations {
|
||||
return NotImplementedError("iteration count is invalid or too high")
|
||||
}
|
||||
|
||||
key := pbkdf(sha1Sum, 20, 64, macData.MacSalt, password, macData.Iterations, 3, 20)
|
||||
|
||||
mac := hmac.New(sha1.New, key)
|
||||
|
||||
@@ -5,10 +5,52 @@
|
||||
package pkcs12
|
||||
|
||||
import (
|
||||
"crypto/x509/pkix"
|
||||
"encoding/asn1"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVerifyMacIterationLimit(t *testing.T) {
|
||||
password, _ := bmpString("Sesame open")
|
||||
message := []byte{11, 12, 13, 14, 15}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
iterations int
|
||||
wantErr bool
|
||||
}{
|
||||
{"at limit", maxIterations, false},
|
||||
{"over limit", maxIterations + 1, true},
|
||||
{"negative", -1, true},
|
||||
{"max int", 1<<31 - 1, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
td := macData{
|
||||
Mac: digestInfo{
|
||||
Algorithm: pkix.AlgorithmIdentifier{
|
||||
Algorithm: oidSHA1,
|
||||
},
|
||||
Digest: nil, // will fail MAC check, but iteration check comes first
|
||||
},
|
||||
MacSalt: []byte{1, 2, 3, 4, 5, 6, 7, 8},
|
||||
Iterations: tt.iterations,
|
||||
}
|
||||
err := verifyMac(&td, message, password)
|
||||
if tt.wantErr {
|
||||
if _, ok := err.(NotImplementedError); !ok {
|
||||
t.Errorf("iterations=%d: got %v, want NotImplementedError", tt.iterations, err)
|
||||
}
|
||||
} else {
|
||||
if _, ok := err.(NotImplementedError); ok {
|
||||
t.Errorf("iterations=%d: got unexpected NotImplementedError", tt.iterations)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyMac(t *testing.T) {
|
||||
td := macData{
|
||||
Mac: digestInfo{
|
||||
|
||||
+226
-3
@@ -26,6 +26,7 @@ import (
|
||||
"io"
|
||||
"math/big"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
@@ -307,17 +308,50 @@ func parseKey(in []byte) (out *Key, rest []byte, err error) {
|
||||
}, record.Rest, nil
|
||||
}
|
||||
|
||||
// pipelineMaxInFlight is the maximum number of outstanding requests the
|
||||
// client will pipeline to the agent before applying backpressure.
|
||||
const pipelineMaxInFlight = 32
|
||||
|
||||
// client is a client for an ssh-agent process.
|
||||
//
|
||||
// Exactly one of pipeline / (mu, conn) is set, chosen by NewClient
|
||||
// based on whether the underlying transport implements io.Closer.
|
||||
type client struct {
|
||||
// conn is typically a *net.UnixConn
|
||||
// pipeline, if non-nil, dispatches requests over a pipelined
|
||||
// connection: requests are written as soon as the wire is
|
||||
// available and responses are routed back to per-call reply
|
||||
// channels in FIFO order by a background reader goroutine.
|
||||
pipeline *pipeline
|
||||
|
||||
// mu and conn are used in fully-serialized mode, when the
|
||||
// transport does not implement io.Closer. Each call takes mu,
|
||||
// writes its request, reads the matching response, and releases
|
||||
// mu before returning. There is no background goroutine.
|
||||
mu sync.Mutex
|
||||
conn io.ReadWriter
|
||||
// mu is used to prevent concurrent access to the agent
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewClient returns an Agent that talks to an ssh-agent process over
|
||||
// the given connection.
|
||||
//
|
||||
// If rw also implements io.Closer (like *net.UnixConn and ssh.Channel
|
||||
// do), the returned client pipelines concurrent requests over the
|
||||
// connection: callers can issue Sign and other operations from
|
||||
// multiple goroutines and they will be written to the agent as soon
|
||||
// as the wire is available, rather than waiting for the previous
|
||||
// responses. The ssh-agent protocol still requires responses to be
|
||||
// returned in request order, so a slow request delays subsequent
|
||||
// responses on the same connection (head-of-line blocking).
|
||||
//
|
||||
// Pipelining requires io.Closer because, on a Write error, the
|
||||
// background reader goroutine must be unblocked by closing the
|
||||
// underlying connection. When rw does not implement io.Closer
|
||||
// this is not possible, so NewClient falls back to fully
|
||||
// serializing each request: a single in-flight call at a time.
|
||||
func NewClient(rw io.ReadWriter) ExtendedAgent {
|
||||
if rwc, ok := rw.(io.ReadWriteCloser); ok {
|
||||
return &client{pipeline: newPipeline(rwc)}
|
||||
}
|
||||
return &client{conn: rw}
|
||||
}
|
||||
|
||||
@@ -340,6 +374,16 @@ func (c *client) call(req []byte) (reply interface{}, err error) {
|
||||
// bytes of the response are returned; no unmarshalling is
|
||||
// performed on the response.
|
||||
func (c *client) callRaw(req []byte) (reply []byte, err error) {
|
||||
if c.pipeline != nil {
|
||||
return c.pipeline.call(req)
|
||||
}
|
||||
return c.serialCall(req)
|
||||
}
|
||||
|
||||
// serialCall implements the fully-serialized request/response path
|
||||
// used when the transport is not an io.Closer. It writes req under mu
|
||||
// and reads the matching response before returning.
|
||||
func (c *client) serialCall(req []byte) (reply []byte, err error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
@@ -577,6 +621,9 @@ func (c *client) insertKey(s interface{}, comment string, constraints []byte) er
|
||||
Constraints: constraints,
|
||||
})
|
||||
case ed25519.PrivateKey:
|
||||
if len(k) != ed25519.PrivateKeySize {
|
||||
return fmt.Errorf("agent: bad ED25519 key size: %d", len(k))
|
||||
}
|
||||
req = ssh.Marshal(ed25519KeyMsg{
|
||||
Type: ssh.KeyAlgoED25519,
|
||||
Pub: []byte(k)[32:],
|
||||
@@ -588,6 +635,9 @@ func (c *client) insertKey(s interface{}, comment string, constraints []byte) er
|
||||
// general idiom is to pass ed25519.PrivateKey by value, not by pointer.
|
||||
// We still support the pointer variant for backwards compatibility.
|
||||
case *ed25519.PrivateKey:
|
||||
if len(*k) != ed25519.PrivateKeySize {
|
||||
return fmt.Errorf("agent: bad ED25519 key size: %d", len(*k))
|
||||
}
|
||||
req = ssh.Marshal(ed25519KeyMsg{
|
||||
Type: ssh.KeyAlgoED25519,
|
||||
Pub: []byte(*k)[32:],
|
||||
@@ -712,6 +762,9 @@ func (c *client) insertCert(s interface{}, cert *ssh.Certificate, comment string
|
||||
Constraints: constraints,
|
||||
})
|
||||
case ed25519.PrivateKey:
|
||||
if len(k) != ed25519.PrivateKeySize {
|
||||
return fmt.Errorf("agent: bad ED25519 key size: %d", len(k))
|
||||
}
|
||||
req = ssh.Marshal(ed25519CertMsg{
|
||||
Type: cert.Type(),
|
||||
CertBytes: cert.Marshal(),
|
||||
@@ -724,6 +777,9 @@ func (c *client) insertCert(s interface{}, cert *ssh.Certificate, comment string
|
||||
// general idiom is to pass ed25519.PrivateKey by value, not by pointer.
|
||||
// We still support the pointer variant for backwards compatibility.
|
||||
case *ed25519.PrivateKey:
|
||||
if len(*k) != ed25519.PrivateKeySize {
|
||||
return fmt.Errorf("agent: bad ED25519 key size: %d", len(*k))
|
||||
}
|
||||
req = ssh.Marshal(ed25519CertMsg{
|
||||
Type: cert.Type(),
|
||||
CertBytes: cert.Marshal(),
|
||||
@@ -861,3 +917,170 @@ func (c *client) Extension(extensionType string, contents []byte) ([]byte, error
|
||||
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// pipelineResult carries either a raw agent reply or an error back to a
|
||||
// caller waiting on the response channel.
|
||||
type pipelineResult struct {
|
||||
reply []byte
|
||||
err error
|
||||
}
|
||||
|
||||
// pipeline implements request pipelining over a single agent connection.
|
||||
//
|
||||
// Writers serialize on writeMu to both register a reply channel in the
|
||||
// pending FIFO queue and write the request bytes on the wire; the two
|
||||
// must be atomic so the queue order matches the wire order. A single
|
||||
// reader goroutine decodes responses from the connection and dispatches
|
||||
// each one to the channel at the head of the queue.
|
||||
//
|
||||
// pending is a chan-of-chan acting as a FIFO queue with a fixed
|
||||
// capacity of pipelineMaxInFlight. The outer channel provides ordering
|
||||
// (reads happen in send order) and natural backpressure (a full queue
|
||||
// blocks new writers). Each inner channel is buffered with capacity
|
||||
// one and is sent to exactly once: either by the reader goroutine
|
||||
// with the agent reply, or by shutdown with the terminal error during
|
||||
// drain. The cap-one buffer makes the producer's send non-blocking,
|
||||
// so the reader and shutdown never have to wait for the caller to be
|
||||
// scheduled on the receive.
|
||||
//
|
||||
// When the reader goroutine exits (on read error or protocol
|
||||
// violation), it closes exitCh to wake any writer blocked on the
|
||||
// pending queue, then serializes with any in-flight writer to close
|
||||
// the pending channel, and finally drains the remaining entries
|
||||
// delivering the terminal error to each waiting caller. The
|
||||
// pipeline relies on conn implementing io.Closer so a writer that
|
||||
// hits a Write error can close the connection to unblock the reader
|
||||
// goroutine; NewClient is responsible for only constructing a
|
||||
// pipeline when this guarantee holds.
|
||||
type pipeline struct {
|
||||
conn io.ReadWriteCloser
|
||||
|
||||
writeMu sync.Mutex
|
||||
// pending is the FIFO queue of reply channels with capacity
|
||||
// pipelineMaxInFlight. See type-level documentation.
|
||||
pending chan chan pipelineResult
|
||||
exitCh chan struct{}
|
||||
|
||||
// err carries the terminal error to callers blocked on a closed
|
||||
// pipeline. It is stored exactly once by the reader goroutine
|
||||
// before exitCh is closed; every read happens after observing
|
||||
// exitCh closed, so the load synchronises through the close and
|
||||
// is guaranteed to return the stored value (never nil).
|
||||
err atomic.Pointer[error]
|
||||
}
|
||||
|
||||
func newPipeline(conn io.ReadWriteCloser) *pipeline {
|
||||
p := &pipeline{
|
||||
conn: conn,
|
||||
pending: make(chan chan pipelineResult, pipelineMaxInFlight),
|
||||
exitCh: make(chan struct{}),
|
||||
}
|
||||
go p.readLoop()
|
||||
return p
|
||||
}
|
||||
|
||||
// readLoop decodes responses from conn and dispatches them in FIFO order
|
||||
// to reply channels in pending. On any failure it invokes shutdown.
|
||||
func (p *pipeline) readLoop() {
|
||||
var finalErr error
|
||||
for {
|
||||
var sizeBuf [4]byte
|
||||
if _, err := io.ReadFull(p.conn, sizeBuf[:]); err != nil {
|
||||
finalErr = err
|
||||
break
|
||||
}
|
||||
respSize := binary.BigEndian.Uint32(sizeBuf[:])
|
||||
if respSize > maxAgentResponseBytes {
|
||||
finalErr = errors.New("response too large")
|
||||
break
|
||||
}
|
||||
buf := make([]byte, respSize)
|
||||
if _, err := io.ReadFull(p.conn, buf); err != nil {
|
||||
finalErr = err
|
||||
break
|
||||
}
|
||||
// Successful writes always enqueue before sending bytes, so
|
||||
// pending has a waiting channel for this response.
|
||||
ch := <-p.pending
|
||||
// The reply channel is buffered with capacity 1 and is only
|
||||
// ever written to once, so this send cannot block.
|
||||
ch <- pipelineResult{reply: buf}
|
||||
}
|
||||
p.shutdown(clientErr(finalErr))
|
||||
}
|
||||
|
||||
// shutdown is called exactly once, from readLoop, when the reader is
|
||||
// terminating. It unblocks pending writers and fails all in-flight
|
||||
// requests with finalErr.
|
||||
func (p *pipeline) shutdown(finalErr error) {
|
||||
// Publish the terminal error before closing exitCh so any
|
||||
// writer that subsequently observes exitCh closed sees err.
|
||||
p.err.Store(&finalErr)
|
||||
|
||||
// Wake any writer blocked waiting for a slot in the pending queue.
|
||||
close(p.exitCh)
|
||||
|
||||
// Wait for any writer currently inside its critical section to
|
||||
// complete. After this lock, no new writer can reach the send on
|
||||
// pending: they will observe exitCh closed in the select and bail
|
||||
// out before attempting the send.
|
||||
p.writeMu.Lock()
|
||||
close(p.pending)
|
||||
p.writeMu.Unlock()
|
||||
|
||||
// Drain entries that were enqueued but never answered, delivering
|
||||
// the terminal error to their waiting callers. The reply channels
|
||||
// are buffered (cap 1) and written to exactly once, so these sends
|
||||
// cannot block.
|
||||
for ch := range p.pending {
|
||||
ch <- pipelineResult{err: finalErr}
|
||||
}
|
||||
}
|
||||
|
||||
// call sends req to the agent and returns the matching raw response.
|
||||
func (p *pipeline) call(req []byte) ([]byte, error) {
|
||||
replyCh := make(chan pipelineResult, 1)
|
||||
|
||||
p.writeMu.Lock()
|
||||
|
||||
// Priority check: if the reader has already finished shutdown,
|
||||
// pending is closed and sending to it would panic. Bail out now.
|
||||
// Once we pass this check while holding writeMu, shutdown cannot
|
||||
// complete close(pending) until we release writeMu, so the send
|
||||
// below is safe against concurrent closure.
|
||||
select {
|
||||
case <-p.exitCh:
|
||||
p.writeMu.Unlock()
|
||||
return nil, *p.err.Load()
|
||||
default:
|
||||
}
|
||||
|
||||
// Enqueue the reply channel before writing the request, so FIFO
|
||||
// order on the wire matches FIFO order in the pending queue. The
|
||||
// exitCh arm handles the case where the reader errors while we
|
||||
// block on a full queue.
|
||||
select {
|
||||
case p.pending <- replyCh:
|
||||
case <-p.exitCh:
|
||||
p.writeMu.Unlock()
|
||||
return nil, *p.err.Load()
|
||||
}
|
||||
|
||||
msg := make([]byte, 4+len(req))
|
||||
binary.BigEndian.PutUint32(msg, uint32(len(req)))
|
||||
copy(msg[4:], req)
|
||||
_, werr := p.conn.Write(msg)
|
||||
p.writeMu.Unlock()
|
||||
|
||||
if werr != nil {
|
||||
// The connection is in an undefined state. Close it so the
|
||||
// reader unblocks promptly and triggers shutdown for every
|
||||
// other in-flight caller. NewClient guarantees conn is a
|
||||
// real io.Closer when the pipeline is in use.
|
||||
p.conn.Close()
|
||||
return nil, clientErr(werr)
|
||||
}
|
||||
|
||||
res := <-replyCh
|
||||
return res.reply, res.err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
// Copyright 2026 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPipelineParallelSigns(t *testing.T) {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
keyring := NewKeyring()
|
||||
if err := keyring.Add(AddedKey{PrivateKey: testPrivateKeys["rsa"]}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go ServeAgent(keyring, c2)
|
||||
|
||||
client := NewClient(c1)
|
||||
pubKey := testPublicKeys["rsa"]
|
||||
|
||||
const N = 64
|
||||
var wg sync.WaitGroup
|
||||
errCh := make(chan error, N)
|
||||
for i := range N {
|
||||
wg.Go(func() {
|
||||
data := []byte{byte(i), byte(i >> 8)}
|
||||
sig, err := client.Sign(pubKey, data)
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
if err := pubKey.Verify(data, sig); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
for err := range errCh {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipelineBackpressure(t *testing.T) {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
|
||||
const total = pipelineMaxInFlight + 8
|
||||
|
||||
// Fake agent: the reader drains requests from the wire independently
|
||||
// of response production, so the write path is never self-blocked.
|
||||
// The responder only writes a canned success reply once unblocked
|
||||
// via gate.
|
||||
var reqsRead int64
|
||||
gate := make(chan struct{}, total)
|
||||
pending := make(chan struct{}, total+pipelineMaxInFlight)
|
||||
readerDone := make(chan struct{})
|
||||
responderDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(readerDone)
|
||||
defer close(pending)
|
||||
for {
|
||||
var sizeBuf [4]byte
|
||||
if _, err := io.ReadFull(c2, sizeBuf[:]); err != nil {
|
||||
return
|
||||
}
|
||||
size := binary.BigEndian.Uint32(sizeBuf[:])
|
||||
req := make([]byte, size)
|
||||
if _, err := io.ReadFull(c2, req); err != nil {
|
||||
return
|
||||
}
|
||||
atomic.AddInt64(&reqsRead, 1)
|
||||
pending <- struct{}{}
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer close(responderDone)
|
||||
for range pending {
|
||||
<-gate
|
||||
var out [5]byte
|
||||
binary.BigEndian.PutUint32(out[:4], 1)
|
||||
out[4] = agentSuccess
|
||||
if _, err := c2.Write(out[:]); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
defer func() {
|
||||
c1.Close()
|
||||
c2.Close()
|
||||
<-readerDone
|
||||
<-responderDone
|
||||
}()
|
||||
|
||||
client := NewClient(c1)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, total)
|
||||
for range total {
|
||||
wg.Go(func() {
|
||||
// RemoveAll maps to a simpleCall expecting agentSuccess.
|
||||
if err := client.RemoveAll(); err != nil {
|
||||
errs <- err
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Wait for the client to fill the pipeline.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if atomic.LoadInt64(&reqsRead) >= pipelineMaxInFlight {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if got := atomic.LoadInt64(&reqsRead); got != pipelineMaxInFlight {
|
||||
t.Errorf("requests in flight before any response: got %d, want %d", got, pipelineMaxInFlight)
|
||||
}
|
||||
|
||||
// Release all responses; every caller should eventually complete.
|
||||
for range total {
|
||||
gate <- struct{}{}
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if got := atomic.LoadInt64(&reqsRead); got != total {
|
||||
t.Errorf("total requests received: got %d, want %d", got, total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipelineConnCloseFailsInFlight(t *testing.T) {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
|
||||
// Server that reads requests but never replies, so all calls are
|
||||
// parked waiting for responses.
|
||||
serverExit := make(chan struct{})
|
||||
go func() {
|
||||
defer close(serverExit)
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
if _, err := c2.Read(buf); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
client := NewClient(c1)
|
||||
|
||||
const N = 8
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, N)
|
||||
for range N {
|
||||
wg.Go(func() {
|
||||
errs <- client.RemoveAll()
|
||||
})
|
||||
}
|
||||
|
||||
// Give callers time to enter the pipeline, then tear down the conn.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
c1.Close()
|
||||
c2.Close()
|
||||
|
||||
// All callers must return promptly with an error.
|
||||
doneCh := make(chan struct{})
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(doneCh)
|
||||
}()
|
||||
select {
|
||||
case <-doneCh:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("pipelined callers did not complete after conn close")
|
||||
}
|
||||
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err == nil {
|
||||
t.Error("expected error after conn close, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// Subsequent calls also fail fast rather than hanging.
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- client.RemoveAll() }()
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err == nil {
|
||||
t.Error("expected error on post-close call, got nil")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("post-close call hung")
|
||||
}
|
||||
|
||||
<-serverExit
|
||||
}
|
||||
|
||||
// readWriter exposes only io.Reader and io.Writer, hiding any Close
|
||||
// method on the wrapped value so NewClient cannot take the pipelined
|
||||
// path.
|
||||
type readWriter struct {
|
||||
io.Reader
|
||||
io.Writer
|
||||
}
|
||||
|
||||
func TestNewClientSerialFallback(t *testing.T) {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
keyring := NewKeyring()
|
||||
if err := keyring.Add(AddedKey{PrivateKey: testPrivateKeys["rsa"]}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go ServeAgent(keyring, c2)
|
||||
|
||||
rw := readWriter{Reader: c1, Writer: c1}
|
||||
ag := NewClient(rw)
|
||||
|
||||
ci, ok := ag.(*client)
|
||||
if !ok {
|
||||
t.Fatalf("NewClient: got %T, want *client", ag)
|
||||
}
|
||||
if ci.pipeline != nil {
|
||||
t.Error("NewClient with non-Closer rw: pipeline must be nil")
|
||||
}
|
||||
if ci.conn == nil {
|
||||
t.Error("NewClient with non-Closer rw: conn must be set")
|
||||
}
|
||||
|
||||
// The serialized path correctly handles a request/response cycle.
|
||||
keys, err := ag.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(keys) != 1 {
|
||||
t.Errorf("List: got %d keys, want 1", len(keys))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSerialFallbackParallelSigns(t *testing.T) {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
keyring := NewKeyring()
|
||||
if err := keyring.Add(AddedKey{PrivateKey: testPrivateKeys["rsa"]}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go ServeAgent(keyring, c2)
|
||||
|
||||
rw := readWriter{Reader: c1, Writer: c1}
|
||||
ag := NewClient(rw)
|
||||
|
||||
if ci, ok := ag.(*client); !ok {
|
||||
t.Fatalf("NewClient: got %T, want *client", ag)
|
||||
} else if ci.pipeline != nil {
|
||||
t.Fatal("pipeline must be nil for non-Closer rw")
|
||||
}
|
||||
|
||||
pubKey := testPublicKeys["rsa"]
|
||||
|
||||
const N = 64
|
||||
var wg sync.WaitGroup
|
||||
errCh := make(chan error, N)
|
||||
for i := range N {
|
||||
wg.Go(func() {
|
||||
data := []byte{byte(i), byte(i >> 8)}
|
||||
sig, err := ag.Sign(pubKey, data)
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
if err := pubKey.Verify(data, sig); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
for err := range errCh {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeFailConn wraps a net.Conn and forces Write to return an error when
|
||||
// the fail flag is set. Close delegates to the underlying conn so the test
|
||||
// can observe whether the pipeline closed it.
|
||||
type writeFailConn struct {
|
||||
net.Conn
|
||||
fail atomic.Bool
|
||||
}
|
||||
|
||||
func (c *writeFailConn) Write(p []byte) (int, error) {
|
||||
if c.fail.Load() {
|
||||
return 0, errors.New("synthetic write failure")
|
||||
}
|
||||
return c.Conn.Write(p)
|
||||
}
|
||||
|
||||
func TestPipelineWriteErrorClosesConn(t *testing.T) {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
defer c2.Close()
|
||||
|
||||
// Peer that reads and discards, exiting when the conn closes.
|
||||
serverExit := make(chan struct{})
|
||||
go func() {
|
||||
defer close(serverExit)
|
||||
io.Copy(io.Discard, c2)
|
||||
}()
|
||||
|
||||
wrapper := &writeFailConn{Conn: c1}
|
||||
wrapper.fail.Store(true)
|
||||
|
||||
client := NewClient(wrapper)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- client.RemoveAll() }()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err == nil {
|
||||
t.Fatal("expected error on forced write failure, got nil")
|
||||
}
|
||||
// The caller whose Write failed must receive the actual
|
||||
// write error, not the reader's "closed connection" error
|
||||
// from the post-shutdown drain.
|
||||
if !strings.Contains(err.Error(), "synthetic write failure") {
|
||||
t.Errorf("got %q, want error containing %q", err, "synthetic write failure")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("RemoveAll hung after write failure")
|
||||
}
|
||||
|
||||
// The pipeline must have closed the underlying conn, so the peer
|
||||
// goroutine returns from its Read.
|
||||
select {
|
||||
case <-serverExit:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("connection was not closed after write failure")
|
||||
}
|
||||
|
||||
// After shutdown, subsequent calls must also fail promptly.
|
||||
errCh2 := make(chan error, 1)
|
||||
go func() { errCh2 <- client.RemoveAll() }()
|
||||
select {
|
||||
case err := <-errCh2:
|
||||
if err == nil {
|
||||
t.Error("expected error on post-shutdown call, got nil")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("post-shutdown call hung")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipelineResponseTooLarge(t *testing.T) {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
// Server: read the request, then reply with a size header that
|
||||
// exceeds maxAgentResponseBytes. The size check fires before the
|
||||
// body is read, so no body needs to follow.
|
||||
serverDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(serverDone)
|
||||
var sizeBuf [4]byte
|
||||
if _, err := io.ReadFull(c2, sizeBuf[:]); err != nil {
|
||||
return
|
||||
}
|
||||
size := binary.BigEndian.Uint32(sizeBuf[:])
|
||||
if _, err := io.CopyN(io.Discard, c2, int64(size)); err != nil {
|
||||
return
|
||||
}
|
||||
var out [4]byte
|
||||
binary.BigEndian.PutUint32(out[:], maxAgentResponseBytes+1)
|
||||
c2.Write(out[:])
|
||||
}()
|
||||
|
||||
client := NewClient(c1)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- client.RemoveAll() }()
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "response too large") {
|
||||
t.Errorf("got %q, want error containing %q", err, "response too large")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("call hung")
|
||||
}
|
||||
|
||||
// Subsequent calls must also fail: the pipeline is shut down.
|
||||
errCh2 := make(chan error, 1)
|
||||
go func() { errCh2 <- client.RemoveAll() }()
|
||||
select {
|
||||
case err := <-errCh2:
|
||||
if err == nil {
|
||||
t.Error("expected error on post-shutdown call, got nil")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("post-shutdown call hung")
|
||||
}
|
||||
|
||||
<-serverDone
|
||||
}
|
||||
|
||||
func TestPipelineShutdownWithBlockedWriter(t *testing.T) {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
|
||||
// Server drains requests off the wire so writes never block on
|
||||
// backpressure from the transport, but never replies. The reader
|
||||
// goroutine therefore stays parked on Read, never dequeues from
|
||||
// pending, and the queue fills up.
|
||||
var reqsRead int64
|
||||
serverExit := make(chan struct{})
|
||||
go func() {
|
||||
defer close(serverExit)
|
||||
for {
|
||||
var sizeBuf [4]byte
|
||||
if _, err := io.ReadFull(c2, sizeBuf[:]); err != nil {
|
||||
return
|
||||
}
|
||||
size := binary.BigEndian.Uint32(sizeBuf[:])
|
||||
if _, err := io.CopyN(io.Discard, c2, int64(size)); err != nil {
|
||||
return
|
||||
}
|
||||
atomic.AddInt64(&reqsRead, 1)
|
||||
}
|
||||
}()
|
||||
|
||||
client := NewClient(c1)
|
||||
|
||||
const total = pipelineMaxInFlight + 4
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, total)
|
||||
for range total {
|
||||
wg.Go(func() {
|
||||
errs <- client.RemoveAll()
|
||||
})
|
||||
}
|
||||
|
||||
// Wait for the queue to fill: once pipelineMaxInFlight requests
|
||||
// are on the wire, pending is at capacity and the +1 writer is
|
||||
// blocked in the second-select arm; the rest are queued behind
|
||||
// writeMu.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if atomic.LoadInt64(&reqsRead) >= pipelineMaxInFlight {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if got := atomic.LoadInt64(&reqsRead); got < pipelineMaxInFlight {
|
||||
t.Fatalf("requests on wire: got %d, want >= %d", got, pipelineMaxInFlight)
|
||||
}
|
||||
// Give the +1 writer a moment to actually park in the second
|
||||
// select before we trigger shutdown.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Trigger shutdown: closing the server side makes the client's
|
||||
// reader hit EOF, which calls shutdown(...).
|
||||
c2.Close()
|
||||
|
||||
doneCh := make(chan struct{})
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(doneCh)
|
||||
}()
|
||||
select {
|
||||
case <-doneCh:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("blocked callers did not complete after shutdown")
|
||||
}
|
||||
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err == nil {
|
||||
t.Error("expected error after shutdown, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
<-serverExit
|
||||
}
|
||||
|
||||
// multiDeviceAgent simulates an ssh-agent backed by numDevices signing
|
||||
// devices, each of which takes latency to process a request. Incoming
|
||||
// requests are round-robin dispatched to devices, and responses are
|
||||
// serialized back to the wire in request order as the protocol requires.
|
||||
//
|
||||
// The simulation does not emit a semantically meaningful reply; it just
|
||||
// writes a 1-byte SSH_AGENT_SUCCESS after the simulated latency has
|
||||
// elapsed. This is enough to exercise callRaw/simpleCall (RemoveAll).
|
||||
func multiDeviceAgent(t testing.TB, conn io.ReadWriteCloser, numDevices int, latency time.Duration) (done <-chan struct{}) {
|
||||
t.Helper()
|
||||
|
||||
type job struct {
|
||||
ready chan struct{}
|
||||
}
|
||||
|
||||
devices := make([]chan job, numDevices)
|
||||
var devicesWG sync.WaitGroup
|
||||
for i := range numDevices {
|
||||
devices[i] = make(chan job, 1024)
|
||||
ch := devices[i]
|
||||
devicesWG.Go(func() {
|
||||
for j := range ch {
|
||||
time.Sleep(latency)
|
||||
close(j.ready)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Order-preserving responder: responses are written in the same
|
||||
// order the requests were read, regardless of which device finished
|
||||
// first.
|
||||
orderCh := make(chan chan struct{}, 1024)
|
||||
responderDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(responderDone)
|
||||
for ready := range orderCh {
|
||||
<-ready
|
||||
var out [5]byte
|
||||
binary.BigEndian.PutUint32(out[:4], 1)
|
||||
out[4] = agentSuccess
|
||||
if _, err := conn.Write(out[:]); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
finished := make(chan struct{})
|
||||
go func() {
|
||||
defer close(finished)
|
||||
defer func() {
|
||||
close(orderCh)
|
||||
<-responderDone
|
||||
for _, d := range devices {
|
||||
close(d)
|
||||
}
|
||||
devicesWG.Wait()
|
||||
}()
|
||||
next := 0
|
||||
for {
|
||||
var sizeBuf [4]byte
|
||||
if _, err := io.ReadFull(conn, sizeBuf[:]); err != nil {
|
||||
return
|
||||
}
|
||||
size := binary.BigEndian.Uint32(sizeBuf[:])
|
||||
if _, err := io.CopyN(io.Discard, conn, int64(size)); err != nil {
|
||||
return
|
||||
}
|
||||
ready := make(chan struct{})
|
||||
devices[next%numDevices] <- job{ready: ready}
|
||||
next++
|
||||
orderCh <- ready
|
||||
}
|
||||
}()
|
||||
|
||||
return finished
|
||||
}
|
||||
|
||||
// BenchmarkPipelineMultiDevice measures throughput of the pipelined
|
||||
// agent client against a simulated multi-device agent, varying the
|
||||
// number of backend devices.
|
||||
//
|
||||
// Setup: each simulated signing device takes 2ms per request. The
|
||||
// protocol serializes responses in request order, but pipelining lets
|
||||
// the client keep multiple devices busy concurrently; throughput
|
||||
// should scale roughly linearly with numDevices up to the pipeline
|
||||
// depth (pipelineMaxInFlight).
|
||||
//
|
||||
// Run with: go test -benchmem -bench BenchmarkPipelineMultiDevice -benchtime=2s ./ssh/agent/
|
||||
func BenchmarkPipelineMultiDevice(b *testing.B) {
|
||||
const (
|
||||
deviceLatency = 2 * time.Millisecond
|
||||
parallelism = 32
|
||||
)
|
||||
|
||||
for _, numDevices := range []int{1, 2, 4, 8, 16} {
|
||||
b.Run(fmt.Sprintf("Devices=%d", numDevices), func(b *testing.B) {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
b.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
serverDone := multiDeviceAgent(b, c2, numDevices, deviceLatency)
|
||||
|
||||
client := NewClient(c1)
|
||||
|
||||
b.ResetTimer()
|
||||
b.SetParallelism(parallelism)
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
if err := client.RemoveAll(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
b.StopTimer()
|
||||
|
||||
c1.Close()
|
||||
c2.Close()
|
||||
<-serverDone
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
@@ -654,3 +655,36 @@ func TestAddConstraintExtensionsWireFormat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRejectsShortEd25519Key(t *testing.T) {
|
||||
short := ed25519.PrivateKey(make([]byte, 16))
|
||||
// insertCert reaches its ed25519 branch only when AddedKey.Certificate
|
||||
// is non-nil. The length check returns before cert.Type/Marshal run, so
|
||||
// an empty certificate is sufficient to drive the path.
|
||||
emptyCert := &ssh.Certificate{}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
add AddedKey
|
||||
}{
|
||||
{"insertKey value", AddedKey{PrivateKey: short}},
|
||||
{"insertKey pointer", AddedKey{PrivateKey: &short}},
|
||||
{"insertCert value", AddedKey{PrivateKey: short, Certificate: emptyCert}},
|
||||
{"insertCert pointer", AddedKey{PrivateKey: &short, Certificate: emptyCert}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
client, cleanup := startKeyringAgent(t)
|
||||
defer cleanup()
|
||||
|
||||
err := client.Add(tc.add)
|
||||
if err == nil {
|
||||
t.Fatal("Add accepted ed25519 key shorter than 64 bytes")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "bad ED25519 key size") {
|
||||
t.Errorf("got error %q, want substring %q", err.Error(), "bad ED25519 key size")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+26
-4
@@ -240,13 +240,35 @@ func setConstraints(key *AddedKey, constraintBytes []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkRSAKeyParams enforces the same bounds as parseRSA in the ssh
|
||||
// package, and additionally caps the prime factors. Without this,
|
||||
// the rsa.PrivateKey built from an Add request would call Precompute()
|
||||
// on arbitrary inputs; the CRT coefficient recomputation is cubic in
|
||||
// |p| and can consume excessive CPU on oversized keys.
|
||||
func checkRSAKeyParams(N, E, P, Q *big.Int) error {
|
||||
if N.BitLen() > 8192 {
|
||||
return errors.New("agent: RSA modulus too large")
|
||||
}
|
||||
if P.BitLen() > 4096 || Q.BitLen() > 4096 {
|
||||
return errors.New("agent: RSA prime too large")
|
||||
}
|
||||
if E.BitLen() > 24 {
|
||||
return errors.New("agent: RSA public exponent too large")
|
||||
}
|
||||
e := E.Int64()
|
||||
if e < 3 || e&1 == 0 {
|
||||
return errors.New("agent: incorrect RSA public exponent")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseRSAKey(req []byte) (*AddedKey, error) {
|
||||
var k rsaKeyMsg
|
||||
if err := ssh.Unmarshal(req, &k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if k.E.BitLen() > 30 {
|
||||
return nil, errors.New("agent: RSA public exponent too large")
|
||||
if err := checkRSAKeyParams(k.N, k.E, k.P, k.Q); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
priv := &rsa.PrivateKey{
|
||||
PublicKey: rsa.PublicKey{
|
||||
@@ -399,8 +421,8 @@ func parseRSACert(req []byte) (*AddedKey, error) {
|
||||
return nil, fmt.Errorf("agent: Unmarshal failed to parse public key: %v", err)
|
||||
}
|
||||
|
||||
if rsaPub.E.BitLen() > 30 {
|
||||
return nil, errors.New("agent: RSA public exponent too large")
|
||||
if err := checkRSAKeyParams(rsaPub.N, rsaPub.E, k.P, k.Q); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
priv := rsa.PrivateKey{
|
||||
|
||||
+4
-1
@@ -634,7 +634,10 @@ func (ch *channel) SendRequest(name string, wantReply bool, payload []byte) (boo
|
||||
drain:
|
||||
for {
|
||||
select {
|
||||
case <-ch.msg:
|
||||
case _, ok := <-ch.msg:
|
||||
if !ok {
|
||||
break drain
|
||||
}
|
||||
default:
|
||||
break drain
|
||||
}
|
||||
|
||||
@@ -88,6 +88,32 @@ func NewClientConn(c net.Conn, addr string, config *ClientConfig) (Conn, <-chan
|
||||
return conn, conn.mux.incomingChannels, conn.mux.incomingRequests, nil
|
||||
}
|
||||
|
||||
// NewControlClientConn establishes an SSH connection over an OpenSSH
|
||||
// ControlMaster socket c in proxy mode.
|
||||
//
|
||||
// Note that this package only implements the client side of the multiplexing
|
||||
// protocol. The provided net.Conn must be a local, secure connection (such as a
|
||||
// Unix domain socket) connected to an already-running OpenSSH process acting as
|
||||
// the ControlMaster.
|
||||
//
|
||||
// WARNING: Because proxy mode bypasses the standard cryptographic handshake
|
||||
// passing a standard network connection (e.g., TCP) will result in plaintext
|
||||
// data leakage.
|
||||
//
|
||||
// The Request and NewChannel channels must be serviced or the connection
|
||||
// will hang.
|
||||
func NewControlClientConn(c net.Conn) (Conn, <-chan NewChannel, <-chan *Request, error) {
|
||||
conn := &connection{
|
||||
sshConn: sshConn{conn: c},
|
||||
}
|
||||
var err error
|
||||
if conn.transport, err = handshakeControlProxy(c); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("ssh: control proxy handshake failed: %w", err)
|
||||
}
|
||||
conn.mux = newMux(conn.transport)
|
||||
return conn, conn.mux.incomingChannels, conn.mux.incomingRequests, nil
|
||||
}
|
||||
|
||||
// clientHandshake performs the client side key exchange. See RFC 4253 Section
|
||||
// 7.
|
||||
func (c *connection) clientHandshake(dialAddress string, config *ClientConfig) error {
|
||||
@@ -197,6 +223,59 @@ type HostKeyCallback func(hostname string, remote net.Addr, key PublicKey) error
|
||||
// the server. A BannerCallback receives the message sent by the remote server.
|
||||
type BannerCallback func(message string) error
|
||||
|
||||
// ClientAuthContext contains information about the current state of the
|
||||
// authentication process, passed to [ClientAuthCallback].
|
||||
type ClientAuthContext struct {
|
||||
// Metadata contains the connection metadata.
|
||||
Metadata ConnMetadata
|
||||
|
||||
// Algorithms contains the negotiated algorithms.
|
||||
Algorithms NegotiatedAlgorithms
|
||||
|
||||
// AllowedMethods lists the authentication methods currently accepted
|
||||
// by the server. These are the protocol-level names defined in RFC 4252
|
||||
// such as "publickey", "password".
|
||||
AllowedMethods []string
|
||||
|
||||
// PartialSuccessMethods lists the authentication methods that have already
|
||||
// succeeded, indicating a multi-step authentication flow. This list
|
||||
// represents the exact sequence of partial successes and may contain
|
||||
// duplicates if the same method succeeded multiple times.
|
||||
PartialSuccessMethods []string
|
||||
|
||||
// TriedMethods lists the methods that have already been attempted and
|
||||
// failed during this session. This list represents the exact sequence of
|
||||
// failures and may contain duplicates. This allows the callback to also
|
||||
// track the number of failed attempts for a specific method.
|
||||
TriedMethods []string
|
||||
}
|
||||
|
||||
// ClientAuthCallback is a hook invoked before each authentication attempt. It
|
||||
// allows the client to dynamically select an authentication method based on the
|
||||
// current context, server capabilities, or previous failures.
|
||||
//
|
||||
// The callback is invoked after the initial "none" authentication method, once
|
||||
// the server's supported authentication methods are known.
|
||||
//
|
||||
// Return values:
|
||||
// - (AuthMethod, nil): The client will attempt this specific method next.
|
||||
// The returned method does NOT need to be present in [ClientConfig.Auth].
|
||||
// This allows for dynamic authentication strategies (e.g., prompting
|
||||
// for a password only if public key auth fails). Callers should inspect
|
||||
// [ClientAuthContext.TriedMethods] to avoid repeatedly returning the
|
||||
// same failing method.
|
||||
// - (nil, nil): The client selects from [ClientConfig.Auth] the first
|
||||
// instance of a method that has not been tried yet, or aborts if none
|
||||
// are left. If authentication is not successful, the callback is invoked
|
||||
// again before the following attempt.
|
||||
// - (nil, error): The authentication process is aborted immediately,
|
||||
// causing the ongoing SSH handshake to fail with the provided error.
|
||||
//
|
||||
// To bound resource use, the client caps the total number of authentication
|
||||
// attempts (failures and partial successes combined) at 64. If the cap is
|
||||
// exceeded the handshake aborts with an error.
|
||||
type ClientAuthCallback func(ctx *ClientAuthContext) (AuthMethod, error)
|
||||
|
||||
// A ClientConfig structure is used to configure a Client. It must not be
|
||||
// modified after having been passed to an SSH function.
|
||||
type ClientConfig struct {
|
||||
@@ -210,6 +289,9 @@ type ClientConfig struct {
|
||||
// Auth contains possible authentication methods to use with the
|
||||
// server. Only the first instance of a particular RFC 4252 method will
|
||||
// be used during authentication.
|
||||
//
|
||||
// If AuthCallback is set, these AuthMethod are only used if the
|
||||
// callback returns nil.
|
||||
Auth []AuthMethod
|
||||
|
||||
// HostKeyCallback is called during the cryptographic
|
||||
@@ -240,6 +322,9 @@ type ClientConfig struct {
|
||||
//
|
||||
// A Timeout of zero means no timeout.
|
||||
Timeout time.Duration
|
||||
|
||||
// AuthCallback, if non-nil, is invoked before each authentication attempt.
|
||||
AuthCallback ClientAuthCallback
|
||||
}
|
||||
|
||||
// InsecureIgnoreHostKey returns a function that can be used for
|
||||
|
||||
+50
-14
@@ -21,6 +21,12 @@ const (
|
||||
authSuccess
|
||||
)
|
||||
|
||||
// maxAuthClientTried bounds the total number of authentication attempts
|
||||
// (failures and partial successes combined) the client makes before
|
||||
// aborting the loop, to prevent unbounded growth when an AuthCallback
|
||||
// keeps supplying methods.
|
||||
const maxAuthClientTried = 64
|
||||
|
||||
// clientAuthenticate authenticates with the remote server. See RFC 4252.
|
||||
func (c *connection) clientAuthenticate(config *ClientConfig) error {
|
||||
// initiate user auth session
|
||||
@@ -67,32 +73,62 @@ func (c *connection) clientAuthenticate(config *ClientConfig) error {
|
||||
// then any untried methods suggested by the server.
|
||||
var tried []string
|
||||
var lastMethods []string
|
||||
var partialSuccess []string
|
||||
|
||||
sessionID := c.transport.getSessionID()
|
||||
for auth := AuthMethod(new(noneAuth)); auth != nil; {
|
||||
ok, methods, err := auth.auth(sessionID, config.User, c.transport, config.Rand, extensions)
|
||||
if err != nil {
|
||||
// On disconnect, return error immediately
|
||||
if _, ok := err.(*disconnectMsg); ok {
|
||||
if _, isDisconnect := err.(*disconnectMsg); isDisconnect {
|
||||
return err
|
||||
}
|
||||
// We return the error later if there is no other method left to
|
||||
// try.
|
||||
// We return the error later if there is no other method
|
||||
// left to try.
|
||||
ok = authFailure
|
||||
}
|
||||
if ok == authSuccess {
|
||||
// success
|
||||
|
||||
switch ok {
|
||||
case authSuccess:
|
||||
return nil
|
||||
} else if ok == authFailure {
|
||||
if m := auth.method(); !slices.Contains(tried, m) {
|
||||
tried = append(tried, m)
|
||||
}
|
||||
case authPartialSuccess:
|
||||
partialSuccess = append(partialSuccess, auth.method())
|
||||
case authFailure:
|
||||
tried = append(tried, auth.method())
|
||||
}
|
||||
if len(partialSuccess)+len(tried) > maxAuthClientTried {
|
||||
return fmt.Errorf("ssh: too many authentication attempts (%d), aborting",
|
||||
len(partialSuccess)+len(tried))
|
||||
}
|
||||
|
||||
if methods == nil {
|
||||
methods = lastMethods
|
||||
}
|
||||
lastMethods = methods
|
||||
|
||||
// If AuthCallback is set it takes precedence: it picks the next
|
||||
// AuthMethod dynamically. The returned method need not be in
|
||||
// config.Auth. If the callback returns (nil, nil) we fall back to
|
||||
// selecting the next untried method from config.Auth below; on
|
||||
// (nil, error) the handshake aborts.
|
||||
if config.AuthCallback != nil {
|
||||
ctx := &ClientAuthContext{
|
||||
Metadata: c,
|
||||
Algorithms: c.Algorithms(),
|
||||
AllowedMethods: slices.Clone(methods),
|
||||
PartialSuccessMethods: slices.Clone(partialSuccess),
|
||||
TriedMethods: slices.Clone(tried),
|
||||
}
|
||||
altAuth, cbErr := config.AuthCallback(ctx)
|
||||
if cbErr != nil {
|
||||
return cbErr
|
||||
}
|
||||
if altAuth != nil {
|
||||
auth = altAuth
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
auth = nil
|
||||
|
||||
findNext:
|
||||
@@ -377,11 +413,11 @@ func (cb publicKeyCallback) auth(session []byte, user string, c packetConn, rand
|
||||
return authFailure, nil, err
|
||||
}
|
||||
|
||||
// If authentication succeeds or the list of available methods does not
|
||||
// contain the "publickey" method, do not attempt to authenticate with any
|
||||
// other keys. According to RFC 4252 Section 7, the latter can occur when
|
||||
// additional authentication methods are required.
|
||||
if success == authSuccess || !slices.Contains(methods, cb.method()) {
|
||||
// If authentication succeeds or partially succeeds, return immediately
|
||||
// so the caller can select the next auth method. According to RFC 4252
|
||||
// Section 7, if the server no longer lists "publickey" among its
|
||||
// allowed methods, do not attempt to authenticate with any other keys.
|
||||
if success == authSuccess || success == authPartialSuccess || !slices.Contains(methods, cb.method()) {
|
||||
return success, methods, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +169,689 @@ func TestClientAuthThirdKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// partialSuccessPublicKeyAndKbdInteractiveServer returns a server config
|
||||
// where the rsa key gets partial success and the server offers both publickey
|
||||
// and keyboard-interactive as next methods. The VerifiedPublicKeyCallback
|
||||
// tracks state so that the same key only triggers partial success once,
|
||||
// avoiding an impossible auth loop.
|
||||
func partialSuccessPublicKeyAndKbdInteractiveServer() *ServerConfig {
|
||||
rsaDone := false
|
||||
serverConf := &ServerConfig{
|
||||
PublicKeyCallback: func(conn ConnMetadata, key PublicKey) (*Permissions, error) {
|
||||
if bytes.Equal(key.Marshal(), testPublicKeys["rsa"].Marshal()) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.New("invalid credentials")
|
||||
},
|
||||
VerifiedPublicKeyCallback: func(conn ConnMetadata, key PublicKey, permissions *Permissions, signatureAlgorithm string) (*Permissions, error) {
|
||||
if !rsaDone && bytes.Equal(key.Marshal(), testPublicKeys["rsa"].Marshal()) {
|
||||
rsaDone = true
|
||||
return nil, &PartialSuccessError{
|
||||
Next: ServerAuthCallbacks{
|
||||
PublicKeyCallback: func(conn ConnMetadata, key PublicKey) (*Permissions, error) {
|
||||
if bytes.Equal(key.Marshal(), testPublicKeys["rsa"].Marshal()) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.New("invalid credentials")
|
||||
},
|
||||
KeyboardInteractiveCallback: func(conn ConnMetadata, client KeyboardInteractiveChallenge) (*Permissions, error) {
|
||||
answers, err := client("", "", []string{"token: "}, []bool{true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(answers) == 1 && answers[0] == "correct-token" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.New("invalid token")
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return nil, errors.New("invalid credentials")
|
||||
},
|
||||
}
|
||||
serverConf.AddHostKey(testSigners["rsa"])
|
||||
return serverConf
|
||||
}
|
||||
|
||||
// partialSuccessMultiKeyServer returns a server config that requires two
|
||||
// public keys in sequence: the first key (rsa) triggers partial success
|
||||
// requiring a second key (ecdsa) to complete authentication.
|
||||
func partialSuccessMultiKeyServer() *ServerConfig {
|
||||
firstKeyDone := false
|
||||
serverConf := &ServerConfig{
|
||||
PublicKeyCallback: func(conn ConnMetadata, key PublicKey) (*Permissions, error) {
|
||||
if bytes.Equal(key.Marshal(), testPublicKeys["rsa"].Marshal()) {
|
||||
return nil, nil
|
||||
}
|
||||
if bytes.Equal(key.Marshal(), testPublicKeys["ecdsa"].Marshal()) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.New("invalid credentials")
|
||||
},
|
||||
VerifiedPublicKeyCallback: func(conn ConnMetadata, key PublicKey, permissions *Permissions, signatureAlgorithm string) (*Permissions, error) {
|
||||
if !firstKeyDone && bytes.Equal(key.Marshal(), testPublicKeys["rsa"].Marshal()) {
|
||||
firstKeyDone = true
|
||||
return nil, &PartialSuccessError{
|
||||
Next: ServerAuthCallbacks{
|
||||
PublicKeyCallback: func(conn ConnMetadata, key PublicKey) (*Permissions, error) {
|
||||
if bytes.Equal(key.Marshal(), testPublicKeys["ecdsa"].Marshal()) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.New("invalid credentials")
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
if firstKeyDone && bytes.Equal(key.Marshal(), testPublicKeys["ecdsa"].Marshal()) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.New("invalid credentials")
|
||||
},
|
||||
}
|
||||
serverConf.AddHostKey(testSigners["rsa"])
|
||||
return serverConf
|
||||
}
|
||||
|
||||
func TestStaticMultiKeyPartialSuccess(t *testing.T) {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
clientConf := &ClientConfig{
|
||||
User: "user",
|
||||
Auth: []AuthMethod{
|
||||
PublicKeys(testSigners["rsa"], testSigners["ecdsa"]),
|
||||
},
|
||||
HostKeyCallback: InsecureIgnoreHostKey(),
|
||||
}
|
||||
|
||||
go NewServerConn(c1, partialSuccessMultiKeyServer())
|
||||
|
||||
_, _, _, err = NewClientConn(c2, "", clientConf)
|
||||
if err != nil {
|
||||
t.Fatalf("client login error: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticPartialSuccess(t *testing.T) {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
clientConf := &ClientConfig{
|
||||
User: "user",
|
||||
Auth: []AuthMethod{
|
||||
PublicKeys(testSigners["rsa"]),
|
||||
KeyboardInteractive(func(name, instruction string, questions []string, echos []bool) ([]string, error) {
|
||||
return []string{"correct-token"}, nil
|
||||
}),
|
||||
},
|
||||
HostKeyCallback: InsecureIgnoreHostKey(),
|
||||
}
|
||||
|
||||
go NewServerConn(c1, partialSuccessPublicKeyAndKbdInteractiveServer())
|
||||
|
||||
_, _, _, err = NewClientConn(c2, "", clientConf)
|
||||
if err != nil {
|
||||
t.Fatalf("client login error: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// partialSuccessKeyAThenKeyBServer returns a server config where keyA (rsa)
|
||||
// gets partial success and the server offers BOTH publickey and
|
||||
// keyboard-interactive. keyB (ecdsa) then completes auth successfully.
|
||||
func partialSuccessKeyAThenKeyBServer() *ServerConfig {
|
||||
keyADone := false
|
||||
serverConf := &ServerConfig{
|
||||
PublicKeyCallback: func(conn ConnMetadata, key PublicKey) (*Permissions, error) {
|
||||
if bytes.Equal(key.Marshal(), testPublicKeys["rsa"].Marshal()) {
|
||||
return nil, nil
|
||||
}
|
||||
if bytes.Equal(key.Marshal(), testPublicKeys["ecdsa"].Marshal()) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.New("invalid credentials")
|
||||
},
|
||||
VerifiedPublicKeyCallback: func(conn ConnMetadata, key PublicKey, permissions *Permissions, signatureAlgorithm string) (*Permissions, error) {
|
||||
if !keyADone && bytes.Equal(key.Marshal(), testPublicKeys["rsa"].Marshal()) {
|
||||
keyADone = true
|
||||
return nil, &PartialSuccessError{
|
||||
Next: ServerAuthCallbacks{
|
||||
PublicKeyCallback: func(conn ConnMetadata, key PublicKey) (*Permissions, error) {
|
||||
if bytes.Equal(key.Marshal(), testPublicKeys["ecdsa"].Marshal()) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.New("invalid credentials")
|
||||
},
|
||||
KeyboardInteractiveCallback: func(conn ConnMetadata, client KeyboardInteractiveChallenge) (*Permissions, error) {
|
||||
answers, err := client("", "", []string{"token: "}, []bool{true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(answers) == 1 && answers[0] == "correct-token" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.New("invalid token")
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
if keyADone && bytes.Equal(key.Marshal(), testPublicKeys["ecdsa"].Marshal()) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.New("invalid credentials")
|
||||
},
|
||||
}
|
||||
serverConf.AddHostKey(testSigners["rsa"])
|
||||
return serverConf
|
||||
}
|
||||
|
||||
func TestStaticPartialSuccessKeyAThenKeyB(t *testing.T) {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
clientConf := &ClientConfig{
|
||||
User: "user",
|
||||
Auth: []AuthMethod{
|
||||
PublicKeys(testSigners["rsa"], testSigners["ecdsa"]),
|
||||
},
|
||||
HostKeyCallback: InsecureIgnoreHostKey(),
|
||||
}
|
||||
|
||||
go NewServerConn(c1, partialSuccessKeyAThenKeyBServer())
|
||||
|
||||
_, _, _, err = NewClientConn(c2, "", clientConf)
|
||||
if err != nil {
|
||||
t.Fatalf("client login error: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// partialSuccessMultiKeyThenKbdInteractiveServer returns a server config
|
||||
// that strictly requires keyA (rsa) AND keyB (ecdsa) as public keys before
|
||||
// allowing keyboard-interactive. After keyA, ONLY publickey is offered
|
||||
// (for keyB). After keyB, ONLY keyboard-interactive is offered.
|
||||
func partialSuccessMultiKeyThenKbdInteractiveServer() *ServerConfig {
|
||||
serverConf := &ServerConfig{
|
||||
PublicKeyCallback: func(conn ConnMetadata, key PublicKey) (*Permissions, error) {
|
||||
if bytes.Equal(key.Marshal(), testPublicKeys["rsa"].Marshal()) {
|
||||
return nil, &PartialSuccessError{
|
||||
Next: ServerAuthCallbacks{
|
||||
PublicKeyCallback: func(conn ConnMetadata, key PublicKey) (*Permissions, error) {
|
||||
if bytes.Equal(key.Marshal(), testPublicKeys["ecdsa"].Marshal()) {
|
||||
return nil, &PartialSuccessError{
|
||||
Next: ServerAuthCallbacks{
|
||||
KeyboardInteractiveCallback: func(conn ConnMetadata, client KeyboardInteractiveChallenge) (*Permissions, error) {
|
||||
answers, err := client("", "", []string{"token: "}, []bool{true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(answers) == 1 && answers[0] == "correct-token" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.New("invalid token")
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return nil, errors.New("invalid credentials")
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return nil, errors.New("invalid credentials")
|
||||
},
|
||||
}
|
||||
serverConf.AddHostKey(testSigners["rsa"])
|
||||
return serverConf
|
||||
}
|
||||
|
||||
func TestStaticMultiKeyPartialThenKbdInteractive(t *testing.T) {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
clientConf := &ClientConfig{
|
||||
User: "user",
|
||||
Auth: []AuthMethod{
|
||||
PublicKeys(testSigners["rsa"], testSigners["ecdsa"]),
|
||||
KeyboardInteractive(func(name, instruction string, questions []string, echos []bool) ([]string, error) {
|
||||
return []string{"correct-token"}, nil
|
||||
}),
|
||||
},
|
||||
HostKeyCallback: InsecureIgnoreHostKey(),
|
||||
}
|
||||
|
||||
go NewServerConn(c1, partialSuccessMultiKeyThenKbdInteractiveServer())
|
||||
|
||||
_, _, _, err = NewClientConn(c2, "", clientConf)
|
||||
if err != nil {
|
||||
t.Fatalf("client login error: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMethodDynamic(t *testing.T) {
|
||||
config := &ClientConfig{
|
||||
User: "testuser",
|
||||
Auth: nil,
|
||||
HostKeyCallback: InsecureIgnoreHostKey(),
|
||||
AuthCallback: func(ctx *ClientAuthContext) (AuthMethod, error) {
|
||||
if len(ctx.Metadata.SessionID()) == 0 {
|
||||
t.Error("empty sessionID")
|
||||
}
|
||||
if ctx.Algorithms.HostKey == "" {
|
||||
t.Error("empty Host Key")
|
||||
}
|
||||
if slices.Contains(ctx.AllowedMethods, "password") {
|
||||
return Password(clientPassword), nil
|
||||
}
|
||||
return nil, errors.New("auth callback error")
|
||||
},
|
||||
}
|
||||
|
||||
if err := tryAuth(t, config); err != nil {
|
||||
t.Fatalf("unable to dial remote side: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCallbackError(t *testing.T) {
|
||||
config := &ClientConfig{
|
||||
User: "testuser",
|
||||
Auth: []AuthMethod{
|
||||
Password(clientPassword),
|
||||
},
|
||||
HostKeyCallback: InsecureIgnoreHostKey(),
|
||||
AuthCallback: func(ctx *ClientAuthContext) (AuthMethod, error) {
|
||||
return nil, errors.New("auth callback error")
|
||||
},
|
||||
}
|
||||
|
||||
if err := tryAuth(t, config); err == nil {
|
||||
t.Fatal("authentication unexpectedly succeeded after AuthCallback returned an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCallbackPartialSuccess(t *testing.T) {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
clientConf := &ClientConfig{
|
||||
User: "user",
|
||||
HostKeyCallback: InsecureIgnoreHostKey(),
|
||||
AuthCallback: func(ctx *ClientAuthContext) (AuthMethod, error) {
|
||||
if slices.Contains(ctx.AllowedMethods, "publickey") && !slices.Contains(ctx.PartialSuccessMethods, "publickey") {
|
||||
return PublicKeys(testSigners["rsa"]), nil
|
||||
}
|
||||
if slices.Contains(ctx.PartialSuccessMethods, "publickey") && slices.Contains(ctx.AllowedMethods, "keyboard-interactive") {
|
||||
return KeyboardInteractive(func(name, instruction string, questions []string, echos []bool) ([]string, error) {
|
||||
return []string{"correct-token"}, nil
|
||||
}), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected auth state: allowed=%v partial=%v", ctx.AllowedMethods, ctx.PartialSuccessMethods)
|
||||
},
|
||||
}
|
||||
|
||||
go NewServerConn(c1, partialSuccessPublicKeyAndKbdInteractiveServer())
|
||||
|
||||
_, _, _, err = NewClientConn(c2, "", clientConf)
|
||||
if err != nil {
|
||||
t.Fatalf("client login error: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCallbackMultiKeyPartialSuccess(t *testing.T) {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
clientConf := &ClientConfig{
|
||||
User: "user",
|
||||
HostKeyCallback: InsecureIgnoreHostKey(),
|
||||
AuthCallback: func(ctx *ClientAuthContext) (AuthMethod, error) {
|
||||
if slices.Contains(ctx.AllowedMethods, "publickey") && !slices.Contains(ctx.PartialSuccessMethods, "publickey") {
|
||||
return PublicKeys(testSigners["rsa"]), nil
|
||||
}
|
||||
if slices.Contains(ctx.PartialSuccessMethods, "publickey") && slices.Contains(ctx.AllowedMethods, "publickey") {
|
||||
return PublicKeys(testSigners["ecdsa"]), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected auth state: allowed=%v partial=%v", ctx.AllowedMethods, ctx.PartialSuccessMethods)
|
||||
},
|
||||
}
|
||||
|
||||
go NewServerConn(c1, partialSuccessMultiKeyServer())
|
||||
|
||||
_, _, _, err = NewClientConn(c2, "", clientConf)
|
||||
if err != nil {
|
||||
t.Fatalf("client login error: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCallbackMultiKeyPartialThenKbdInteractive(t *testing.T) {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
clientConf := &ClientConfig{
|
||||
User: "user",
|
||||
HostKeyCallback: InsecureIgnoreHostKey(),
|
||||
AuthCallback: func(ctx *ClientAuthContext) (AuthMethod, error) {
|
||||
partialCount := len(ctx.PartialSuccessMethods)
|
||||
if partialCount == 0 && slices.Contains(ctx.AllowedMethods, "publickey") {
|
||||
return PublicKeys(testSigners["rsa"]), nil
|
||||
}
|
||||
if partialCount == 1 && slices.Contains(ctx.AllowedMethods, "publickey") {
|
||||
return PublicKeys(testSigners["ecdsa"]), nil
|
||||
}
|
||||
if partialCount >= 2 && slices.Contains(ctx.AllowedMethods, "keyboard-interactive") {
|
||||
return KeyboardInteractive(func(name, instruction string, questions []string, echos []bool) ([]string, error) {
|
||||
return []string{"correct-token"}, nil
|
||||
}), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected auth state: allowed=%v partial=%v tried=%v",
|
||||
ctx.AllowedMethods, ctx.PartialSuccessMethods, ctx.TriedMethods)
|
||||
},
|
||||
}
|
||||
|
||||
go NewServerConn(c1, partialSuccessMultiKeyThenKbdInteractiveServer())
|
||||
|
||||
_, _, _, err = NewClientConn(c2, "", clientConf)
|
||||
if err != nil {
|
||||
t.Fatalf("client login error: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCallbackPartialSuccessKeyAThenKeyB(t *testing.T) {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
clientConf := &ClientConfig{
|
||||
User: "user",
|
||||
HostKeyCallback: InsecureIgnoreHostKey(),
|
||||
AuthCallback: func(ctx *ClientAuthContext) (AuthMethod, error) {
|
||||
if len(ctx.PartialSuccessMethods) == 0 && slices.Contains(ctx.AllowedMethods, "publickey") {
|
||||
return PublicKeys(testSigners["rsa"]), nil
|
||||
}
|
||||
if len(ctx.PartialSuccessMethods) == 1 && slices.Contains(ctx.AllowedMethods, "publickey") {
|
||||
return PublicKeys(testSigners["ecdsa"]), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected auth state: allowed=%v partial=%v",
|
||||
ctx.AllowedMethods, ctx.PartialSuccessMethods)
|
||||
},
|
||||
}
|
||||
|
||||
go NewServerConn(c1, partialSuccessKeyAThenKeyBServer())
|
||||
|
||||
_, _, _, err = NewClientConn(c2, "", clientConf)
|
||||
if err != nil {
|
||||
t.Fatalf("client login error: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCallbackPartialSuccessWithStaticAuth(t *testing.T) {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
clientConf := &ClientConfig{
|
||||
User: "user",
|
||||
Auth: []AuthMethod{
|
||||
PublicKeys(testSigners["rsa"]),
|
||||
},
|
||||
HostKeyCallback: InsecureIgnoreHostKey(),
|
||||
AuthCallback: func(ctx *ClientAuthContext) (AuthMethod, error) {
|
||||
if slices.Contains(ctx.PartialSuccessMethods, "publickey") && slices.Contains(ctx.AllowedMethods, "keyboard-interactive") {
|
||||
return KeyboardInteractive(func(name, instruction string, questions []string, echos []bool) ([]string, error) {
|
||||
return []string{"correct-token"}, nil
|
||||
}), nil
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
go NewServerConn(c1, partialSuccessPublicKeyAndKbdInteractiveServer())
|
||||
|
||||
_, _, _, err = NewClientConn(c2, "", clientConf)
|
||||
if err != nil {
|
||||
t.Fatalf("client login error: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func partialSuccessPubKeyForbiddenAfterPartialServer(maxTries int) *ServerConfig {
|
||||
serverConf := &ServerConfig{
|
||||
PublicKeyCallback: func(conn ConnMetadata, key PublicKey) (*Permissions, error) {
|
||||
if bytes.Equal(key.Marshal(), testPublicKeys["rsa"].Marshal()) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.New("invalid credentials")
|
||||
},
|
||||
VerifiedPublicKeyCallback: func(conn ConnMetadata, key PublicKey, permissions *Permissions, signatureAlgorithm string) (*Permissions, error) {
|
||||
if bytes.Equal(key.Marshal(), testPublicKeys["rsa"].Marshal()) {
|
||||
return nil, &PartialSuccessError{
|
||||
Next: ServerAuthCallbacks{
|
||||
PublicKeyCallback: func(conn ConnMetadata, key PublicKey) (*Permissions, error) {
|
||||
return nil, errors.New("legacy public key authentication is forbidden")
|
||||
},
|
||||
KeyboardInteractiveCallback: func(conn ConnMetadata, client KeyboardInteractiveChallenge) (*Permissions, error) {
|
||||
answers, err := client("", "", []string{"token: "}, []bool{true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(answers) == 1 && answers[0] == "correct-token" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.New("invalid token")
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return nil, errors.New("invalid credentials")
|
||||
},
|
||||
MaxAuthTries: maxTries,
|
||||
}
|
||||
serverConf.AddHostKey(testSigners["rsa"])
|
||||
return serverConf
|
||||
}
|
||||
|
||||
func TestAuthCallbackPartialSuccessWithStaticMultiKey(t *testing.T) {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
clientConf := &ClientConfig{
|
||||
User: "user",
|
||||
Auth: []AuthMethod{
|
||||
PublicKeys(
|
||||
testSigners["rsa"],
|
||||
testSigners["ecdsa"],
|
||||
testSigners["ed25519"],
|
||||
),
|
||||
},
|
||||
HostKeyCallback: InsecureIgnoreHostKey(),
|
||||
AuthCallback: func(ctx *ClientAuthContext) (AuthMethod, error) {
|
||||
if slices.Contains(ctx.PartialSuccessMethods, "publickey") && slices.Contains(ctx.AllowedMethods, "keyboard-interactive") {
|
||||
return KeyboardInteractive(func(name, instruction string, questions []string, echos []bool) ([]string, error) {
|
||||
return []string{"correct-token"}, nil
|
||||
}), nil
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
go NewServerConn(c1, partialSuccessPubKeyForbiddenAfterPartialServer(2))
|
||||
|
||||
_, _, _, err = NewClientConn(c2, "", clientConf)
|
||||
if err != nil {
|
||||
t.Fatalf("client login error: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCallbackPartialSuccessShortCircuit(t *testing.T) {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
serverConf := partialSuccessPubKeyForbiddenAfterPartialServer(2)
|
||||
|
||||
pubkeyAttempts := 0
|
||||
clientConf := &ClientConfig{
|
||||
User: "user",
|
||||
HostKeyCallback: InsecureIgnoreHostKey(),
|
||||
AuthCallback: func(ctx *ClientAuthContext) (AuthMethod, error) {
|
||||
if slices.Contains(ctx.AllowedMethods, "publickey") && !slices.Contains(ctx.PartialSuccessMethods, "publickey") {
|
||||
pubkeyAttempts++
|
||||
return PublicKeys(testSigners["rsa"], testSigners["ecdsa"], testSigners["ed25519"]), nil
|
||||
}
|
||||
if slices.Contains(ctx.PartialSuccessMethods, "publickey") && slices.Contains(ctx.AllowedMethods, "keyboard-interactive") {
|
||||
return KeyboardInteractive(func(name, instruction string, questions []string, echos []bool) ([]string, error) {
|
||||
return []string{"correct-token"}, nil
|
||||
}), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected auth state: allowed=%v partial=%v tried=%v",
|
||||
ctx.AllowedMethods, ctx.PartialSuccessMethods, ctx.TriedMethods)
|
||||
},
|
||||
}
|
||||
|
||||
go NewServerConn(c1, serverConf)
|
||||
|
||||
_, _, _, err = NewClientConn(c2, "", clientConf)
|
||||
if err != nil {
|
||||
t.Fatalf("client login error: %s", err)
|
||||
}
|
||||
if pubkeyAttempts != 1 {
|
||||
t.Errorf("expected publickey to be attempted once before short-circuit, got %d", pubkeyAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCallbackTriedLoopBound(t *testing.T) {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
serverConf := &ServerConfig{
|
||||
MaxAuthTries: -1,
|
||||
PasswordCallback: func(conn ConnMetadata, pass []byte) (*Permissions, error) {
|
||||
return nil, errors.New("password rejected")
|
||||
},
|
||||
}
|
||||
serverConf.AddHostKey(testSigners["rsa"])
|
||||
|
||||
invocations := 0
|
||||
clientConf := &ClientConfig{
|
||||
User: "user",
|
||||
HostKeyCallback: InsecureIgnoreHostKey(),
|
||||
AuthCallback: func(ctx *ClientAuthContext) (AuthMethod, error) {
|
||||
invocations++
|
||||
return Password("wrong"), nil
|
||||
},
|
||||
}
|
||||
|
||||
go NewServerConn(c1, serverConf)
|
||||
|
||||
_, _, _, err = NewClientConn(c2, "", clientConf)
|
||||
if err == nil {
|
||||
t.Fatal("expected the client to abort after exceeding the attempts cap")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "too many authentication attempts") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if invocations != maxAuthClientTried {
|
||||
t.Errorf("AuthCallback invoked %d times; expected %d",
|
||||
invocations, maxAuthClientTried)
|
||||
}
|
||||
}
|
||||
|
||||
func alwaysPartialPubKeyServer() *ServerConfig {
|
||||
var alwaysPartial func(ConnMetadata, PublicKey) (*Permissions, error)
|
||||
alwaysPartial = func(conn ConnMetadata, key PublicKey) (*Permissions, error) {
|
||||
if !bytes.Equal(key.Marshal(), testPublicKeys["rsa"].Marshal()) {
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
return nil, &PartialSuccessError{
|
||||
Next: ServerAuthCallbacks{
|
||||
PublicKeyCallback: alwaysPartial,
|
||||
},
|
||||
}
|
||||
}
|
||||
serverConf := &ServerConfig{
|
||||
MaxAuthTries: -1,
|
||||
PublicKeyCallback: alwaysPartial,
|
||||
}
|
||||
serverConf.AddHostKey(testSigners["rsa"])
|
||||
return serverConf
|
||||
}
|
||||
|
||||
func TestAuthCallbackPartialSuccessLoopBound(t *testing.T) {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
invocations := 0
|
||||
clientConf := &ClientConfig{
|
||||
User: "user",
|
||||
HostKeyCallback: InsecureIgnoreHostKey(),
|
||||
AuthCallback: func(ctx *ClientAuthContext) (AuthMethod, error) {
|
||||
invocations++
|
||||
return PublicKeys(testSigners["rsa"]), nil
|
||||
},
|
||||
}
|
||||
|
||||
go NewServerConn(c1, alwaysPartialPubKeyServer())
|
||||
|
||||
_, _, _, err = NewClientConn(c2, "", clientConf)
|
||||
if err == nil {
|
||||
t.Fatal("expected the client to abort after exceeding the attempts cap")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "too many authentication attempts") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if invocations != maxAuthClientTried {
|
||||
t.Errorf("AuthCallback invoked %d times; expected %d",
|
||||
invocations, maxAuthClientTried)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMethodPassword(t *testing.T) {
|
||||
config := &ClientConfig{
|
||||
User: "testuser",
|
||||
@@ -971,6 +1654,74 @@ func TestAuthMethodGSSAPIWithMIC(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type maliciousGSSAPIWithMICAuthMethod struct{}
|
||||
|
||||
func (maliciousGSSAPIWithMICAuthMethod) method() string {
|
||||
// Reuse an advertised auth method name so the client attempts this
|
||||
// AuthMethod even when gssapi-with-mic is not advertised.
|
||||
return "password"
|
||||
}
|
||||
|
||||
func (maliciousGSSAPIWithMICAuthMethod) auth(session []byte, user string, c packetConn, rand io.Reader, _ map[string][]byte) (authResult, []string, error) {
|
||||
req := &userAuthRequestMsg{
|
||||
User: user,
|
||||
Service: serviceSSH,
|
||||
Method: "gssapi-with-mic",
|
||||
}
|
||||
req.Payload = appendU32(req.Payload, 1)
|
||||
req.Payload = appendString(req.Payload, string(krb5OID))
|
||||
if err := c.writePacket(Marshal(req)); err != nil {
|
||||
return authFailure, nil, err
|
||||
}
|
||||
|
||||
packet, err := c.readPacket()
|
||||
if err != nil {
|
||||
return authFailure, nil, err
|
||||
}
|
||||
switch packet[0] {
|
||||
case msgUserAuthFailure:
|
||||
var msg userAuthFailureMsg
|
||||
if err := Unmarshal(packet, &msg); err != nil {
|
||||
return authFailure, nil, err
|
||||
}
|
||||
if msg.PartialSuccess {
|
||||
return authPartialSuccess, msg.Methods, nil
|
||||
}
|
||||
return authFailure, msg.Methods, nil
|
||||
default:
|
||||
return authFailure, nil, unexpectedMessageError(msgUserAuthFailure, packet[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGSSAPIWithMICPartialConfigRejectedAtRuntime(t *testing.T) {
|
||||
config := &ClientConfig{
|
||||
User: "testuser",
|
||||
Auth: []AuthMethod{
|
||||
maliciousGSSAPIWithMICAuthMethod{},
|
||||
},
|
||||
HostKeyCallback: InsecureIgnoreHostKey(),
|
||||
}
|
||||
|
||||
clientErr, serverErrs := tryAuthBothSides(t, config, &GSSAPIWithMICConfig{
|
||||
Server: &FakeServer{},
|
||||
})
|
||||
|
||||
if clientErr == nil || !strings.Contains(clientErr.Error(), "ssh: unable to authenticate") {
|
||||
t.Fatalf("client got %v, want authentication failure", clientErr)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, err := range serverErrs {
|
||||
if err != nil && strings.Contains(err.Error(), "ssh: gssapi-with-mic auth not configured") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("server auth errors = %v, want gssapi-with-mic auth not configured", serverErrs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatibleAlgoAndSignatures(t *testing.T) {
|
||||
type testcase struct {
|
||||
algo string
|
||||
|
||||
+9
-1
@@ -91,9 +91,17 @@ func DiscardRequests(in <-chan *Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// A connTransport represents the transport for a connection.
|
||||
type connTransport interface {
|
||||
packetConn
|
||||
getAlgorithms() NegotiatedAlgorithms
|
||||
getSessionID() []byte
|
||||
waitSession() error
|
||||
}
|
||||
|
||||
// A connection represents an incoming connection.
|
||||
type connection struct {
|
||||
transport *handshakeTransport
|
||||
transport connTransport
|
||||
sshConn
|
||||
|
||||
// The connection protocol.
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
// Copyright 2026 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"golang.org/x/crypto/cryptobyte"
|
||||
)
|
||||
|
||||
const (
|
||||
muxProtocolVersion = 4
|
||||
|
||||
muxMsgHello = 0x00000001
|
||||
muxCProxy = 0x1000000f
|
||||
muxSProxy = 0x8000000f
|
||||
)
|
||||
|
||||
const controlProxyRequestID = 0
|
||||
|
||||
// handshakeControlProxy attempts to establish a transport connection with an
|
||||
// OpenSSH ControlMaster socket in proxy mode. For details see:
|
||||
// https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.mux
|
||||
func handshakeControlProxy(rw io.ReadWriteCloser) (connTransport, error) {
|
||||
if err := controlProxyWritePacket(rw, func(b *cryptobyte.Builder) {
|
||||
b.AddUint32(muxMsgHello)
|
||||
b.AddUint32(muxProtocolVersion)
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("mux hello write failed: %w", err)
|
||||
}
|
||||
if err := controlProxyWritePacket(rw, func(b *cryptobyte.Builder) {
|
||||
b.AddUint32(muxCProxy)
|
||||
b.AddUint32(controlProxyRequestID)
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("mux client proxy write failed: %w", err)
|
||||
}
|
||||
|
||||
messageType, body, err := controlProxyReadMessage(rw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mux hello read failed: %w", err)
|
||||
}
|
||||
if messageType != muxMsgHello {
|
||||
return nil, fmt.Errorf("expected hello response, got %v", messageType)
|
||||
}
|
||||
var v uint32
|
||||
if !body.ReadUint32(&v) {
|
||||
return nil, errors.New("EOF reading mux protocol version")
|
||||
}
|
||||
if v != muxProtocolVersion {
|
||||
return nil, fmt.Errorf("mux server has unsupported version %v", v)
|
||||
}
|
||||
messageType, body, err = controlProxyReadMessage(rw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mux server proxy read failed: %w", err)
|
||||
}
|
||||
if messageType != muxSProxy {
|
||||
return nil, fmt.Errorf("expected server proxy response, got %v", messageType)
|
||||
}
|
||||
var reqID uint32
|
||||
if !body.ReadUint32(&reqID) {
|
||||
return nil, errors.New("EOF reading request id")
|
||||
}
|
||||
if reqID != controlProxyRequestID {
|
||||
return nil, fmt.Errorf("expected request id %v, got %v", controlProxyRequestID, reqID)
|
||||
}
|
||||
return &controlProxyTransport{rw}, nil
|
||||
}
|
||||
|
||||
// controlProxyTransport implements the connTransport interface for
|
||||
// ControlMaster connections. Each controlMessage has zero length padding and
|
||||
// no MAC.
|
||||
type controlProxyTransport struct {
|
||||
rw io.ReadWriteCloser
|
||||
}
|
||||
|
||||
func (p *controlProxyTransport) Close() error {
|
||||
return p.rw.Close()
|
||||
}
|
||||
|
||||
func (p *controlProxyTransport) writePacket(controlMessage []byte) error {
|
||||
return controlProxyWritePacket(p.rw, func(b *cryptobyte.Builder) {
|
||||
b.AddUint8(0) // Padding length.
|
||||
b.AddBytes(controlMessage)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *controlProxyTransport) readPacket() ([]byte, error) {
|
||||
buf, err := controlProxyReadPacket(p.rw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ssh: error reading control message: %w", err)
|
||||
}
|
||||
// Discard the padding length.
|
||||
if len(buf) < 1 {
|
||||
return nil, errors.New("ssh: EOF reading padding length")
|
||||
}
|
||||
if buf[0] != 0 {
|
||||
return nil, errors.New("ssh: unexpected non-zero padding in control message")
|
||||
}
|
||||
return buf[1:], nil
|
||||
}
|
||||
|
||||
func (p *controlProxyTransport) getAlgorithms() NegotiatedAlgorithms {
|
||||
return NegotiatedAlgorithms{}
|
||||
}
|
||||
|
||||
func (p *controlProxyTransport) getSessionID() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *controlProxyTransport) waitSession() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func controlProxyWritePacket(w io.Writer, f cryptobyte.BuilderContinuation) error {
|
||||
var buf []byte
|
||||
b := cryptobyte.NewBuilder(buf)
|
||||
b.AddUint32LengthPrefixed(f)
|
||||
out, err := b.Bytes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(out)
|
||||
return err
|
||||
}
|
||||
|
||||
func controlProxyReadPacket(r io.Reader) (cryptobyte.String, error) {
|
||||
var l uint32
|
||||
if err := binary.Read(r, binary.BigEndian, &l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if l > maxPacket {
|
||||
return nil, fmt.Errorf("message length %v exceeds maximum %v", l, maxPacket)
|
||||
}
|
||||
buf := make([]byte, l)
|
||||
if _, err := io.ReadFull(r, buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func controlProxyReadMessage(r io.Reader) (messageType uint32, body cryptobyte.String, err error) {
|
||||
body, err = controlProxyReadPacket(r)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("error reading message body: %w", err)
|
||||
}
|
||||
if !body.ReadUint32(&messageType) {
|
||||
return 0, nil, errors.New("EOF reading message type")
|
||||
}
|
||||
return messageType, body, nil
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// Copyright 2026 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestControlClientHandshake(t *testing.T) {
|
||||
reqs := [][]byte{
|
||||
// Hello request.
|
||||
{0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04},
|
||||
// Client proxy request.
|
||||
{0x00, 0x00, 0x00, 0x08, 0x10, 0x00, 0x00, 0x0f, 0x00, 0x00, 0x00, 0x00},
|
||||
}
|
||||
respsNormal := [][]byte{
|
||||
// Hello response.
|
||||
{0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04},
|
||||
// Server proxy response.
|
||||
{0x00, 0x00, 0x00, 0x08, 0x80, 0x00, 0x00, 0x0f, 0x00, 0x00, 0x00, 0x00},
|
||||
}
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
resps [][]byte
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "normal handshake",
|
||||
resps: respsNormal,
|
||||
},
|
||||
{
|
||||
name: "length greater than max",
|
||||
resps: [][]byte{
|
||||
{0xff, 0xff, 0xff, 0xff},
|
||||
respsNormal[1],
|
||||
},
|
||||
expectedErr: "message length 4294967295 exceeds maximum",
|
||||
},
|
||||
{
|
||||
name: "missing hello response",
|
||||
resps: [][]byte{
|
||||
{},
|
||||
},
|
||||
expectedErr: "use of closed network connection",
|
||||
},
|
||||
{
|
||||
name: "hello response too short",
|
||||
resps: [][]byte{
|
||||
{0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00},
|
||||
respsNormal[1],
|
||||
},
|
||||
expectedErr: "EOF",
|
||||
},
|
||||
{
|
||||
name: "bad hello response type",
|
||||
resps: [][]byte{
|
||||
{0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04},
|
||||
respsNormal[1],
|
||||
},
|
||||
expectedErr: "expected hello response, got 0",
|
||||
},
|
||||
{
|
||||
name: "bad protocol version",
|
||||
resps: [][]byte{
|
||||
{0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00},
|
||||
respsNormal[1],
|
||||
},
|
||||
expectedErr: "mux server has unsupported version 0",
|
||||
},
|
||||
{
|
||||
name: "missing server proxy response",
|
||||
resps: [][]byte{
|
||||
respsNormal[0],
|
||||
},
|
||||
expectedErr: "use of closed network connection",
|
||||
},
|
||||
{
|
||||
name: "server proxy response too short",
|
||||
resps: [][]byte{
|
||||
respsNormal[0],
|
||||
{0x00, 0x00, 0x00, 0x06, 0x80, 0x00, 0x00, 0x0f, 0x00, 0x00},
|
||||
},
|
||||
expectedErr: "EOF",
|
||||
},
|
||||
{
|
||||
name: "bad server proxy response type",
|
||||
resps: [][]byte{
|
||||
respsNormal[0],
|
||||
{0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
|
||||
},
|
||||
expectedErr: "expected server proxy response, got 0",
|
||||
},
|
||||
{
|
||||
name: "bad request id",
|
||||
resps: [][]byte{
|
||||
respsNormal[0],
|
||||
{0x00, 0x00, 0x00, 0x08, 0x80, 0x00, 0x00, 0x0f, 0x00, 0x00, 0x00, 0x01},
|
||||
},
|
||||
expectedErr: "expected request id 0, got 1",
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
done := make(chan error, 1)
|
||||
|
||||
ok := func() bool {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
_, _, _, err := NewControlClientConn(c2)
|
||||
c2.Write([]byte{0}) // Dummy message to unblock the final read.
|
||||
done <- err
|
||||
}()
|
||||
|
||||
i := 0
|
||||
for ; i < len(reqs) && i < len(tt.resps); i++ {
|
||||
expected := reqs[i]
|
||||
buf := make([]byte, len(expected))
|
||||
if _, err := io.ReadFull(c1, buf); err != nil {
|
||||
t.Errorf("error reading message %d: %v", i+1, err)
|
||||
return false
|
||||
}
|
||||
if !bytes.Equal(buf, expected) {
|
||||
t.Errorf(
|
||||
"unexpected message %d: got %v, want %v",
|
||||
i+1, buf, expected,
|
||||
)
|
||||
return false
|
||||
}
|
||||
_, err = c1.Write(tt.resps[i])
|
||||
if err != nil {
|
||||
t.Errorf("error writing message %d: %v", i+1, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Wait for the next message so that the final response can be read.
|
||||
buf := make([]byte, 1)
|
||||
c1.Read(buf)
|
||||
return true
|
||||
}()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
err := <-done
|
||||
if tt.expectedErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.expectedErr) {
|
||||
t.Fatalf("got err %q; want err containing %q", err, tt.expectedErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("got err %q; want no err", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlClientTransport(t *testing.T) {
|
||||
type response struct {
|
||||
status bool
|
||||
payload []byte
|
||||
err error
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
resp []byte
|
||||
respStatus bool
|
||||
respPayload []byte
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "successful request",
|
||||
resp: []byte{0x00, 0x00, 0x00, 0x02, 0x00, 0x51},
|
||||
respStatus: true,
|
||||
},
|
||||
{
|
||||
name: "failed request",
|
||||
resp: []byte{0x00, 0x00, 0x00, 0x02, 0x00, 0x52},
|
||||
respStatus: false,
|
||||
},
|
||||
{
|
||||
name: "short response",
|
||||
resp: []byte{0x00, 0x00, 0x00, 0x00},
|
||||
expectedErr: "EOF",
|
||||
},
|
||||
{
|
||||
name: "response with payload",
|
||||
resp: []byte{0x00, 0x00, 0x00, 0x05, 0x00, 0x51, 0x01, 0x02, 0x03},
|
||||
respStatus: true,
|
||||
respPayload: []byte{1, 2, 3},
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
// Handshake responses.
|
||||
c1.Write([]byte{
|
||||
0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04,
|
||||
0x00, 0x00, 0x00, 0x08, 0x80, 0x00, 0x00, 0x0f, 0x00, 0x00, 0x00, 0x00,
|
||||
})
|
||||
|
||||
conn, chans, reqs, err := NewControlClientConn(c2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := NewClient(conn, chans, reqs)
|
||||
|
||||
done := make(chan response, 1)
|
||||
go func() {
|
||||
defer close(done)
|
||||
status, payload, err := client.SendRequest("hello", true, nil)
|
||||
if err != nil {
|
||||
done <- response{err: err}
|
||||
return
|
||||
}
|
||||
done <- response{
|
||||
status: status,
|
||||
payload: payload,
|
||||
}
|
||||
}()
|
||||
|
||||
// Discard handshake.
|
||||
io.CopyN(io.Discard, c1, 24)
|
||||
|
||||
expectedReq := []byte{
|
||||
0x00, 0x00, 0x00, 0x0c, 0x00, 0x50,
|
||||
0x00, 0x00, 0x00, 0x05, 'h', 'e', 'l', 'l', 'o',
|
||||
0x01,
|
||||
}
|
||||
buf := make([]byte, len(expectedReq))
|
||||
if _, err := io.ReadFull(c1, buf); err != nil {
|
||||
t.Fatalf("reading request: %v", err)
|
||||
}
|
||||
if !bytes.Equal(buf, expectedReq) {
|
||||
t.Fatalf("got request %v; want %v", buf, expectedReq)
|
||||
}
|
||||
|
||||
c1.Write(tt.resp)
|
||||
|
||||
resp := <-done
|
||||
if tt.expectedErr != "" {
|
||||
if resp.err == nil || !strings.Contains(resp.err.Error(), tt.expectedErr) {
|
||||
t.Fatalf("got err %q; want err containing %q", resp.err, tt.expectedErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if resp.err != nil {
|
||||
t.Fatalf("got err %q; want no err", resp.err)
|
||||
}
|
||||
if resp.status != tt.respStatus {
|
||||
t.Fatalf("got status %v; want %v", resp.status, tt.respStatus)
|
||||
}
|
||||
if !bytes.Equal(resp.payload, tt.respPayload) {
|
||||
t.Errorf("got payload %v; want %v", resp.payload, tt.respPayload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+66
-9
@@ -16,6 +16,7 @@ import (
|
||||
"io"
|
||||
"math/big"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/curve25519"
|
||||
)
|
||||
@@ -718,15 +719,9 @@ func (gex *dhGEXSHA) Server(c packetConn, randSource io.Reader, magics *handshak
|
||||
kexDHGexRequest.MaxBits, kexDHGexRequest.PreferredBits)
|
||||
}
|
||||
|
||||
var p *big.Int
|
||||
// We hardcode sending Oakley Group 14 (2048 bits), Oakley Group 15 (3072
|
||||
// bits) or Oakley Group 16 (4096 bits), based on the requested max size.
|
||||
if kexDHGexRequest.MaxBits < 3072 {
|
||||
p, _ = new(big.Int).SetString(oakleyGroup14, 16)
|
||||
} else if kexDHGexRequest.MaxBits < 4096 {
|
||||
p, _ = new(big.Int).SetString(oakleyGroup15, 16)
|
||||
} else {
|
||||
p, _ = new(big.Int).SetString(oakleyGroup16, 16)
|
||||
p, err := chooseDH(kexDHGexRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
g := big.NewInt(2)
|
||||
@@ -805,3 +800,65 @@ func (gex *dhGEXSHA) Server(c packetConn, randSource io.Reader, magics *handshak
|
||||
Hash: gex.hashFunc,
|
||||
}, err
|
||||
}
|
||||
|
||||
type dhKEXGroup struct {
|
||||
size int
|
||||
p *big.Int
|
||||
}
|
||||
|
||||
// supportedDHKEXGroups returns the DH groups the server is willing to offer
|
||||
// for diffie-hellman-group-exchange-* key exchanges. The list is built lazily
|
||||
// on first use to keep the hex-to-big.Int parse out of package initialization.
|
||||
var supportedDHKEXGroups = sync.OnceValue(func() []dhKEXGroup {
|
||||
specs := []struct {
|
||||
size int
|
||||
hex string
|
||||
}{
|
||||
{2048, oakleyGroup14},
|
||||
{3072, oakleyGroup15},
|
||||
{4096, oakleyGroup16},
|
||||
}
|
||||
out := make([]dhKEXGroup, 0, len(specs))
|
||||
for _, s := range specs {
|
||||
p, _ := new(big.Int).SetString(s.hex, 16)
|
||||
out = append(out, dhKEXGroup{size: s.size, p: p})
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
// chooseDH picks a DH group for the given client request, mirroring the
|
||||
// algorithm used by OpenSSH's choose_dh in dh.c: prefer the smallest known
|
||||
// group larger than or equal to the client's PreferredBits, and otherwise pick
|
||||
// the largest group within the accepted [MinBits, MaxBits] range.
|
||||
func chooseDH(req kexDHGexRequestMsg) (*big.Int, error) {
|
||||
var best *big.Int
|
||||
bestSize := 0
|
||||
wantBits := int(req.PreferredBits)
|
||||
|
||||
for _, group := range supportedDHKEXGroups() {
|
||||
if uint32(group.size) < req.MinBits || uint32(group.size) > req.MaxBits {
|
||||
continue
|
||||
}
|
||||
|
||||
if bestSize == 0 {
|
||||
best = group.p
|
||||
bestSize = group.size
|
||||
continue
|
||||
}
|
||||
|
||||
closerFromAbove := group.size >= wantBits && group.size < bestSize
|
||||
closerFromBelow := group.size > bestSize && bestSize < wantBits
|
||||
|
||||
if closerFromAbove || closerFromBelow {
|
||||
best = group.p
|
||||
bestSize = group.size
|
||||
}
|
||||
}
|
||||
|
||||
if bestSize == 0 {
|
||||
return nil, fmt.Errorf("ssh: no suitable DH group found for request min: %d, preferred: %d, max: %d",
|
||||
req.MinBits, req.PreferredBits, req.MaxBits)
|
||||
}
|
||||
|
||||
return best, nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ package ssh
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -65,6 +66,88 @@ func TestKexes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChooseDH(t *testing.T) {
|
||||
oakley := map[int]string{
|
||||
2048: oakleyGroup14,
|
||||
3072: oakleyGroup15,
|
||||
4096: oakleyGroup16,
|
||||
}
|
||||
expected := func(size int) *big.Int {
|
||||
hex, ok := oakley[size]
|
||||
if !ok {
|
||||
t.Fatalf("test setup: no Oakley group for size %d", size)
|
||||
}
|
||||
p, _ := new(big.Int).SetString(hex, 16)
|
||||
return p
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
request kexDHGexRequestMsg
|
||||
want int // expected bit size; 0 means error expected
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Standard 2048 request",
|
||||
request: kexDHGexRequestMsg{MinBits: 1024, PreferredBits: 2048, MaxBits: 8192},
|
||||
want: 2048,
|
||||
},
|
||||
{
|
||||
name: "Standard 3072 request",
|
||||
request: kexDHGexRequestMsg{MinBits: 1024, PreferredBits: 3072, MaxBits: 8192},
|
||||
want: 3072,
|
||||
},
|
||||
{
|
||||
name: "Standard 4096 request",
|
||||
request: kexDHGexRequestMsg{MinBits: 1024, PreferredBits: 4096, MaxBits: 8192},
|
||||
want: 4096,
|
||||
},
|
||||
{
|
||||
name: "Preferred 2500 -> Expect 3072 (round up)",
|
||||
request: kexDHGexRequestMsg{MinBits: 1024, PreferredBits: 2500, MaxBits: 8192},
|
||||
want: 3072,
|
||||
},
|
||||
{
|
||||
name: "Preferred 3500 -> Expect 4096 (round up)",
|
||||
request: kexDHGexRequestMsg{MinBits: 1024, PreferredBits: 3500, MaxBits: 8192},
|
||||
want: 4096,
|
||||
},
|
||||
{
|
||||
name: "Preferred too high (8000) -> Expect 4096 (cap at max available)",
|
||||
request: kexDHGexRequestMsg{MinBits: 1024, PreferredBits: 8000, MaxBits: 8192},
|
||||
want: 4096,
|
||||
},
|
||||
{
|
||||
name: "No group in range",
|
||||
request: kexDHGexRequestMsg{MinBits: 2500, PreferredBits: 2500, MaxBits: 2900},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := chooseDH(tt.request)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("chooseDH() error = %v, wantErr %t", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("chooseDH() returned nil big.Int but expected success")
|
||||
}
|
||||
if got.BitLen() != tt.want {
|
||||
t.Errorf("chooseDH() got size = %d, want %d", got.BitLen(), tt.want)
|
||||
}
|
||||
if want := expected(tt.want); got.Cmp(want) != 0 {
|
||||
t.Errorf("chooseDH() returned the wrong group for size %d", tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkKexes(b *testing.B) {
|
||||
type kexResultErr struct {
|
||||
result *kexResult
|
||||
|
||||
+38
-3
@@ -76,7 +76,7 @@ func parsePubKey(in []byte, algo string) (pubKey PublicKey, rest []byte, err err
|
||||
case InsecureKeyAlgoDSA:
|
||||
return parseDSA(in)
|
||||
case KeyAlgoECDSA256, KeyAlgoECDSA384, KeyAlgoECDSA521:
|
||||
return parseECDSA(in)
|
||||
return parseECDSA(in, algo)
|
||||
case KeyAlgoSKECDSA256:
|
||||
return parseSKECDSA(in)
|
||||
case KeyAlgoED25519:
|
||||
@@ -806,7 +806,7 @@ func supportedEllipticCurve(curve elliptic.Curve) bool {
|
||||
}
|
||||
|
||||
// parseECDSA parses an ECDSA key according to RFC 5656, section 3.1.
|
||||
func parseECDSA(in []byte) (out PublicKey, rest []byte, err error) {
|
||||
func parseECDSA(in []byte, expectedType string) (out PublicKey, rest []byte, err error) {
|
||||
var w struct {
|
||||
Curve string
|
||||
KeyBytes []byte
|
||||
@@ -817,6 +817,12 @@ func parseECDSA(in []byte) (out PublicKey, rest []byte, err error) {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
actualType := "ecdsa-sha2-" + w.Curve
|
||||
if expectedType != actualType {
|
||||
return nil, nil, fmt.Errorf("ssh: algorithm type mismatch: expected %q, found curve %q (type %q)",
|
||||
expectedType, w.Curve, actualType)
|
||||
}
|
||||
|
||||
key := new(ecdsa.PublicKey)
|
||||
|
||||
switch w.Curve {
|
||||
@@ -1466,6 +1472,17 @@ func passphraseProtectedOpenSSHKey(passphrase []byte) openSSHDecryptFunc {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// OpenSSH does not impose an upper bound on the bcrypt round count
|
||||
// stored in the key file, but bcrypt_pbkdf cost is linear in rounds:
|
||||
// the default is 16, ssh-keygen lets users pick anything up to
|
||||
// INT_MAX. Cap at 2048 (128x the default, a few seconds of CPU) so
|
||||
// that an oversized value in the file cannot tie up the caller for
|
||||
// months.
|
||||
const maxRounds = 1 << 11
|
||||
if opts.Rounds > maxRounds {
|
||||
return nil, fmt.Errorf("ssh: bcrypt KDF rounds %d exceed maximum %d", opts.Rounds, maxRounds)
|
||||
}
|
||||
|
||||
k, err := bcrypt_pbkdf.Key(passphrase, []byte(opts.Salt), int(opts.Rounds), 32+16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1635,10 +1652,28 @@ func parseOpenSSHPrivateKey(key []byte, decrypt openSSHDecryptFunc) (crypto.Priv
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Mirror the validation done in parseRSA for public keys: cap the
|
||||
// modulus at the same limit enforced by crypto/tls, reject oversized
|
||||
// or invalid exponents, and additionally bound the prime factors to
|
||||
// avoid the expensive CRT coefficient recomputation in pk.Precompute.
|
||||
if key.N.BitLen() > 8192 {
|
||||
return nil, errors.New("ssh: rsa modulus too large")
|
||||
}
|
||||
if key.P.BitLen() > 4096 || key.Q.BitLen() > 4096 {
|
||||
return nil, errors.New("ssh: rsa prime too large")
|
||||
}
|
||||
if key.E.BitLen() > 24 {
|
||||
return nil, errors.New("ssh: exponent too large")
|
||||
}
|
||||
e := key.E.Int64()
|
||||
if e < 3 || e&1 == 0 {
|
||||
return nil, errors.New("ssh: incorrect exponent")
|
||||
}
|
||||
|
||||
pk := &rsa.PrivateKey{
|
||||
PublicKey: rsa.PublicKey{
|
||||
N: key.N,
|
||||
E: int(key.E.Int64()),
|
||||
E: int(e),
|
||||
},
|
||||
D: key.D,
|
||||
Primes: []*big.Int{key.P, key.Q},
|
||||
|
||||
@@ -232,6 +232,200 @@ func TestParseRSAModulusTooLarge(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrivateKeyRSAModulusTooLarge(t *testing.T) {
|
||||
rsa16384 := []byte(`-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
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
|
||||
QHAxNnM=
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
`)
|
||||
_, err := ParseRawPrivateKey(rsa16384)
|
||||
if err == nil {
|
||||
t.Fatal("ParseRawPrivateKey accepted a 16384-bit modulus; expected it to be rejected")
|
||||
}
|
||||
|
||||
expectedError := "rsa modulus too large"
|
||||
if !strings.Contains(err.Error(), expectedError) {
|
||||
t.Errorf("unexpected error message: got %q, want substring %q", err.Error(), expectedError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseECPrivateKey(t *testing.T) {
|
||||
key := testPrivateKeys["ecdsa"]
|
||||
|
||||
@@ -312,6 +506,38 @@ func TestParseEncryptedPrivateKeysWithIncorrectPassphrase(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEncryptedPrivateKeyExcessiveBcryptRounds(t *testing.T) {
|
||||
// Craft a minimal openssh-key-v1 blob whose KdfOpts declares a bcrypt
|
||||
// round count above the accepted maximum. The check must reject the file
|
||||
// before bcrypt_pbkdf is invoked, so the rest of the blob (public key,
|
||||
// encrypted body) can be empty.
|
||||
kdfOpts := Marshal(struct {
|
||||
Salt []byte
|
||||
Rounds uint32
|
||||
}{
|
||||
Salt: []byte("salt-not-used"),
|
||||
Rounds: (1 << 11) + 1,
|
||||
})
|
||||
header := Marshal(openSSHEncryptedPrivateKey{
|
||||
CipherName: "aes256-ctr",
|
||||
KdfName: "bcrypt",
|
||||
KdfOpts: string(kdfOpts),
|
||||
NumKeys: 1,
|
||||
})
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "OPENSSH PRIVATE KEY",
|
||||
Bytes: append([]byte(privateKeyAuthMagic), header...),
|
||||
})
|
||||
|
||||
_, err := ParseRawPrivateKeyWithPassphrase(pemBytes, []byte("password"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for excessive bcrypt rounds, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "bcrypt KDF rounds") {
|
||||
t.Errorf("got error %q, want substring %q", err.Error(), "bcrypt KDF rounds")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDSA(t *testing.T) {
|
||||
// We actually exercise the ParsePrivateKey codepath here, as opposed to
|
||||
// using the ParseRawPrivateKey+NewSignerFromKey path that testdata_test.go
|
||||
@@ -1235,3 +1461,39 @@ cLYUOHfQDw==
|
||||
t.Fatal("parsing an SSH certificate using another certificate as signature key succeeded; expected failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseECDSAAlgorithmMismatch(t *testing.T) {
|
||||
cases := []struct {
|
||||
keyName string // key fixture in testPublicKeys
|
||||
nativeAlgo string // algorithm actually carried in the key blob
|
||||
askedAlgo string // algorithm passed to parsePubKey
|
||||
}{
|
||||
{"ecdsap256", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384"},
|
||||
{"ecdsap256", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp521"},
|
||||
{"ecdsap384", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp256"},
|
||||
{"ecdsap384", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521"},
|
||||
{"ecdsap521", "ecdsa-sha2-nistp521", "ecdsa-sha2-nistp256"},
|
||||
{"ecdsap521", "ecdsa-sha2-nistp521", "ecdsa-sha2-nistp384"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.nativeAlgo+"_as_"+tc.askedAlgo, func(t *testing.T) {
|
||||
pubKey := testPublicKeys[tc.keyName]
|
||||
algo, in, ok := parseString(pubKey.Marshal())
|
||||
if !ok {
|
||||
t.Fatal("unable to parse public key wire format")
|
||||
}
|
||||
if string(algo) != tc.nativeAlgo {
|
||||
t.Fatalf("test setup failed: expected %q, got %q", tc.nativeAlgo, algo)
|
||||
}
|
||||
|
||||
_, _, err := parsePubKey(in, tc.askedAlgo)
|
||||
if err == nil {
|
||||
t.Fatal("expected error due to algorithm mismatch, but got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "algorithm type mismatch") {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ func nextWord(line []byte) (string, []byte) {
|
||||
return string(line), nil
|
||||
}
|
||||
|
||||
return string(line[:i]), bytes.TrimSpace(line[i:])
|
||||
return string(line[:i]), trimSpace(line[i:])
|
||||
}
|
||||
|
||||
func parseLine(line []byte) (marker, host string, key ssh.PublicKey, err error) {
|
||||
@@ -188,12 +188,17 @@ func parseLine(line []byte) (marker, host string, key ssh.PublicKey, err error)
|
||||
}
|
||||
|
||||
host, line = nextWord(line)
|
||||
// If the extracted 'host' starts with '@', it means we either encountered
|
||||
// a second marker (e.g., "@cert-authority @revoked") or an unknown marker
|
||||
// (e.g., "@unknown"). Both are invalid.
|
||||
if len(host) > 0 && host[0] == '@' {
|
||||
return "", "", nil, fmt.Errorf("knownhosts: unexpected marker: %q", host)
|
||||
}
|
||||
if len(line) == 0 {
|
||||
return "", "", nil, errors.New("knownhosts: missing host pattern")
|
||||
}
|
||||
|
||||
// ignore the keytype as it's in the key blob anyway.
|
||||
_, line = nextWord(line)
|
||||
wantType, line := nextWord(line)
|
||||
if len(line) == 0 {
|
||||
return "", "", nil, errors.New("knownhosts: missing key type pattern")
|
||||
}
|
||||
@@ -209,6 +214,10 @@ func parseLine(line []byte) (marker, host string, key ssh.PublicKey, err error)
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
if key.Type() != wantType {
|
||||
return "", "", nil, fmt.Errorf("knownhosts: key type mismatch: found %q, want %q", key.Type(), wantType)
|
||||
}
|
||||
|
||||
return marker, host, key, nil
|
||||
}
|
||||
|
||||
@@ -387,7 +396,7 @@ func (db *hostKeyDB) Read(r io.Reader, filename string) error {
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := scanner.Bytes()
|
||||
line = bytes.TrimSpace(line)
|
||||
line = trimSpace(line)
|
||||
if len(line) == 0 || line[0] == '#' {
|
||||
continue
|
||||
}
|
||||
@@ -535,3 +544,10 @@ func newHashedHost(encoded string) (*hashedHost, error) {
|
||||
func (h *hashedHost) match(a addr) bool {
|
||||
return bytes.Equal(hashHost(Normalize(a.String()), h.salt), h.hash)
|
||||
}
|
||||
|
||||
// trimSpace removes leading and trailing ASCII whitespace (space and tab). It
|
||||
// is used instead of bytes.TrimSpace to match OpenSSH behavior, which strictly
|
||||
// parses only ASCII space (0x20) and tab (0x09) as whitespace.
|
||||
func trimSpace(in []byte) []byte {
|
||||
return bytes.Trim(in, " \t")
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
@@ -407,3 +409,94 @@ func TestIssue36126(t *testing.T) {
|
||||
t.Errorf("should have passed the check, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnicodeSpace(t *testing.T) {
|
||||
line := fmt.Sprintf("server.org %s %s", edKey.Type(), base64.StdEncoding.EncodeToString(edKey.Marshal()))
|
||||
|
||||
db := newHostKeyDB()
|
||||
err := db.Read(bytes.NewBufferString(line), "testdb")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Read succeeded on line with Unicode space, expected error due to strict ASCII parsing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnicodeSpaceTrimming(t *testing.T) {
|
||||
const unicodeSpace = " "
|
||||
line := fmt.Sprintf("%sserver.org %s", unicodeSpace, edKeyStr)
|
||||
|
||||
db := newHostKeyDB()
|
||||
err := db.Read(bytes.NewBufferString(line), "testdb")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Read failed: %v", err)
|
||||
}
|
||||
|
||||
lines := db.lines
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("Expected 1 line, got %d", len(lines))
|
||||
}
|
||||
|
||||
cleanAddr := addr{host: "server.org", port: "22"}
|
||||
dirtyAddr := addr{host: unicodeSpace + "server.org", port: "22"}
|
||||
|
||||
if lines[0].match(cleanAddr) {
|
||||
t.Errorf("Matched clean host 'server.org', implying Unicode space was trimmed")
|
||||
}
|
||||
if !lines[0].match(dirtyAddr) {
|
||||
t.Errorf("Did not match dirty host, implying parsing issue")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyTypeMismatch(t *testing.T) {
|
||||
line := fmt.Sprintf("server.org ssh-rsa %s", base64.StdEncoding.EncodeToString(edKey.Marshal()))
|
||||
|
||||
db := newHostKeyDB()
|
||||
err := db.Read(bytes.NewBufferString(line), "testdb")
|
||||
if err == nil {
|
||||
t.Fatal("Read succeeded on line with key type mismatch, expected error")
|
||||
}
|
||||
|
||||
expectedErr := `knownhosts: key type mismatch: found "ssh-ed25519", want "ssh-rsa"`
|
||||
if !strings.Contains(err.Error(), expectedErr) {
|
||||
t.Fatalf("got error %q, want to contain %q", err.Error(), expectedErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleMarkers(t *testing.T) {
|
||||
lineDouble := fmt.Sprintf("@cert-authority @revoked server.org %s %s", edKey.Type(), base64.StdEncoding.EncodeToString(edKey.Marshal()))
|
||||
db := newHostKeyDB()
|
||||
err := db.Read(bytes.NewBufferString(lineDouble), "testdb")
|
||||
if err == nil {
|
||||
t.Fatal("Read succeeded on line with multiple markers, expected error")
|
||||
}
|
||||
expectedError := "unexpected marker"
|
||||
if !strings.Contains(err.Error(), expectedError) {
|
||||
t.Errorf("got error %q, want it to contain %q", err.Error(), expectedError)
|
||||
}
|
||||
|
||||
lineUnknown := fmt.Sprintf("@unknown-marker server.org %s %s", edKey.Type(), base64.StdEncoding.EncodeToString(edKey.Marshal()))
|
||||
err = db.Read(bytes.NewBufferString(lineUnknown), "testdb")
|
||||
if err == nil {
|
||||
t.Fatal("Read succeeded on line with unknown marker, expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), expectedError) {
|
||||
t.Errorf("got error %q, want it to contain %q", err.Error(), expectedError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownMarker(t *testing.T) {
|
||||
line := fmt.Sprintf("@unknown-marker server.org %s %s", edKey.Type(), base64.StdEncoding.EncodeToString(edKey.Marshal()))
|
||||
|
||||
db := newHostKeyDB()
|
||||
err := db.Read(bytes.NewBufferString(line), "testdb")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Read succeeded on line with unknown marker, expected error")
|
||||
}
|
||||
|
||||
expectedError := "unexpected marker"
|
||||
if !strings.Contains(err.Error(), expectedError) {
|
||||
t.Errorf("got error %q, want it to contain %q", err.Error(), expectedError)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -155,7 +155,10 @@ func (m *mux) SendRequest(name string, wantReply bool, payload []byte) (bool, []
|
||||
drain:
|
||||
for {
|
||||
select {
|
||||
case <-m.globalResponses:
|
||||
case _, ok := <-m.globalResponses:
|
||||
if !ok {
|
||||
break drain
|
||||
}
|
||||
default:
|
||||
break drain
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func muxPair() (*mux, *mux) {
|
||||
@@ -1366,3 +1367,79 @@ func TestChannelResponseAcceptedWhilePending(t *testing.T) {
|
||||
writer.Close()
|
||||
<-serverDone
|
||||
}
|
||||
|
||||
func TestChannelSendRequestAfterCloseNoSpinloop(t *testing.T) {
|
||||
writer, reader, mux := channelPair(t)
|
||||
defer writer.Close()
|
||||
defer mux.Close()
|
||||
|
||||
// Close the channel, this closes reader.msg.
|
||||
if err := reader.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
// Wait for reader.msg to actually be closed by the mux loop.
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-reader.msg:
|
||||
if !ok {
|
||||
goto closed
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("reader.msg was not closed")
|
||||
}
|
||||
}
|
||||
closed:
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := reader.SendRequest("ping", true, nil)
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("SendRequest after close: got %v, want io.EOF", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("SendRequest spinloop on closed ch.msg")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMuxSendRequestAfterCloseNoSpinloop(t *testing.T) {
|
||||
a, b := muxPair()
|
||||
defer a.Close()
|
||||
|
||||
// Closing the underlying conn causes mux.loop to exit and close
|
||||
// b.globalResponses.
|
||||
if err := b.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
// Wait for globalResponses to actually be closed by loop().
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-b.globalResponses:
|
||||
if !ok {
|
||||
goto closed
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("globalResponses was not closed")
|
||||
}
|
||||
}
|
||||
closed:
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, _, err := b.SendRequest("ping", true, nil)
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("SendRequest after close: got nil error")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("SendRequest spinloop on closed globalResponses")
|
||||
}
|
||||
}
|
||||
|
||||
+33
-5
@@ -54,6 +54,9 @@ type Permissions struct {
|
||||
ExtraData map[any]any
|
||||
}
|
||||
|
||||
// GSSAPIWithMICConfig includes the server callbacks for gssapi-with-mic
|
||||
// authentication. If either field is nil, gssapi-with-mic is considered not
|
||||
// configured.
|
||||
type GSSAPIWithMICConfig struct {
|
||||
// AllowLogin, must be set, is called when gssapi-with-mic
|
||||
// authentication is selected (RFC 4462 section 3). The srcName is from the
|
||||
@@ -68,6 +71,10 @@ type GSSAPIWithMICConfig struct {
|
||||
Server GSSAPIServer
|
||||
}
|
||||
|
||||
func gssapiWithMICConfigured(config *GSSAPIWithMICConfig) bool {
|
||||
return config != nil && config.AllowLogin != nil && config.Server != nil
|
||||
}
|
||||
|
||||
// SendAuthBanner implements [ServerPreAuthConn].
|
||||
func (s *connection) SendAuthBanner(msg string) error {
|
||||
return s.transport.writePacket(Marshal(&userAuthBannerMsg{
|
||||
@@ -382,8 +389,7 @@ func (s *connection) serverHandshake(config *ServerConfig) (*Permissions, error)
|
||||
}
|
||||
|
||||
if !config.NoClientAuth && config.PasswordCallback == nil && config.PublicKeyCallback == nil &&
|
||||
config.KeyboardInteractiveCallback == nil && (config.GSSAPIWithMICConfig == nil ||
|
||||
config.GSSAPIWithMICConfig.AllowLogin == nil || config.GSSAPIWithMICConfig.Server == nil) {
|
||||
config.KeyboardInteractiveCallback == nil && !gssapiWithMICConfigured(config.GSSAPIWithMICConfig) {
|
||||
return nil, errors.New("ssh: no authentication methods configured but NoClientAuth is also false")
|
||||
}
|
||||
|
||||
@@ -607,6 +613,15 @@ func (b *BannerError) Error() string {
|
||||
return b.Err.Error()
|
||||
}
|
||||
|
||||
// maxAuthServerAttempts caps the total number of SSH_MSG_USERAUTH_REQUEST
|
||||
// messages the server will process on a single connection, regardless of
|
||||
// outcome (failure, partial success, public key query, or none). It is a
|
||||
// backstop against clients that drive the authentication loop indefinitely
|
||||
// without ever incurring a real failure — for example by repeatedly
|
||||
// triggering PartialSuccessError or by spamming public key offer queries —
|
||||
// neither of which increment the MaxAuthTries failure counter.
|
||||
const maxAuthServerAttempts = 128
|
||||
|
||||
func (s *connection) serverAuthenticate(config *ServerConfig) (*Permissions, error) {
|
||||
if config.PreAuthConnCallback != nil {
|
||||
config.PreAuthConnCallback(s)
|
||||
@@ -617,6 +632,7 @@ func (s *connection) serverAuthenticate(config *ServerConfig) (*Permissions, err
|
||||
var perms *Permissions
|
||||
|
||||
authFailures := 0
|
||||
authAttempts := 0
|
||||
noneAuthCount := 0
|
||||
var authErrs []error
|
||||
var calledBannerCallback bool
|
||||
@@ -645,6 +661,19 @@ userAuthLoop:
|
||||
return nil, &ServerAuthError{Errors: authErrs}
|
||||
}
|
||||
|
||||
if authAttempts >= maxAuthServerAttempts {
|
||||
discMsg := &disconnectMsg{
|
||||
Reason: 2,
|
||||
Message: "too many authentication attempts",
|
||||
}
|
||||
if err := s.transport.writePacket(Marshal(discMsg)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authErrs = append(authErrs, discMsg)
|
||||
return nil, &ServerAuthError{Errors: authErrs}
|
||||
}
|
||||
authAttempts++
|
||||
|
||||
var userAuthReq userAuthRequestMsg
|
||||
if packet, err := s.transport.readPacket(); err != nil {
|
||||
if err == io.EOF {
|
||||
@@ -846,7 +875,7 @@ userAuthLoop:
|
||||
}
|
||||
}
|
||||
case "gssapi-with-mic":
|
||||
if authConfig.GSSAPIWithMICConfig == nil {
|
||||
if !gssapiWithMICConfigured(authConfig.GSSAPIWithMICConfig) {
|
||||
authErr = errors.New("ssh: gssapi-with-mic auth not configured")
|
||||
break
|
||||
}
|
||||
@@ -979,8 +1008,7 @@ userAuthLoop:
|
||||
if authConfig.KeyboardInteractiveCallback != nil {
|
||||
failureMsg.Methods = append(failureMsg.Methods, "keyboard-interactive")
|
||||
}
|
||||
if authConfig.GSSAPIWithMICConfig != nil && authConfig.GSSAPIWithMICConfig.Server != nil &&
|
||||
authConfig.GSSAPIWithMICConfig.AllowLogin != nil {
|
||||
if gssapiWithMICConfigured(authConfig.GSSAPIWithMICConfig) {
|
||||
failureMsg.Methods = append(failureMsg.Methods, "gssapi-with-mic")
|
||||
}
|
||||
|
||||
|
||||
+58
-5
@@ -16,6 +16,7 @@ import (
|
||||
"math/big"
|
||||
"net"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -165,6 +166,52 @@ func TestMaxAuthTriesNoneMethod(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxAuthServerAttempts(t *testing.T) {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
invocations := 0
|
||||
clientConf := &ClientConfig{
|
||||
User: "user",
|
||||
HostKeyCallback: InsecureIgnoreHostKey(),
|
||||
AuthCallback: func(ctx *ClientAuthContext) (AuthMethod, error) {
|
||||
invocations++
|
||||
return PublicKeys(testSigners["rsa"]), nil
|
||||
},
|
||||
}
|
||||
|
||||
go NewServerConn(c1, alwaysPartialPubKeyServer())
|
||||
|
||||
_, _, _, err = NewClientConn(c2, "", clientConf)
|
||||
if err == nil {
|
||||
t.Fatal("expected the server to disconnect after exceeding the attempts cap")
|
||||
}
|
||||
// The error must be the server's disconnect, not the client-side
|
||||
// "too many authentication attempts (N), aborting" bound. The
|
||||
// disconnect format is set by disconnectMsg.Error().
|
||||
if !strings.Contains(err.Error(), "ssh: disconnect") ||
|
||||
!strings.Contains(err.Error(), "too many authentication attempts") {
|
||||
t.Fatalf("expected server disconnect, got: %v", err)
|
||||
}
|
||||
// Server-side request sequence per AuthCallback iteration:
|
||||
// 1 "none" (initial, before any AuthCallback)
|
||||
// 2 pubkey query (AuthCallback #k, server returns OK)
|
||||
// 3 pubkey signed (AuthCallback #k, server returns partial)
|
||||
// So after k complete callbacks the server has processed 1+2k requests.
|
||||
// The (k+1)-th callback's query is processed as request number 2(k+1);
|
||||
// the subsequent signed request is rejected by the cap check
|
||||
// (authAttempts >= maxAuthServerAttempts), so 2(k+1) = maxAuthServerAttempts
|
||||
// and invocations = maxAuthServerAttempts / 2.
|
||||
expected := maxAuthServerAttempts / 2
|
||||
if invocations != expected {
|
||||
t.Errorf("AuthCallback invoked %d times; want %d", invocations, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxAuthTriesFirstNoneAuthErrorIgnored(t *testing.T) {
|
||||
username := "testuser"
|
||||
serverConfig := &ServerConfig{
|
||||
@@ -531,12 +578,18 @@ func TestVerifiedPublicCallbackPartialSuccess(t *testing.T) {
|
||||
serverConf.AddHostKey(testSigners["rsa"])
|
||||
|
||||
clientConf := ClientConfig{
|
||||
User: "user",
|
||||
Auth: []AuthMethod{
|
||||
PublicKeys(testSigners["rsa"]),
|
||||
Password(clientPassword),
|
||||
},
|
||||
User: "user",
|
||||
Auth: nil,
|
||||
HostKeyCallback: InsecureIgnoreHostKey(),
|
||||
AuthCallback: func(ctx *ClientAuthContext) (AuthMethod, error) {
|
||||
if slices.Contains(ctx.AllowedMethods, "publickey") && !slices.Contains(ctx.PartialSuccessMethods, "publickey") {
|
||||
return PublicKeys(testSigners["rsa"]), nil
|
||||
}
|
||||
if slices.Contains(ctx.AllowedMethods, "password") && slices.Contains(ctx.PartialSuccessMethods, "publickey") {
|
||||
return Password(clientPassword), nil
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
go NewServerConn(c1, serverConf)
|
||||
|
||||
@@ -423,6 +423,9 @@ func (s *Session) wait(reqs <-chan *Request) error {
|
||||
for msg := range reqs {
|
||||
switch msg.Type {
|
||||
case "exit-status":
|
||||
if len(msg.Payload) < 4 {
|
||||
return errors.New("ssh: malformed exit-status request")
|
||||
}
|
||||
wm.status = int(binary.BigEndian.Uint32(msg.Payload))
|
||||
case "exit-signal":
|
||||
var sigval struct {
|
||||
|
||||
@@ -334,6 +334,25 @@ func TestExitWithoutStatusOrSignal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitStatusShortPayload(t *testing.T) {
|
||||
for _, payload := range [][]byte{nil, {0}, {0, 0}, {0, 0, 0}} {
|
||||
conn := dial(exitStatusShortPayloadHandler(payload), t)
|
||||
session, err := conn.NewSession()
|
||||
if err != nil {
|
||||
t.Fatalf("unable to request new session: %v", err)
|
||||
}
|
||||
if err := session.Shell(); err != nil {
|
||||
t.Fatalf("unable to execute command: %v", err)
|
||||
}
|
||||
err = session.Wait()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for payload length %d", len(payload))
|
||||
}
|
||||
session.Close()
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// windowTestBytes is the number of bytes that we'll send to the SSH server.
|
||||
const windowTestBytes = 16000 * 200
|
||||
|
||||
@@ -482,6 +501,17 @@ func exitWithoutSignalOrStatus(ch Channel, in <-chan *Request, t *testing.T) {
|
||||
readLine(shell, t)
|
||||
}
|
||||
|
||||
func exitStatusShortPayloadHandler(payload []byte) serverType {
|
||||
return func(ch Channel, in <-chan *Request, t *testing.T) {
|
||||
defer ch.Close()
|
||||
shell := newServerShell(ch, in, "> ")
|
||||
readLine(shell, t)
|
||||
if _, err := ch.SendRequest("exit-status", false, payload); err != nil {
|
||||
t.Errorf("unable to send status: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shellHandler(ch Channel, in <-chan *Request, t *testing.T) {
|
||||
defer ch.Close()
|
||||
// this string is returned to stdout
|
||||
|
||||
+93
-4
@@ -7,11 +7,13 @@ package test
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/internal/testenv"
|
||||
"golang.org/x/crypto/ssh"
|
||||
@@ -88,7 +90,8 @@ func TestSSHCLIAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
// test public key authentication.
|
||||
cmd := testenv.Command(t, sshCLI, "-vvv", "-i", keyPrivPath, "-o", "StrictHostKeyChecking=no",
|
||||
cmd := testenv.Command(t, sshCLI, "-F", "none", "-vvv", "-i", keyPrivPath,
|
||||
"-o", "StrictHostKeyChecking=no", "-o", "IdentityAgent=none",
|
||||
"-p", port, "testpubkey@127.0.0.1", "true")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
@@ -97,7 +100,8 @@ func TestSSHCLIAuth(t *testing.T) {
|
||||
// Test SSH user certificate authentication.
|
||||
// The username must match one of the principals included in the certificate.
|
||||
// The certificate "rsa-user-testcertificate" has "testcertificate" as principal.
|
||||
cmd = testenv.Command(t, sshCLI, "-vvv", "-i", keyPrivPath, "-o", "StrictHostKeyChecking=no",
|
||||
cmd = testenv.Command(t, sshCLI, "-F", "none", "-vvv", "-i", keyPrivPath,
|
||||
"-o", "StrictHostKeyChecking=no", "-o", "IdentityAgent=none",
|
||||
"-p", port, "testcertificate@127.0.0.1", "true")
|
||||
out, err = cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
@@ -119,7 +123,7 @@ func TestSSHCLIKeyExchanges(t *testing.T) {
|
||||
keyExchanges := append(ssh.SupportedAlgorithms().KeyExchanges, ssh.InsecureAlgorithms().KeyExchanges...)
|
||||
for _, kex := range keyExchanges {
|
||||
t.Run(kex, func(t *testing.T) {
|
||||
cmd := testenv.Command(t, sshCLI, "-Q", "kex")
|
||||
cmd := testenv.Command(t, sshCLI, "-F", "none", "-Q", "kex")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%s failed to check if the KEX is supported, error: %v, command output %q", kex, err, string(out))
|
||||
@@ -152,7 +156,8 @@ func TestSSHCLIKeyExchanges(t *testing.T) {
|
||||
t.Fatalf("unable to get server port: %v", err)
|
||||
}
|
||||
|
||||
cmd = testenv.Command(t, sshCLI, "-vvv", "-i", keyPrivPath, "-o", "StrictHostKeyChecking=no",
|
||||
cmd = testenv.Command(t, sshCLI, "-F", "none", "-vvv", "-i", keyPrivPath,
|
||||
"-o", "StrictHostKeyChecking=no", "-o", "IdentityAgent=none",
|
||||
"-o", fmt.Sprintf("KexAlgorithms=%s", kex), "-p", port, "testpubkey@127.0.0.1", "true")
|
||||
out, err = cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
@@ -161,3 +166,87 @@ func TestSSHCLIKeyExchanges(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHCLIControlClientConn(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skipf("always fails on Windows, see #64403")
|
||||
}
|
||||
sshCLI := sshClient(t)
|
||||
keyFiles := map[string][]byte{
|
||||
"rsa": testdata.PEMBytes["rsa"],
|
||||
"rsa.pub": ssh.MarshalAuthorizedKey(testPublicKeys["rsa"]),
|
||||
}
|
||||
keyPrivPath := setupSSHCLIKeys(t, keyFiles, "rsa")
|
||||
|
||||
config := &ssh.ServerConfig{
|
||||
PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
||||
if conn.User() == "testcontrolproxy" && bytes.Equal(key.Marshal(), testPublicKeys["rsa"].Marshal()) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("pubkey for %q not acceptable", conn.User())
|
||||
},
|
||||
}
|
||||
config.AddHostKey(testSigners["rsa"])
|
||||
|
||||
server, err := newTestServer(config)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to start test server: %v", err)
|
||||
}
|
||||
defer server.Close()
|
||||
|
||||
port, err := server.port()
|
||||
if err != nil {
|
||||
t.Fatalf("unable to get server port: %v", err)
|
||||
}
|
||||
|
||||
dir, err := os.MkdirTemp("", "controlSocket")
|
||||
if err != nil {
|
||||
t.Fatalf("unable to create temp dir for control socket: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
csPath := filepath.Join(dir, "c")
|
||||
cmd := testenv.Command(t, sshCLI, "-vvv", "-i", keyPrivPath, "-o", "StrictHostKeyChecking=no",
|
||||
"-p", port, "-o", "ControlPath="+csPath, "-o", "ControlMaster=yes", "-N", "testcontrolproxy@127.0.0.1")
|
||||
var output bytes.Buffer
|
||||
cmd.Stdout = &output
|
||||
cmd.Stderr = &output
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("control socket master start failed, error: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
if t.Failed() {
|
||||
t.Logf("OpenSSH output:\n\n%s", cmd.Stdout)
|
||||
}
|
||||
}()
|
||||
for i := range 10 {
|
||||
if _, err := os.Stat(csPath); err == nil {
|
||||
break
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatalf("unable to stat control socket: %v", err)
|
||||
}
|
||||
time.Sleep((1 << i) * 5 * time.Millisecond)
|
||||
}
|
||||
|
||||
conn, err := net.Dial("unix", csPath)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to open control socket: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
cc, chans, reqs, err := ssh.NewControlClientConn(conn)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to create client: %v", err)
|
||||
}
|
||||
client := ssh.NewClient(cc, chans, reqs)
|
||||
defer client.Close()
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
t.Fatalf("unable to create session: %v", err)
|
||||
}
|
||||
defer session.Close()
|
||||
out, err := session.CombinedOutput("true")
|
||||
if err != nil {
|
||||
t.Fatalf("command execution failed, error: %v, command output %q", err, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user