6 Commits

Author SHA1 Message Date
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
8 changed files with 301 additions and 36 deletions

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
}

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"
) )
@@ -57,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)
} }
@@ -105,6 +118,14 @@ func doMount(client *udisks.Client, cleartext *udisks.BlockDevice, mp string) er
return nil return nil
} }
// isPrivilegedMapping checks if a LUKS device was opened via arca's
// cryptsetup path by checking if the expected mapper name exists.
func isPrivilegedMapping(dev *udisks.BlockDevice) bool {
expected := cryptsetup.MapperName(dev.DevicePath)
_, err := os.Stat("/dev/mapper/" + expected)
return err == nil && strings.HasPrefix(expected, "arca-")
}
func readPassphrase() (string, error) { func readPassphrase() (string, error) {
fmt.Fprint(os.Stderr, "Passphrase: ") fmt.Fprint(os.Stderr, "Passphrase: ")
pass, err := term.ReadPassword(int(os.Stdin.Fd())) pass, err := term.ReadPassword(int(os.Stdin.Fd()))

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\n", target, result.Device.DevicePath)
return nil
}

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.1.0"; version = "1.2.0";
in in
{ {
packages.${system}.default = pkgs.buildGoModule { packages.${system}.default = pkgs.buildGoModule {

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

@@ -64,7 +64,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:]...)
@@ -73,6 +74,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
} }