From 293bcdcdae25ce3266223c61c0740c08eda536ca Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Mon, 30 Mar 2026 15:00:20 -0700 Subject: [PATCH] Add create command for LUKS volume provisioning and Makefile New `arca create ` 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) --- Makefile | 16 ++++ cmd/create.go | 129 ++++++++++++++++++++++++++++++ internal/cryptsetup/cryptsetup.go | 106 ++++++++++++++++++++++++ 3 files changed, 251 insertions(+) create mode 100644 Makefile create mode 100644 cmd/create.go diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ea81754 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +.PHONY: arca vet lint test clean + +arca: + go build -o arca . + +vet: + go vet ./... + +lint: + golangci-lint run ./... + +test: + go test ./... + +clean: + rm -f arca diff --git a/cmd/create.go b/cmd/create.go new file mode 100644 index 0000000..2413596 --- /dev/null +++ b/cmd/create.go @@ -0,0 +1,129 @@ +package cmd + +import ( + "fmt" + "os" + + "git.wntrmute.dev/kyle/arca/internal/config" + "git.wntrmute.dev/kyle/arca/internal/cryptsetup" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var createFsType string + +var createCmd = &cobra.Command{ + Use: "create ", + Short: "Create a new LUKS-encrypted volume", + Long: "Formats a device with LUKS encryption, creates a filesystem, and adds it to the arca config.", + Args: cobra.ExactArgs(2), + RunE: runCreate, +} + +func init() { + createCmd.Flags().StringVarP(&createFsType, "filesystem", "t", "ext3", "filesystem type for mkfs") + rootCmd.AddCommand(createCmd) +} + +func runCreate(cmd *cobra.Command, args []string) error { + device := args[0] + alias := args[1] + + cfg := config.Load() + if _, exists := cfg.Devices[alias]; exists { + return fmt.Errorf("alias %q already in use", alias) + } + + // Confirm destructive operation. + fmt.Fprintf(os.Stderr, "This will ERASE all data on %s and create a new LUKS-encrypted volume.\n", device) + fmt.Fprint(os.Stderr, "Continue? [y/N] ") + var answer string + fmt.Scanln(&answer) + if answer != "y" && answer != "Y" { + fmt.Fprintln(os.Stderr, "Aborted.") + return nil + } + + // Read passphrase with confirmation. + fmt.Fprint(os.Stderr, "Passphrase: ") + pass, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err != nil { + return fmt.Errorf("reading passphrase: %w", err) + } + + fmt.Fprint(os.Stderr, "Confirm passphrase: ") + pass2, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err != nil { + return fmt.Errorf("reading passphrase: %w", err) + } + + if string(pass) != string(pass2) { + return fmt.Errorf("passphrases do not match") + } + + passphrase := string(pass) + + // Step 1: LUKS format. + fmt.Fprintf(os.Stderr, "Formatting %s with LUKS...\n", device) + if err := cryptsetup.LuksFormat(device, passphrase); err != nil { + return err + } + + // Step 2: Unlock. + mapperName := cryptsetup.MapperName(device) + fmt.Fprintf(os.Stderr, "Unlocking %s...\n", device) + if err := cryptsetup.OpenWithPassphrase(device, mapperName, passphrase); err != nil { + return err + } + + // Step 3: Create filesystem. + clearPath := "/dev/mapper/" + mapperName + fmt.Fprintf(os.Stderr, "Creating %s filesystem on %s...\n", createFsType, clearPath) + if err := cryptsetup.Mkfs(clearPath, createFsType); err != nil { + cryptsetup.Close(mapperName) + return err + } + + // Step 4: Close the volume. + if err := cryptsetup.Close(mapperName); err != nil { + return err + } + + // Step 5: Offer FIDO2 enrollment if a device is detected. + methods := []string{"passphrase"} + if output, err := cryptsetup.ListFIDO2Devices(); err == nil && output != "" { + fmt.Fprintf(os.Stderr, "FIDO2 device detected:\n%s\n", output) + fmt.Fprint(os.Stderr, "Enroll FIDO2 device as unlock method? [y/N] ") + var fido2Answer string + fmt.Scanln(&fido2Answer) + if fido2Answer == "y" || fido2Answer == "Y" { + if err := cryptsetup.EnrollFIDO2(device); err != nil { + fmt.Fprintf(os.Stderr, "FIDO2 enrollment failed: %v\n", err) + fmt.Fprintln(os.Stderr, "Continuing without FIDO2.") + } else { + methods = []string{"fido2", "passphrase"} + fmt.Fprintln(os.Stderr, "FIDO2 enrolled successfully.") + } + } + } + + // Step 6: Get UUID and add to config. + uuid, err := cryptsetup.LuksUUID(device) + if err != nil { + return fmt.Errorf("reading UUID: %w", err) + } + + cfg.Devices[alias] = config.DeviceConfig{ + UUID: uuid, + Methods: methods, + } + + if err := cfg.Save(); err != nil { + return fmt.Errorf("saving config: %w", err) + } + + fmt.Printf("Created LUKS volume %s (UUID %s) as %q\n", device, uuid, alias) + return nil +} diff --git a/internal/cryptsetup/cryptsetup.go b/internal/cryptsetup/cryptsetup.go index 17fdd43..f56c8be 100644 --- a/internal/cryptsetup/cryptsetup.go +++ b/internal/cryptsetup/cryptsetup.go @@ -101,6 +101,112 @@ func Unmount(mountpoint string) error { 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-" from a device path, e.g. "/dev/sda1" -> "arca-sda1". func MapperName(devicePath string) string { return "arca-" + filepath.Base(devicePath)