diff --git a/changes/46227-luks-key-escrow-any-slot b/changes/46227-luks-key-escrow-any-slot new file mode 100644 index 0000000000..4c2ffb249b --- /dev/null +++ b/changes/46227-luks-key-escrow-any-slot @@ -0,0 +1 @@ +* Fixed LUKS disk encryption key escrow failing with a misleading "passphrase incorrect" error on Linux hosts whose passphrase is stored in a key slot other than slot 0. The existing passphrase is now validated against any key slot. diff --git a/orbit/pkg/luks/luks_linux.go b/orbit/pkg/luks/luks_linux.go index d8a03c86fe..e1add4a3c3 100644 --- a/orbit/pkg/luks/luks_linux.go +++ b/orbit/pkg/luks/luks_linux.go @@ -36,11 +36,19 @@ const ( infoSuccessText = "Disk encryption key escrowed to Fleet. Close this window, navigate to your Fleet My Device page, and select Refetch to clear the yellow banner." timeoutMessage = "Please visit Fleet Desktop > My device and click Create key" maxKeySlots = 8 - userKeySlot = 0 // Key slot 0 is assumed to be the location of the user's passphrase ) var ErrKeySlotFull = regexp.MustCompile(`Key slot \d+ is full`) +// luksDevice abstracts the subset of the go-blockdevice LUKS operations that +// the escrow flow needs. *luksdevice.LUKS satisfies it; tests substitute a +// fake so the prompt/validate logic can be exercised without cryptsetup or a +// real LUKS volume. +type luksDevice interface { + CheckKey(ctx context.Context, devname string, key *encryption.Key) (bool, error) + AddKey(ctx context.Context, devname string, key, newKey *encryption.Key) error +} + func isInstalled(toolName string) bool { path, err := exec.LookPath(toolName) if err != nil { @@ -172,41 +180,16 @@ func (lr *LuksRunner) getEscrowKey(ctx context.Context, devicePath string) ([]by // AESXTSPlain64Cipher is the default cipher used by ubuntu/kubuntu/fedora device := luksdevice.New(luksdevice.AESXTSPlain64Cipher) - // Prompt user for existing LUKS passphrase - passphrase, err := lr.entryPrompt(entryDialogTitle, entryDialogText) + // Prompt the user for their existing LUKS passphrase and validate it. A nil + // passphrase with no error means the dialog was canceled or timed out. + passphrase, err := lr.promptAndValidatePassphrase(ctx, device, devicePath) if err != nil { - return nil, nil, fmt.Errorf("Failed to show passphrase entry prompt: %w", err) + return nil, nil, err } - if len(passphrase) == 0 { - log.Debug().Msg("Passphrase is empty, no password supplied, dialog was canceled, or timed out") return nil, nil, nil } - // Validate the passphrase - for { - log.Debug().Msg("Validating disk passphrase") - valid, err := lr.passphraseIsValid(ctx, device, devicePath, passphrase, userKeySlot) - if err != nil { - return nil, nil, fmt.Errorf("Failed validating passphrase: %w", err) - } - - if valid { - break - } - - passphrase, err = lr.entryPrompt(entryDialogTitle, retryEntryDialogText) - if err != nil { - return nil, nil, fmt.Errorf("Failed re-prompting for passphrase: %w", err) - } - - if len(passphrase) == 0 { - log.Debug().Msg("Passphrase is empty, no password supplied, dialog was canceled, or timed out") - return nil, nil, nil - } - - } - log.Debug().Msg("Generating random disk encryption passphrase") escrowPassphrase, err := generateRandomPassphrase() if err != nil { @@ -220,32 +203,89 @@ func (lr *LuksRunner) getEscrowKey(ctx context.Context, devicePath string) ([]by } log.Debug().Msgf("Found available keyslot: %d", keySlot) - userKey := encryption.NewKey(userKeySlot, passphrase) - escrowKey := encryption.NewKey(int(keySlot), escrowPassphrase) // #nosec G115 - - if err := device.AddKey(ctx, devicePath, userKey, escrowKey); err != nil { - return nil, nil, fmt.Errorf("Failed to add key: %w", err) - } - - log.Debug().Msg("Validating newly inserted key") - valid, err := lr.passphraseIsValid(ctx, device, devicePath, escrowPassphrase, keySlot) - if err != nil { - return nil, nil, fmt.Errorf("Error while validating escrow passphrase: %w", err) - } - - if !valid { - return nil, nil, errors.New("Failed to validate escrow passphrase") + if err := lr.addEscrowKey(ctx, device, devicePath, passphrase, escrowPassphrase, keySlot); err != nil { + return nil, nil, err } return escrowPassphrase, &keySlot, nil } -func (lr *LuksRunner) passphraseIsValid(ctx context.Context, device *luksdevice.LUKS, devicePath string, passphrase []byte, keyslot uint) (bool, error) { +// promptAndValidatePassphrase asks the end user for their existing LUKS +// passphrase and validates it, re-prompting with retry copy until a valid +// passphrase is entered. It returns a nil passphrase with no error when the +// user cancels or the dialog times out (empty entry). +// +// Validation is performed against any key slot (encryption.AnyKeyslot) rather +// than assuming slot 0 — a user's passphrase can legitimately live in a higher +// slot, and pinning the check to slot 0 made correct passphrases look invalid +// (issue #46227). +func (lr *LuksRunner) promptAndValidatePassphrase(ctx context.Context, device luksDevice, devicePath string) ([]byte, error) { + passphrase, err := lr.entryPrompt(entryDialogTitle, entryDialogText) + if err != nil { + return nil, fmt.Errorf("Failed to show passphrase entry prompt: %w", err) + } + + if len(passphrase) == 0 { + log.Debug().Msg("Passphrase is empty, no password supplied, dialog was canceled, or timed out") + return nil, nil + } + + for { + log.Debug().Msg("Validating disk passphrase") + valid, err := lr.passphraseIsValid(ctx, device, devicePath, passphrase, encryption.AnyKeyslot) + if err != nil { + return nil, fmt.Errorf("Failed validating passphrase: %w", err) + } + + if valid { + return passphrase, nil + } + + passphrase, err = lr.entryPrompt(entryDialogTitle, retryEntryDialogText) + if err != nil { + return nil, fmt.Errorf("Failed re-prompting for passphrase: %w", err) + } + + if len(passphrase) == 0 { + log.Debug().Msg("Passphrase is empty, no password supplied, dialog was canceled, or timed out") + return nil, nil + } + } +} + +// addEscrowKey adds escrowPassphrase to keySlot using the user's existing +// passphrase to unlock the volume, then verifies the new key is usable. +// +// The existing key is created with encryption.AnyKeyslot so cryptsetup finds +// whichever slot the user's passphrase actually lives in — it is not +// necessarily slot 0. +func (lr *LuksRunner) addEscrowKey(ctx context.Context, device luksDevice, devicePath string, passphrase, escrowPassphrase []byte, keySlot uint) error { + userKey := encryption.NewKey(encryption.AnyKeyslot, passphrase) + escrowKey := encryption.NewKey(int(keySlot), escrowPassphrase) // #nosec G115 + + if err := device.AddKey(ctx, devicePath, userKey, escrowKey); err != nil { + return fmt.Errorf("Failed to add key: %w", err) + } + + log.Debug().Msg("Validating newly inserted key") + valid, err := lr.passphraseIsValid(ctx, device, devicePath, escrowPassphrase, int(keySlot)) // #nosec G115 + if err != nil { + return fmt.Errorf("Error while validating escrow passphrase: %w", err) + } + + if !valid { + return errors.New("Failed to validate escrow passphrase") + } + + return nil +} + +func (lr *LuksRunner) passphraseIsValid(ctx context.Context, device luksDevice, devicePath string, passphrase []byte, keyslot int) (bool, error) { if len(passphrase) == 0 { return false, nil } - valid, err := device.CheckKey(ctx, devicePath, encryption.NewKey(int(keyslot), passphrase)) // #nosec G115 + valid, err := device.CheckKey(ctx, devicePath, encryption.NewKey(keyslot, passphrase)) if err != nil { return false, fmt.Errorf("Error validating passphrase: %w", err) } diff --git a/orbit/pkg/luks/luks_linux_test.go b/orbit/pkg/luks/luks_linux_test.go new file mode 100644 index 0000000000..7a440a5331 --- /dev/null +++ b/orbit/pkg/luks/luks_linux_test.go @@ -0,0 +1,276 @@ +//go:build linux + +package luks + +import ( + "context" + "errors" + "testing" + + "github.com/fleetdm/fleet/v4/orbit/pkg/dialog" + "github.com/siderolabs/go-blockdevice/v2/encryption" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// scriptedEntry is a single canned response from the fake dialog's ShowEntry. +type scriptedEntry struct { + value []byte + err error +} + +// fakeDialog is a dialog.Dialog test double. ShowEntry returns the scripted +// entries in order and records the text it was shown with each call. +type fakeDialog struct { + entries []scriptedEntry + callIdx int + shownText []string + infoTexts []string +} + +func (f *fakeDialog) ShowEntry(opts dialog.EntryOptions) ([]byte, error) { + f.shownText = append(f.shownText, opts.Text) + if f.callIdx >= len(f.entries) { + return nil, dialog.ErrCanceled + } + e := f.entries[f.callIdx] + f.callIdx++ + return e.value, e.err +} + +func (f *fakeDialog) ShowInfo(opts dialog.InfoOptions) error { + f.infoTexts = append(f.infoTexts, opts.Text) + return nil +} + +// fakeLUKSDevice is a luksDevice test double. CheckKey returns valid only when +// validIn(slot, passphrase) reports true, simulating cryptsetup accepting the +// passphrase against a particular set of slots. It records every slot it was +// asked to check so tests can assert which slot the escrow flow used. +type fakeLUKSDevice struct { + validIn func(slot int, passphrase []byte) bool + checkErr error + checkedSlots []int + + addErr error + addCalled bool + addExistingKey *encryption.Key + addNewKey *encryption.Key + addedSlots map[int][]byte // slot -> passphrase, populated by AddKey + dontRegisterAdded bool // when true, AddKey succeeds but the key won't validate +} + +func (d *fakeLUKSDevice) CheckKey(_ context.Context, _ string, key *encryption.Key) (bool, error) { + d.checkedSlots = append(d.checkedSlots, key.Slot) + if d.checkErr != nil { + return false, d.checkErr + } + // A key just added by AddKey validates against its concrete slot. + if pw, ok := d.addedSlots[key.Slot]; ok && string(pw) == string(key.Value) { + return true, nil + } + if d.validIn == nil { + return false, nil + } + return d.validIn(key.Slot, key.Value), nil +} + +func (d *fakeLUKSDevice) AddKey(_ context.Context, _ string, key, newKey *encryption.Key) error { + d.addCalled = true + d.addExistingKey = key + d.addNewKey = newKey + if d.addErr != nil { + return d.addErr + } + if d.dontRegisterAdded { + return nil + } + if d.addedSlots == nil { + d.addedSlots = make(map[int][]byte) + } + d.addedSlots[newKey.Slot] = newKey.Value + return nil +} + +// TestPromptAndValidatePassphraseValidatesAgainstAnySlot is the core +// regression test for issue #46227: a passphrase that only validates when no +// specific key slot is requested (i.e. the user's key lives in a non-zero +// slot) must still be accepted. The old code pinned the check to slot 0 and +// rejected such passphrases as if they were incorrect. +func TestPromptAndValidatePassphraseValidatesAgainstAnySlot(t *testing.T) { + ctx := t.Context() + correct := []byte("correct horse") + + dlg := &fakeDialog{entries: []scriptedEntry{{value: correct}}} + dev := &fakeLUKSDevice{ + // Mimics cryptsetup behavior with the user's key in a non-zero slot: + // rejected when --key-slot=0 is forced, accepted when any slot is allowed. + validIn: func(slot int, passphrase []byte) bool { + return slot == encryption.AnyKeyslot && string(passphrase) == string(correct) + }, + } + lr := &LuksRunner{notifier: dlg} + + got, err := lr.promptAndValidatePassphrase(ctx, dev, "/dev/sda") + require.NoError(t, err) + assert.Equal(t, correct, got) + + // The passphrase must have been validated against any slot, not slot 0. + require.Len(t, dev.checkedSlots, 1) + assert.Equal(t, encryption.AnyKeyslot, dev.checkedSlots[0]) + // User was only prompted once, no retry. + assert.Equal(t, []string{entryDialogText}, dlg.shownText) +} + +// TestPromptAndValidatePassphraseRetries verifies that an incorrect passphrase +// re-prompts with the retry copy and that a subsequently correct passphrase is +// accepted. +func TestPromptAndValidatePassphraseRetries(t *testing.T) { + ctx := t.Context() + correct := []byte("right") + + dlg := &fakeDialog{entries: []scriptedEntry{ + {value: []byte("wrong")}, + {value: correct}, + }} + dev := &fakeLUKSDevice{ + validIn: func(_ int, passphrase []byte) bool { + return string(passphrase) == string(correct) + }, + } + lr := &LuksRunner{notifier: dlg} + + got, err := lr.promptAndValidatePassphrase(ctx, dev, "/dev/sda") + require.NoError(t, err) + assert.Equal(t, correct, got) + + assert.Len(t, dev.checkedSlots, 2) + // First prompt used the initial copy, second used the retry copy. + assert.Equal(t, []string{entryDialogText, retryEntryDialogText}, dlg.shownText) +} + +// TestPromptAndValidatePassphraseCanceled verifies that an empty entry (user +// canceled or the dialog timed out) returns a nil passphrase with no error and +// never attempts validation. +func TestPromptAndValidatePassphraseCanceled(t *testing.T) { + ctx := t.Context() + + dlg := &fakeDialog{entries: []scriptedEntry{{value: nil}}} + dev := &fakeLUKSDevice{} + lr := &LuksRunner{notifier: dlg} + + got, err := lr.promptAndValidatePassphrase(ctx, dev, "/dev/sda") + require.NoError(t, err) + assert.Nil(t, got) + assert.Empty(t, dev.checkedSlots) +} + +// TestPromptAndValidatePassphraseCanceledDuringRetry verifies that canceling +// at the retry prompt (after an incorrect first attempt) aborts cleanly. +func TestPromptAndValidatePassphraseCanceledDuringRetry(t *testing.T) { + ctx := t.Context() + + dlg := &fakeDialog{entries: []scriptedEntry{ + {value: []byte("wrong")}, + {value: nil}, + }} + dev := &fakeLUKSDevice{ + validIn: func(_ int, _ []byte) bool { return false }, + } + lr := &LuksRunner{notifier: dlg} + + got, err := lr.promptAndValidatePassphrase(ctx, dev, "/dev/sda") + require.NoError(t, err) + assert.Nil(t, got) + assert.Len(t, dev.checkedSlots, 1) +} + +// TestPromptAndValidatePassphraseCheckKeyError verifies that a genuine error +// from the device (as opposed to a rejected passphrase) is surfaced wrapped, +// rather than being treated as an incorrect passphrase. +func TestPromptAndValidatePassphraseCheckKeyError(t *testing.T) { + ctx := t.Context() + + dlg := &fakeDialog{entries: []scriptedEntry{{value: []byte("whatever")}}} + dev := &fakeLUKSDevice{checkErr: errors.New("cryptsetup boom")} + lr := &LuksRunner{notifier: dlg} + + got, err := lr.promptAndValidatePassphrase(ctx, dev, "/dev/sda") + require.Error(t, err) + assert.Contains(t, err.Error(), "Failed validating passphrase") + assert.Nil(t, got) +} + +// TestPassphraseIsValidEmpty verifies the short-circuit: an empty passphrase is +// invalid without touching the device. +func TestPassphraseIsValidEmpty(t *testing.T) { + ctx := t.Context() + dev := &fakeLUKSDevice{} + lr := &LuksRunner{} + + valid, err := lr.passphraseIsValid(ctx, dev, "/dev/sda", nil, encryption.AnyKeyslot) + require.NoError(t, err) + assert.False(t, valid) + assert.Empty(t, dev.checkedSlots) +} + +// TestAddEscrowKeyUsesAnyKeyslotForExistingKey verifies that when adding the +// escrow key, the user's *existing* passphrase is presented with +// encryption.AnyKeyslot so cryptsetup finds whichever slot it lives in, while +// the new escrow key is pinned to the discovered free slot. +func TestAddEscrowKeyUsesAnyKeyslotForExistingKey(t *testing.T) { + ctx := t.Context() + userPassphrase := []byte("user secret in slot 3") + escrowPassphrase := []byte("AAAA-BBBB-CCCC-DDDD") + const escrowSlot uint = 4 + + dev := &fakeLUKSDevice{} + lr := &LuksRunner{} + + err := lr.addEscrowKey(ctx, dev, "/dev/sda", userPassphrase, escrowPassphrase, escrowSlot) + require.NoError(t, err) + + require.True(t, dev.addCalled) + // Existing key must not be pinned to a specific slot. + require.NotNil(t, dev.addExistingKey) + assert.Equal(t, encryption.AnyKeyslot, dev.addExistingKey.Slot) + assert.Equal(t, userPassphrase, dev.addExistingKey.Value) + // New escrow key must be pinned to the discovered free slot. + require.NotNil(t, dev.addNewKey) + assert.Equal(t, int(escrowSlot), dev.addNewKey.Slot) + assert.Equal(t, escrowPassphrase, dev.addNewKey.Value) + // Post-add validation checks the concrete escrow slot, not AnyKeyslot. + assert.Equal(t, []int{int(escrowSlot)}, dev.checkedSlots) +} + +// TestAddEscrowKeyValidationFails verifies that a freshly added key that does +// not validate surfaces an error rather than reporting success. +func TestAddEscrowKeyValidationFails(t *testing.T) { + ctx := t.Context() + dev := &fakeLUKSDevice{ + // AddKey succeeds but the key is never registered, so post-add + // validation reports it invalid. + dontRegisterAdded: true, + } + lr := &LuksRunner{} + + err := lr.addEscrowKey(ctx, dev, "/dev/sda", []byte("user"), []byte("escrow"), 2) + require.Error(t, err) + assert.Contains(t, err.Error(), "Failed to validate escrow passphrase") +} + +// TestAddEscrowKeyAddKeyError verifies that an error from device.AddKey is +// surfaced wrapped as "Failed to add key" and that no post-add validation is +// attempted. +func TestAddEscrowKeyAddKeyError(t *testing.T) { + ctx := t.Context() + dev := &fakeLUKSDevice{addErr: errors.New("cryptsetup add boom")} + lr := &LuksRunner{} + + err := lr.addEscrowKey(ctx, dev, "/dev/sda", []byte("user"), []byte("escrow"), 2) + require.Error(t, err) + assert.Contains(t, err.Error(), "Failed to add key") + assert.Contains(t, err.Error(), "cryptsetup add boom") + // AddKey failed, so the escrow key was never validated. + assert.Empty(t, dev.checkedSlots) +}