a73x

internal/server/api/sshcert_test.go

Ref:   Size: 6.2 KiB   History

package api

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

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

// newCASigner returns a throwaway ed25519 ssh.Signer to stand in for the CA.
func newCASigner(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
}

// genUserPubKey returns a fresh ed25519 public key in authorized-keys form.
func genUserPubKey(t *testing.T) string {
	t.Helper()
	pub, _, err := ed25519.GenerateKey(rand.Reader)
	require.NoError(t, err)
	sp, err := ssh.NewPublicKey(pub)
	require.NoError(t, err)
	return string(ssh.MarshalAuthorizedKey(sp))
}

func TestSSHCAEndpointServesCAWhenEnabled(t *testing.T) {
	ts, _, _, _, a := newServer(t)
	a.SetSSHCAAuthorizedKey("ssh-ed25519 AAAAtestca eitri-host-ca")

	// Unauthenticated: it is public material and clients need it before auth.
	resp := do(t, "GET", ts.URL+"/api/v1/ssh-ca", "", nil)
	require.Equal(t, http.StatusOK, resp.StatusCode)
	var out map[string]string
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
	assert.Equal(t, "ssh-ed25519 AAAAtestca eitri-host-ca", out["ca"])
}

func TestSSHCAEndpointGateOffIs404(t *testing.T) {
	ts, _, _, _, _ := newServer(t)
	resp := do(t, "GET", ts.URL+"/api/v1/ssh-ca", "", nil)
	assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}

// TestCreateVMHoldsNoGuestKeyMaterial asserts what a VM create now does with
// key material: nothing. The guest's host key is generated by the host that
// runs it, so the row starts empty and the API answer carries no key field at
// all.
func TestCreateVMHoldsNoGuestKeyMaterial(t *testing.T) {
	ts, st, _, _, _ := newServer(t)
	out := enroll(t, ts)

	resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
		map[string]any{"host_id": out["host_id"], "name": "hosty"})
	require.Equal(t, http.StatusCreated, resp.StatusCode)

	vm, err := st.VMByTenantName(testTenant, "hosty")
	require.NoError(t, err)
	assert.Empty(t, vm.SSHHostPubKey, "the host reports its guest's public key; create invents none")
	assert.Empty(t, vm.SSHHostCert, "there is nothing to certify until a host reports a key")

	listResp := do(t, "GET", ts.URL+"/api/v1/vms", testPAT, nil)
	require.Equal(t, http.StatusOK, listResp.StatusCode)
	body, err := io.ReadAll(listResp.Body)
	require.NoError(t, err)
	assert.NotContains(t, string(body), "OPENSSH PRIVATE KEY")
	assert.NotContains(t, string(body), "ssh_host_key")
}

// TestSSHCertRevokeBySerial revokes a cert by its raw serial and confirms the
// list endpoint reflects it (serial as a string, to survive JS clients).
func TestSSHCertRevokeBySerial(t *testing.T) {
	ts, st, _, _, _ := newServer(t)

	const serial = uint64(0xFFFFFFFF00000001) // > MaxInt64, exercises the bit-cast
	resp := do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", testPAT,
		map[string]any{"serial": serial, "reason": "lost yubikey"})
	require.Equal(t, http.StatusNoContent, resp.StatusCode)

	revoked, err := st.IsSSHCertRevoked(testTenant, serial)
	require.NoError(t, err)
	assert.True(t, revoked)

	// List endpoint reflects it, serial rendered as a string.
	listResp := do(t, "GET", ts.URL+"/api/v1/ssh-certs/revoked", testPAT, nil)
	require.Equal(t, http.StatusOK, listResp.StatusCode)
	var out []map[string]any
	require.NoError(t, json.NewDecoder(listResp.Body).Decode(&out))
	require.Len(t, out, 1)
	assert.Equal(t, "18446744069414584321", out[0]["serial"])
	assert.Equal(t, "lost yubikey", out[0]["reason"])
}

// TestSSHCertRevokeByCertLine revokes by pasting a CA-signed cert authorized-key
// line; the server extracts the serial and the matching serial reads revoked.
// The cert is hand-built (eitri no longer mints user certs) — the revoke handler
// only reads the serial off the line, it does not verify the signature.
func TestSSHCertRevokeByCertLine(t *testing.T) {
	ts, st, _, _, _ := newServer(t)
	ca := newCASigner(t)

	pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(genUserPubKey(t)))
	require.NoError(t, err)
	cert := &ssh.Certificate{
		Key:         pk,
		Serial:      0x1234abcd,
		CertType:    ssh.UserCert,
		ValidBefore: ssh.CertTimeInfinity,
	}
	require.NoError(t, cert.SignCert(rand.Reader, ca))
	line := string(ssh.MarshalAuthorizedKey(cert))

	resp := do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", testPAT,
		map[string]any{"certificate": line})
	require.Equal(t, http.StatusNoContent, resp.StatusCode)

	revoked, err := st.IsSSHCertRevoked(testTenant, cert.Serial)
	require.NoError(t, err)
	assert.True(t, revoked, "the pasted cert's serial must be revoked")
}

// TestSSHCertRevokeIdempotent confirms re-revoking the same serial is a 204
// no-op that leaves a single list entry.
func TestSSHCertRevokeIdempotent(t *testing.T) {
	ts, _, _, _, _ := newServer(t)
	body := map[string]any{"serial": uint64(7)}
	require.Equal(t, http.StatusNoContent, do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", testPAT, body).StatusCode)
	require.Equal(t, http.StatusNoContent, do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", testPAT, body).StatusCode)

	listResp := do(t, "GET", ts.URL+"/api/v1/ssh-certs/revoked", testPAT, nil)
	var out []map[string]any
	require.NoError(t, json.NewDecoder(listResp.Body).Decode(&out))
	assert.Len(t, out, 1)
}

func TestSSHCertRevokeMissingFieldsIs400(t *testing.T) {
	ts, _, _, _, _ := newServer(t)
	resp := do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", testPAT, map[string]any{"reason": "no serial"})
	assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}

func TestSSHCertRevokeBadCertLineIs400(t *testing.T) {
	ts, _, _, _, _ := newServer(t)
	resp := do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", testPAT, map[string]any{"certificate": "not-a-cert"})
	assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}

func TestSSHCertRevokeRequiresAdmin(t *testing.T) {
	ts, _, _, _, _ := newServer(t)
	body := map[string]any{"serial": uint64(1)}
	assert.Equal(t, 401, do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", "", body).StatusCode)
	assert.Equal(t, 401, do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", "wrong", body).StatusCode)
	assert.Equal(t, 401, do(t, "GET", ts.URL+"/api/v1/ssh-certs/revoked", "", nil).StatusCode)
}