Files
arca/internal/unlock/unlock.go
Kyle Isom 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

120 lines
3.5 KiB
Go

package unlock
import (
"errors"
"fmt"
"os"
"time"
"git.wntrmute.dev/kyle/arca/internal/cryptsetup"
"git.wntrmute.dev/kyle/arca/internal/udisks"
"git.wntrmute.dev/kyle/arca/internal/verbose"
)
// Result holds the outcome of a successful unlock.
type Result struct {
Device *udisks.BlockDevice
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.
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) {
verbose.Printf("unlock %s: methods %v", dev.DevicePath, methods)
var errs []error
for _, method := range methods {
verbose.Printf("trying method: %s", method)
res, err := u.tryMethod(dev, method)
if err == nil {
verbose.Printf("method %s succeeded", method)
return res, nil
}
verbose.Printf("method %s failed: %v", 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...))
}
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, Method: method}, nil
case "keyfile":
ct, err := u.unlockKeyfile(dev)
if err != nil {
return nil, err
}
return &Result{Device: ct, Privileged: false, Method: method}, nil
case "fido2", "tpm2":
ct, openResult, err := u.unlockCryptsetup(dev)
if err != nil {
return nil, err
}
return &Result{Device: ct, Privileged: true, Method: method, KeySlot: openResult.KeySlot}, 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, cryptsetup.OpenResult, error) {
name := cryptsetup.MapperName(dev.DevicePath)
openResult, err := cryptsetup.Open(dev.DevicePath, name)
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.
for i := 0; i < 10; i++ {
ct, err := u.client.CleartextDevice(dev)
if err == nil {
return ct, openResult, nil
}
time.Sleep(200 * time.Millisecond)
}
return nil, openResult, fmt.Errorf("timed out waiting for udisks2 to discover cleartext device")
}