a73x

internal/server/delegation/delegation_test.go

Ref:   Size: 14.0 KiB   History

package delegation

import (
	"crypto/ed25519"
	"crypto/rand"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"golang.org/x/crypto/ssh"
)

var now = time.Unix(1_800_000_000, 0).UTC()

func fixedNow() time.Time { return now }

// newCA returns a signer standing in for a tenant's own SSH user CA.
func newCA(t *testing.T) ssh.Signer {
	t.Helper()
	_, priv, err := ed25519.GenerateKey(rand.Reader)
	require.NoError(t, err)
	s, err := ssh.NewSignerFromSigner(priv)
	require.NoError(t, err)
	return s
}

// trusts builds the trust callback for a fixed set of registered CAs.
func trusts(cas ...ssh.Signer) func(ssh.PublicKey) (bool, error) {
	return func(k ssh.PublicKey) (bool, error) {
		for _, ca := range cas {
			if string(ca.PublicKey().Marshal()) == string(k.Marshal()) {
				return true, nil
			}
		}
		return false, nil
	}
}

type certOpts struct {
	certType    uint32
	principals  []string
	validAfter  time.Time
	validBefore time.Time
	key         ssh.PublicKey // defaults to the pubLine argument
	critical    map[string]string
}

// sign builds the certificate a tenant would produce with `ssh-keygen -s`.
func sign(t *testing.T, ca ssh.Signer, pubLine string, o certOpts) string {
	t.Helper()
	key := o.key
	if key == nil {
		parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubLine))
		require.NoError(t, err)
		key = parsed
	}
	if o.certType == 0 {
		o.certType = ssh.UserCert
	}
	if o.principals == nil {
		o.principals = []string{"ubuntu"}
	}
	if o.validAfter.IsZero() {
		o.validAfter = now.Add(-time.Minute)
	}
	if o.validBefore.IsZero() {
		o.validBefore = now.Add(8 * time.Hour)
	}
	cert := &ssh.Certificate{
		Key:             key,
		Serial:          42,
		CertType:        o.certType,
		KeyId:           "eitri-delegation",
		ValidPrincipals: o.principals,
		ValidAfter:      uint64(o.validAfter.Unix()),
		ValidBefore:     uint64(o.validBefore.Unix()),
		Permissions: ssh.Permissions{
			CriticalOptions: o.critical,
			Extensions: map[string]string{
				"permit-pty": "", "permit-port-forwarding": "",
			},
		},
	}
	require.NoError(t, cert.SignCert(rand.Reader, ca))
	return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(cert)))
}

// delegate runs the whole happy path and returns the keyring, the CA and the
// resulting description.
func delegate(t *testing.T) (*Keyring, ssh.Signer, Delegation) {
	t.Helper()
	k := New(fixedNow, "ubuntu")
	ca := newCA(t)
	pub, err := k.Begin("acme")
	require.NoError(t, err)
	d, err := k.Complete("acme", sign(t, ca, pub, certOpts{}), trusts(ca))
	require.NoError(t, err)
	return k, ca, d
}

func TestBeginIsStableAndPerTenant(t *testing.T) {
	k := New(fixedNow, "ubuntu")
	a1, err := k.Begin("acme")
	require.NoError(t, err)
	a2, err := k.Begin("acme")
	require.NoError(t, err)
	assert.Equal(t, a1, a2, "re-delegating must not need a new key")

	b, err := k.Begin("other")
	require.NoError(t, err)
	assert.NotEqual(t, a1, b, "one tenant's delegation must never be usable as another's")
	assert.True(t, strings.HasPrefix(a1, "ssh-ed25519 "), "got %q", a1)
}

func TestCompleteAcceptsAWellFormedCertificate(t *testing.T) {
	k, ca, d := delegate(t)

	assert.Equal(t, ssh.FingerprintSHA256(ca.PublicKey()), d.CAFingerprint)
	assert.Equal(t, "eitri-delegation", d.KeyID)
	assert.Equal(t, uint64(42), d.Serial)
	assert.Equal(t, []string{"ubuntu"}, d.Principals)
	assert.Equal(t, now.Add(8*time.Hour), d.ExpiresAt)

	signer, ok := k.Signer("acme")
	require.True(t, ok)
	cert, ok := signer.PublicKey().(*ssh.Certificate)
	require.True(t, ok, "eitri must authenticate as key-plus-certificate")
	assert.Equal(t, d.PublicKey, strings.TrimSpace(string(ssh.MarshalAuthorizedKey(cert.Key))))
}

