internal/gateclient/auth_test.go
Ref: Size: 8.9 KiB History
package gateclient
import (
"context"
"crypto/ed25519"
"crypto/rand"
"errors"
"net"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)
// fakeCertAuthority is an in-memory CertAuthority backed by a real ed25519
// host-CA signer, so tests exercise real host-cert verification without an
// httptest server. User certs are now self-signed by GateAuth locally, so this
// fake only serves the host CA (FetchSSHCA) and records user-CA uploads.
type fakeCertAuthority struct {
caSigner ssh.Signer // host CA served by FetchSSHCA
mu sync.Mutex
fetchCACalls int
uploadCalls int
lastTenant string
lastUploadLine string
fetchErr error // if set, FetchSSHCA returns it
uploadErr error // if set, UploadUserCA returns it
}
func newFakeCertAuthority(t *testing.T) *fakeCertAuthority {
t.Helper()
return &fakeCertAuthority{caSigner: newTestSigner(t)}
}
func (f *fakeCertAuthority) FetchSSHCA(ctx context.Context) (ssh.PublicKey, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.fetchCACalls++
if f.fetchErr != nil {
return nil, f.fetchErr
}
return f.caSigner.PublicKey(), nil
}
func (f *fakeCertAuthority) UploadUserCA(ctx context.Context, tenant, caLine string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.uploadCalls++
f.lastTenant = tenant
f.lastUploadLine = caLine
return f.uploadErr
}
func (f *fakeCertAuthority) setFetchErr(err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.fetchErr = err
}
func (f *fakeCertAuthority) counts() (fetchCA, upload int) {
f.mu.Lock()
defer f.mu.Unlock()
return f.fetchCACalls, f.uploadCalls
}
// newTestSigner generates a fresh ed25519 ssh.Signer for use as a test CA.
func newTestSigner(t *testing.T) ssh.Signer {
t.Helper()
_, priv, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
signer, err := ssh.NewSignerFromSigner(priv)
require.NoError(t, err)
return signer
}
// newTestGateAuth builds a GateAuth wired to fake, self-signing with userCA
// under tenant "default" as login user "ubuntu" and driven by clock (nil =
// time.Now).
func newTestGateAuth(fake *fakeCertAuthority, userCA ssh.Signer, clock func() time.Time) *GateAuth {
return NewGateAuth(fake, userCA, "default", "ubuntu", clock)
}
// certOf returns the *ssh.Certificate a cert-signer's public key carries.
func certOf(t *testing.T, s ssh.Signer) *ssh.Certificate {
t.Helper()
cert, ok := s.PublicKey().(*ssh.Certificate)
require.True(t, ok, "signer public key is not a certificate")
return cert
}
// hostCert builds and signs a host certificate for a fresh ephemeral host
// key, using ca as the signing authority.
func hostCert(t *testing.T, ca ssh.Signer) *ssh.Certificate {
t.Helper()
pub, _, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
sshPub, err := ssh.NewPublicKey(pub)
require.NoError(t, err)
cert := &ssh.Certificate{
Key: sshPub,
CertType: ssh.HostCert,
ValidPrincipals: []string{"vm-name"},
ValidBefore: ssh.CertTimeInfinity,
}
require.NoError(t, cert.SignCert(rand.Reader, ca))
return cert
}
func TestGateAuthSignerSelfSignsOnceAndReuses(t *testing.T) {
fake := newFakeCertAuthority(t)
userCA := newTestSigner(t)
ga := newTestGateAuth(fake, userCA, nil)
s1, err := ga.Signer(t.Context())
require.NoError(t, err)
s2, err := ga.Signer(t.Context())
require.NoError(t, err)
assert.Same(t, s1, s2, "expected the same cached signer to be returned")
assert.Regexp(t, `-cert-v01@openssh\.com$`, s1.PublicKey().Type())
// The cert is self-signed by our own user CA, for principal "ubuntu".
cert := certOf(t, s1)
assert.Equal(t, []string{"ubuntu"}, cert.ValidPrincipals)
assert.Equal(t, ssh.UserCert, int(cert.CertType))
assert.Equal(t, userCA.PublicKey().Marshal(), cert.SignatureKey.Marshal(),
"cert must be signed by our own user CA")
}
// TestGateAuthCertPrincipalTracksLoginUser locks the anti-drift guarantee: the
// cert's principal (and KeyId) is exactly the login user GateAuth was built
// with, and LoginUser reports that same value — so a non-default guest account
// (here "debian") cannot end up logging in as one user while its only cert
// principal names another.
func TestGateAuthCertPrincipalTracksLoginUser(t *testing.T) {
fake := newFakeCertAuthority(t)
userCA := newTestSigner(t)
ga := NewGateAuth(fake, userCA, "default", "debian", nil)
s, err := ga.Signer(t.Context())
require.NoError(t, err)
cert := certOf(t, s)
assert.Equal(t, "debian", ga.LoginUser())
assert.Equal(t, []string{"debian"}, cert.ValidPrincipals,
"the cert principal must be the login user, never a hardcoded default")
assert.Equal(t, "debian", cert.KeyId)
}
func TestGateAuthSignerRefreshesNearExpiry(t *testing.T) {
fake := newFakeCertAuthority(t)
userCA := newTestSigner(t)
current := time.Unix(1_700_000_000, 0)
clock := func() time.Time { return current }
ga := newTestGateAuth(fake, userCA, clock)
// First sign: cert is valid until now+30m.
s1, err := ga.Signer(t.Context())
require.NoError(t, err)
serial1 := certOf(t, s1).Serial
// Advance the clock to within a minute of expiry: this must re-sign.
current = current.Add(30 * time.Minute)
s2, err := ga.Signer(t.Context())
require.NoError(t, err)
serial2 := certOf(t, s2).Serial
assert.NotEqual(t, serial1, serial2, "expected re-sign when cached cert expires within a minute")
// Same "now" again: the fresh cert has ~30m left, so this must NOT re-sign.
s3, err := ga.Signer(t.Context())
require.NoError(t, err)
assert.Same(t, s2, s3, "expected no re-sign when cached cert has >1min remaining")
}
func TestGateAuthRegisterUploadsOnce(t *testing.T) {
fake := newFakeCertAuthority(t)
userCA := newTestSigner(t)
ga := newTestGateAuth(fake, userCA, nil)
require.NoError(t, ga.Register(t.Context()))
require.NoError(t, ga.Register(t.Context()))
_, uploads := fake.counts()
assert.Equal(t, 1, uploads, "Register must be idempotent (upload once)")
assert.Equal(t, "default", fake.lastTenant)
assert.Equal(t, string(ssh.MarshalAuthorizedKey(userCA.PublicKey())), fake.lastUploadLine+"\n",
"uploaded line must be our user CA public key")
}
func TestGateAuthRegisterSurfacesUploadError(t *testing.T) {
fake := newFakeCertAuthority(t)
fake.uploadErr = errors.New("upload boom")
ga := newTestGateAuth(fake, newTestSigner(t), nil)
err := ga.Register(t.Context())
require.Error(t, err)
assert.Contains(t, err.Error(), "registering user CA")
// A failed upload must not flip the once-guard: a retry re-attempts.
fake.uploadErr = nil
require.NoError(t, ga.Register(t.Context()))
_, uploads := fake.counts()
assert.Equal(t, 2, uploads, "failed upload must be retried, not swallowed")
}
func TestGateAuthConnectName(t *testing.T) {
fake := newFakeCertAuthority(t)
ga := newTestGateAuth(fake, newTestSigner(t), nil)
name, err := ga.ConnectName(t.Context(), "web-1")
require.NoError(t, err)
assert.Equal(t, "default.web-1", name)
}
func TestGateAuthHostKeyCallbackAcceptsCASignedHostCert(t *testing.T) {
fake := newFakeCertAuthority(t)
ga := newTestGateAuth(fake, newTestSigner(t), nil)
cert := hostCert(t, fake.caSigner)
err := ga.HostKeyCallback()("vm-name:22", &net.TCPAddr{}, cert)
assert.NoError(t, err)
}
func TestGateAuthHostKeyCallbackRejectsForeignCAHostCert(t *testing.T) {
fake := newFakeCertAuthority(t)
ga := newTestGateAuth(fake, newTestSigner(t), nil)
foreignCA := newTestSigner(t)
cert := hostCert(t, foreignCA)
err := ga.HostKeyCallback()("vm-name:22", &net.TCPAddr{}, cert)
assert.Error(t, err)
}
func TestGateAuthHostKeyCallbackRejectsBareHostKey(t *testing.T) {
fake := newFakeCertAuthority(t)
ga := newTestGateAuth(fake, newTestSigner(t), nil)
pub, _, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
sshPub, err := ssh.NewPublicKey(pub)
require.NoError(t, err)
err = ga.HostKeyCallback()("vm-name:22", &net.TCPAddr{}, sshPub)
assert.Error(t, err)
}
func TestGateAuthFetchesCAOnlyOnce(t *testing.T) {
fake := newFakeCertAuthority(t)
ga := newTestGateAuth(fake, newTestSigner(t), nil)
cb := ga.HostKeyCallback()
cert := hostCert(t, fake.caSigner)
require.NoError(t, cb("vm-name:22", &net.TCPAddr{}, cert))
require.NoError(t, cb("vm-name:22", &net.TCPAddr{}, cert))
_, err := ga.Signer(t.Context())
require.NoError(t, err)
_, err = ga.Signer(t.Context())
require.NoError(t, err)
fetchCA, _ := fake.counts()
assert.Equal(t, 1, fetchCA, "expected the CA to be fetched exactly once")
}
func TestGateAuthHostKeyCallbackRejectsWhenCAFetchFails(t *testing.T) {
fake := newFakeCertAuthority(t)
fake.setFetchErr(errors.New("ssh-ca gate is not enabled"))
ga := newTestGateAuth(fake, newTestSigner(t), nil)
// A perfectly valid, CA-signed host cert must STILL be rejected when we
// cannot fetch the CA to verify against it — failing closed, never open.
cert := hostCert(t, fake.caSigner)
err := ga.HostKeyCallback()("vm-name:22", &net.TCPAddr{}, cert)
assert.Error(t, err, "host must be rejected when the CA cannot be fetched")
}