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