From ce10c41466208f2a125c71b908e15ec02a304044 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Tue, 24 Mar 2026 09:37:19 -0700 Subject: [PATCH] Add unlock and lock commands for decrypt-only operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cmd/lock.go | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++ cmd/unlock.go | 59 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 cmd/lock.go create mode 100644 cmd/unlock.go diff --git a/cmd/lock.go b/cmd/lock.go new file mode 100644 index 0000000..b7633b4 --- /dev/null +++ b/cmd/lock.go @@ -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 ", + 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 +} diff --git a/cmd/unlock.go b/cmd/unlock.go new file mode 100644 index 0000000..b07c4cc --- /dev/null +++ b/cmd/unlock.go @@ -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 ", + 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 +}