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