a73x

internal/server/api/delegations_test.go

Ref:   Size: 8.7 KiB   History

package api

import (
	"crypto/ed25519"
	"crypto/rand"
	"encoding/json"
	"io"
	"net/http"
	"strings"
	"testing"
	"time"

	"github.com/a73x/eitri/internal/server/api/types"
	"github.com/a73x/eitri/internal/server/delegation"
	"github.com/a73x/eitri/internal/server/store"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"golang.org/x/crypto/ssh"
)

// newDelegationCA stands in for a tenant's own SSH user CA.
func newDelegationCA(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
}

// registerTenantCA registers ca to tenant, the precondition for delegating with
// it: eitri only accepts a certificate from a CA the tenant's guests trust.
func registerTenantCA(t *testing.T, st *store.Store, tenant string, ca ssh.Signer) {
	t.Helper()
	line := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(ca.PublicKey())))
	require.NoError(t, st.AddTenantUserCA(tenant, line, "tenant", "mine", "test"))
}

// signFor is `ssh-keygen -s`, in code.
func signFor(t *testing.T, ca ssh.Signer, pubLine, principal string, expiry time.Time) string {
	t.Helper()
	pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubLine))
	require.NoError(t, err)
	cert := &ssh.Certificate{
		Key:             pub,
		Serial:          99,
		CertType:        ssh.UserCert,
		KeyId:           "eitri-delegation",
		ValidPrincipals: []string{principal},
		ValidAfter:      uint64(time.Now().Add(-time.Minute).Unix()),
		ValidBefore:     uint64(expiry.Unix()),
	}
	require.NoError(t, cert.SignCert(rand.Reader, ca))
	return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(cert)))
}

// withDelegations wires a real keyring onto a test server.
func withDelegations(a *API) *delegation.Keyring {
	k := delegation.New(time.Now, "ubuntu")
	a.SetDelegations(k)
	return k
}

// bodyText reads an error response body.
func bodyText(t *testing.T, resp *http.Response) string {
	t.Helper()
	defer resp.Body.Close()
	b, err := io.ReadAll(resp.Body)
	require.NoError(t, err)
	return string(b)
}

func decodeInto(t *testing.T, resp *http.Response, v any) {
	t.Helper()
	defer resp.Body.Close()
	require.NoError(t, json.NewDecoder(resp.Body).Decode(v))
}

// TestDelegationRoundTrip is the whole exchange over HTTP: a challenge, a
// certificate signed elsewhere, and a live delegation eitri could not have
// created for itself.
func TestDelegationRoundTrip(t *testing.T) {
	ts, st, _, _, a := newServer(t)
	k := withDelegations(a)
	ca := newDelegationCA(t)
	registerTenantCA(t, st, testTenant, ca)

	resp := do(t, "POST", ts.URL+"/api/v1/delegations", testPAT, nil)
	require.Equal(t, http.StatusOK, resp.StatusCode)
	var challenge types.DelegationChallenge
	decodeInto(t, resp, &challenge)
	assert.True(t, strings.HasPrefix(challenge.PublicKey, "ssh-ed25519 "))
	assert.Equal(t, "ubuntu", challenge.Principal)
	assert.Contains(t, challenge.Instructions, "ssh-keygen -s <your-ca-key>")
	assert.Contains(t, challenge.Instructions, "-n ubuntu")
	assert.Contains(t, challenge.Instructions, challenge.PublicKey,
		"the instructions must carry the key, so a caller need not assemble it")

	expiry := time.Now().Add(8 * time.Hour).Truncate(time.Second)
	resp = do(t, "PUT", ts.URL+"/api/v1/delegations", testPAT,
		map[string]any{"certificate": signFor(t, ca, challenge.PublicKey, "ubuntu", expiry)})
	require.Equal(t, http.StatusOK, resp.StatusCode)
	var d types.Delegation
	decodeInto(t, resp, &d)
	assert.Equal(t, ssh.FingerprintSHA256(ca.PublicKey()), d.CAFingerprint)
	assert.Equal(t, "99", d.Serial, "a serial is a string on this API, so a JS client keeps every digit")
	assert.Equal(t, []string{"ubuntu"}, d.Principals)
	assert.Equal(t, expiry.UTC().Format(time.RFC3339), d.ExpiresAt)

	_, ok := k.Signer(testTenant)
	assert.True(t, ok)

	// GET reports it, so its expiry is never a surprise.
	resp = do(t, "GET", ts.URL+"/api/v1/delegations", testPAT, nil)
	require.Equal(t, http.StatusOK, resp.StatusCode)
	var got types.Delegation
	decodeInto(t, resp, &got)
	assert.Equal(t, d, got)

	// DELETE ends it now.
	resp = do(t, "DELETE", ts.URL+"/api/v1/delegations", testPAT, nil)
	require.Equal(t, http.StatusNoContent, resp.StatusCode)
	_, ok = k.Signer(testTenant)
	assert.False(t, ok)
	resp = do(t, "GET", ts.URL+"/api/v1/delegations", testPAT, nil)
	assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}

