a73x

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