10 Commits

Author SHA1 Message Date
4d014f7872 Bump version to 1.3.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:04:42 -07:00
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
ce10c41466 Add unlock and lock commands for decrypt-only operations
unlock: decrypts a LUKS volume without mounting. Idempotent — reports
existing cleartext device if already unlocked.

lock: locks a LUKS volume. If mounted, unmounts first (udisks2 with
privileged fallback) then locks. Idempotent — reports if already locked.

Both commands support alias completion and config resolution.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:37:19 -07:00
aadc01383b Fix privileged mount for unlocked-but-not-mounted devices
When a device was unlocked via arca's cryptsetup path (FIDO2/TPM2) but
not yet mounted, the mount command tried the udisks2 path which failed
with "Not authorized". Now detects arca-managed mappings by checking
/dev/mapper/arca-* and uses privileged mount automatically.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:36:40 -07:00
9c59e78e38 Bump version to 1.2.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:39:54 -07:00
53cd2d35f1 M10: clean up empty mount directories after unmount
Privileged unmount now does a best-effort rmdir on the mount point after
umount succeeds. Only removes empty directories; non-empty dirs and
errors are silently ignored.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:39:42 -07:00
71e20925f6 M9: init --merge to add new devices without overwriting
Add --merge flag to init that loads existing config, skips devices
whose UUID is already configured, and appends only new discoveries.
--force and --merge are mutually exclusive. Uses Config.Save() from M8.
Error message now suggests both --force and --merge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:39:08 -07:00
feb22db039 M8: add command to append a single device to config
New 'arca add <device>' subcommand detects a LUKS device via udisks2 and
appends it to the config with passphrase as default method. Supports
--alias/-a to override the generated name. Skips if UUID already
configured. Adds Config.Save() and Config.HasUUID() to config package.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:38:32 -07:00
0c19f94292 M7: add verbose mode for debugging
Add -v/--verbose persistent flag that prints debug info to stderr:
D-Bus connection status, token plugin directory discovery, unlock method
sequencing with per-method success/failure, and full cryptsetup command
lines including LD_LIBRARY_PATH.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:37:08 -07:00
e44dd382dd M6: shell completions with dynamic alias lookup
Add ValidArgsFunction to mount and unmount commands that reads config
aliases for tab completion. Install zsh, bash, and fish completion
scripts via flake postInstall. Update PLAN.md with post-1.0 roadmap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:35:19 -07:00
20 changed files with 755 additions and 205 deletions

264
PLAN.md
View File

