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