Files
arca/internal/cryptsetup/cryptsetup.go
Kyle Isom 293bcdcdae Add create command for LUKS volume provisioning and Makefile
New `arca create <device> <alias>` command that formats a device with
LUKS encryption, creates a filesystem, optionally enrolls a FIDO2
keyslot, and adds the device to the arca config.

Adds Makefile with arca, vet, lint, test, and clean targets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:00:20 -07:00

273 lines
7.9 KiB
Go

package cryptsetup
import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"git.wntrmute.dev/kyle/arca/internal/verbose"
)
// OpenResult holds information about a successful cryptsetup open.
type OpenResult struct {
KeySlot string // e.g., "1", or "" if not parsed
}
var keySlotPattern = regexp.MustCompile(`Key slot (\d+) unlocked`)
// Open opens a LUKS device using cryptsetup with token-based unlock.
// Returns info about which key slot was used.
func Open(devicePath, mapperName string) (OpenResult, error) {
args := withTokenPluginEnv([]string{"cryptsetup", "open", devicePath, mapperName, "--token-only", "-v"})
args = withPrivilege(args)
verbose.Printf("exec: %s", strings.Join(args, " "))
var buf bytes.Buffer
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = io.MultiWriter(os.Stderr, &buf)
if err := cmd.Run(); err != nil {
return OpenResult{}, fmt.Errorf("cryptsetup open --token-only: %w", err)
}
var result OpenResult
if m := keySlotPattern.FindStringSubmatch(buf.String()); len(m) > 1 {
result.KeySlot = m[1]
}
return result, nil
}
// Close closes a LUKS mapping.
func Close(mapperName string) error {
args := []string{"cryptsetup", "close", mapperName}
args = withPrivilege(args)
cmd := exec.Command(args[0], args[1:]...)
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("cryptsetup close: %w", err)
}
return nil
}
// Mount mounts a device at the given mountpoint using privileged mount.
// If mountpoint is empty, mounts under /mnt/<basename>.
func Mount(devicePath, mountpoint string) (string, error) {
if mountpoint == "" {
mountpoint = "/mnt/" + filepath.Base(devicePath)
}
mkdirArgs := withPrivilege([]string{"mkdir", "-p", mountpoint})
if err := exec.Command(mkdirArgs[0], mkdirArgs[1:]...).Run(); err != nil {
return "", fmt.Errorf("creating mountpoint: %w", err)
}
args := withPrivilege([]string{"mount", devicePath, mountpoint})
cmd := exec.Command(args[0], args[1:]...)
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("mount %s: %w", devicePath, err)
}
return mountpoint, nil
}
// Unmount unmounts the given mountpoint using privileged umount, then
// removes the mount directory if it is empty.
func Unmount(mountpoint string) error {
args := withPrivilege([]string{"umount", mountpoint})
cmd := exec.Command(args[0], args[1:]...)
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("umount %s: %w", mountpoint, err)
}
// Clean up empty mount directory. Best-effort — ignore errors
// (directory may not be empty or may be a system path).
rmdirArgs := withPrivilege([]string{"rmdir", mountpoint})
exec.Command(rmdirArgs[0], rmdirArgs[1:]...).Run()
return nil
}
// LuksFormat formats a device with LUKS encryption using the given passphrase.
func LuksFormat(devicePath, passphrase string) error {
args := []string{"cryptsetup", "luksFormat", "-q", "--key-file=-", devicePath}
args = withPrivilege(args)
verbose.Printf("exec: %s", strings.Join(args, " "))
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = strings.NewReader(passphrase)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("cryptsetup luksFormat: %w", err)
}
return nil
}
// OpenWithPassphrase opens a LUKS device using a passphrase piped via stdin.
func OpenWithPassphrase(devicePath, mapperName, passphrase string) error {
args := []string{"cryptsetup", "open", "--key-file=-", devicePath, mapperName}
args = withPrivilege(args)
verbose.Printf("exec: %s", strings.Join(args, " "))
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = strings.NewReader(passphrase)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("cryptsetup open: %w", err)
}
return nil
}
// LuksUUID returns the UUID of a LUKS device.
func LuksUUID(devicePath string) (string, error) {
args := []string{"cryptsetup", "luksUUID", devicePath}
args = withPrivilege(args)
var buf bytes.Buffer
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdout = &buf
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("cryptsetup luksUUID: %w", err)
}
return strings.TrimSpace(buf.String()), nil
}
// Mkfs creates a filesystem on the given device.
func Mkfs(devicePath, fsType string) error {
mkfsCmd := fmt.Sprintf("mkfs.%s", fsType)
args := withPrivilege([]string{mkfsCmd, devicePath})
verbose.Printf("exec: %s", strings.Join(args, " "))
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("%s: %w", mkfsCmd, err)
}
return nil
}
// ListFIDO2Devices returns the output of systemd-cryptenroll --fido2-device=list,
// or an error if no devices are found or the command is unavailable.
func ListFIDO2Devices() (string, error) {
if _, err := exec.LookPath("systemd-cryptenroll"); err != nil {
return "", err
}
var buf bytes.Buffer
cmd := exec.Command("systemd-cryptenroll", "--fido2-device=list")
cmd.Stdout = &buf
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return "", err
}
return strings.TrimSpace(buf.String()), nil
}
// EnrollFIDO2 enrolls a FIDO2 device as a keyslot on the given LUKS device.
// This is interactive — it prompts for the existing passphrase and FIDO2 touch.
func EnrollFIDO2(devicePath string) error {
args := []string{"systemd-cryptenroll", "--fido2-device=auto", devicePath}
args = withPrivilege(args)
verbose.Printf("exec: %s", strings.Join(args, " "))
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("systemd-cryptenroll: %w", err)
}
return nil
}
// MapperName returns "arca-<dev>" from a device path, e.g. "/dev/sda1" -> "arca-sda1".
func MapperName(devicePath string) string {
return "arca-" + filepath.Base(devicePath)
}
// withTokenPluginEnv wraps the command with `env LD_LIBRARY_PATH=...` if
// the systemd cryptsetup token plugins are found.
//
// On NixOS, cryptsetup uses dlopen to load token plugins, but glibc only
// searches the binary's baked-in RUNPATH — which doesn't include the
// systemd plugin directory. LD_LIBRARY_PATH is the only reliable way to
// make dlopen find them.
//
// We inject it via `env` rather than cmd.Env so it survives privilege
// escalation through doas/sudo.
func withTokenPluginEnv(args []string) []string {
pluginDir := findTokenPluginDir()
if pluginDir == "" {
return args
}
return append([]string{"env", "LD_LIBRARY_PATH=" + pluginDir}, args...)
}
// findTokenPluginDir locates the directory containing libcryptsetup-token-*.so.
// It checks the NixOS system profile first, then falls back to resolving
// from the systemd-cryptenroll binary.
func findTokenPluginDir() string {
// NixOS stable symlink — survives rebuilds.
const nixSystemPath = "/run/current-system/sw/lib/cryptsetup"
if hasTokenPlugins(nixSystemPath) {
verbose.Printf("token plugin dir: %s", nixSystemPath)
return nixSystemPath
}
// Fallback: derive from systemd-cryptenroll location.
if bin, err := exec.LookPath("systemd-cryptenroll"); err == nil {
if resolved, err := filepath.EvalSymlinks(bin); err == nil {
dir := filepath.Join(filepath.Dir(filepath.Dir(resolved)), "lib", "cryptsetup")
if hasTokenPlugins(dir) {
verbose.Printf("token plugin dir (from systemd-cryptenroll): %s", dir)
return dir
}
}
}
verbose.Printf("no token plugin directory found")
return ""
}
func hasTokenPlugins(dir string) bool {
matches, _ := filepath.Glob(filepath.Join(dir, "libcryptsetup-token-*.so"))
return len(matches) > 0
}
func withPrivilege(args []string) []string {
if _, err := exec.LookPath("doas"); err == nil {
return append([]string{"doas"}, args...)
}
if _, err := exec.LookPath("sudo"); err == nil {
return append([]string{"sudo"}, args...)
}
return args
}