Go CLI using cobra with mount, unmount, status, and init subcommands. Unlocks via udisks2 D-Bus (passphrase/keyfile) or cryptsetup (FIDO2/TPM2) with ordered method fallback. Includes NixOS-specific LD_LIBRARY_PATH injection for systemd cryptsetup token plugins. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
112 lines
2.9 KiB
Go
112 lines
2.9 KiB
Go
package unlock
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
|
|
"git.wntrmute.dev/kyle/arca/internal/cryptsetup"
|
|
"git.wntrmute.dev/kyle/arca/internal/udisks"
|
|
)
|
|
|
|
// Result holds the outcome of a successful unlock.
|
|
type Result struct {
|
|
Device *udisks.BlockDevice
|
|
Privileged bool // true if unlock required root (cryptsetup path)
|
|
}
|
|
|
|
// 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) {
|
|
var errs []error
|
|
for _, method := range methods {
|
|
res, err := u.tryMethod(dev, method)
|
|
if err == nil {
|
|
return res, nil
|
|
}
|
|
errs = append(errs, fmt.Errorf("%s: %w", method, err))
|
|
}
|
|
return nil, fmt.Errorf("all unlock methods failed:\n%w", 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}, nil
|
|
case "keyfile":
|
|
ct, err := u.unlockKeyfile(dev)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Result{Device: ct, Privileged: false}, nil
|
|
case "fido2", "tpm2":
|
|
ct, err := u.unlockCryptsetup(dev)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Result{Device: ct, Privileged: true}, 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, error) {
|
|
name := cryptsetup.MapperName(dev.DevicePath)
|
|
if err := cryptsetup.Open(dev.DevicePath, name); err != nil {
|
|
return nil, 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, nil
|
|
}
|
|
time.Sleep(200 * time.Millisecond)
|
|
}
|
|
return nil, fmt.Errorf("timed out waiting for udisks2 to discover cleartext device")
|
|
}
|