Files
arca/internal/cryptsetup/cryptsetup.go
Kyle Isom e9247c720a Add config validation, remove command, status filtering, and unlock method display
config check: validates UUID format, recognized methods, keyfile
consistency and existence. Reports all issues with alias context.

remove: deletes a device from config by alias. Inverse of add.

status: --mounted, --unlocked, --locked flags filter the device table.
Flags combine as OR.

mount/unlock: display which method succeeded and key slot used, e.g.
"(fido2, key slot 1)". cryptsetup Open now runs with -v and parses
"Key slot N unlocked" from stderr via io.MultiWriter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:22:52 -07:00

167 lines
4.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
}
// 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
}