// TestBeginIsStableAcrossCalls: re-delegating after an expiry is one signing
// step, not a new round trip for a key that has not changed.
func TestBeginIsStableAcrossCalls(t *testing.T) {
	ts, _, _, _, a := newServer(t)
	withDelegations(a)

	var first, second types.DelegationChallenge
	decodeInto(t, do(t, "POST", ts.URL+"/api/v1/delegations", testPAT, nil), &first)
	decodeInto(t, do(t, "POST", ts.URL+"/api/v1/delegations", testPAT, nil), &second)
	assert.Equal(t, first.PublicKey, second.PublicKey)
}

// TestCompleteBeforeBeginIsACleanRefusal: there is no key to have signed yet,
// so whatever was posted is for something else.
func TestCompleteBeforeBeginIsACleanRefusal(t *testing.T) {
	ts, st, _, _, a := newServer(t)
	withDelegations(a)
	ca := newDelegationCA(t)
	registerTenantCA(t, st, testTenant, ca)

	// A certificate over some other key, posted first.
	other := newDelegationCA(t)
	otherLine := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(other.PublicKey())))
	resp := do(t, "PUT", ts.URL+"/api/v1/delegations", testPAT,
		map[string]any{"certificate": signFor(t, ca, otherLine, "ubuntu", time.Now().Add(time.Hour))})
	assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
	assert.Contains(t, bodyText(t, resp), "current delegation key is")
}

// TestCompleteRefusesAnotherTenantsCA: the trust check is scoped to the CAs the
// CALLER's tenant registered, not to every CA the fleet knows.
func TestCompleteRefusesAnotherTenantsCA(t *testing.T) {
	ts, st, _, _, a := newServer(t)
	withDelegations(a)

	other, err := st.CreateTenantForIdentity("https://idp", "someone-else", "them@example.com")
	require.NoError(t, err)
	theirCA := newDelegationCA(t)
	registerTenantCA(t, st, other.ID, theirCA)

	var challenge types.DelegationChallenge
	decodeInto(t, do(t, "POST", ts.URL+"/api/v1/delegations", testPAT, nil), &challenge)

	resp := do(t, "PUT", ts.URL+"/api/v1/delegations", testPAT,
		map[string]any{"certificate": signFor(t, theirCA, challenge.PublicKey, "ubuntu", time.Now().Add(time.Hour))})
	assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
	assert.Contains(t, bodyText(t, resp), "not a CA registered to this tenant")
}

// TestCompleteRequiresACertificate: an empty body is a caller error, not a 500.
func TestCompleteRequiresACertificate(t *testing.T) {
	ts, _, _, _, a := newServer(t)
	withDelegations(a)
	resp := do(t, "PUT", ts.URL+"/api/v1/delegations", testPAT, map[string]any{})
	assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}

// TestDelegationRoutesNeedACredential: every one of them acts on the caller's
// own tenant, so every one of them needs to know who the caller is.
func TestDelegationRoutesNeedACredential(t *testing.T) {
	ts, _, _, _, a := newServer(t)
	withDelegations(a)
	for _, m := range []string{"POST", "PUT", "GET", "DELETE"} {
		resp := do(t, m, ts.URL+"/api/v1/delegations", "", nil)
		assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, m)
	}
}

// TestDelegationRoutesWithoutAGate: with no SSH CA there is nothing to verify a
// certificate against, and the refusal says which condition it is.
func TestDelegationRoutesWithoutAGate(t *testing.T) {
	ts, _, _, _, _ := newServer(t) // no SetDelegations
	for _, m := range []string{"POST", "PUT", "GET", "DELETE"} {
		resp := do(t, m, ts.URL+"/api/v1/delegations", testPAT, map[string]any{"certificate": "x"})
		assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode, m)
		assert.Contains(t, bodyText(t, resp), "no SSH CA configured")
	}
}

// TestDelegationIsAudited: the fingerprint, key id, serial and expiry are the
// record of what eitri was lent. The key itself never appears anywhere.
func TestDelegationIsAudited(t *testing.T) {
	ts, st, _, _, a := newServer(t)
	withDelegations(a)
	ca := newDelegationCA(t)
	registerTenantCA(t, st, testTenant, ca)

	var challenge types.DelegationChallenge
	decodeInto(t, do(t, "POST", ts.URL+"/api/v1/delegations", testPAT, nil), &challenge)
	do(t, "PUT", ts.URL+"/api/v1/delegations", testPAT,
		map[string]any{"certificate": signFor(t, ca, challenge.PublicKey, "ubuntu", time.Now().Add(time.Hour))})
	do(t, "DELETE", ts.URL+"/api/v1/delegations", testPAT, nil)

	rows, err := st.ListAudit(testTenant, 50)
	require.NoError(t, err)
	var actions []string
	for _, r := range rows {
		actions = append(actions, r.Action)
	}
	assert.Contains(t, actions, "delegation.begin")
	assert.Contains(t, actions, "delegation.complete")
	assert.Contains(t, actions, "delegation.revoke")
}