From da41cb6962f1b336a73d27e2d9e56266784f3edc Mon Sep 17 00:00:00 2001 From: Malte Poll Date: Mon, 15 Aug 2022 14:50:03 +0200 Subject: [PATCH] disk-mapper: systemd cryptsetup unit for state disk --- state/cmd/main.go | 1 + state/internal/setup/interface.go | 6 + state/internal/setup/setup.go | 34 ++++- state/internal/setup/setup_test.go | 201 +++++++++++++++---------- state/internal/systemd/systemd.go | 113 ++++++++++++++ state/internal/systemd/systemd_test.go | 43 ++++++ 6 files changed, 311 insertions(+), 87 deletions(-) create mode 100644 state/internal/systemd/systemd.go create mode 100644 state/internal/systemd/systemd_test.go diff --git a/state/cmd/main.go b/state/cmd/main.go index fe5d434f1..89026244d 100644 --- a/state/cmd/main.go +++ b/state/cmd/main.go @@ -95,6 +95,7 @@ func main() { setupManger := setup.New( log.Named("setupManager"), *csp, + diskPath, afero.Afero{Fs: afero.NewOsFs()}, keyservice.New(log.Named("keyService"), issuer, metadata, 20*time.Second, 20*time.Second), // try to request a key every 20 seconds mapper, diff --git a/state/internal/setup/interface.go b/state/internal/setup/interface.go index ec820753d..68538f9d3 100644 --- a/state/internal/setup/interface.go +++ b/state/internal/setup/interface.go @@ -18,6 +18,7 @@ type DeviceMapper interface { DiskUUID() string FormatDisk(passphrase string) error MapDisk(target string, passphrase string) error + UnmapDisk(target string) error } // KeyWaiter is an interface to request and wait for disk decryption keys. @@ -26,6 +27,11 @@ type KeyWaiter interface { ResetKey() } +// ConfigurationGenerator is an interface for generating systemd-cryptsetup@.service unit files. +type ConfigurationGenerator interface { + Generate(volumeName, encryptedDevice, keyFile, options string) error +} + // DiskMounter uses the syscall package to mount disks. type DiskMounter struct{} diff --git a/state/internal/setup/setup.go b/state/internal/setup/setup.go index 62a81116e..be0fbb0e3 100644 --- a/state/internal/setup/setup.go +++ b/state/internal/setup/setup.go @@ -14,6 +14,7 @@ import ( "github.com/edgelesssys/constellation/internal/crypto" "github.com/edgelesssys/constellation/internal/file" "github.com/edgelesssys/constellation/internal/logger" + "github.com/edgelesssys/constellation/state/internal/systemd" "github.com/spf13/afero" "go.uber.org/zap" ) @@ -24,29 +25,34 @@ const ( keyFile = "state.key" stateDiskMappedName = "state" stateDiskMountPath = "/var/run/state" + cryptsetupOptions = "cipher=aes-xts-plain64,integrity=hmac-sha256" stateInfoPath = stateDiskMountPath + "/constellation/node_state.json" ) -// SetupManager handles formating, mapping, mounting and unmounting of state disks. +// SetupManager handles formatting, mapping, mounting and unmounting of state disks. type SetupManager struct { log *logger.Logger csp string + diskPath string fs afero.Afero keyWaiter KeyWaiter mapper DeviceMapper mounter Mounter + config ConfigurationGenerator openTPM vtpm.TPMOpenFunc } // New initializes a SetupManager with the given parameters. -func New(log *logger.Logger, csp string, fs afero.Afero, keyWaiter KeyWaiter, mapper DeviceMapper, mounter Mounter, openTPM vtpm.TPMOpenFunc) *SetupManager { +func New(log *logger.Logger, csp string, diskPath string, fs afero.Afero, keyWaiter KeyWaiter, mapper DeviceMapper, mounter Mounter, openTPM vtpm.TPMOpenFunc) *SetupManager { return &SetupManager{ log: log, csp: csp, + diskPath: diskPath, fs: fs, keyWaiter: keyWaiter, mapper: mapper, mounter: mounter, + config: systemd.New(fs), openTPM: openTPM, } } @@ -92,6 +98,10 @@ getKey: return err } + if err := s.saveConfiguration(passphrase); err != nil { + return err + } + return s.mounter.Unmount(stateDiskMountPath, 0) } @@ -100,15 +110,11 @@ func (s *SetupManager) PrepareNewDisk() error { s.log.Infof("Preparing new state disk") // generate and save temporary passphrase - if err := s.fs.MkdirAll(keyPath, os.ModePerm); err != nil { - return err - } - passphrase := make([]byte, crypto.RNGLengthDefault) if _, err := rand.Read(passphrase); err != nil { return err } - if err := s.fs.WriteFile(filepath.Join(keyPath, keyFile), passphrase, 0o400); err != nil { + if err := s.saveConfiguration(passphrase); err != nil { return err } @@ -132,3 +138,17 @@ func (s *SetupManager) readMeasurementSalt(path string) ([]byte, error) { return state.MeasurementSalt, nil } + +// saveConfiguration saves the given passphrase and cryptsetup mapping configuration to disk. +func (s *SetupManager) saveConfiguration(passphrase []byte) error { + // passphrase + if err := s.fs.MkdirAll(keyPath, os.ModePerm); err != nil { + return err + } + if err := s.fs.WriteFile(filepath.Join(keyPath, keyFile), passphrase, 0o400); err != nil { + return err + } + + // systemd cryptsetup unit + return s.config.Generate(stateDiskMappedName, s.diskPath, filepath.Join(keyPath, keyFile), cryptsetupOptions) +} diff --git a/state/internal/setup/setup_test.go b/state/internal/setup/setup_test.go index 2b478f5dd..51aa0b723 100644 --- a/state/internal/setup/setup_test.go +++ b/state/internal/setup/setup_test.go @@ -26,81 +26,89 @@ func TestPrepareExistingDisk(t *testing.T) { someErr := errors.New("error") testCases := map[string]struct { - fs afero.Afero - keyWaiter *stubKeyWaiter - mapper *stubMapper - mounter *stubMounter - openTPM vtpm.TPMOpenFunc - missingState bool - wantErr bool + keyWaiter *stubKeyWaiter + mapper *stubMapper + mounter *stubMounter + configGenerator *stubConfigurationGenerator + openTPM vtpm.TPMOpenFunc + missingState bool + wantErr bool }{ "success": { - fs: afero.Afero{Fs: afero.NewMemMapFs()}, - keyWaiter: &stubKeyWaiter{}, - mapper: &stubMapper{uuid: "test"}, - mounter: &stubMounter{}, - openTPM: vtpm.OpenNOPTPM, + keyWaiter: &stubKeyWaiter{}, + mapper: &stubMapper{uuid: "test"}, + mounter: &stubMounter{}, + configGenerator: &stubConfigurationGenerator{}, + openTPM: vtpm.OpenNOPTPM, }, "WaitForDecryptionKey fails": { - fs: afero.Afero{Fs: afero.NewMemMapFs()}, - keyWaiter: &stubKeyWaiter{waitErr: someErr}, - mapper: &stubMapper{uuid: "test"}, - mounter: &stubMounter{}, - openTPM: vtpm.OpenNOPTPM, - wantErr: true, + keyWaiter: &stubKeyWaiter{waitErr: someErr}, + mapper: &stubMapper{uuid: "test"}, + mounter: &stubMounter{}, + configGenerator: &stubConfigurationGenerator{}, + openTPM: vtpm.OpenNOPTPM, + wantErr: true, }, "MapDisk fails causes a repeat": { - fs: afero.Afero{Fs: afero.NewMemMapFs()}, keyWaiter: &stubKeyWaiter{}, mapper: &stubMapper{ uuid: "test", mapDiskErr: someErr, mapDiskRepeatedCalls: 2, }, - mounter: &stubMounter{}, - openTPM: vtpm.OpenNOPTPM, - wantErr: false, + mounter: &stubMounter{}, + configGenerator: &stubConfigurationGenerator{}, + openTPM: vtpm.OpenNOPTPM, + wantErr: false, }, "MkdirAll fails": { - fs: afero.Afero{Fs: afero.NewMemMapFs()}, - keyWaiter: &stubKeyWaiter{}, - mapper: &stubMapper{uuid: "test"}, - mounter: &stubMounter{mkdirAllErr: someErr}, - openTPM: vtpm.OpenNOPTPM, - wantErr: true, + keyWaiter: &stubKeyWaiter{}, + mapper: &stubMapper{uuid: "test"}, + mounter: &stubMounter{mkdirAllErr: someErr}, + configGenerator: &stubConfigurationGenerator{}, + openTPM: vtpm.OpenNOPTPM, + wantErr: true, }, "Mount fails": { - fs: afero.Afero{Fs: afero.NewMemMapFs()}, - keyWaiter: &stubKeyWaiter{}, - mapper: &stubMapper{uuid: "test"}, - mounter: &stubMounter{mountErr: someErr}, - openTPM: vtpm.OpenNOPTPM, - wantErr: true, + keyWaiter: &stubKeyWaiter{}, + mapper: &stubMapper{uuid: "test"}, + mounter: &stubMounter{mountErr: someErr}, + configGenerator: &stubConfigurationGenerator{}, + openTPM: vtpm.OpenNOPTPM, + wantErr: true, }, "Unmount fails": { - fs: afero.Afero{Fs: afero.NewMemMapFs()}, - keyWaiter: &stubKeyWaiter{}, - mapper: &stubMapper{uuid: "test"}, - mounter: &stubMounter{unmountErr: someErr}, - openTPM: vtpm.OpenNOPTPM, - wantErr: true, + keyWaiter: &stubKeyWaiter{}, + mapper: &stubMapper{uuid: "test"}, + mounter: &stubMounter{unmountErr: someErr}, + configGenerator: &stubConfigurationGenerator{}, + openTPM: vtpm.OpenNOPTPM, + wantErr: true, }, "MarkNodeAsBootstrapped fails": { - fs: afero.Afero{Fs: afero.NewMemMapFs()}, - keyWaiter: &stubKeyWaiter{}, - mapper: &stubMapper{uuid: "test"}, - mounter: &stubMounter{unmountErr: someErr}, - openTPM: failOpener, - wantErr: true, + keyWaiter: &stubKeyWaiter{}, + mapper: &stubMapper{uuid: "test"}, + mounter: &stubMounter{unmountErr: someErr}, + configGenerator: &stubConfigurationGenerator{}, + openTPM: failOpener, + wantErr: true, + }, + "Generating config fails": { + keyWaiter: &stubKeyWaiter{}, + mapper: &stubMapper{uuid: "test"}, + mounter: &stubMounter{}, + configGenerator: &stubConfigurationGenerator{generateErr: someErr}, + openTPM: failOpener, + wantErr: true, }, "no state file": { - fs: afero.Afero{Fs: afero.NewMemMapFs()}, - keyWaiter: &stubKeyWaiter{}, - mapper: &stubMapper{uuid: "test"}, - mounter: &stubMounter{}, - openTPM: vtpm.OpenNOPTPM, - missingState: true, - wantErr: true, + keyWaiter: &stubKeyWaiter{}, + mapper: &stubMapper{uuid: "test"}, + mounter: &stubMounter{}, + configGenerator: &stubConfigurationGenerator{}, + openTPM: vtpm.OpenNOPTPM, + missingState: true, + wantErr: true, }, } @@ -108,21 +116,24 @@ func TestPrepareExistingDisk(t *testing.T) { t.Run(name, func(t *testing.T) { assert := assert.New(t) + fs := afero.Afero{Fs: afero.NewMemMapFs()} salt := []byte("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") if !tc.missingState { - handler := file.NewHandler(tc.fs) + handler := file.NewHandler(fs) require.NoError(t, handler.WriteJSON(stateInfoPath, nodestate.NodeState{MeasurementSalt: salt}, file.OptMkdirAll)) } - setupManager := New( - logger.NewTest(t), - "test", - tc.fs, - tc.keyWaiter, - tc.mapper, - tc.mounter, - tc.openTPM, - ) + setupManager := &SetupManager{ + log: logger.NewTest(t), + csp: "test", + diskPath: "disk-path", + fs: fs, + keyWaiter: tc.keyWaiter, + mapper: tc.mapper, + mounter: tc.mounter, + config: tc.configGenerator, + openTPM: tc.openTPM, + } err := setupManager.PrepareExistingDisk() if tc.wantErr { @@ -146,18 +157,21 @@ func failOpener() (io.ReadWriteCloser, error) { func TestPrepareNewDisk(t *testing.T) { someErr := errors.New("error") testCases := map[string]struct { - fs afero.Afero - mapper *stubMapper - wantErr bool + fs afero.Afero + mapper *stubMapper + configGenerator *stubConfigurationGenerator + wantErr bool }{ "success": { - fs: afero.Afero{Fs: afero.NewMemMapFs()}, - mapper: &stubMapper{uuid: "test"}, + fs: afero.Afero{Fs: afero.NewMemMapFs()}, + mapper: &stubMapper{uuid: "test"}, + configGenerator: &stubConfigurationGenerator{}, }, "creating directory fails": { - fs: afero.Afero{Fs: afero.NewReadOnlyFs(afero.NewMemMapFs())}, - mapper: &stubMapper{}, - wantErr: true, + fs: afero.Afero{Fs: afero.NewReadOnlyFs(afero.NewMemMapFs())}, + mapper: &stubMapper{}, + configGenerator: &stubConfigurationGenerator{}, + wantErr: true, }, "FormatDisk fails": { fs: afero.Afero{Fs: afero.NewMemMapFs()}, @@ -165,7 +179,8 @@ func TestPrepareNewDisk(t *testing.T) { uuid: "test", formatDiskErr: someErr, }, - wantErr: true, + configGenerator: &stubConfigurationGenerator{}, + wantErr: true, }, "MapDisk fails": { fs: afero.Afero{Fs: afero.NewMemMapFs()}, @@ -174,7 +189,14 @@ func TestPrepareNewDisk(t *testing.T) { mapDiskErr: someErr, mapDiskRepeatedCalls: 1, }, - wantErr: true, + configGenerator: &stubConfigurationGenerator{}, + wantErr: true, + }, + "Generating config fails": { + fs: afero.Afero{Fs: afero.NewMemMapFs()}, + mapper: &stubMapper{uuid: "test"}, + configGenerator: &stubConfigurationGenerator{generateErr: someErr}, + wantErr: true, }, } @@ -182,7 +204,14 @@ func TestPrepareNewDisk(t *testing.T) { t.Run(name, func(t *testing.T) { assert := assert.New(t) - setupManager := New(logger.NewTest(t), "test", tc.fs, nil, tc.mapper, nil, nil) + setupManager := &SetupManager{ + log: logger.NewTest(t), + csp: "test", + diskPath: "disk-path", + fs: tc.fs, + mapper: tc.mapper, + config: tc.configGenerator, + } err := setupManager.PrepareNewDisk() if tc.wantErr { @@ -203,22 +232,18 @@ func TestPrepareNewDisk(t *testing.T) { func TestReadMeasurementSalt(t *testing.T) { salt := []byte("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") testCases := map[string]struct { - fs afero.Afero salt []byte writeFile bool wantErr bool }{ "success": { - fs: afero.Afero{Fs: afero.NewMemMapFs()}, salt: salt, writeFile: true, }, "no state file": { - fs: afero.Afero{Fs: afero.NewMemMapFs()}, wantErr: true, }, "missing salt": { - fs: afero.Afero{Fs: afero.NewMemMapFs()}, writeFile: true, wantErr: true, }, @@ -229,13 +254,14 @@ func TestReadMeasurementSalt(t *testing.T) { assert := assert.New(t) require := require.New(t) + fs := afero.Afero{Fs: afero.NewMemMapFs()} if tc.writeFile { - handler := file.NewHandler(tc.fs) + handler := file.NewHandler(fs) state := nodestate.NodeState{MeasurementSalt: tc.salt} require.NoError(handler.WriteJSON("test-state.json", state, file.OptMkdirAll)) } - setupManager := New(logger.NewTest(t), "test", tc.fs, nil, nil, nil, nil) + setupManager := New(logger.NewTest(t), "test", "disk-path", fs, nil, nil, nil, nil) measurementSalt, err := setupManager.readMeasurementSalt("test-state.json") if tc.wantErr { @@ -254,6 +280,8 @@ type stubMapper struct { mapDiskRepeatedCalls int mapDiskCalled bool mapDiskErr error + unmapDiskCalled bool + unmapDiskErr error uuid string } @@ -275,6 +303,11 @@ func (s *stubMapper) MapDisk(string, string) error { return s.mapDiskErr } +func (s *stubMapper) UnmapDisk(string) error { + s.unmapDiskCalled = true + return s.unmapDiskErr +} + type stubMounter struct { mountCalled bool mountErr error @@ -317,3 +350,11 @@ func (s *stubKeyWaiter) WaitForDecryptionKey(uuid, addr string) ([]byte, []byte, func (s *stubKeyWaiter) ResetKey() { s.waitCalled = false } + +type stubConfigurationGenerator struct { + generateErr error +} + +func (s *stubConfigurationGenerator) Generate(volumeName, encryptedDevice, keyFile, options string) error { + return s.generateErr +} diff --git a/state/internal/systemd/systemd.go b/state/internal/systemd/systemd.go new file mode 100644 index 000000000..2c3482cf5 --- /dev/null +++ b/state/internal/systemd/systemd.go @@ -0,0 +1,113 @@ +package systemd + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/spf13/afero" +) + +const ( + systemdRuntimeUnitPath = "/run/systemd/system" + systemdUnitName = "systemd-cryptsetup@state.service" + systemdDeviceRequires = "dev-mapper-state.device.requires" + systemdCryptsetupTargetRequires = "cryptsetup.target.requires" +) + +// CryptsetupUnitGenerator generates systemd-cryptsetup@.service unit files. +type CryptsetupUnitGenerator struct { + fs afero.Afero +} + +// New returns a new CryptsetupUnitGenerator. +func New(fs afero.Afero) CryptsetupUnitGenerator { + return CryptsetupUnitGenerator{fs: fs} +} + +// Generate generates a systemd-cryptsetup@.service unit file and its dependencies. +func (g CryptsetupUnitGenerator) Generate(volumeName, encryptedDevice, keyFile, options string) error { + unitContents, err := g.configureUnit(volumeName, encryptedDevice, keyFile, options) + if err != nil { + return err + } + return g.writeUnits(unitContents) +} + +// configureUnit generates the systemd-cryptsetup@.service unit file contents. +func (g CryptsetupUnitGenerator) configureUnit(volumeName, encryptedDevice, keyFile, options string) (string, error) { + deviceUnit := strings.ReplaceAll(encryptedDevice, "/", "-") + ".device" + deviceUnit = strings.TrimPrefix(deviceUnit, "-") + templ, err := template.New("").Parse(`[Unit] +Description=Cryptography Setup for %I +Documentation=man:crypttab(5) man:systemd-cryptsetup-generator(8) man:systemd-cryptsetup@.service(8) +DefaultDependencies=no +IgnoreOnIsolate=true +After=cryptsetup-pre.target systemd-udevd-kernel.socket +Before=blockdev@dev-mapper-%i.target +Wants=blockdev@dev-mapper-%i.target +Conflicts=umount.target +Before=cryptsetup.target +RequiresMountsFor={{.keyFile}} +BindsTo={{.deviceUnit}} +After={{.deviceUnit}} +Before=umount.target + +[Service] +Type=oneshot +RemainAfterExit=yes +TimeoutSec=0 +KeyringMode=shared +OOMScoreAdjust=500 +ExecStart=/usr/lib/systemd/systemd-cryptsetup attach '{{.volumeName}}' '{{.encryptedDevice}}' '{{.keyFile}}' '{{.options}}' +ExecStop=/usr/lib/systemd/systemd-cryptsetup detach '{{.volumeName}}' +`) + if err != nil { + return "", err + } + + var buf bytes.Buffer + err = templ.Execute(&buf, map[string]string{ + "volumeName": volumeName, + "encryptedDevice": encryptedDevice, + "deviceUnit": deviceUnit, + "keyFile": keyFile, + "options": options, + }) + if err != nil { + return "", err + } + return buf.String(), nil +} + +// writeUnits writes the unit file and its dependencies to the filesystem. +func (g CryptsetupUnitGenerator) writeUnits(unitContents string) error { + if err := g.fs.MkdirAll(systemdRuntimeUnitPath, os.ModePerm); err != nil { + return err + } + if err := g.fs.Mkdir(filepath.Join(systemdRuntimeUnitPath, systemdDeviceRequires), os.ModePerm); err != nil { + return err + } + if err := g.fs.Mkdir(filepath.Join(systemdRuntimeUnitPath, systemdCryptsetupTargetRequires), os.ModePerm); err != nil { + return err + } + unitPath := filepath.Join(systemdRuntimeUnitPath, systemdUnitName) + if err := g.fs.WriteFile(unitPath, []byte(unitContents), 0o444); err != nil { + return err + } + if symlinker, ok := g.fs.Fs.(afero.Symlinker); ok { + if err := symlinker.SymlinkIfPossible(unitPath, filepath.Join(systemdRuntimeUnitPath, systemdDeviceRequires, systemdUnitName)); err != nil { + return fmt.Errorf("creating device symlink: %w", err) + } + if err := symlinker.SymlinkIfPossible(unitPath, filepath.Join(systemdRuntimeUnitPath, systemdCryptsetupTargetRequires, systemdUnitName)); err != nil { + return fmt.Errorf("creating cryptsetup target symlink: %w", err) + } + } else { + return errors.New("fs does not support symlinks") + } + return nil +} diff --git a/state/internal/systemd/systemd_test.go b/state/internal/systemd/systemd_test.go new file mode 100644 index 000000000..054b240d5 --- /dev/null +++ b/state/internal/systemd/systemd_test.go @@ -0,0 +1,43 @@ +package systemd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +func TestConfigureUnit(t *testing.T) { + assert := assert.New(t) + generator := CryptsetupUnitGenerator{} + got, err := generator.configureUnit("volumeName", "/encrypted/device/path", "/key/file/path", "options") + assert.NoError(err) + assert.Equal(`[Unit] +Description=Cryptography Setup for %I +Documentation=man:crypttab(5) man:systemd-cryptsetup-generator(8) man:systemd-cryptsetup@.service(8) +DefaultDependencies=no +IgnoreOnIsolate=true +After=cryptsetup-pre.target systemd-udevd-kernel.socket +Before=blockdev@dev-mapper-%i.target +Wants=blockdev@dev-mapper-%i.target +Conflicts=umount.target +Before=cryptsetup.target +RequiresMountsFor=/key/file/path +BindsTo=encrypted-device-path.device +After=encrypted-device-path.device +Before=umount.target + +[Service] +Type=oneshot +RemainAfterExit=yes +TimeoutSec=0 +KeyringMode=shared +OOMScoreAdjust=500 +ExecStart=/usr/lib/systemd/systemd-cryptsetup attach 'volumeName' '/encrypted/device/path' '/key/file/path' 'options' +ExecStop=/usr/lib/systemd/systemd-cryptsetup detach 'volumeName' +`, got) +}