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>
This commit is contained in:
16
Makefile
Normal file
16
Makefile
Normal file
@@ -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
|
||||||
129
cmd/create.go
Normal file
129
cmd/create.go
Normal file
@@ -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 <device> <alias>",
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -101,6 +101,112 @@ func Unmount(mountpoint string) error {
|
|||||||
return nil
|
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".
|
// MapperName returns "arca-<dev>" from a device path, e.g. "/dev/sda1" -> "arca-sda1".
|
||||||
func MapperName(devicePath string) string {
|
func MapperName(devicePath string) string {
|
||||||
return "arca-" + filepath.Base(devicePath)
|
return "arca-" + filepath.Base(devicePath)
|
||||||
|
|||||||
Reference in New Issue
Block a user