LUKS key escrow validate against any keyslot (#48815)

**Related issue:** Resolves #46227
This commit is contained in:
Dante Catalfamo
2026-07-09 13:04:31 -04:00
committed by GitHub
parent d4a6ec3b03
commit 374aa7e612
3 changed files with 364 additions and 47 deletions
+1
View File
@@ -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.
+87 -47
View File
@@ -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)
}
+276
View File
@@ -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)
}