LUKS key escrow validate against any keyslot (#48815)
**Related issue:** Resolves #46227
This commit is contained in:
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user