func TestCompleteRefusals(t *testing.T) {
	ca := newCA(t)
	stranger := newCA(t)
	otherTenantCA := newCA(t)

	cases := []struct {
		name string
		// cert builds the line to post, given the tenant's own public key.
		cert func(t *testing.T, pub string) string
		// trust is the tenant's registered CA set.
		trust    func(ssh.PublicKey) (bool, error)
		contains string
	}{
		{
			name:     "a bare public key",
			cert:     func(t *testing.T, pub string) string { return pub },
			trust:    trusts(ca),
			contains: "public key, not a certificate",
		},
		{
			name:     "garbage bytes",
			cert:     func(t *testing.T, _ string) string { return "not a key at all" },
			trust:    trusts(ca),
			contains: "not an SSH certificate",
		},
		{
			name: "a host certificate",
			cert: func(t *testing.T, pub string) string {
				return sign(t, ca, pub, certOpts{certType: ssh.HostCert})
			},
			trust:    trusts(ca),
			contains: "HOST certificate",
		},
		{
			name: "a certificate for somebody else's key",
			cert: func(t *testing.T, _ string) string {
				return sign(t, ca, "", certOpts{key: newCA(t).PublicKey()})
			},
			trust:    trusts(ca),
			contains: "current delegation key is",
		},
		{
			name: "a certificate over a key from before a restart",
			cert: func(t *testing.T, _ string) string {
				return sign(t, ca, "", certOpts{key: newCA(t).PublicKey()})
			},
			trust: trusts(ca),
			// The remedy, not just the diagnosis: the key changed underneath
			// the caller and only delegate_begin hands out the new one.
			contains: "Call delegate_begin again",
		},
		{
			name:     "a certificate from an unregistered CA",
			cert:     func(t *testing.T, pub string) string { return sign(t, stranger, pub, certOpts{}) },
			trust:    trusts(ca),
			contains: "not a CA registered to this tenant",
		},
		{
			name:     "a certificate from ANOTHER tenant's registered CA",
			cert:     func(t *testing.T, pub string) string { return sign(t, otherTenantCA, pub, certOpts{}) },
			trust:    trusts(ca), // this tenant's set; the other tenant's CA is not in it
			contains: "not a CA registered to this tenant",
		},
		{
			name: "principals that omit the login user",
			cert: func(t *testing.T, pub string) string {
				return sign(t, ca, pub, certOpts{principals: []string{"alex"}})
			},
			trust:    trusts(ca),
			contains: "must include \"ubuntu\"",
		},
		{
			name: "an already-expired certificate",
			cert: func(t *testing.T, pub string) string {
				return sign(t, ca, pub, certOpts{
					validAfter:  now.Add(-2 * time.Hour),
					validBefore: now.Add(-time.Hour),
				})
			},
			trust:    trusts(ca),
			contains: "not usable",
		},
		{
			name: "a not-yet-valid certificate",
			cert: func(t *testing.T, pub string) string {
				return sign(t, ca, pub, certOpts{
					validAfter:  now.Add(time.Hour),
					validBefore: now.Add(2 * time.Hour),
				})
			},
			trust:    trusts(ca),
			contains: "not usable",
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			k := New(fixedNow, "ubuntu")
			pub, err := k.Begin("acme")
			require.NoError(t, err)

			_, err = k.Complete("acme", tc.cert(t, pub), tc.trust)
			require.Error(t, err)
			assert.Contains(t, err.Error(), tc.contains)

			_, ok := k.Signer("acme")
			assert.False(t, ok, "a refused certificate must leave eitri with nothing")
		})
	}
}

func TestCompleteReportsATrustLookupFailure(t *testing.T) {
	k := New(fixedNow, "ubuntu")
	ca := newCA(t)
	pub, err := k.Begin("acme")
	require.NoError(t, err)

	_, err = k.Complete("acme", sign(t, ca, pub, certOpts{}), func(ssh.PublicKey) (bool, error) {
		return false, assert.AnError
	})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "checking which CA signed")
}

