commit c835358829e42e6d8f63778ec9e7f3e4379f0734 Author: Kyle Isom Date: Tue Mar 24 07:42:38 2026 -0700 Initial implementation of arca, a LUKS volume manager. Go CLI using cobra with mount, unmount, status, and init subcommands. Unlocks via udisks2 D-Bus (passphrase/keyfile) or cryptsetup (FIDO2/TPM2) with ordered method fallback. Includes NixOS-specific LD_LIBRARY_PATH injection for systemd cryptsetup token plugins. Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..0d4c85b --- /dev/null +++ b/PLAN.md @@ -0,0 +1,168 @@ +# arca — Implementation Plan + +## Overview + +A Go CLI that manages LUKS volumes. Uses udisks2 D-Bus for +passphrase/keyfile unlock (no root needed), and falls back to +`cryptsetup` for FIDO2/TPM2 tokens (requires root/doas). Three +subcommands: `mount`, `unmount`, `status`. + +## Dependencies + +- `github.com/spf13/cobra` — CLI framework +- `github.com/godbus/dbus/v5` — D-Bus client +- `gopkg.in/yaml.v3` — config file parsing + +## Project Structure + +``` +arca/ +├── main.go +├── cmd/ +│ ├── root.go # root command, global flags +│ ├── mount.go # mount subcommand +│ ├── unmount.go # unmount subcommand +│ └── status.go # status subcommand +├── internal/ +│ ├── udisks/ # udisks2 D-Bus client +│ │ ├── client.go # connection + device discovery +│ │ ├── encrypt.go # Unlock / Lock operations +│ │ ├── mount.go # Mount / Unmount operations +│ │ └── types.go # object path helpers, constants +│ ├── cryptsetup/ # cryptsetup CLI wrapper (FIDO2/TPM2) +│ │ └── cryptsetup.go +│ ├── unlock/ # method sequencing logic +│ │ └── unlock.go +│ └── config/ +│ └── config.go # YAML config loading + device resolution +├── flake.nix +├── go.mod +├── go.sum +├── README.md +└── PLAN.md +``` + +## Phase 1: Core Unlock/Lock Backend + +Build `internal/udisks/` for D-Bus operations and `internal/cryptsetup/` +for FIDO2/TPM2 fallback. + +### udisks2 D-Bus (passphrase + keyfile) + +1. Connect to the system bus. +2. Enumerate block devices under `/org/freedesktop/UDisks2/block_devices/`. +3. Find a block device by device path or UUID. +4. `Unlock(passphrase)` — call `org.freedesktop.UDisks2.Encrypted.Unlock` + on the LUKS partition. Returns the cleartext device object path. +5. `Mount()` — call `org.freedesktop.UDisks2.Filesystem.Mount` on the + cleartext device. Returns the mount point path. +6. `Unmount()` + `Lock()` — reverse operations. + +Key D-Bus details: +- Bus name: `org.freedesktop.UDisks2` +- Object paths: `/org/freedesktop/UDisks2/block_devices/` +- Interfaces: `org.freedesktop.UDisks2.Encrypted` (Unlock, Lock), + `org.freedesktop.UDisks2.Filesystem` (Mount, Unmount), + `org.freedesktop.UDisks2.Block` (device properties, UUID lookup) + +### cryptsetup fallback (FIDO2 + TPM2) + +udisks2 does not support FIDO2/TPM2 token-based keyslots. For these, +fall back to invoking `cryptsetup` (via doas/sudo): + +- FIDO2: `cryptsetup open --token-type systemd-fido2 ` +- TPM2: `cryptsetup open --token-type systemd-tpm2 ` +- `--token-only` can attempt all enrolled tokens automatically. + +After `cryptsetup open`, mount via udisks2 D-Bus or plain `mount`. + +### Unlock method sequencing + +Each device has an ordered list of methods to try. The unlock logic walks +the list in order, stopping at the first success: + +1. Try the first method (e.g., `fido2`). +2. If it fails (device not plugged in, timeout, etc.), try the next. +3. Continue until one succeeds or all are exhausted. + +Default when no config exists: `[passphrase]`. + +## Phase 2: Config File + +Build `internal/config/`: + +1. Load `~/.config/arca/config.yaml` (respect `$XDG_CONFIG_HOME`). +2. Resolve an alias to a UUID and unlock methods. +3. If no config file exists, that's fine — device path arguments still work + with the default method sequence `[passphrase]`. + +Config schema: + +```yaml +devices: + backup: + uuid: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + mountpoint: "/mnt/backup" # optional + methods: # optional, default: [passphrase] + - fido2 + - passphrase + media: + uuid: "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" + methods: + - keyfile + - passphrase + keyfile: "/path/to/media.key" # required if keyfile is in methods +``` + +Supported methods: `passphrase`, `keyfile`, `fido2`, `tpm2`. + +## Phase 3: CLI Commands + +Wire up cobra subcommands: + +### `arca mount ` + +1. Resolve argument: if it matches a config alias, look up UUID and + methods list; otherwise treat as a device path with default methods. +2. Find the block device via udisks2. +3. Walk the methods list in order, attempting each unlock strategy: + - `passphrase` / `keyfile`: via udisks2 D-Bus (no root). + - `fido2` / `tpm2`: via `cryptsetup` CLI (requires doas/sudo). + - Stop at the first success; if all fail, report each error. +4. Mount the resulting cleartext device via udisks2. +5. Print the mount point. + +### `arca unmount ` + +1. Resolve argument to the LUKS block device. +2. Find the associated cleartext (mapped) device. +3. Call Unmount on the cleartext device. +4. Call Lock on the LUKS device. + +### `arca status` + +1. Enumerate all block devices with the Encrypted interface. +2. For each, check if unlocked (has a cleartext device). +3. If mounted, show mount point. +4. Print a table: device, UUID, alias (if configured), state, mount point. + +## Phase 4: Credential Input + +- **Passphrase**: read from terminal with echo disabled (`golang.org/x/term`). + Alternatively, accept from stdin for scripting. +- **Keyfile**: read path from config, pass contents to udisks2. +- **FIDO2**: `cryptsetup` handles the token interaction (tap prompt comes + from libfido2). +- **TPM2**: `cryptsetup` handles TPM unsealing automatically. + +## Phase 5: Packaging + +- `flake.nix` with `buildGoModule`. +- Add as a flake input to the NixOS config repo. + +## Open Questions + +- Should `mount` accept a `--mountpoint` flag to override the default? +- Should there be a `--dry-run` flag that shows what D-Bus calls would be + made? +- Is there value in a `--json` output flag for `status`? diff --git a/README.md b/README.md new file mode 100644 index 0000000..7086cbc --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# arca + +A CLI tool for mounting and unmounting LUKS-encrypted volumes. Latin for +"strongbox." + +arca talks to udisks2 over D-Bus, so no root privileges are required. It +handles the unlock-then-mount and unmount-then-lock sequences as single +commands. + +## Usage + +``` +arca mount /dev/sda1 # unlock + mount by device path +arca mount backup # unlock + mount by config alias +arca unmount backup # unmount + lock +arca status # show unlocked LUKS volumes +``` + +## Configuration + +Optional. Without a config file, arca works with device paths directly. + +`~/.config/arca/config.yaml`: + +```yaml +devices: + backup: + uuid: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + mountpoint: "/mnt/backup" # optional — udisks2 picks if omitted + methods: # optional — default: [passphrase] + - fido2 + - passphrase + media: + uuid: "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" + methods: + - keyfile + - passphrase + keyfile: "/path/to/media.key" +``` + +Aliases let you refer to devices by name and ensure stable identification +via UUID regardless of device path changes. + +The `methods` list defines the unlock strategies to try in order. If the +first method fails (e.g., FIDO2 key not present), arca tries the next. +Supported methods: `passphrase`, `keyfile`, `fido2`, `tpm2`. + +## Installation + +### Nix flake + +```nix +# flake.nix +inputs.arca.url = "git+https://git.wntrmute.dev/kyle/arca"; + +# in your NixOS config or home packages +environment.systemPackages = [ inputs.arca.packages.${system}.default ]; +``` + +### From source + +``` +go install git.wntrmute.dev/kyle/arca@latest +``` + +## Requirements + +- Linux with udisks2 running (standard on most desktop distributions) +- D-Bus session or system bus access diff --git a/cmd/init.go b/cmd/init.go new file mode 100644 index 0000000..6e73bf7 --- /dev/null +++ b/cmd/init.go @@ -0,0 +1,108 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "git.wntrmute.dev/kyle/arca/internal/config" + "git.wntrmute.dev/kyle/arca/internal/udisks" + "github.com/godbus/dbus/v5" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +var forceInit bool + +var initCmd = &cobra.Command{ + Use: "init", + Short: "Generate config from detected LUKS devices", + Long: "Scans for LUKS-encrypted devices, excludes the root filesystem, and writes a config file with passphrase as the default unlock method.", + RunE: runInit, +} + +func init() { + initCmd.Flags().BoolVarP(&forceInit, "force", "f", false, "overwrite existing config file") + rootCmd.AddCommand(initCmd) +} + +func runInit(cmd *cobra.Command, args []string) error { + cfgPath := config.Path() + + if !forceInit { + if _, err := os.Stat(cfgPath); err == nil { + return fmt.Errorf("config already exists at %s (use --force to overwrite)", cfgPath) + } + } + + client, err := udisks.NewClient() + if err != nil { + return fmt.Errorf("connecting to udisks2: %w", err) + } + defer client.Close() + + encrypted, err := client.ListEncryptedDevices() + if err != nil { + return err + } + + rootBacking, err := client.RootBackingDevices() + if err != nil { + return fmt.Errorf("detecting root device: %w", err) + } + + cfg := config.Config{ + Devices: make(map[string]config.DeviceConfig), + } + + for _, dev := range encrypted { + if isRootBacking(dev.ObjectPath, rootBacking) { + fmt.Fprintf(os.Stderr, "Skipping %s (root filesystem)\n", dev.DevicePath) + continue + } + + alias := aliasFromPath(dev.DevicePath) + cfg.Devices[alias] = config.DeviceConfig{ + UUID: dev.UUID, + Methods: []string{"passphrase"}, + } + fmt.Fprintf(os.Stderr, "Found %s (UUID %s) -> alias %q\n", dev.DevicePath, dev.UUID, alias) + } + + if len(cfg.Devices) == 0 { + fmt.Println("No non-root LUKS devices found.") + return nil + } + + data, err := yaml.Marshal(&cfg) + if err != nil { + return fmt.Errorf("marshaling config: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil { + return fmt.Errorf("creating config directory: %w", err) + } + + if err := os.WriteFile(cfgPath, data, 0o644); err != nil { + return fmt.Errorf("writing config: %w", err) + } + + fmt.Printf("Config written to %s\n", cfgPath) + return nil +} + +func isRootBacking(path dbus.ObjectPath, rootDevices []dbus.ObjectPath) bool { + for _, r := range rootDevices { + if path == r { + return true + } + } + return false +} + +func aliasFromPath(devPath string) string { + // "/dev/sda1" -> "sda1", "/dev/nvme0n1p2" -> "nvme0n1p2" + base := filepath.Base(devPath) + return strings.TrimPrefix(base, "dm-") +} diff --git a/cmd/mount.go b/cmd/mount.go new file mode 100644 index 0000000..c0c0593 --- /dev/null +++ b/cmd/mount.go @@ -0,0 +1,75 @@ +package cmd + +import ( + "fmt" + "os" + + "git.wntrmute.dev/kyle/arca/internal/config" + "git.wntrmute.dev/kyle/arca/internal/cryptsetup" + "git.wntrmute.dev/kyle/arca/internal/udisks" + "git.wntrmute.dev/kyle/arca/internal/unlock" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var mountCmd = &cobra.Command{ + Use: "mount ", + Short: "Unlock and mount a LUKS volume", + Args: cobra.ExactArgs(1), + RunE: runMount, +} + +func init() { + rootCmd.AddCommand(mountCmd) +} + +func runMount(cmd *cobra.Command, args []string) error { + cfg := config.Load() + target := args[0] + + client, err := udisks.NewClient() + if err != nil { + return fmt.Errorf("connecting to udisks2: %w", err) + } + defer client.Close() + + devCfg := cfg.ResolveDevice(target) + + dev, err := client.FindDevice(devCfg.UUID, target) + if err != nil { + return err + } + + u := unlock.New(client, unlock.Options{ + ReadPassphrase: readPassphrase, + KeyfilePath: devCfg.Keyfile, + }) + + result, err := u.Unlock(dev, devCfg.Methods) + if err != nil { + return err + } + + var mountpoint string + if result.Privileged { + mountpoint, err = cryptsetup.Mount(result.Device.DevicePath, devCfg.Mountpoint) + } else { + mountpoint, err = client.Mount(result.Device) + } + if err != nil { + return fmt.Errorf("mounting: %w", err) + } + + fmt.Println(mountpoint) + return nil +} + +func readPassphrase() (string, error) { + 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) + } + return string(pass), nil +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..0da7431 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,20 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "arca", + Short: "Mount and unmount LUKS-encrypted volumes", +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/status.go b/cmd/status.go new file mode 100644 index 0000000..e87cea8 --- /dev/null +++ b/cmd/status.go @@ -0,0 +1,59 @@ +package cmd + +import ( + "fmt" + "os" + "text/tabwriter" + + "git.wntrmute.dev/kyle/arca/internal/config" + "git.wntrmute.dev/kyle/arca/internal/udisks" + "github.com/spf13/cobra" +) + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Show LUKS volume status", + RunE: runStatus, +} + +func init() { + rootCmd.AddCommand(statusCmd) +} + +func runStatus(cmd *cobra.Command, args []string) error { + cfg := config.Load() + + client, err := udisks.NewClient() + if err != nil { + return fmt.Errorf("connecting to udisks2: %w", err) + } + defer client.Close() + + devices, err := client.ListEncryptedDevices() + if err != nil { + return err + } + + w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(w, "DEVICE\tUUID\tALIAS\tSTATE\tMOUNTPOINT") + + for _, dev := range devices { + alias := cfg.AliasFor(dev.UUID) + state := "locked" + mountpoint := "" + + if ct, err := client.CleartextDevice(&dev); err == nil { + state = "unlocked" + if mp, err := client.MountPoint(ct); err == nil && mp != "" { + mountpoint = mp + state = "mounted" + } + } + + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", + dev.DevicePath, dev.UUID, alias, state, mountpoint) + } + + w.Flush() + return nil +} diff --git a/cmd/unmount.go b/cmd/unmount.go new file mode 100644 index 0000000..4551409 --- /dev/null +++ b/cmd/unmount.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "fmt" + "strings" + + "git.wntrmute.dev/kyle/arca/internal/config" + "git.wntrmute.dev/kyle/arca/internal/cryptsetup" + "git.wntrmute.dev/kyle/arca/internal/udisks" + "github.com/spf13/cobra" +) + +var unmountCmd = &cobra.Command{ + Use: "unmount ", + Aliases: []string{"umount"}, + Short: "Unmount and lock a LUKS volume", + Args: cobra.ExactArgs(1), + RunE: runUnmount, +} + +func init() { + rootCmd.AddCommand(unmountCmd) +} + +func runUnmount(cmd *cobra.Command, args []string) error { + cfg := config.Load() + target := args[0] + + client, err := udisks.NewClient() + if err != nil { + return fmt.Errorf("connecting to udisks2: %w", err) + } + defer client.Close() + + devCfg := cfg.ResolveDevice(target) + + dev, err := client.FindDevice(devCfg.UUID, target) + if err != nil { + return err + } + + cleartext, err := client.CleartextDevice(dev) + if err != nil { + return fmt.Errorf("finding cleartext device: %w", err) + } + + // Unmount: try udisks2 first, fall back to privileged umount. + mp, _ := client.MountPoint(cleartext) + if err := client.Unmount(cleartext); err != nil { + if mp == "" { + return fmt.Errorf("unmounting: %w", err) + } + if err := cryptsetup.Unmount(mp); err != nil { + return fmt.Errorf("unmounting: %w", err) + } + } + + // Lock: try udisks2 first, fall back to cryptsetup close if it's + // an arca-managed mapping. + if err := client.Lock(dev); err != nil { + mapperName := cryptsetup.MapperName(dev.DevicePath) + if strings.HasPrefix(mapperName, "arca-") { + if err := cryptsetup.Close(mapperName); err != nil { + return fmt.Errorf("locking: %w", err) + } + } else { + return fmt.Errorf("locking: %w", err) + } + } + + fmt.Printf("Unmounted and locked %s\n", target) + return nil +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..98bee68 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "arca - LUKS volume manager"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + }; + + outputs = + { self, nixpkgs }: + let + system = "x86_64-linux"; + pkgs = nixpkgs.legacyPackages.${system}; + in + { + packages.${system}.default = pkgs.buildGoModule { + pname = "arca"; + version = "0.1.0"; + src = ./.; + vendorHash = null; + }; + }; +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bf17a65 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module git.wntrmute.dev/kyle/arca + +go 1.25.7 + +require ( + github.com/godbus/dbus/v5 v5.2.2 + github.com/spf13/cobra v1.10.2 + golang.org/x/term v0.41.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.42.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..60d170f --- /dev/null +++ b/go.sum @@ -0,0 +1,19 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..26f8be7 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,100 @@ +package config + +import ( + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// Config holds the arca configuration. +type Config struct { + Devices map[string]DeviceConfig `yaml:"devices"` +} + +// DeviceConfig holds per-device settings. +type DeviceConfig struct { + UUID string `yaml:"uuid"` + Mountpoint string `yaml:"mountpoint,omitempty"` + Methods []string `yaml:"methods,omitempty"` + Keyfile string `yaml:"keyfile,omitempty"` +} + +// ResolvedDevice holds the effective settings for a device lookup. +type ResolvedDevice struct { + UUID string + Mountpoint string + Methods []string + Keyfile string +} + +// Load reads the config file. Returns an empty config if the file doesn't exist. +func Load() *Config { + cfg := &Config{ + Devices: make(map[string]DeviceConfig), + } + + data, err := os.ReadFile(configPath()) + if err != nil { + return cfg + } + + yaml.Unmarshal(data, cfg) + return cfg +} + +// ResolveDevice looks up by alias name first, then checks if the argument +// is a device path matching a known alias (e.g. "/dev/sda1" matches "sda1"). +// If nothing matches, returns defaults for a bare device path. +func (c *Config) ResolveDevice(nameOrPath string) ResolvedDevice { + // Direct alias match. + if dev, ok := c.Devices[nameOrPath]; ok { + return resolvedFrom(dev) + } + + // Match device path against aliases: "/dev/sda1" matches alias "sda1". + base := filepath.Base(nameOrPath) + if dev, ok := c.Devices[base]; ok { + return resolvedFrom(dev) + } + + return ResolvedDevice{ + Methods: []string{"passphrase"}, + } +} + +func resolvedFrom(dev DeviceConfig) ResolvedDevice { + methods := dev.Methods + if len(methods) == 0 { + methods = []string{"passphrase"} + } + return ResolvedDevice{ + UUID: dev.UUID, + Mountpoint: dev.Mountpoint, + Methods: methods, + Keyfile: dev.Keyfile, + } +} + +// AliasFor returns the config alias for a given UUID, or "" if none. +func (c *Config) AliasFor(uuid string) string { + for name, dev := range c.Devices { + if dev.UUID == uuid { + return name + } + } + return "" +} + +// Path returns the config file path, respecting XDG_CONFIG_HOME. +func Path() string { + return configPath() +} + +func configPath() string { + if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { + return filepath.Join(dir, "arca", "config.yaml") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "arca", "config.yaml") +} diff --git a/internal/cryptsetup/cryptsetup.go b/internal/cryptsetup/cryptsetup.go new file mode 100644 index 0000000..5ede88b --- /dev/null +++ b/internal/cryptsetup/cryptsetup.go @@ -0,0 +1,133 @@ +package cryptsetup + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// Open opens a LUKS device using cryptsetup with token-based unlock. +func Open(devicePath, mapperName string) error { + args := withTokenPluginEnv([]string{"cryptsetup", "open", devicePath, mapperName, "--token-only"}) + args = withPrivilege(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("cryptsetup open --token-only: %w", err) + } + return 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/. +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. +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) + } + 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) +} + +// 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) { + 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) { + return dir + } + } + } + + 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 +} diff --git a/internal/udisks/client.go b/internal/udisks/client.go new file mode 100644 index 0000000..571d22f --- /dev/null +++ b/internal/udisks/client.go @@ -0,0 +1,177 @@ +package udisks + +import ( + "bytes" + "fmt" + + "github.com/godbus/dbus/v5" +) + +// Client communicates with udisks2 over D-Bus. +type Client struct { + conn *dbus.Conn +} + +// NewClient connects to the system D-Bus and returns a udisks2 client. +func NewClient() (*Client, error) { + conn, err := dbus.SystemBus() + if err != nil { + return nil, fmt.Errorf("connecting to system bus: %w", err) + } + return &Client{conn: conn}, nil +} + +// Close closes the D-Bus connection. +func (c *Client) Close() { + c.conn.Close() +} + +// FindDevice finds a block device by UUID (if non-empty) or device path. +func (c *Client) FindDevice(uuid, pathOrName string) (*BlockDevice, error) { + devices, err := c.listBlockDevices() + if err != nil { + return nil, err + } + + if uuid != "" { + for i := range devices { + if devices[i].UUID == uuid { + return &devices[i], nil + } + } + return nil, fmt.Errorf("no device with UUID %s", uuid) + } + + for i := range devices { + if devices[i].DevicePath == pathOrName { + return &devices[i], nil + } + } + return nil, fmt.Errorf("device %s not found", pathOrName) +} + +// ListEncryptedDevices returns all block devices with the Encrypted interface. +func (c *Client) ListEncryptedDevices() ([]BlockDevice, error) { + devices, err := c.listBlockDevices() + if err != nil { + return nil, err + } + + var encrypted []BlockDevice + for _, d := range devices { + if d.HasEncrypted { + encrypted = append(encrypted, d) + } + } + return encrypted, nil +} + +// CleartextDevice finds the unlocked cleartext device for a LUKS device. +func (c *Client) CleartextDevice(dev *BlockDevice) (*BlockDevice, error) { + devices, err := c.listBlockDevices() + if err != nil { + return nil, err + } + + for i := range devices { + if devices[i].CryptoBackingDevice == dev.ObjectPath { + return &devices[i], nil + } + } + return nil, fmt.Errorf("no cleartext device for %s (is it unlocked?)", dev.DevicePath) +} + +// MountPoint returns the current mount point of a device, or "" if not mounted. +func (c *Client) MountPoint(dev *BlockDevice) (string, error) { + obj := c.conn.Object(busName, dev.ObjectPath) + variant, err := obj.GetProperty(ifaceFilesystem + ".MountPoints") + if err != nil { + return "", err + } + + mountPoints, ok := variant.Value().([][]byte) + if !ok || len(mountPoints) == 0 { + return "", nil + } + + return string(bytes.TrimRight(mountPoints[0], "\x00")), nil +} + +// DeviceAtPath returns the block device at the given D-Bus object path. +func (c *Client) DeviceAtPath(path dbus.ObjectPath) (*BlockDevice, error) { + devices, err := c.listBlockDevices() + if err != nil { + return nil, err + } + for i := range devices { + if devices[i].ObjectPath == path { + return &devices[i], nil + } + } + return nil, fmt.Errorf("device at %s not found", path) +} + +// RootBackingDevices returns the object paths of LUKS devices that back +// the root filesystem. Used to exclude them from init config generation. +func (c *Client) RootBackingDevices() ([]dbus.ObjectPath, error) { + devices, err := c.listBlockDevices() + if err != nil { + return nil, err + } + + var backing []dbus.ObjectPath + for i := range devices { + if !devices[i].HasFilesystem { + continue + } + mp, err := c.MountPoint(&devices[i]) + if err != nil || mp != "/" { + continue + } + if devices[i].CryptoBackingDevice != "" && devices[i].CryptoBackingDevice != "/" { + backing = append(backing, devices[i].CryptoBackingDevice) + } + } + return backing, nil +} + +func (c *Client) listBlockDevices() ([]BlockDevice, error) { + obj := c.conn.Object(busName, dbus.ObjectPath(objectPrefix)) + var managed map[dbus.ObjectPath]map[string]map[string]dbus.Variant + err := obj.Call(ifaceObjectManager+".GetManagedObjects", 0).Store(&managed) + if err != nil { + return nil, fmt.Errorf("listing devices: %w", err) + } + + var devices []BlockDevice + for path, ifaces := range managed { + blockProps, ok := ifaces[ifaceBlock] + if !ok { + continue + } + + dev := BlockDevice{ObjectPath: path} + + if v, ok := blockProps["Device"]; ok { + if bs, ok := v.Value().([]byte); ok { + dev.DevicePath = string(bytes.TrimRight(bs, "\x00")) + } + } + if v, ok := blockProps["IdUUID"]; ok { + if s, ok := v.Value().(string); ok { + dev.UUID = s + } + } + if v, ok := blockProps["CryptoBackingDevice"]; ok { + if p, ok := v.Value().(dbus.ObjectPath); ok { + dev.CryptoBackingDevice = p + } + } + + _, dev.HasEncrypted = ifaces[ifaceEncrypted] + _, dev.HasFilesystem = ifaces[ifaceFilesystem] + + devices = append(devices, dev) + } + return devices, nil +} diff --git a/internal/udisks/encrypt.go b/internal/udisks/encrypt.go new file mode 100644 index 0000000..89d7b33 --- /dev/null +++ b/internal/udisks/encrypt.go @@ -0,0 +1,43 @@ +package udisks + +import ( + "fmt" + + "github.com/godbus/dbus/v5" +) + +// Unlock unlocks a LUKS device with a passphrase and returns the cleartext device. +func (c *Client) Unlock(dev *BlockDevice, passphrase string) (*BlockDevice, error) { + obj := c.conn.Object(busName, dev.ObjectPath) + + var cleartextPath dbus.ObjectPath + err := obj.Call(ifaceEncrypted+".Unlock", 0, passphrase, noOptions()).Store(&cleartextPath) + if err != nil { + return nil, fmt.Errorf("unlocking %s: %w", dev.DevicePath, err) + } + + return c.DeviceAtPath(cleartextPath) +} + +// UnlockWithKeyfile unlocks a LUKS device with keyfile contents. +func (c *Client) UnlockWithKeyfile(dev *BlockDevice, keyContents []byte) (*BlockDevice, error) { + obj := c.conn.Object(busName, dev.ObjectPath) + + options := map[string]dbus.Variant{ + "keyfile_contents": dbus.MakeVariant(keyContents), + } + + var cleartextPath dbus.ObjectPath + err := obj.Call(ifaceEncrypted+".Unlock", 0, "", options).Store(&cleartextPath) + if err != nil { + return nil, fmt.Errorf("unlocking %s with keyfile: %w", dev.DevicePath, err) + } + + return c.DeviceAtPath(cleartextPath) +} + +// Lock locks a LUKS device. +func (c *Client) Lock(dev *BlockDevice) error { + obj := c.conn.Object(busName, dev.ObjectPath) + return obj.Call(ifaceEncrypted+".Lock", 0, noOptions()).Err +} diff --git a/internal/udisks/mount.go b/internal/udisks/mount.go new file mode 100644 index 0000000..1d6ce0e --- /dev/null +++ b/internal/udisks/mount.go @@ -0,0 +1,23 @@ +package udisks + +import ( + "fmt" +) + +// Mount mounts a filesystem and returns the mount point chosen by udisks2. +func (c *Client) Mount(dev *BlockDevice) (string, error) { + obj := c.conn.Object(busName, dev.ObjectPath) + + var mountpoint string + err := obj.Call(ifaceFilesystem+".Mount", 0, noOptions()).Store(&mountpoint) + if err != nil { + return "", fmt.Errorf("mounting %s: %w", dev.DevicePath, err) + } + return mountpoint, nil +} + +// Unmount unmounts a filesystem. +func (c *Client) Unmount(dev *BlockDevice) error { + obj := c.conn.Object(busName, dev.ObjectPath) + return obj.Call(ifaceFilesystem+".Unmount", 0, noOptions()).Err +} diff --git a/internal/udisks/types.go b/internal/udisks/types.go new file mode 100644 index 0000000..786c70b --- /dev/null +++ b/internal/udisks/types.go @@ -0,0 +1,27 @@ +package udisks + +import "github.com/godbus/dbus/v5" + +const ( + busName = "org.freedesktop.UDisks2" + objectPrefix = "/org/freedesktop/UDisks2" + + ifaceObjectManager = "org.freedesktop.DBus.ObjectManager" + ifaceBlock = "org.freedesktop.UDisks2.Block" + ifaceEncrypted = "org.freedesktop.UDisks2.Encrypted" + ifaceFilesystem = "org.freedesktop.UDisks2.Filesystem" +) + +// BlockDevice represents a udisks2 block device. +type BlockDevice struct { + ObjectPath dbus.ObjectPath + DevicePath string // e.g., "/dev/sda1" + UUID string + HasEncrypted bool + HasFilesystem bool + CryptoBackingDevice dbus.ObjectPath +} + +func noOptions() map[string]dbus.Variant { + return make(map[string]dbus.Variant) +} diff --git a/internal/unlock/unlock.go b/internal/unlock/unlock.go new file mode 100644 index 0000000..630da4e --- /dev/null +++ b/internal/unlock/unlock.go @@ -0,0 +1,111 @@ +package unlock + +import ( + "errors" + "fmt" + "os" + "time" + + "git.wntrmute.dev/kyle/arca/internal/cryptsetup" + "git.wntrmute.dev/kyle/arca/internal/udisks" +) + +// Result holds the outcome of a successful unlock. +type Result struct { + Device *udisks.BlockDevice + Privileged bool // true if unlock required root (cryptsetup path) +} + +// Options configures the unlock behavior. +type Options struct { + ReadPassphrase func() (string, error) + KeyfilePath string +} + +// Unlocker tries unlock methods in sequence. +type Unlocker struct { + client *udisks.Client + opts Options +} + +// New creates a new Unlocker. +func New(client *udisks.Client, opts Options) *Unlocker { + return &Unlocker{client: client, opts: opts} +} + +// Unlock tries each method in order and returns the result on first success. +func (u *Unlocker) Unlock(dev *udisks.BlockDevice, methods []string) (*Result, error) { + var errs []error + for _, method := range methods { + res, err := u.tryMethod(dev, method) + if err == nil { + return res, nil + } + errs = append(errs, fmt.Errorf("%s: %w", method, err)) + } + return nil, fmt.Errorf("all unlock methods failed:\n%w", errors.Join(errs...)) +} + +func (u *Unlocker) tryMethod(dev *udisks.BlockDevice, method string) (*Result, error) { + switch method { + case "passphrase": + ct, err := u.unlockPassphrase(dev) + if err != nil { + return nil, err + } + return &Result{Device: ct, Privileged: false}, nil + case "keyfile": + ct, err := u.unlockKeyfile(dev) + if err != nil { + return nil, err + } + return &Result{Device: ct, Privileged: false}, nil + case "fido2", "tpm2": + ct, err := u.unlockCryptsetup(dev) + if err != nil { + return nil, err + } + return &Result{Device: ct, Privileged: true}, nil + default: + return nil, fmt.Errorf("unknown unlock method: %s", method) + } +} + +func (u *Unlocker) unlockPassphrase(dev *udisks.BlockDevice) (*udisks.BlockDevice, error) { + if u.opts.ReadPassphrase == nil { + return nil, fmt.Errorf("no passphrase reader configured") + } + pass, err := u.opts.ReadPassphrase() + if err != nil { + return nil, err + } + return u.client.Unlock(dev, pass) +} + +func (u *Unlocker) unlockKeyfile(dev *udisks.BlockDevice) (*udisks.BlockDevice, error) { + if u.opts.KeyfilePath == "" { + return nil, fmt.Errorf("no keyfile configured") + } + contents, err := os.ReadFile(u.opts.KeyfilePath) + if err != nil { + return nil, fmt.Errorf("reading keyfile: %w", err) + } + return u.client.UnlockWithKeyfile(dev, contents) +} + +func (u *Unlocker) unlockCryptsetup(dev *udisks.BlockDevice) (*udisks.BlockDevice, error) { + name := cryptsetup.MapperName(dev.DevicePath) + if err := cryptsetup.Open(dev.DevicePath, name); err != nil { + return nil, err + } + + // Wait for udisks2 to pick up the new dm device. + for i := 0; i < 10; i++ { + ct, err := u.client.CleartextDevice(dev) + if err == nil { + return ct, nil + } + time.Sleep(200 * time.Millisecond) + } + return nil, fmt.Errorf("timed out waiting for udisks2 to discover cleartext device") +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..12ab116 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "git.wntrmute.dev/kyle/arca/cmd" + +func main() { + cmd.Execute() +} diff --git a/vendor/github.com/godbus/dbus/v5/.cirrus.yml b/vendor/github.com/godbus/dbus/v5/.cirrus.yml new file mode 100644 index 0000000..6e20902 --- /dev/null +++ b/vendor/github.com/godbus/dbus/v5/.cirrus.yml @@ -0,0 +1,11 @@ +# See https://cirrus-ci.org/guide/FreeBSD/ +freebsd_instance: + image_family: freebsd-14-3 + +task: + name: Test on FreeBSD + install_script: pkg install -y go125 dbus + test_script: | + /usr/local/etc/rc.d/dbus onestart && \ + eval `dbus-launch --sh-syntax` && \ + go125 test -v ./... diff --git a/vendor/github.com/godbus/dbus/v5/.golangci.yml b/vendor/github.com/godbus/dbus/v5/.golangci.yml new file mode 100644 index 0000000..5bbdd93 --- /dev/null +++ b/vendor/github.com/godbus/dbus/v5/.golangci.yml @@ -0,0 +1,13 @@ +version: "2" + +linters: + enable: + - unconvert + - unparam + exclusions: + presets: + - std-error-handling + +formatters: + enable: + - gofumpt diff --git a/vendor/github.com/godbus/dbus/v5/CONTRIBUTING.md b/vendor/github.com/godbus/dbus/v5/CONTRIBUTING.md new file mode 100644 index 0000000..c88f9b2 --- /dev/null +++ b/vendor/github.com/godbus/dbus/v5/CONTRIBUTING.md @@ -0,0 +1,50 @@ +# How to Contribute + +## Getting Started + +- Fork the repository on GitHub +- Read the [README](README.markdown) for build and test instructions +- Play with the project, submit bugs, submit patches! + +## Contribution Flow + +This is a rough outline of what a contributor's workflow looks like: + +- Create a topic branch from where you want to base your work (usually master). +- Make commits of logical units. +- Make sure your commit messages are in the proper format (see below). +- Push your changes to a topic branch in your fork of the repository. +- Make sure the tests pass, and add any new tests as appropriate. +- Submit a pull request to the original repository. + +Thanks for your contributions! + +### Format of the Commit Message + +We follow a rough convention for commit messages that is designed to answer two +questions: what changed and why. The subject line should feature the what and +the body of the commit should describe the why. + +``` +scripts: add the test-cluster command + +this uses tmux to setup a test cluster that you can easily kill and +start for debugging. + +Fixes #38 +``` + +The format can be described more formally as follows: + +``` +: + + + +