Initial implementation of arca, a LUKS volume manager.

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>
This commit is contained in:
2026-03-24 07:42:38 -07:00
commit c835358829
538 changed files with 259597 additions and 0 deletions

111
internal/unlock/unlock.go Normal file
View File

@@ -0,0 +1,111 @@
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")
}