func TestARefusalNamesTheCommandThatFixesIt(t *testing.T) {
	k := New(fixedNow, "ubuntu")
	pub, err := k.Begin("acme")
	require.NoError(t, err)
	_, err = k.Complete("acme", pub, trusts())
	require.Error(t, err)
	assert.Contains(t, err.Error(), "ssh-keygen -s", "a refusal must say what to run next")
	assert.Contains(t, err.Error(), "-n ubuntu")
}

func TestSignerStopsAtExpiry(t *testing.T) {
	k := New(fixedNow, "ubuntu")
	ca := newCA(t)
	pub, err := k.Begin("acme")
	require.NoError(t, err)
	_, err = k.Complete("acme", sign(t, ca, pub, certOpts{validBefore: now.Add(time.Hour)}), trusts(ca))
	require.NoError(t, err)

	_, ok := k.Signer("acme")
	require.True(t, ok)

	clock := now
	k.Now = func() time.Time { return clock }
	clock = now.Add(59 * time.Minute)
	_, ok = k.Signer("acme")
	assert.True(t, ok, "still inside the window")

	clock = now.Add(time.Hour)
	_, ok = k.Signer("acme")
	assert.False(t, ok, "eitri must lose access the moment the certificate does")
	_, ok = k.Status("acme")
	assert.False(t, ok)
}

func TestSignerAndStatusAreAbsentBeforeAnyDelegation(t *testing.T) {
	k := New(fixedNow, "ubuntu")
	_, ok := k.Signer("acme")
	assert.False(t, ok)
	_, ok = k.Status("acme")
	assert.False(t, ok)

	// Even after Begin: a key with no certificate is not access.
	_, err := k.Begin("acme")
	require.NoError(t, err)
	_, ok = k.Signer("acme")
	assert.False(t, ok)
}

func TestRevokeIsImmediateAndKeepsTheKey(t *testing.T) {
	k, ca, d := delegate(t)
	k.Revoke("acme")

	_, ok := k.Signer("acme")
	assert.False(t, ok)

	// The same key comes back, so re-delegating is one signing step.
	pub, err := k.Begin("acme")
	require.NoError(t, err)
	assert.Equal(t, d.PublicKey, pub)
	_, err = k.Complete("acme", sign(t, ca, pub, certOpts{}), trusts(ca))
	require.NoError(t, err)
	_, ok = k.Signer("acme")
	assert.True(t, ok)
}

func TestSweepDropsWhatIsNotBeingUsed(t *testing.T) {
	clock := now
	k := New(func() time.Time { return clock }, "ubuntu")
	ca := newCA(t)

	live, err := k.Begin("live")
	require.NoError(t, err)
	_, err = k.Complete("live", sign(t, ca, live, certOpts{validBefore: now.Add(8 * time.Hour)}), trusts(ca))
	require.NoError(t, err)

	dead, err := k.Begin("dead")
	require.NoError(t, err)
	_, err = k.Complete("dead", sign(t, ca, dead, certOpts{validBefore: now.Add(time.Hour)}), trusts(ca))
	require.NoError(t, err)

	_, err = k.Begin("never-finished")
	require.NoError(t, err)

	clock = now.Add(2 * time.Hour)
	k.Sweep()

	k.mu.Lock()
	_, hasLive := k.tenants["live"]
	_, hasDead := k.tenants["dead"]
	_, hasPending := k.tenants["never-finished"]
	k.mu.Unlock()

	assert.True(t, hasLive)
	assert.False(t, hasDead, "an expired delegation is dead weight")
	assert.False(t, hasPending, "a begin two hours old is well past its grace, and a keyring is bounded by active tenants")
}

func TestConcurrentUseIsSafe(t *testing.T) {
	k := New(fixedNow, "ubuntu")
	ca := newCA(t)
	var wg sync.WaitGroup
	for i := range 8 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			tenant := []string{"a", "b"}[i%2]
			pub, err := k.Begin(tenant)
			assert.NoError(t, err)
			_, _ = k.Complete(tenant, sign(t, ca, pub, certOpts{}), trusts(ca))
			k.Signer(tenant)
			k.Status(tenant)
			k.Sweep()
		}()
	}
	wg.Wait()
}