@@ -1,158 +1,154 @@
# arca — v1.0.0 Plan # arca — Post-1.0 Plan
## Current State (v0.1.0) ## Current State (v1.0.0)
Working proof of concept. FIDO2 unlock via cryptsetup with passphrase Released. FIDO2/passphrase/keyfile/TPM2 unlock with method sequencing,
fallback, privileged mount/unmount, config aliases with method idempotent mount/unmount, config aliases, init command, status command,
sequencing, init and status commands. NixOS LD_LIBRARY_PATH workaround for cryptsetup token plugins, unit
tests, nix flake packaging.
## M1: Idempotent mount/unmount
`mount` and `unmount` should be safe to run at any point in a device's
lifecycle without producing confusing errors.
### Files changed
- `cmd/mount.go`
- `cmd/unmount.go`
- `internal/udisks/client.go`
### Work
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.
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.
3. Add `client.IsMounted(dev)` helper that returns `(mountpoint, bool)`
to reduce duplicated mount-point checking logic.
--- ---
## M2: Error messages ## v1.1: Shell completions and verbose mode
Replace generic D-Bus errors with actionable messages. ### M6: Shell completions
### Files changed Cobra generates completion scripts. Wire them up so alias names
complete in the shell.
- `internal/udisks/client.go` #### Files changed
- `cmd/mount.go`, `cmd/unmount.go`, `cmd/init.go`, `cmd/status.go`
### Work
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?"`
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)"`
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?)"`
---
## M3: Unit tests
Cover pure logic that doesn't need D-Bus or real devices.
### Files changed
- `internal/config/config_test.go` (new)
- `internal/cryptsetup/cryptsetup_test.go` (new)
### Work
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
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
---
## M4: CLI polish
Small usability improvements.
### Files changed
- `cmd/root.go` - `cmd/root.go`
- `cmd/mount.go`
- `cmd/init.go`
- `main.go`
- `.gitignore` (new)
### Work
1. Add `var version = "dev"` to `main.go`. Set `rootCmd.Version` in
`cmd/root.go`. Build with `-ldflags "-X main.version=..."` in
`flake.nix`.
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.
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.
4. Create `.gitignore` containing `/arca`.
---
## M5: Documentation and packaging
Make the project installable and the README trustworthy.
### Files changed
- `README.md`
- `flake.nix` - `flake.nix`
### Work #### Work
1. Update `README.md`: 1. Cobra provides `completion` subcommand by default (`arca completion
- Replace placeholder UUIDs with realistic examples from actual zsh`, `arca completion bash`, etc.). Verify it works.
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`: 2. Add a custom `ValidArgsFunction` to `mountCmd` and `unmountCmd`
- Run `nix build` and confirm it produces a working binary. that reads the config and returns alias names + device paths from
- Add `-ldflags` for version injection from `self.rev` or a `arca status`. This gives dynamic completion for:
`version` variable. ```
- Test the flake output: `./result/bin/arca --version`. arca mount <TAB> -> backup media /dev/sda1
arca unmount <TAB> -> backup media
```
3. Tag `v1.0.0`. 3. In `flake.nix`, install the zsh completion file to
`$out/share/zsh/site-functions/_arca` so NixOS picks it up
automatically.
### M7: Verbose mode
Add `-v` / `--verbose` flag to aid debugging (e.g., the FIDO2 plugin
discovery issue we hit during development).
#### Files changed
- `cmd/root.go`
- `internal/cryptsetup/cryptsetup.go`
- `internal/unlock/unlock.go`
- `internal/udisks/client.go`
#### Work
1. Add a `--verbose` / `-v` persistent flag on the root command. Store
in a package-level `Verbose bool`.
2. In `cryptsetup.Open`: print the full command being executed
(including `LD_LIBRARY_PATH` and privilege wrapper).
3. In `findTokenPluginDir`: print which directory was found (or that
none was found).
4. In `unlock.Unlock`: print which method is being attempted before
each try.
5. In `udisks.NewClient`: print the D-Bus connection status.
Use `fmt.Fprintf(os.Stderr, "arca: ...")` for verbose output — no
logging framework needed.
--- ---
## Non-goals for v1.0.0 ## v1.2: Config management and cleanup
### M8: `arca add <device>`
Add a single device to an existing config without regenerating
everything.
#### Files changed
- `cmd/add.go` (new)
- `internal/config/config.go`
#### Work
1. New subcommand: `arca add /dev/sda1` or `arca add --uuid <uuid>`.
2. Resolve the device via udisks2 to get UUID.
3. Generate a UUID-prefix alias (same as `init`).
4. Prompt for alias name (default: UUID prefix), methods (default:
passphrase), and optional mountpoint.
5. Append to existing config. If the UUID is already configured, print
the existing alias and exit.
6. Add `config.Save(*Config)` to write the config file (currently
only `init` writes, with inline marshaling).
### M9: `init --merge`
Merge newly discovered devices into an existing config without
overwriting existing entries.
#### Files changed
- `cmd/init.go`
- `internal/config/config.go`
#### Work
1. Add `--merge` flag to `init`. When set, load existing config
first.
2. For each discovered device, skip if its UUID already exists in
config (regardless of alias name).
3. Add new devices with UUID-prefix aliases.
4. Write the merged config.
This replaces the current `--force` behavior for the common case of
"I plugged in a new drive."
### M10: Mount cleanup
Privileged unmount leaves empty directories under `/mnt/`.
#### Files changed
- `internal/cryptsetup/cryptsetup.go`
- `cmd/unmount.go`
#### Work
1. After `Unmount`, attempt `rmdir` on the mount point. Only remove
if empty (not `rm -rf`).
2. Use privileged `rmdir` since the directory was created with
privilege.
---
## Non-goals
- `--dry-run` flag - `--dry-run` flag
- `--json` output for `status` - `--json` output for `status`
- udev auto-mount on plug - udev auto-mount on plug
- Keyfile creation/management - Keyfile creation/management
- Multiple config files or config includes - Multiple config files or config includes
- Systemd mount unit integration

87
cmd/add.go Normal file
View File

@@ -0,0 +1,87 @@
package cmd
import (
"fmt"
"strings"
"git.wntrmute.dev/kyle/arca/internal/config"
"git.wntrmute.dev/kyle/arca/internal/udisks"
"github.com/spf13/cobra"
)
var addAlias string
var addCmd = &cobra.Command{
Use: "add <device>",
Short: "Add a device to the config",
Long: "Detects a LUKS device via udisks2 and adds it to the config file with a default passphrase method.",
Args: cobra.ExactArgs(1),
RunE: runAdd,
}
func init() {
addCmd.Flags().StringVarP(&addAlias, "alias", "a", "", "alias name (default: first 8 chars of UUID)")
rootCmd.AddCommand(addCmd)
}
func runAdd(cmd *cobra.Command, args []string) error {
target := args[0]
client, err := udisks.NewClient()
if err != nil {
return fmt.Errorf("connecting to udisks2: %w", err)
}
defer client.Close()
// Find the device to get its UUID.
dev, err := client.FindDevice("", target)
if err != nil {
return err
}
if !dev.HasEncrypted {
return fmt.Errorf("%s is not a LUKS-encrypted device", target)
}
if dev.UUID == "" {
return fmt.Errorf("%s has no UUID", target)
}
cfg := config.Load()
// Check if already configured.
if existing := cfg.AliasFor(dev.UUID); existing != "" {
fmt.Printf("Device %s (UUID %s) already configured as %q\n", dev.DevicePath, dev.UUID, existing)
return nil
}
alias := addAlias
if alias == "" {
alias = aliasFromUUID(dev.UUID)
}
// Check for alias collision.
if _, exists := cfg.Devices[alias]; exists {
return fmt.Errorf("alias %q already in use — choose a different name with --alias", alias)
}
cfg.Devices[alias] = config.DeviceConfig{
UUID: dev.UUID,
Methods: []string{"passphrase"},
}
if err := cfg.Save(); err != nil {
return fmt.Errorf("saving config: %w", err)
}
fmt.Printf("Added %s (UUID %s) as %q\n", dev.DevicePath, dev.UUID, alias)
return nil
}
func aliasFromUUID(uuid string) string {
clean := strings.ReplaceAll(uuid, "-", "")
if len(clean) > 8 {
clean = clean[:8]
}
return clean
}

25
cmd/completion.go Normal file
View File

@@ -0,0 +1,25 @@
package cmd
import (
"sort"
"git.wntrmute.dev/kyle/arca/internal/config"
"github.com/spf13/cobra"
)
// completeDeviceOrAlias provides dynamic completion for device aliases
// and device paths from the config file.
func completeDeviceOrAlias(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
cfg := config.Load()
var completions []string
for alias := range cfg.Devices {
completions = append(completions, alias)
}
sort.Strings(completions)
return completions, cobra.ShellCompDirectiveNoFileComp
}

45
cmd/config.go Normal file
View File

@@ -0,0 +1,45 @@
package cmd
import (
"fmt"
"os"
"git.wntrmute.dev/kyle/arca/internal/config"
"github.com/spf13/cobra"
)
var configCmd = &cobra.Command{
Use: "config",
Short: "Manage arca configuration",
}
var configCheckCmd = &cobra.Command{
Use: "check",
Short: "Validate the config file",
RunE: runConfigCheck,
}
func init() {
configCmd.AddCommand(configCheckCmd)
rootCmd.AddCommand(configCmd)
}
func runConfigCheck(cmd *cobra.Command, args []string) error {
cfg := config.Load()
if len(cfg.Devices) == 0 {
fmt.Println("No devices configured.")
return nil
}
errs := config.Validate(cfg)
if len(errs) == 0 {
fmt.Printf("Config OK (%d device(s))\n", len(cfg.Devices))
return nil
}
for _, e := range errs {
fmt.Fprintln(os.Stderr, e)
}
return fmt.Errorf("config has %d issue(s)", len(errs))
}

19
cmd/format.go Normal file
View File

@@ -0,0 +1,19 @@
package cmd
import (
"fmt"
"git.wntrmute.dev/kyle/arca/internal/unlock"
)
// formatMethod returns a parenthesized string describing how a device
// was unlocked, e.g. "(fido2, key slot 1)" or "(passphrase)".
func formatMethod(r *unlock.Result) string {
if r.Method == "" {
return ""
}
if r.KeySlot != "" {
return fmt.Sprintf("(%s, key slot %s)", r.Method, r.KeySlot)
}
return fmt.Sprintf("(%s)", r.Method)
}

View File

@@ -3,18 +3,17 @@ package cmd
import ( import (
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings"
"git.wntrmute.dev/kyle/arca/internal/config" "git.wntrmute.dev/kyle/arca/internal/config"
"git.wntrmute.dev/kyle/arca/internal/udisks" "git.wntrmute.dev/kyle/arca/internal/udisks"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"gopkg.in/yaml.v3"
) )
var forceInit bool var (
forceInit bool
mergeInit bool
)
var initCmd = &cobra.Command{ var initCmd = &cobra.Command{
Use: "init", Use: "init",
@@ -25,17 +24,26 @@ var initCmd = &cobra.Command{
func init() { func init() {
initCmd.Flags().BoolVarP(&forceInit, "force", "f", false, "overwrite existing config file") initCmd.Flags().BoolVarP(&forceInit, "force", "f", false, "overwrite existing config file")
initCmd.Flags().BoolVar(&mergeInit, "merge", false, "add new devices to existing config without overwriting")
initCmd.MarkFlagsMutuallyExclusive("force", "merge")
rootCmd.AddCommand(initCmd) rootCmd.AddCommand(initCmd)
} }
func runInit(cmd *cobra.Command, args []string) error { func runInit(cmd *cobra.Command, args []string) error {
cfgPath := config.Path() cfgPath := config.Path()
// Load existing config for merge, or start fresh.
var cfg *config.Config
if mergeInit {
cfg = config.Load()
} else {
if !forceInit { if !forceInit {
if _, err := os.Stat(cfgPath); err == nil { if _, err := os.Stat(cfgPath); err == nil {
return fmt.Errorf("config already exists at %s (use --force to overwrite)", cfgPath) return fmt.Errorf("config already exists at %s (use --force to overwrite or --merge to add new devices)", cfgPath)
} }
} }
cfg = &config.Config{Devices: make(map[string]config.DeviceConfig)}
}
client, err := udisks.NewClient() client, err := udisks.NewClient()
if err != nil { if err != nil {
@@ -53,43 +61,42 @@ func runInit(cmd *cobra.Command, args []string) error {
return fmt.Errorf("detecting root device: %w", err) return fmt.Errorf("detecting root device: %w", err)
} }
cfg := config.Config{ added := 0
Devices: make(map[string]config.DeviceConfig),
}
for _, dev := range encrypted { for _, dev := range encrypted {
if isRootBacking(dev.ObjectPath, rootBacking) { if isRootBacking(dev.ObjectPath, rootBacking) {
fmt.Fprintf(os.Stderr, "Skipping %s (root filesystem)\n", dev.DevicePath) fmt.Fprintf(os.Stderr, "Skipping %s (root filesystem)\n", dev.DevicePath)
continue continue
} }
if cfg.HasUUID(dev.UUID) {
fmt.Fprintf(os.Stderr, "Skipping %s (already configured)\n", dev.DevicePath)
continue
}
alias := aliasFromUUID(dev.UUID) alias := aliasFromUUID(dev.UUID)
cfg.Devices[alias] = config.DeviceConfig{ cfg.Devices[alias] = config.DeviceConfig{
UUID: dev.UUID, UUID: dev.UUID,
Methods: []string{"passphrase"}, Methods: []string{"passphrase"},
} }
fmt.Fprintf(os.Stderr, "Found %s (UUID %s) -> alias %q\n", dev.DevicePath, dev.UUID, alias) fmt.Fprintf(os.Stderr, "Found %s (UUID %s) -> alias %q\n", dev.DevicePath, dev.UUID, alias)
added++
} }
if len(cfg.Devices) == 0 { if added == 0 && !mergeInit {
fmt.Println("No non-root LUKS devices found.") fmt.Println("No non-root LUKS devices found.")
return nil return nil
} }
data, err := yaml.Marshal(&cfg) if added == 0 && mergeInit {
if err != nil { fmt.Println("No new devices to add.")
return fmt.Errorf("marshaling config: %w", err) return nil
} }
if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil { if err := cfg.Save(); err != nil {
return fmt.Errorf("creating config directory: %w", err) return fmt.Errorf("saving config: %w", err)
} }
if err := os.WriteFile(cfgPath, data, 0o644); err != nil { fmt.Printf("Config written to %s (%d device(s) added)\n", cfgPath, added)
return fmt.Errorf("writing config: %w", err)
}
fmt.Printf("Config written to %s\n", cfgPath)
return nil return nil
} }
@@ -101,13 +108,3 @@ func isRootBacking(path dbus.ObjectPath, rootDevices []dbus.ObjectPath) bool {
} }
return false return false
} }
func aliasFromUUID(uuid string) string {
// Use first 8 chars of UUID as a stable alias.
// "b8b2f8e3-4cde-4aca-a96e-df9274019f9f" -> "b8b2f8e3"
clean := strings.ReplaceAll(uuid, "-", "")
if len(clean) > 8 {
clean = clean[:8]
}
return clean
}

68
cmd/lock.go Normal file
View File

@@ -0,0 +1,68 @@
package cmd
import (
"fmt"
"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 lockCmd = &cobra.Command{
Use: "lock <device|alias>",
Short: "Lock a LUKS volume without unmounting",
Long: "Locks (closes) a LUKS volume. If the volume is mounted, it will be unmounted first.",
Args: cobra.ExactArgs(1),
RunE: runLock,
ValidArgsFunction: completeDeviceOrAlias,
}
func init() {
rootCmd.AddCommand(lockCmd)
}
func runLock(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
}
// Check if already locked.
cleartext, err := client.CleartextDevice(dev)
if err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "%s is already locked\n", target)
return nil
}
// If mounted, unmount first — can't lock a mounted device.
if mp, mounted := client.IsMounted(cleartext); mounted {
if err := client.Unmount(cleartext); err != nil {
if err := cryptsetup.Unmount(mp); err != nil {
return fmt.Errorf("unmounting before lock: %w", err)
}
}
}
// Lock: try udisks2 first, fall back to cryptsetup close.
if err := client.Lock(dev); err != nil {
mapperName := cryptsetup.MapperName(dev.DevicePath)
if err := cryptsetup.Close(mapperName); err != nil {
return fmt.Errorf("locking: %w", err)
}
}
fmt.Printf("Locked %s\n", target)
return nil
}

View File

@@ -3,11 +3,13 @@ package cmd
import ( import (
"fmt" "fmt"
"os" "os"
"strings"
"git.wntrmute.dev/kyle/arca/internal/config" "git.wntrmute.dev/kyle/arca/internal/config"
"git.wntrmute.dev/kyle/arca/internal/cryptsetup" "git.wntrmute.dev/kyle/arca/internal/cryptsetup"
"git.wntrmute.dev/kyle/arca/internal/udisks" "git.wntrmute.dev/kyle/arca/internal/udisks"
"git.wntrmute.dev/kyle/arca/internal/unlock" "git.wntrmute.dev/kyle/arca/internal/unlock"
"git.wntrmute.dev/kyle/arca/internal/verbose"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/term" "golang.org/x/term"
) )
@@ -19,6 +21,7 @@ var mountCmd = &cobra.Command{
Short: "Unlock and mount a LUKS volume", Short: "Unlock and mount a LUKS volume",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runMount, RunE: runMount,
ValidArgsFunction: completeDeviceOrAlias,
} }
func init() { func init() {
@@ -56,7 +59,18 @@ func runMount(cmd *cobra.Command, args []string) error {
fmt.Println(existing) fmt.Println(existing)
return nil return nil
} }
// Unlocked but not mounted — just mount it. // Unlocked but not mounted — mount it. If the mapper name
// indicates arca opened it via cryptsetup (privileged path),
// use privileged mount since udisks2 won't authorize it.
if isPrivilegedMapping(dev) {
verbose.Printf("detected arca-managed mapping, using privileged mount")
mnt, err := cryptsetup.Mount(cleartext.DevicePath, mp)
if err != nil {
return fmt.Errorf("mounting: %w", err)
}
fmt.Println(mnt)
return nil
}
return doMount(client, cleartext, mp) return doMount(client, cleartext, mp)
} }
@@ -71,37 +85,52 @@ func runMount(cmd *cobra.Command, args []string) error {
return err return err
} }
methodInfo := formatMethod(result)
if result.Privileged { if result.Privileged {
mnt, err := cryptsetup.Mount(result.Device.DevicePath, mp) mnt, err := cryptsetup.Mount(result.Device.DevicePath, mp)
if err != nil { if err != nil {
return fmt.Errorf("mounting: %w", err) return fmt.Errorf("mounting: %w", err)
} }
fmt.Println(mnt) fmt.Printf("%s %s\n", mnt, methodInfo)
return nil return nil
} }
if mp != "" { if mp != "" {
fmt.Fprintf(os.Stderr, "warning: --mountpoint is ignored for udisks2 mounts (passphrase/keyfile path)\n") fmt.Fprintf(os.Stderr, "warning: --mountpoint is ignored for udisks2 mounts (passphrase/keyfile path)\n")
} }
return doMount(client, result.Device, "") return doMountWithInfo(client, result.Device, "", methodInfo)
} }
func doMount(client *udisks.Client, cleartext *udisks.BlockDevice, mp string) error { func doMount(client *udisks.Client, cleartext *udisks.BlockDevice, mp string) error {
return doMountWithInfo(client, cleartext, mp, "")
}
func doMountWithInfo(client *udisks.Client, cleartext *udisks.BlockDevice, mp, methodInfo string) error {
var mnt string
var err error
if mp != "" { if mp != "" {
// udisks2 doesn't support custom mount points; use privileged mount. mnt, err = cryptsetup.Mount(cleartext.DevicePath, mp)
mnt, err := cryptsetup.Mount(cleartext.DevicePath, mp) } else {
mnt, err = client.Mount(cleartext)
}
if err != nil { if err != nil {
return fmt.Errorf("mounting: %w", err) return fmt.Errorf("mounting: %w", err)
} }
if methodInfo != "" {
fmt.Printf("%s %s\n", mnt, methodInfo)
} else {
fmt.Println(mnt) fmt.Println(mnt)
}
return nil return nil
} }
mnt, err := client.Mount(cleartext)
if err != nil { // isPrivilegedMapping checks if a LUKS device was opened via arca's
return fmt.Errorf("mounting: %w", err) // cryptsetup path by checking if the expected mapper name exists.
} func isPrivilegedMapping(dev *udisks.BlockDevice) bool {
fmt.Println(mnt) expected := cryptsetup.MapperName(dev.DevicePath)
return nil _, err := os.Stat("/dev/mapper/" + expected)
return err == nil && strings.HasPrefix(expected, "arca-")
} }
func readPassphrase() (string, error) { func readPassphrase() (string, error) {

38
cmd/remove.go Normal file
View File

@@ -0,0 +1,38 @@
package cmd
import (
"fmt"
"git.wntrmute.dev/kyle/arca/internal/config"
"github.com/spf13/cobra"
)
var removeCmd = &cobra.Command{
Use: "remove <alias>",
Short: "Remove a device from the config",
Args: cobra.ExactArgs(1),
RunE: runRemove,
ValidArgsFunction: completeDeviceOrAlias,
}
func init() {
rootCmd.AddCommand(removeCmd)
}
func runRemove(cmd *cobra.Command, args []string) error {
alias := args[0]
cfg := config.Load()
if _, ok := cfg.Devices[alias]; !ok {
return fmt.Errorf("no device with alias %q in config", alias)
}
delete(cfg.Devices, alias)
if err := cfg.Save(); err != nil {
return fmt.Errorf("saving config: %w", err)
}
fmt.Printf("Removed %q from config\n", alias)
return nil
}

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"git.wntrmute.dev/kyle/arca/internal/verbose"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -12,6 +13,10 @@ var rootCmd = &cobra.Command{
Short: "Mount and unmount LUKS-encrypted volumes", Short: "Mount and unmount LUKS-encrypted volumes",
} }
func init() {
rootCmd.PersistentFlags().BoolVarP(&verbose.Enabled, "verbose", "v", false, "print debug information to stderr")
}
func SetVersion(v string) { func SetVersion(v string) {
rootCmd.Version = v rootCmd.Version = v
} }

View File

@@ -10,6 +10,12 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var (
filterMounted bool
filterUnlocked bool
filterLocked bool
)
var statusCmd = &cobra.Command{ var statusCmd = &cobra.Command{
Use: "status", Use: "status",
Short: "Show LUKS volume status", Short: "Show LUKS volume status",
@@ -17,6 +23,9 @@ var statusCmd = &cobra.Command{
} }
func init() { func init() {
statusCmd.Flags().BoolVar(&filterMounted, "mounted", false, "show only mounted devices")
statusCmd.Flags().BoolVar(&filterUnlocked, "unlocked", false, "show only unlocked (but not mounted) devices")
statusCmd.Flags().BoolVar(&filterLocked, "locked", false, "show only locked devices")
rootCmd.AddCommand(statusCmd) rootCmd.AddCommand(statusCmd)
} }
@@ -34,6 +43,8 @@ func runStatus(cmd *cobra.Command, args []string) error {
return err return err
} }
filtering := filterMounted || filterUnlocked || filterLocked
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "DEVICE\tUUID\tALIAS\tSTATE\tMOUNTPOINT") fmt.Fprintln(w, "DEVICE\tUUID\tALIAS\tSTATE\tMOUNTPOINT")
@@ -50,6 +61,23 @@ func runStatus(cmd *cobra.Command, args []string) error {
} }
} }
if filtering {
switch state {
case "mounted":
if !filterMounted {
continue
}
case "unlocked":
if !filterUnlocked {
continue
}
case "locked":
if !filterLocked {
continue
}
}
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
dev.DevicePath, dev.UUID, alias, state, mountpoint) dev.DevicePath, dev.UUID, alias, state, mountpoint)
} }

59
cmd/unlock.go Normal file
View File

@@ -0,0 +1,59 @@
package cmd
import (
"fmt"
"git.wntrmute.dev/kyle/arca/internal/config"
"git.wntrmute.dev/kyle/arca/internal/udisks"
"git.wntrmute.dev/kyle/arca/internal/unlock"
"github.com/spf13/cobra"
)
var unlockCmd = &cobra.Command{
Use: "unlock <device|alias>",
Short: "Unlock a LUKS volume without mounting",
Args: cobra.ExactArgs(1),
RunE: runUnlock,
ValidArgsFunction: completeDeviceOrAlias,
}
func init() {
rootCmd.AddCommand(unlockCmd)
}
func runUnlock(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
}
// Check if already unlocked.
if cleartext, err := client.CleartextDevice(dev); err == nil {
fmt.Printf("%s is already unlocked (%s)\n", target, cleartext.DevicePath)
return nil
}
u := unlock.New(client, unlock.Options{
ReadPassphrase: readPassphrase,
KeyfilePath: devCfg.Keyfile,
})
result, err := u.Unlock(dev, devCfg.Methods)
if err != nil {
return err
}
fmt.Printf("Unlocked %s -> %s %s\n", target, result.Device.DevicePath, formatMethod(result))
return nil
}

View File

@@ -15,6 +15,7 @@ var unmountCmd = &cobra.Command{
Short: "Unmount and lock a LUKS volume", Short: "Unmount and lock a LUKS volume",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runUnmount, RunE: runUnmount,
ValidArgsFunction: completeDeviceOrAlias,
} }
func init() { func init() {

View File

@@ -10,7 +10,7 @@
let let
system = "x86_64-linux"; system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
version = "1.0.0"; version = "1.3.0";
in in
{ {
packages.${system}.default = pkgs.buildGoModule { packages.${system}.default = pkgs.buildGoModule {
@@ -21,6 +21,14 @@
ldflags = [ ldflags = [
"-X main.version=${version}" "-X main.version=${version}"
]; ];
postInstall = ''
mkdir -p $out/share/zsh/site-functions
mkdir -p $out/share/bash-completion/completions
mkdir -p $out/share/fish/vendor_completions.d
$out/bin/arca completion zsh > $out/share/zsh/site-functions/_arca
$out/bin/arca completion bash > $out/share/bash-completion/completions/arca
$out/bin/arca completion fish > $out/share/fish/vendor_completions.d/arca.fish
'';
}; };
}; };
} }

View File

@@ -1,6 +1,7 @@
package config package config
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -76,6 +77,31 @@ func resolvedFrom(dev DeviceConfig) ResolvedDevice {
} }
} }
// HasUUID returns true if a device with the given UUID is already configured.
func (c *Config) HasUUID(uuid string) bool {
for _, dev := range c.Devices {
if dev.UUID == uuid {
return true
}
}
return false
}
// Save writes the config to the config file.
func (c *Config) Save() error {
path := configPath()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("creating config directory: %w", err)
}
data, err := yaml.Marshal(c)
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
return os.WriteFile(path, data, 0o644)
}
// AliasFor returns the config alias for a given UUID, or "" if none. // AliasFor returns the config alias for a given UUID, or "" if none.
func (c *Config) AliasFor(uuid string) string { func (c *Config) AliasFor(uuid string) string {
for name, dev := range c.Devices { for name, dev := range c.Devices {

View File

@@ -0,0 +1,60 @@
package config
import (
"fmt"
"os"
"regexp"
)
var (
ValidMethods = []string{"passphrase", "keyfile", "fido2", "tpm2"}
uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
)
// Validate checks the config for common issues. Returns a list of errors.
func Validate(cfg *Config) []error {
var errs []error
for alias, dev := range cfg.Devices {
if dev.UUID == "" {
errs = append(errs, fmt.Errorf("%s: missing uuid", alias))
} else if !uuidPattern.MatchString(dev.UUID) {
errs = append(errs, fmt.Errorf("%s: malformed uuid %q", alias, dev.UUID))
}
for _, m := range dev.Methods {
if !isValidMethod(m) {
errs = append(errs, fmt.Errorf("%s: unknown method %q (valid: %v)", alias, m, ValidMethods))
}
}
hasKeyfileMethod := false
for _, m := range dev.Methods {
if m == "keyfile" {
hasKeyfileMethod = true
break
}
}
if hasKeyfileMethod && dev.Keyfile == "" {
errs = append(errs, fmt.Errorf("%s: method 'keyfile' listed but no keyfile path set", alias))
}
if dev.Keyfile != "" {
if _, err := os.Stat(dev.Keyfile); err != nil {
errs = append(errs, fmt.Errorf("%s: keyfile %q not found (may be on removable media)", alias, dev.Keyfile))
}
}
}
return errs
}
func isValidMethod(m string) bool {
for _, v := range ValidMethods {
if m == v {
return true
}
}
return false
}

View File

@@ -1,26 +1,49 @@
package cryptsetup package cryptsetup
import ( import (
"bytes"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "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. // Open opens a LUKS device using cryptsetup with token-based unlock.
func Open(devicePath, mapperName string) error { // Returns info about which key slot was used.
args := withTokenPluginEnv([]string{"cryptsetup", "open", devicePath, mapperName, "--token-only"}) func Open(devicePath, mapperName string) (OpenResult, error) {
args := withTokenPluginEnv([]string{"cryptsetup", "open", devicePath, mapperName, "--token-only", "-v"})
args = withPrivilege(args) args = withPrivilege(args)
verbose.Printf("exec: %s", strings.Join(args, " "))
var buf bytes.Buffer
cmd := exec.Command(args[0], args[1:]...) cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = os.Stdin cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = io.MultiWriter(os.Stderr, &buf)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return fmt.Errorf("cryptsetup open --token-only: %w", err) return OpenResult{}, fmt.Errorf("cryptsetup open --token-only: %w", err)
} }
return nil
var result OpenResult
if m := keySlotPattern.FindStringSubmatch(buf.String()); len(m) > 1 {
result.KeySlot = m[1]
}
return result, nil
} }
// Close closes a LUKS mapping. // Close closes a LUKS mapping.
@@ -59,7 +82,8 @@ func Mount(devicePath, mountpoint string) (string, error) {
return mountpoint, nil return mountpoint, nil
} }
// Unmount unmounts the given mountpoint using privileged umount. // Unmount unmounts the given mountpoint using privileged umount, then
// removes the mount directory if it is empty.
func Unmount(mountpoint string) error { func Unmount(mountpoint string) error {
args := withPrivilege([]string{"umount", mountpoint}) args := withPrivilege([]string{"umount", mountpoint})
cmd := exec.Command(args[0], args[1:]...) cmd := exec.Command(args[0], args[1:]...)
@@ -68,6 +92,12 @@ func Unmount(mountpoint string) error {
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return fmt.Errorf("umount %s: %w", mountpoint, err) 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 return nil
} }
@@ -101,6 +131,7 @@ func findTokenPluginDir() string {
// NixOS stable symlink — survives rebuilds. // NixOS stable symlink — survives rebuilds.
const nixSystemPath = "/run/current-system/sw/lib/cryptsetup" const nixSystemPath = "/run/current-system/sw/lib/cryptsetup"
if hasTokenPlugins(nixSystemPath) { if hasTokenPlugins(nixSystemPath) {
verbose.Printf("token plugin dir: %s", nixSystemPath)
return nixSystemPath return nixSystemPath
} }
@@ -109,11 +140,13 @@ func findTokenPluginDir() string {
if resolved, err := filepath.EvalSymlinks(bin); err == nil { if resolved, err := filepath.EvalSymlinks(bin); err == nil {
dir := filepath.Join(filepath.Dir(filepath.Dir(resolved)), "lib", "cryptsetup") dir := filepath.Join(filepath.Dir(filepath.Dir(resolved)), "lib", "cryptsetup")
if hasTokenPlugins(dir) { if hasTokenPlugins(dir) {
verbose.Printf("token plugin dir (from systemd-cryptenroll): %s", dir)
return dir return dir
} }
} }
} }
verbose.Printf("no token plugin directory found")
return "" return ""
} }

View File

@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"git.wntrmute.dev/kyle/arca/internal/verbose"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
) )
@@ -18,6 +19,7 @@ func NewClient() (*Client, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot connect to udisks2 — is the udisks2 service running? (%w)", err) return nil, fmt.Errorf("cannot connect to udisks2 — is the udisks2 service running? (%w)", err)
} }
verbose.Printf("connected to system D-Bus")
return &Client{conn: conn}, nil return &Client{conn: conn}, nil
} }

View File

@@ -8,12 +8,15 @@ import (
"git.wntrmute.dev/kyle/arca/internal/cryptsetup" "git.wntrmute.dev/kyle/arca/internal/cryptsetup"
"git.wntrmute.dev/kyle/arca/internal/udisks" "git.wntrmute.dev/kyle/arca/internal/udisks"
"git.wntrmute.dev/kyle/arca/internal/verbose"
) )
// Result holds the outcome of a successful unlock. // Result holds the outcome of a successful unlock.
type Result struct { type Result struct {
Device *udisks.BlockDevice Device *udisks.BlockDevice
Privileged bool // true if unlock required root (cryptsetup path) Privileged bool // true if unlock required root (cryptsetup path)
Method string // which method succeeded: "fido2", "tpm2", "passphrase", "keyfile"
KeySlot string // key slot used, from cryptsetup verbose output (or "")
} }
// Options configures the unlock behavior. // Options configures the unlock behavior.
@@ -35,12 +38,16 @@ func New(client *udisks.Client, opts Options) *Unlocker {
// Unlock tries each method in order and returns the result on first success. // Unlock tries each method in order and returns the result on first success.
func (u *Unlocker) Unlock(dev *udisks.BlockDevice, methods []string) (*Result, error) { func (u *Unlocker) Unlock(dev *udisks.BlockDevice, methods []string) (*Result, error) {
verbose.Printf("unlock %s: methods %v", dev.DevicePath, methods)
var errs []error var errs []error
for _, method := range methods { for _, method := range methods {
verbose.Printf("trying method: %s", method)
res, err := u.tryMethod(dev, method) res, err := u.tryMethod(dev, method)
if err == nil { if err == nil {
verbose.Printf("method %s succeeded", method)
return res, nil return res, nil
} }
verbose.Printf("method %s failed: %v", method, err)
errs = append(errs, fmt.Errorf("%s: %w", method, err)) errs = append(errs, fmt.Errorf("%s: %w", method, err))
} }
return nil, fmt.Errorf("all unlock methods failed for %s:\n%w", dev.DevicePath, errors.Join(errs...)) return nil, fmt.Errorf("all unlock methods failed for %s:\n%w", dev.DevicePath, errors.Join(errs...))
@@ -53,19 +60,19 @@ func (u *Unlocker) tryMethod(dev *udisks.BlockDevice, method string) (*Result, e
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &Result{Device: ct, Privileged: false}, nil return &Result{Device: ct, Privileged: false, Method: method}, nil
case "keyfile": case "keyfile":
ct, err := u.unlockKeyfile(dev) ct, err := u.unlockKeyfile(dev)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &Result{Device: ct, Privileged: false}, nil return &Result{Device: ct, Privileged: false, Method: method}, nil
case "fido2", "tpm2": case "fido2", "tpm2":
ct, err := u.unlockCryptsetup(dev) ct, openResult, err := u.unlockCryptsetup(dev)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &Result{Device: ct, Privileged: true}, nil return &Result{Device: ct, Privileged: true, Method: method, KeySlot: openResult.KeySlot}, nil
default: default:
return nil, fmt.Errorf("unknown unlock method: %s", method) return nil, fmt.Errorf("unknown unlock method: %s", method)
} }
@@ -93,19 +100,20 @@ func (u *Unlocker) unlockKeyfile(dev *udisks.BlockDevice) (*udisks.BlockDevice,
return u.client.UnlockWithKeyfile(dev, contents) return u.client.UnlockWithKeyfile(dev, contents)
} }
func (u *Unlocker) unlockCryptsetup(dev *udisks.BlockDevice) (*udisks.BlockDevice, error) { func (u *Unlocker) unlockCryptsetup(dev *udisks.BlockDevice) (*udisks.BlockDevice, cryptsetup.OpenResult, error) {
name := cryptsetup.MapperName(dev.DevicePath) name := cryptsetup.MapperName(dev.DevicePath)
if err := cryptsetup.Open(dev.DevicePath, name); err != nil { openResult, err := cryptsetup.Open(dev.DevicePath, name)
return nil, fmt.Errorf("%w (is the FIDO2/TPM2 key plugged in?)", err) if err != nil {
return nil, openResult, fmt.Errorf("%w (is the FIDO2/TPM2 key plugged in?)", err)
} }
// Wait for udisks2 to pick up the new dm device. // Wait for udisks2 to pick up the new dm device.
for i := 0; i < 10; i++ { for i := 0; i < 10; i++ {
ct, err := u.client.CleartextDevice(dev) ct, err := u.client.CleartextDevice(dev)
if err == nil { if err == nil {
return ct, nil return ct, openResult, nil
} }
time.Sleep(200 * time.Millisecond) time.Sleep(200 * time.Millisecond)
} }
return nil, fmt.Errorf("timed out waiting for udisks2 to discover cleartext device") return nil, openResult, fmt.Errorf("timed out waiting for udisks2 to discover cleartext device")
} }

View File

@@ -0,0 +1,16 @@
package verbose
import (
"fmt"
"os"
)
// Enabled is set by the root command's --verbose flag.
var Enabled bool
// Printf prints to stderr if verbose mode is enabled.
func Printf(format string, args ...any) {
if Enabled {
fmt.Fprintf(os.Stderr, "arca: "+format+"\n", args...)
}
}