Files
2026-07-09 13:04:31 -04:00

538 lines
16 KiB
Go

//go:build linux
package luks
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"math/big"
"os/exec"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/Masterminds/semver"
"github.com/fleetdm/fleet/v4/orbit/pkg/dialog"
"github.com/fleetdm/fleet/v4/orbit/pkg/kdialog"
"github.com/fleetdm/fleet/v4/orbit/pkg/lvm"
"github.com/fleetdm/fleet/v4/orbit/pkg/zenity"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/rs/zerolog/log"
"github.com/siderolabs/go-blockdevice/v2/encryption"
luksdevice "github.com/siderolabs/go-blockdevice/v2/encryption/luks"
)
const (
entryDialogTitle = "Enter disk encryption passphrase"
entryDialogText = "Passphrase:"
retryEntryDialogText = "Passphrase incorrect. Please try again."
infoTitle = "Disk encryption"
infoFailedText = "Failed to escrow key. Please try again later."
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
)
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 {
return false
}
return path != ""
}
// ensureNotifier sets lr.notifier to the first available desktop dialog tool
// (zenity, then kdialog). If neither is installed the notifier stays nil and
// callers must treat notifications as best-effort.
func (lr *LuksRunner) ensureNotifier() {
if lr.notifier != nil {
return
}
switch {
case isInstalled("zenity"):
lr.notifier = zenity.New()
case isInstalled("kdialog"):
lr.notifier = kdialog.New()
}
}
func (lr *LuksRunner) Run(oc *fleet.OrbitConfig) error {
ctx := context.Background()
if !oc.Notifications.RunDiskEncryptionEscrow {
return nil
}
// Pick a notifier up front so both escrow paths can surface user-facing
// warnings. The passphrase path treats "no dialog tool" as fatal (it needs
// to prompt for a passphrase); the snapd/recovery-key path treats it as
// best-effort (silent success is fine, the failure notification just
// degrades to a log line).
lr.ensureNotifier()
// cryptsetup is a prerequisite for both the snapd detection path (which
// reads LUKS2 metadata via luksDump) and the passphrase escrow path (which
// adds a key slot), so check it up front before doing any detection.
if !isInstalled("cryptsetup") {
return errors.New("cryptsetup is not installed")
}
// snapd-managed TPM-backed FDE (e.g. Ubuntu 26) escrows a recovery key
// silently and needs no desktop dialog. Detect it first and take that path
// when present. Errors during detection are fatal here: a metadata read
// failure could mean the host IS snapd-managed but we cannot confirm, and
// silently falling through to the passphrase escrow path would show a
// misleading prompt for a passphrase the user doesn't have.
log.Info().Msg("disk encryption escrow requested; determining escrow path")
snapd := newSnapdFDE()
isSnapd, err := snapd.Detect(ctx)
if err != nil {
return fmt.Errorf("detecting snapd-managed FDE: %w", err)
}
if isSnapd {
log.Info().Msg("host uses snapd-managed TPM-backed FDE; escrowing recovery key via the snapd socket")
return lr.runRecoveryKeyEscrow(ctx, snapd)
}
log.Info().Msg("host is not snapd-managed FDE; using the passphrase escrow path")
if lr.notifier == nil {
return errors.New("No supported dialog tool found")
}
devicePath, err := lvm.FindRootDisk()
if err != nil {
return fmt.Errorf("Failed to find LUKS Root Partition: %w", err)
}
var response LuksResponse
key, keyslot, err := lr.getEscrowKey(ctx, devicePath)
if err != nil {
response.Err = err.Error()
}
if len(key) == 0 && err == nil {
// dialog was canceled or timed out
return nil
}
response.Passphrase = string(key)
response.KeySlot = keyslot
if keyslot != nil {
salt, err := getSaltforKeySlot(ctx, devicePath, *keyslot)
if err != nil {
if err := removeKeySlot(ctx, devicePath, *keyslot); err != nil {
log.Error().Err(err).Msgf("failed to remove key slot %d", *keyslot)
}
response.Err = fmt.Sprintf("Failed to get salt for key slot: %s", err)
}
response.Salt = salt
}
if err := lr.escrower.SendLinuxKeyEscrowResponse(response); err != nil {
// If sending the response fails, remove the key slot
if keyslot != nil {
if err := removeKeySlot(ctx, devicePath, *keyslot); err != nil {
log.Error().Err(err).Msg("failed to remove key slot")
}
}
// Show error in dialog
if err := lr.infoPrompt(infoTitle, infoFailedText); err != nil {
log.Info().Err(err).Msg("failed to show failed escrow key dialog")
}
return fmt.Errorf("escrower escrowKey err: %w", err)
}
if response.Err != "" {
if err := lr.infoPrompt(infoTitle, response.Err); err != nil {
log.Info().Err(err).Msg("failed to show response error dialog")
}
return fmt.Errorf("error getting linux escrow key: %s", response.Err)
}
// Show success dialog
if err := lr.infoPrompt(infoTitle, infoSuccessText); err != nil {
log.Info().Err(err).Msg("failed to show success escrow key dialog")
}
return nil
}
func (lr *LuksRunner) getEscrowKey(ctx context.Context, devicePath string) ([]byte, *uint, error) {
// AESXTSPlain64Cipher is the default cipher used by ubuntu/kubuntu/fedora
device := luksdevice.New(luksdevice.AESXTSPlain64Cipher)
// 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, err
}
if len(passphrase) == 0 {
return nil, nil, nil
}
log.Debug().Msg("Generating random disk encryption passphrase")
escrowPassphrase, err := generateRandomPassphrase()
if err != nil {
return nil, nil, fmt.Errorf("Failed to generate random passphrase: %w", err)
}
log.Debug().Msg("Getting the next available keyslot")
keySlot, err := getNextAvailableKeySlot(ctx, devicePath)
if err != nil {
return nil, nil, fmt.Errorf("finding available keyslot: %w", err)
}
log.Debug().Msgf("Found available keyslot: %d", keySlot)
if err := lr.addEscrowKey(ctx, device, devicePath, passphrase, escrowPassphrase, keySlot); err != nil {
return nil, nil, err
}
return escrowPassphrase, &keySlot, nil
}
// 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(keyslot, passphrase))
if err != nil {
return false, fmt.Errorf("Error validating passphrase: %w", err)
}
return valid, nil
}
func getNextAvailableKeySlot(ctx context.Context, devicePath string) (uint, error) {
dump, err := GetLuksDump(ctx, devicePath)
if err != nil {
return 0, fmt.Errorf("get next available key slot: %w", err)
}
keysTaken := []uint32{}
for keyStr := range dump.Keyslots {
key, err := strconv.ParseUint(keyStr, 10, 32)
if err != nil {
return 0, fmt.Errorf("parse next available key slot: %w", err)
}
keysTaken = append(keysTaken, uint32(key))
}
sort.Slice(keysTaken, func(i, j int) bool {
return keysTaken[i] < keysTaken[j]
})
// Check for gaps in keys in case one was deleted
var unusedKey uint32
for _, keySlot := range keysTaken {
if unusedKey == keySlot {
unusedKey++
}
}
if unusedKey >= maxKeySlots {
return 0, fmt.Errorf("no empty key slots available: %d", unusedKey)
}
return uint(unusedKey), nil
}
// generateRandomPassphrase generates a random passphrase with 32 characters
// in the format XXXX-XXXX-XXXX-XXXX where X is a random character from the
// set [0-9A-Za-z].
func generateRandomPassphrase() ([]byte, error) {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
const length = 35 // 32 characters + 3 dashes
passphrase := make([]byte, length)
for i := 0; i < length; i++ {
// Insert dashes at positions 8, 17, and 26
if i == 8 || i == 17 || i == 26 {
passphrase[i] = '-'
continue
}
num, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
if err != nil {
return nil, err
}
passphrase[i] = chars[num.Int64()]
}
return passphrase, nil
}
func (lr *LuksRunner) entryPrompt(title, text string) ([]byte, error) {
passphrase, err := lr.notifier.ShowEntry(dialog.EntryOptions{
Title: title,
Text: text,
HideText: true,
TimeOut: 1 * time.Minute,
})
if err != nil {
switch {
case errors.Is(err, dialog.ErrCanceled):
log.Debug().Msg("end user canceled key escrow dialog")
return nil, nil
case errors.Is(err, dialog.ErrTimeout):
log.Debug().Msg("key escrow dialog timed out")
err := lr.infoPrompt(infoTitle, timeoutMessage)
if err != nil {
log.Info().Err(err).Msg("failed to show timeout dialog")
}
return nil, nil
case errors.Is(err, dialog.ErrUnknown):
return nil, err
default:
return nil, err
}
}
return passphrase, nil
}
func (lr *LuksRunner) infoPrompt(title, text string) error {
err := lr.notifier.ShowInfo(dialog.InfoOptions{
Title: title,
Text: text,
TimeOut: 1 * time.Minute,
})
if err != nil {
switch {
case errors.Is(err, dialog.ErrTimeout):
log.Debug().Msg("successPrompt timed out")
return nil
default:
return err
}
}
return nil
}
func GetLuksDump(ctx context.Context, devicePath string) (*LuksDump, error) {
var jsonFlag string
var jsonNeedsExtraction bool
lessThan2_4, err := isCryptsetupVersionLessThan2_4()
if err != nil {
return nil, fmt.Errorf("Failed to check cryptsetup version: %w", err)
}
if lessThan2_4 {
jsonFlag = "--debug-json"
jsonNeedsExtraction = true
} else {
jsonFlag = "--dump-json-metadata"
}
cmd := exec.CommandContext(ctx, "cryptsetup", "luksDump", jsonFlag, devicePath)
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("Failed to run cryptsetup luksDump: %w", err)
}
if jsonNeedsExtraction {
output, err = extractJSON(output)
if err != nil {
return nil, fmt.Errorf("Failed to extract JSON from cryptsetup luksDump output: %w", err)
}
}
var dump LuksDump
if err := json.Unmarshal(output, &dump); err != nil {
return nil, fmt.Errorf("Failed to unmarshal luksDump output: %w", err)
}
return &dump, nil
}
func getSaltforKeySlot(ctx context.Context, devicePath string, keySlot uint) (string, error) {
dump, err := GetLuksDump(ctx, devicePath)
if err != nil {
return "", fmt.Errorf("getting salt for key slot: %w", err)
}
slot, ok := dump.Keyslots[fmt.Sprintf("%d", keySlot)]
if !ok {
return "", errors.New("key slot not found")
}
return slot.KDF.Salt, nil
}
func removeKeySlot(ctx context.Context, devicePath string, keySlot uint) error {
cmd := exec.CommandContext(ctx, "cryptsetup", "luksKillSlot", devicePath, fmt.Sprintf("%d", keySlot)) // #nosec G204
if err := cmd.Run(); err != nil {
return fmt.Errorf("Failed to run cryptsetup luksKillSlot: %w", err)
}
return nil
}
// snapdFDE is the production SnapdFDE implementation. It manages TPM-backed FDE
// recovery keys exclusively through the snapd REST API socket, which is
// guaranteed present wherever snapd-managed FDE is in use and requires no
// network or snap store access. Detection is pure LUKS2 metadata inspection.
type snapdFDE struct {
socket *snapdSocketFDE
}
func newSnapdFDE() SnapdFDE {
return &snapdFDE{socket: newSnapdSocketFDE()}
}
func (s *snapdFDE) Detect(ctx context.Context) (bool, error) {
// Decide purely from the LUKS2 metadata whether the volume is managed by
// snapd's secboot stack; plain or systemd-cryptenroll volumes are handled by
// the legacy passphrase path.
devicePath, err := lvm.FindRootDisk()
if err != nil {
// No LUKS root partition found; nothing for us to manage.
log.Debug().Err(err).Msg("no LUKS root disk found while detecting snapd FDE")
return false, nil
}
log.Debug().Str("device", devicePath).Msg("inspecting LUKS root disk for snapd-managed FDE")
dump, err := GetLuksDump(ctx, devicePath)
if err != nil {
return false, fmt.Errorf("inspecting LUKS metadata: %w", err)
}
tokenTypes := make([]string, 0, len(dump.Tokens))
for _, tok := range dump.Tokens {
tokenTypes = append(tokenTypes, tok.Type)
}
managed := IsSnapdManaged(dump)
log.Debug().Str("device", devicePath).Strs("luks_tokens", tokenTypes).
Int("keyslots", len(dump.Keyslots)).Bool("snapd_managed", managed).
Msg("inspected LUKS2 metadata for snapd-managed FDE")
return managed, nil
}
func (s *snapdFDE) EnsureFleetRecoveryKey(ctx context.Context) (string, error) {
return s.socket.ensureFleetRecoveryKey(ctx)
}
// isCryptsetupVersionLessThan2_4 checks if the installed cryptsetup version is less than 2.4.0
func isCryptsetupVersionLessThan2_4() (bool, error) {
cmd := exec.Command("cryptsetup", "--version")
output, err := cmd.Output()
if err != nil {
return false, fmt.Errorf("failed to run cryptsetup: %w", err)
}
// Parse the output
// Examples of output:
// "cryptsetup 2.7.0 flags: UDEV BLKID KEYRING FIPS KERNEL_CAPI HW_OPAL"
// "cryptsetup 2.2.2"
outputStr := strings.TrimSpace(string(output))
parts := strings.Fields(outputStr)
// The second field should always contain the version number
if len(parts) < 2 {
return false, fmt.Errorf("unexpected output format: %s", outputStr)
}
installedVersion, err := semver.NewVersion(parts[1])
if err != nil {
return false, fmt.Errorf("failed to parse version: %w", err)
}
// Compare against version 2.4.0
targetVersion := semver.MustParse("2.4.0")
return installedVersion.LessThan(targetVersion), nil
}