// TestCompleteRefusesSourceAddress proves a delegation certificate restricted
// by source-address is refused with a diagnostic rather than accepted and then
// rejected opaquely by the guest, which sees its host's address on the inner
// hop.
func TestCompleteRefusesSourceAddress(t *testing.T) {
	k := New(fixedNow, "ubuntu")
	ca := newCA(t)
	pub, err := k.Begin("acme")
	require.NoError(t, err)

	line := sign(t, ca, pub, certOpts{critical: map[string]string{"source-address": "203.0.113.0/24"}})
	_, err = k.Complete("acme", line, trusts(ca))
	require.Error(t, err)
	assert.Contains(t, err.Error(), "source-address")
	assert.Contains(t, err.Error(), "203.0.113.0/24", "the refusal must name the restriction to re-sign without")

	_, ok := k.Signer("acme")
	assert.False(t, ok, "a refused certificate must leave no delegation behind")
}

// TestSweepKeepsAPendingBeginThroughTheSigningWindow proves the promise Begin
// makes. Signing is a human step, and the sweep ticks on its own schedule, so a
// tick landing between Begin and Complete must not rotate the key underneath a
// certificate that is already being signed.
func TestSweepKeepsAPendingBeginThroughTheSigningWindow(t *testing.T) {
	clock := now
	k := New(func() time.Time { return clock }, "ubuntu")

	first, err := k.Begin("acme")
	require.NoError(t, err)

	clock = now.Add(15 * time.Minute)
	k.Sweep()

	again, err := k.Begin("acme")
	require.NoError(t, err)
	assert.Equal(t, first, again, "a sweep between begin and complete must not rotate the key")
}

// TestSweepDropsAPendingBeginPastItsGrace pins both edges of the grace: a
// keypair nobody signed is kept up to it and abandoned at it.
func TestSweepDropsAPendingBeginPastItsGrace(t *testing.T) {
	clock := now
	k := New(func() time.Time { return clock }, "ubuntu")

	first, err := k.Begin("acme")
	require.NoError(t, err)

	clock = now.Add(defaultPendingGrace - time.Second)
	k.Sweep()
	kept, err := k.Begin("acme")
	require.NoError(t, err)
	assert.Equal(t, first, kept, "still inside the grace")

	clock = now.Add(defaultPendingGrace)
	k.Sweep()
	fresh, err := k.Begin("acme")
	require.NoError(t, err)
	assert.NotEqual(t, first, fresh, "a begin left unsigned past its grace is abandoned")
}

// TestSweepDropsAnExpiredDelegationInsideTheBeginGrace proves the grace covers
// only the wait for a certificate. Once one has arrived, its own expiry is what
// bounds the entry, and it is dropped even though the Begin behind it is recent.
func TestSweepDropsAnExpiredDelegationInsideTheBeginGrace(t *testing.T) {
	clock := now
	k := New(func() time.Time { return clock }, "ubuntu")
	ca := newCA(t)

	pub, err := k.Begin("acme")
	require.NoError(t, err)
	_, err = k.Complete("acme", sign(t, ca, pub, certOpts{validBefore: now.Add(30 * time.Minute)}), trusts(ca))
	require.NoError(t, err)

	clock = now.Add(45 * time.Minute)
	require.Less(t, clock.Sub(now), defaultPendingGrace, "the begin must still be inside its grace")
	k.Sweep()

	k.mu.Lock()
	_, held := k.tenants["acme"]
	k.mu.Unlock()
	assert.False(t, held, "an expired certificate is dead weight whatever the begin grace says")
}

// TestTheWrongKeyRefusalNamesBothWaysTheKeyChanges checks the diagnosis covers
// the sweep as well as a restart; a caller told only about restarts has nothing
// to act on when the plane has been up for hours.
func TestTheWrongKeyRefusalNamesBothWaysTheKeyChanges(t *testing.T) {
	k := New(fixedNow, "ubuntu")
	ca := newCA(t)
	_, err := k.Begin("acme")
	require.NoError(t, err)

	_, err = k.Complete("acme", sign(t, ca, "", certOpts{key: newCA(t).PublicKey()}), trusts(ca))
	require.Error(t, err)
	assert.Contains(t, err.Error(), "restarts")
	assert.Contains(t, err.Error(), "unsigned")
}