diff --git a/PLAN.md b/PLAN.md index 0d4c85b..43bb30e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,168 +1,158 @@ -# arca — Implementation Plan +# arca — v1.0.0 Plan -## Overview +## Current State (v0.1.0) -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`. +Working proof of concept. FIDO2 unlock via cryptsetup with passphrase +fallback, privileged mount/unmount, config aliases with method +sequencing, init and status commands. -## Dependencies +## M1: Idempotent mount/unmount -- `github.com/spf13/cobra` — CLI framework -- `github.com/godbus/dbus/v5` — D-Bus client -- `gopkg.in/yaml.v3` — config file parsing +`mount` and `unmount` should be safe to run at any point in a device's +lifecycle without producing confusing errors. -## Project Structure +### Files changed -``` -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 -``` +- `cmd/mount.go` +- `cmd/unmount.go` +- `internal/udisks/client.go` -## Phase 1: Core Unlock/Lock Backend +### Work -Build `internal/udisks/` for D-Bus operations and `internal/cryptsetup/` -for FIDO2/TPM2 fallback. +1. In `runMount`, after `FindDevice`, check if the device is already + unlocked (`CleartextDevice` succeeds). If the cleartext device is + already mounted, print the existing mount point and return success. + If unlocked but not mounted, skip unlock and go straight to mount. -### udisks2 D-Bus (passphrase + keyfile) +2. In `runUnmount`, handle each failure case: + - `CleartextDevice` fails (already locked): print "already locked" + and return success. + - `Unmount` fails because not mounted: proceed to lock anyway. + - `Lock` fails because already locked: return success. -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. +3. Add `client.IsMounted(dev)` helper that returns `(mountpoint, bool)` + to reduce duplicated mount-point checking logic. -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) +## M2: Error messages -udisks2 does not support FIDO2/TPM2 token-based keyslots. For these, -fall back to invoking `cryptsetup` (via doas/sudo): +Replace generic D-Bus errors with actionable messages. -- FIDO2: `cryptsetup open --token-type systemd-fido2 ` -- TPM2: `cryptsetup open --token-type systemd-tpm2 ` -- `--token-only` can attempt all enrolled tokens automatically. +### Files changed -After `cryptsetup open`, mount via udisks2 D-Bus or plain `mount`. +- `internal/udisks/client.go` +- `cmd/mount.go`, `cmd/unmount.go`, `cmd/init.go`, `cmd/status.go` -### Unlock method sequencing +### Work -Each device has an ordered list of methods to try. The unlock logic walks -the list in order, stopping at the first success: +1. Wrap the `dbus.SystemBus()` error in `NewClient` to detect + "connection refused" or "no such file" and print: + `"cannot connect to udisks2 — is the udisks2 service running?"` -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. +2. In `FindDevice`, when no device matches, include what was searched + and suggest `arca status` or `arca init`: + `"device /dev/sda1 not found (run 'arca status' to list devices)"` -Default when no config exists: `[passphrase]`. +3. In the unlock sequencer, prefix each method error with context: + `"fido2: cryptsetup open --token-only: exit status 5 (is the FIDO2 key plugged in?)"` -## Phase 2: Config File +--- -Build `internal/config/`: +## M3: Unit tests -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]`. +Cover pure logic that doesn't need D-Bus or real devices. -Config schema: +### Files changed -```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 -``` +- `internal/config/config_test.go` (new) +- `internal/cryptsetup/cryptsetup_test.go` (new) -Supported methods: `passphrase`, `keyfile`, `fido2`, `tpm2`. +### Work -## Phase 3: CLI Commands +1. **config_test.go** — test cases: + - `ResolveDevice` with exact alias match + - `ResolveDevice` with device path match (`/dev/sda1` -> `sda1`) + - `ResolveDevice` with unknown name returns default methods + - `ResolveDevice` with empty methods list defaults to `[passphrase]` + - `AliasFor` finds alias by UUID + - `AliasFor` returns "" for unknown UUID + - `Load` with nonexistent file returns empty config + - `Load` with valid YAML parses correctly -Wire up cobra subcommands: +2. **cryptsetup_test.go** — test cases: + - `MapperName("/dev/sda1")` == `"arca-sda1"` + - `MapperName("/dev/nvme0n1p2")` == `"arca-nvme0n1p2"` + - `hasTokenPlugins` with a temp dir containing a matching .so + - `hasTokenPlugins` with an empty temp dir -### `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. +## M4: CLI polish -### `arca unmount ` +Small usability improvements. -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. +### Files changed -### `arca status` +- `cmd/root.go` +- `cmd/mount.go` +- `cmd/init.go` +- `main.go` +- `.gitignore` (new) -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. +### Work -## Phase 4: Credential Input +1. Add `var version = "dev"` to `main.go`. Set `rootCmd.Version` in + `cmd/root.go`. Build with `-ldflags "-X main.version=..."` in + `flake.nix`. -- **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. +2. Add `--mountpoint` / `-m` flag to `mount` subcommand. When set, + pass it to `cryptsetup.Mount` (privileged path) or log a warning + that udisks2 doesn't support custom mount points. -## Phase 5: Packaging +3. In `init`, use first 8 chars of UUID as alias instead of device + path basename. Example: `b8b2f8e3` instead of `sda1`. UUIDs are + stable across boots; device paths are not. -- `flake.nix` with `buildGoModule`. -- Add as a flake input to the NixOS config repo. +4. Create `.gitignore` containing `/arca`. -## 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`? +## M5: Documentation and packaging + +Make the project installable and the README trustworthy. + +### Files changed + +- `README.md` +- `flake.nix` + +### Work + +1. Update `README.md`: + - Replace placeholder UUIDs with realistic examples from actual + tested usage. + - Add "NixOS notes" section documenting the `LD_LIBRARY_PATH` + requirement for FIDO2/TPM2 and why `--external-tokens-path` + doesn't work. + - Add "Troubleshooting" section: no FIDO2 token enrolled, udisks2 + not running, permission denied on mount. + - Document `init` subcommand. + +2. Verify `flake.nix`: + - Run `nix build` and confirm it produces a working binary. + - Add `-ldflags` for version injection from `self.rev` or a + `version` variable. + - Test the flake output: `./result/bin/arca --version`. + +3. Tag `v1.0.0`. + +--- + +## Non-goals for v1.0.0 + +- `--dry-run` flag +- `--json` output for `status` +- udev auto-mount on plug +- Keyfile creation/management +- Multiple config files or config includes diff --git a/cmd/mount.go b/cmd/mount.go index c0c0593..6f6c9ef 100644 --- a/cmd/mount.go +++ b/cmd/mount.go @@ -40,6 +40,18 @@ func runMount(cmd *cobra.Command, args []string) error { return err } + // Check if already unlocked. + if cleartext, err := client.CleartextDevice(dev); err == nil { + // Already unlocked — check if mounted too. + if mp, mounted := client.IsMounted(cleartext); mounted { + fmt.Println(mp) + return nil + } + // Unlocked but not mounted — just mount it. + return doMount(client, cleartext, devCfg) + } + + // Need to unlock. u := unlock.New(client, unlock.Options{ ReadPassphrase: readPassphrase, KeyfilePath: devCfg.Keyfile, @@ -50,16 +62,23 @@ func runMount(cmd *cobra.Command, args []string) error { return err } - var mountpoint string if result.Privileged { - mountpoint, err = cryptsetup.Mount(result.Device.DevicePath, devCfg.Mountpoint) - } else { - mountpoint, err = client.Mount(result.Device) + mountpoint, err := cryptsetup.Mount(result.Device.DevicePath, devCfg.Mountpoint) + if err != nil { + return fmt.Errorf("mounting: %w", err) + } + fmt.Println(mountpoint) + return nil } + + return doMount(client, result.Device, devCfg) +} + +func doMount(client *udisks.Client, cleartext *udisks.BlockDevice, devCfg config.ResolvedDevice) error { + mountpoint, err := client.Mount(cleartext) if err != nil { return fmt.Errorf("mounting: %w", err) } - fmt.Println(mountpoint) return nil } diff --git a/cmd/unmount.go b/cmd/unmount.go index 4551409..81db308 100644 --- a/cmd/unmount.go +++ b/cmd/unmount.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "strings" "git.wntrmute.dev/kyle/arca/internal/config" "git.wntrmute.dev/kyle/arca/internal/cryptsetup" @@ -39,31 +38,27 @@ func runUnmount(cmd *cobra.Command, args []string) error { return err } + // Check if already locked. cleartext, err := client.CleartextDevice(dev) if err != nil { - return fmt.Errorf("finding cleartext device: %w", err) + fmt.Fprintf(cmd.ErrOrStderr(), "%s is already locked\n", target) + return nil } - // 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) + // Unmount if mounted. + if mp, mounted := client.IsMounted(cleartext); mounted { + if err := client.Unmount(cleartext); err != nil { + // udisks2 unmount failed — try privileged umount. + 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. + // Lock: try udisks2 first, fall back to cryptsetup close. 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 { + if err := cryptsetup.Close(mapperName); err != nil { return fmt.Errorf("locking: %w", err) } } diff --git a/internal/udisks/client.go b/internal/udisks/client.go index 571d22f..5b0bd90 100644 --- a/internal/udisks/client.go +++ b/internal/udisks/client.go @@ -97,6 +97,18 @@ func (c *Client) MountPoint(dev *BlockDevice) (string, error) { return string(bytes.TrimRight(mountPoints[0], "\x00")), nil } +// IsMounted returns the mount point and true if the device is mounted. +func (c *Client) IsMounted(dev *BlockDevice) (string, bool) { + if !dev.HasFilesystem { + return "", false + } + mp, err := c.MountPoint(dev) + if err != nil || mp == "" { + return "", false + } + return mp, true +} + // 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()