diff --git a/CMakeLists.txt b/CMakeLists.txt index e0b913e0f..b0b82afe9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,6 +26,14 @@ endif() set(NITRO_CFLAGS '-I${CMAKE_BINARY_DIR}/nitro/${CARGOTARGET} -I${CMAKE_BINARY_DIR}/nitro/${CARGOTARGET}/headers') set(NITRO_LDFLAGS '${CMAKE_BINARY_DIR}/nitro/${CARGOTARGET}/libnitro.a ${RUST_STATICLIB_LDFLAGS}') +# +# core-os disk-mapper +# +add_custom_target(disk-mapper + go build -o ${CMAKE_BINARY_DIR}/disk-mapper -ldflags "-s -w" + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/state/cmd +) + # # coordinator # diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 9da6757c2..695f7b4c2 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -10,7 +10,7 @@ import ( // This function WILL cause a system crash! // DO NOT call it in any code that may be covered by automatic or manual tests. func KernelPanic(err error) { - fmt.Println(err) + fmt.Fprint(os.Stderr, err) _ = os.WriteFile("/proc/sys/kernel/sysrq", []byte("1"), 0o644) _ = os.WriteFile("/proc/sysrq-trigger", []byte("c"), 0o644) panic(err) diff --git a/state/README.md b/state/README.md new file mode 100644 index 000000000..74f2adb6c --- /dev/null +++ b/state/README.md @@ -0,0 +1,13 @@ +# State + +Files and source code for mounting persistent state disks + +## Testing + +Integration test is available in `state/test/integration_test.go`. +The integration test requires root privileges since it uses dm-crypt. +Build and run the test: +```bash +go test -c ./state/test/ +sudo ./test.test +``` diff --git a/state/cmd/main.go b/state/cmd/main.go new file mode 100644 index 000000000..793f9940b --- /dev/null +++ b/state/cmd/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "crypto/rand" + "flag" + "os" + "path/filepath" + + "github.com/edgelesssys/constellation/internal/utils" + "github.com/edgelesssys/constellation/state/mapper" +) + +const ( + keyPath = "/run/cryptsetup-keys.d" + keyFile = "state.key" +) + +var csp = flag.String("csp", "", "Cloud Service Provider the image is running on") + +func main() { + flag.Parse() + diskPath, err := mapper.GetDiskPath(*csp) + if err != nil { + utils.KernelPanic(err) + } + + mapper, err := mapper.New(diskPath) + if err != nil { + utils.KernelPanic(err) + } + defer mapper.Close() + + // generate and save temporary passphrase + if err := os.MkdirAll(keyPath, os.ModePerm); err != nil { + utils.KernelPanic(err) + } + passphrase := make([]byte, 32) + if _, err := rand.Read(passphrase); err != nil { + utils.KernelPanic(err) + } + if err := os.WriteFile(filepath.Join(keyPath, keyFile), passphrase, 0o400); err != nil { + utils.KernelPanic(err) + } + + if err := mapper.FormatDisk(string(passphrase)); err != nil { + utils.KernelPanic(err) + } + if err := mapper.MapDisk("state", string(passphrase)); err != nil { + utils.KernelPanic(err) + } +} diff --git a/state/mapper/cryptdevice.go b/state/mapper/cryptdevice.go new file mode 100644 index 000000000..5cd43d0a9 --- /dev/null +++ b/state/mapper/cryptdevice.go @@ -0,0 +1,33 @@ +package mapper + +import cryptsetup "github.com/martinjungblut/go-cryptsetup" + +type cryptDevice interface { + // ActivateByPassphrase activates a device by using a passphrase from a specific keyslot. + // Returns nil on success, or an error otherwise. + // C equivalent: crypt_activate_by_passphrase + ActivateByPassphrase(deviceName string, keyslot int, passphrase string, flags int) error + // Deactivate deactivates a device. + // Returns nil on success, or an error otherwise. + // C equivalent: crypt_deactivate + Deactivate(deviceName string) error + // Format formats a Device, using a specific device type, and type-independent parameters. + // Returns nil on success, or an error otherwise. + // C equivalent: crypt_format + Format(deviceType cryptsetup.DeviceType, genericParams cryptsetup.GenericParams) error + // Free releases crypt device context and used memory. + // C equivalent: crypt_free + Free() bool + // Load loads crypt device parameters from the on-disk header. + // Returns nil on success, or an error otherwise. + // C equivalent: crypt_load + Load(cryptsetup.DeviceType) error + // KeyslotAddByVolumeKey adds a key slot using a volume key to perform the required security check. + // Returns nil on success, or an error otherwise. + // C equivalent: crypt_keyslot_add_by_volume_key + KeyslotAddByVolumeKey(keyslot int, volumeKey string, passphrase string) error + // KeyslotChangeByPassphrase changes a defined a key slot using a previously added passphrase to perform the required security check. + // Returns nil on success, or an error otherwise. + // C equivalent: crypt_keyslot_change_by_passphrase + KeyslotChangeByPassphrase(currentKeyslot int, newKeyslot int, currentPassphrase string, newPassphrase string) error +} diff --git a/state/mapper/mapper.go b/state/mapper/mapper.go new file mode 100644 index 000000000..be3769532 --- /dev/null +++ b/state/mapper/mapper.go @@ -0,0 +1,106 @@ +package mapper + +import ( + "errors" + "fmt" + "path/filepath" + + cryptsetup "github.com/martinjungblut/go-cryptsetup" +) + +const ( + gcpStateDiskPath = "/dev/disk/by-id/google-state-disk" + azureStateDiskPath = "/dev/disk/azure/scsi1/lun0" + fallBackPath = "/dev/disk/by-id/state-disk" +) + +// Mapper handles actions for formating and mapping crypt devices. +type Mapper struct { + device cryptDevice +} + +// New creates a new crypt device for the device at path. +func New(path string) (*Mapper, error) { + device, err := cryptsetup.Init(path) + if err != nil { + return nil, fmt.Errorf("initializing crypt device for disk %q: %w", path, err) + } + return &Mapper{device: device}, nil +} + +// Close closes and frees memory allocated for the crypt device. +func (m *Mapper) Close() error { + if m.device.Free() { + return nil + } + return errors.New("unable to close crypt device") +} + +// FormatDisk formats the disk and adds passphrase in keyslot 0. +func (m *Mapper) FormatDisk(passphrase string) error { + luksParams := cryptsetup.LUKS2{ + SectorSize: 4096, + PBKDFType: &cryptsetup.PbkdfType{ + // Use low memory recommendation from https://datatracker.ietf.org/doc/html/rfc9106#section-7 + Type: "argon2id", + TimeMs: 2000, + Iterations: 3, + ParallelThreads: 4, + MaxMemoryKb: 65536, // ~64MiB + }, + } + + genericParams := cryptsetup.GenericParams{ + Cipher: "aes", + CipherMode: "xts-plain64", + VolumeKeySize: 64, + } + + if err := m.device.Format(luksParams, genericParams); err != nil { + return fmt.Errorf("formating disk: %w", err) + } + + if err := m.device.KeyslotAddByVolumeKey(0, "", passphrase); err != nil { + return fmt.Errorf("adding keyslot: %w", err) + } + + return nil +} + +// MapDisk maps a crypt device to /dev/mapper/target using the provided passphrase. +func (m *Mapper) MapDisk(target, passphrase string) error { + if err := m.device.ActivateByPassphrase(target, 0, passphrase, 0); err != nil { + return fmt.Errorf("mapping disk as %q: %w", target, err) + } + return nil +} + +// UnmapDisk removes the mapping of target. +func (m *Mapper) UnmapDisk(target string) error { + return m.device.Deactivate(target) +} + +// GetDiskPath returns the device path of the data disk by cloud provider. +// +// For GCP a symlink to the disk is expected at /dev/disk/by-id/google-state-disk +// For Azure a symlink to the disk is expected at /dev/disk/azure/scsi1/lun0 +// If no symlink can be found at the given path, or if no known cloud provider is supplied, +// we instead return the device path of the os-disk stateful partition at /dev/disk/by-partlabel/stateful. +func GetDiskPath(csp string) (string, error) { + var diskPath string + var err error + + switch csp { + case "gcp": + diskPath, err = filepath.EvalSymlinks(gcpStateDiskPath) + case "azure": + diskPath, err = filepath.EvalSymlinks(azureStateDiskPath) + default: + diskPath = fallBackPath + } + + if err != nil { + return filepath.EvalSymlinks(fallBackPath) + } + return diskPath, nil +} diff --git a/state/test/integration_test.go b/state/test/integration_test.go new file mode 100644 index 000000000..fb5d2e968 --- /dev/null +++ b/state/test/integration_test.go @@ -0,0 +1,57 @@ +// go:build integration +package integration + +import ( + "fmt" + "os" + "os/exec" + "testing" + + "github.com/edgelesssys/constellation/state/mapper" + "github.com/martinjungblut/go-cryptsetup" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + devicePath = "testDevice" + mappedDevice = "mappedDevice" +) + +func setup() error { + return exec.Command("/bin/dd", "if=/dev/zero", fmt.Sprintf("of=%s", devicePath), "bs=64M", "count=1").Run() +} + +func teardown() error { + return exec.Command("/bin/rm", "-f", devicePath).Run() +} + +func TestMain(m *testing.M) { + if os.Getuid() != 0 { + fmt.Printf("This test suite requires root privileges, as libcrypsetup uses the kernel's device mapper.\n") + os.Exit(1) + } + + result := m.Run() + os.Exit(result) +} + +func TestMapper(t *testing.T) { + cryptsetup.SetDebugLevel(cryptsetup.CRYPT_LOG_VERBOSE) + cryptsetup.SetLogCallback(func(level int, message string) { fmt.Println(message) }) + assert := assert.New(t) + require := require.New(t) + require.NoError(setup(), "failed to setup test disk") + defer func() { require.NoError(teardown(), "failed to delete test disk") }() + + mapper, err := mapper.New(devicePath) + require.NoError(err, "failed to initialize crypt device") + defer func() { require.NoError(mapper.Close(), "failed to close crypt device") }() + + passphrase := "unit-test" + require.NoError(mapper.FormatDisk(passphrase), "failed to format disk") + require.NoError(mapper.MapDisk(mappedDevice, passphrase), "failed to map disk") + + require.NoError(mapper.UnmapDisk(mappedDevice), "failed to remove disk mapping") + assert.Error(mapper.MapDisk(mappedDevice, "invalid-passphrase"), "was able to map disk with incorrect passphrase") +}