Ctrl K
ComunidadGuías…

tests/device_key_verification_test.go

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package main

import (
	"context"
	"crypto/rsa"
	"encoding/base64"
	"net/http"
	"testing"
	"time"

	"github.com/shellhub-io/shellhub/pkg/api/requests"
	"github.com/shellhub-io/shellhub/pkg/models"
	"github.com/shellhub-io/shellhub/tests/environment"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/testcontainers/testcontainers-go"
	"golang.org/x/crypto/ssh"
)

func TestDeviceKeyVerification(t *testing.T) {
	t.Parallel()

	compose := newSSHEnvironment(t, t.Context(), legacyNamespace)
	requireVerifiedDeviceKeys(t, compose, true)

	unsigned := newDeviceAuthRequest(t, "verified", "02:00:00:00:02:00")
	stored, ok := devicePrivateKeys.Load(unsigned.PublicKey)
	require.True(t, ok)

	key, ok := stored.(*rsa.PrivateKey)
	require.True(t, ok)

	challenge := func(t *testing.T) string {
		t.Helper()

		issued, err := requestDeviceAuthChallenge(t.Context(), compose)
		require.NoError(t, err)

		return issued
	}

	signed := func(t *testing.T, challenge string) requests.DeviceAuth {
		t.Helper()

		req, err := signDeviceAuthWith(key, unsigned, challenge)
		require.NoError(t, err)

		return req
	}

	status := func(t *testing.T, req requests.DeviceAuth) int {
		t.Helper()

		resp, err := compose.Anonymous(t.Context()).SetBody(req).Post("/api/devices/auth")
		require.NoError(t, err)

		return resp.StatusCode()
	}

	t.Run("a namespace that requires verified device keys refuses the device's public metadata alone", func(t *testing.T) {
		assert.Equal(t, http.StatusForbidden, status(t, unsigned))
	})

	issued := challenge(t)

	t.Run("a signature by the device key enrolls the device", func(t *testing.T) {
		verified := authDevice(t, compose, signed(t, issued))
		assert.Equal(t, models.DeviceStatusPending, requireDeviceAuthStatus(t, compose, verified.Token))
	})

	t.Run("the same signed request is honoured again within the window", func(t *testing.T) {
		assert.Equal(t, http.StatusOK, status(t, signed(t, issued)))
	})

	t.Run("a challenge with a tampered tag is refused", func(t *testing.T) {
		raw, err := base64.RawURLEncoding.DecodeString(challenge(t))
		require.NoError(t, err)
		raw[len(raw)-1] ^= 0x01

		assert.Equal(t, http.StatusForbidden, status(t, signed(t, base64.RawURLEncoding.EncodeToString(raw))))
	})
}

const releasedAgentVersion = "v0.26.0"

func buildReleasedAgentImage(ctx context.Context) (string, error) {
	return cachedAgentImage(ctx, "released-"+releasedAgentVersion, environment.AgentBuild{
		Repository: "shellhub-e2e/agent-released",
		Context:    "../agent",
		Dockerfile: "Dockerfile.released",
		Version:    releasedAgentVersion,
		Output:     agentBuildLog(),
	})
}

func startReleasedAgent(t *testing.T, ctx context.Context, compose *environment.DockerCompose, opts ...NewAgentContainerOption) testcontainers.Container {
	t.Helper()

	image, err := buildReleasedAgentImage(ctx)
	require.NoError(t, err)

	agent, err := newAgentContainer(ctx, compose.Service(environment.ServiceRelay).GetContainerID(), image, opts...)
	require.NoError(t, err)

	return runAgent(t, ctx, agent)
}

func reachDevice(ctx context.Context, addr string, device *models.Device) error {
	client, err := dialClientOnce(ctx, addr, &ssh.ClientConfig{
		User:            deviceSSHID(device),
		Auth:            []ssh.AuthMethod{ssh.Password(ShellHubAgentPassword)},
		HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec // the gateway's own host key is not what these tests are about
		Timeout:         10 * time.Second,
	})
	if err != nil {
		return err
	}

	defer client.Close() //nolint:errcheck // the command already ran; closing reports nothing about the device

	_, err = deviceOutput(client, "true")

	return err
}

func requireSession(t *testing.T, ctx context.Context, compose *environment.DockerCompose, device *models.Device) {
	t.Helper()

	require.EventuallyWithT(t, func(tt *assert.CollectT) {
		assert.NoError(tt, reachDevice(ctx, compose.SSHAddress(), device))
	}, 60*time.Second, 2*time.Second, "no session reached %s", device.Name)
}

func requireVerifiedDeviceKeys(t *testing.T, compose *environment.DockerCompose, required bool) {
	t.Helper()

	resp, err := compose.R(t.Context()).
		SetBody(map[string]any{"settings": map[string]bool{"require_verified_device_key": required}}).
		Put("/api/namespaces/" + ShellHubNamespace)
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
}

func TestDeviceKeyVerificationWithAReleasedAgent(t *testing.T) {
	t.Parallel()

	ctx := t.Context()
	compose := newSSHEnvironment(t, ctx, legacyNamespace)
	requireVerifiedDeviceKeys(t, compose, true)

	_, current := startAcceptedAgent(t, ctx, compose)
	requireSession(t, ctx, compose, current)

	released := startReleasedAgent(t, ctx, compose, NewAgentContainerWithHostname("released"))
	environment.AwaitLogContains(t, released, "status_code=403")

	t.Run("a namespace that requires verified device keys never enrolls an agent that cannot sign", func(t *testing.T) {
		assert.NotContains(t, deviceNames(compose.ListDevices(t, models.DeviceStatusEmpty)), "released")
	})

	t.Run("the refused agent stays a single line in the tenant ID history while it retries", func(t *testing.T) {
		require.EventuallyWithT(t, func(tt *assert.CollectT) {
			var events []models.ProvisioningKeyEvent
			resp, err := compose.R(ctx).SetResult(&events).Get("/api/devices/tenant-registrations")
			require.NoError(tt, err)
			require.Equal(tt, http.StatusOK, resp.StatusCode(), resp.String())

			var refused []models.ProvisioningKeyEvent
			for _, event := range events {
				if event.Hostname == "released" {
					refused = append(refused, event)
				}
			}

			require.Len(tt, refused, 1)
			assert.Equal(tt, models.ProvisioningKeyEventRefusalUnverifiedDeviceKey, refused[0].Refusal)
			assert.GreaterOrEqual(tt, refused[0].Attempts, 2)
			assert.Empty(tt, refused[0].DeviceStatus)
		}, 3*time.Minute, 2*time.Second)
	})

	t.Run("the refused agent lands pending once the namespace stops requiring verified device keys", func(t *testing.T) {
		requireVerifiedDeviceKeys(t, compose, false)

		require.EventuallyWithT(t, func(tt *assert.CollectT) {
			var devices []models.Device
			resp, err := compose.R(ctx).SetQueryParam("status", string(models.DeviceStatusPending)).SetResult(&devices).Get("/api/devices")
			require.NoError(tt, err)
			require.Equal(tt, http.StatusOK, resp.StatusCode(), resp.String())
			assert.Contains(tt, deviceNames(devices), "released")
		}, 3*time.Minute, 2*time.Second)
	})
}