Files
cli/pkg/key/backend_darwin.go
zeekay 76a04394ea Add native BadgerDB incremental snapshots and cleanup tools
- Add `lux snapshot` command with native BadgerDB backup API
  - Incremental backups (only changes since last backup)
  - ~96MB vs 5GB+ for raw file copies (160x smaller)
  - Zstd compression with manifest tracking

- Add `lux snapshot clean` for disk cleanup
  - Removes old logs, stale runs, backup directories
  - Dry-run mode for preview

- Update `lux network stop` to use incremental by default
  - --incremental (default: true)
  - --full flag to force full backup
  - --cleanup flag to clean logs on stop

- Add key migration and session cache improvements
  - Secure memory locking (mlock) for sensitive data
  - Session-based key caching
  - Key migration tooling
2026-01-19 21:00:03 -08:00

407 lines
9.6 KiB
Go

// Copyright (C) 2022-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build darwin
package key
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/luxfi/crypto/secp256k1"
)
// KeychainBackend uses macOS Keychain with optional TouchID/biometrics
type KeychainBackend struct {
dataDir string
sessions map[string]*keySession
sessionMu sync.RWMutex
sessionTimeout time.Duration
}
const (
keychainService = "io.lux.cli"
keychainAccess = "Lux CLI Key Management"
)
// NewKeychainBackend creates a macOS Keychain backend
func NewKeychainBackend() *KeychainBackend {
return &KeychainBackend{
sessions: make(map[string]*keySession),
sessionTimeout: GetSessionTimeout(),
}
}
func (*KeychainBackend) Type() BackendType {
return BackendKeychain
}
func (*KeychainBackend) Name() string {
return "macOS Keychain (TouchID)"
}
func (*KeychainBackend) Available() bool {
// Check if security command is available
_, err := exec.LookPath("security")
return err == nil
}
func (*KeychainBackend) RequiresPassword() bool {
return false // Uses biometrics or keychain password
}
func (*KeychainBackend) RequiresHardware() bool {
return false
}
func (*KeychainBackend) SupportsRemoteSigning() bool {
return false
}
func (b *KeychainBackend) Initialize(ctx context.Context) error {
if b.dataDir == "" {
keysDir, err := GetKeysDir()
if err != nil {
return err
}
b.dataDir = keysDir
}
return os.MkdirAll(b.dataDir, 0o700)
}
func (b *KeychainBackend) Close() error {
b.sessionMu.Lock()
defer b.sessionMu.Unlock()
for _, s := range b.sessions {
for i := range s.key {
s.key[i] = 0
}
}
b.sessions = make(map[string]*keySession)
return nil
}
func (b *KeychainBackend) CreateKey(ctx context.Context, name string, opts CreateKeyOptions) (*HDKeySet, error) {
keyDir := filepath.Join(b.dataDir, name)
if _, err := os.Stat(keyDir); err == nil {
return nil, ErrKeyExists
}
// Generate mnemonic
var mnemonic string
if opts.Mnemonic != "" {
if !ValidateMnemonic(opts.Mnemonic) {
return nil, errors.New("invalid mnemonic phrase")
}
mnemonic = opts.Mnemonic
} else {
var err error
mnemonic, err = GenerateMnemonic()
if err != nil {
return nil, fmt.Errorf("failed to generate mnemonic: %w", err)
}
}
// Derive keys
keySet, err := DeriveAllKeys(name, mnemonic)
if err != nil {
return nil, fmt.Errorf("failed to derive keys: %w", err)
}
// Save to keychain
if err := b.SaveKey(ctx, keySet, ""); err != nil {
return nil, err
}
return keySet, nil
}
func (b *KeychainBackend) LoadKey(ctx context.Context, name, password string) (*HDKeySet, error) {
// Check session cache
b.sessionMu.RLock()
if s, ok := b.sessions[name]; ok && time.Now().Before(s.expiresAt) {
b.sessionMu.RUnlock()
return b.loadFromSession(name, s.key)
}
b.sessionMu.RUnlock()
// Read from keychain (will prompt for TouchID or password)
data, err := b.readFromKeychain(name)
if err != nil {
if strings.Contains(err.Error(), "could not be found") {
return nil, ErrKeyNotFound
}
return nil, fmt.Errorf("keychain read failed: %w", err)
}
keySet, err := parseKeySetJSON(data)
if err != nil {
return nil, err
}
// Cache in session
b.sessionMu.Lock()
b.sessions[name] = &keySession{
name: name,
key: data,
unlockedAt: time.Now(),
expiresAt: time.Now().Add(b.sessionTimeout),
}
b.sessionMu.Unlock()
return keySet, nil
}
func (b *KeychainBackend) SaveKey(ctx context.Context, keySet *HDKeySet, password string) error {
keyDir := filepath.Join(b.dataDir, keySet.Name)
if err := os.MkdirAll(keyDir, 0o700); err != nil {
return fmt.Errorf("failed to create key directory: %w", err)
}
// Serialize key set
data, err := serializeKeySet(keySet)
if err != nil {
return fmt.Errorf("failed to serialize keys: %w", err)
}
// Store in keychain with biometric protection
if err := b.writeToKeychain(keySet.Name, data); err != nil {
return fmt.Errorf("keychain write failed: %w", err)
}
// Write public info
pubInfo := map[string]interface{}{
"name": keySet.Name,
"ec_address": keySet.ECAddress,
"node_id": keySet.NodeID,
"created_at": time.Now().Format(time.RFC3339),
"backend": string(BackendKeychain),
}
pubData, _ := json.MarshalIndent(pubInfo, "", " ")
_ = os.WriteFile(filepath.Join(keyDir, "info.json"), pubData, 0o644) //nolint:gosec // G306: Public info file needs to be readable
return nil
}
func (b *KeychainBackend) DeleteKey(ctx context.Context, name string) error {
// Remove from keychain
if err := b.deleteFromKeychain(name); err != nil {
// Ignore if not found
if !strings.Contains(err.Error(), "could not be found") {
return err
}
}
// Remove session
b.sessionMu.Lock()
if s, ok := b.sessions[name]; ok {
for i := range s.key {
s.key[i] = 0
}
delete(b.sessions, name)
}
b.sessionMu.Unlock()
// Remove local files
keyDir := filepath.Join(b.dataDir, name)
return os.RemoveAll(keyDir)
}
func (b *KeychainBackend) ListKeys(ctx context.Context) ([]KeyInfo, error) {
entries, err := os.ReadDir(b.dataDir)
if err != nil {
if os.IsNotExist(err) {
return []KeyInfo{}, nil
}
return nil, err
}
keys := make([]KeyInfo, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
keyDir := filepath.Join(b.dataDir, name)
info := KeyInfo{
Name: name,
Encrypted: true,
Locked: b.IsLocked(name),
}
// Check if stored in keychain
pubPath := filepath.Join(keyDir, "info.json")
if data, err := os.ReadFile(pubPath); err == nil { //nolint:gosec // G304: Reading from user's key directory
var pubInfo struct {
ECAddress string `json:"ec_address"`
NodeID string `json:"node_id"`
CreatedAt string `json:"created_at"`
Backend string `json:"backend"`
}
if json.Unmarshal(data, &pubInfo) == nil {
if pubInfo.Backend != string(BackendKeychain) {
continue // Not a keychain key
}
info.Address = pubInfo.ECAddress
info.NodeID = pubInfo.NodeID
if t, err := time.Parse(time.RFC3339, pubInfo.CreatedAt); err == nil {
info.CreatedAt = t
}
}
}
keys = append(keys, info)
}
return keys, nil
}
func (b *KeychainBackend) Lock(ctx context.Context, name string) error {
b.sessionMu.Lock()
defer b.sessionMu.Unlock()
if s, ok := b.sessions[name]; ok {
for i := range s.key {
s.key[i] = 0
}
delete(b.sessions, name)
}
return nil
}
func (b *KeychainBackend) Unlock(ctx context.Context, name, password string) error {
_, err := b.LoadKey(ctx, name, password)
return err
}
func (b *KeychainBackend) IsLocked(name string) bool {
b.sessionMu.RLock()
defer b.sessionMu.RUnlock()
s, ok := b.sessions[name]
if !ok {
return true
}
return time.Now().After(s.expiresAt)
}
func (b *KeychainBackend) Sign(ctx context.Context, name string, request SignRequest) (*SignResponse, error) {
keySet, err := b.LoadKey(ctx, name, "")
if err != nil {
return nil, err
}
privKey, err := secp256k1.ToPrivateKey(keySet.ECPrivateKey)
if err != nil {
return nil, fmt.Errorf("failed to load private key: %w", err)
}
sig, err := privKey.Sign(request.DataHash[:])
if err != nil {
return nil, fmt.Errorf("failed to sign: %w", err)
}
return &SignResponse{
Signature: sig,
PublicKey: privKey.PublicKey().Bytes(),
Address: keySet.ECAddress,
}, nil
}
// Keychain operations using security command
func (b *KeychainBackend) writeToKeychain(name string, data []byte) error {
account := fmt.Sprintf("lux-key-%s", name)
// Delete existing item if present
_ = b.deleteFromKeychain(name)
// Add new item with access control for biometrics
// -T "" allows access without app confirmation
// -w stores the data as password
cmd := exec.Command("security", "add-generic-password", //nolint:gosec // G204: Intentional keychain command
"-a", account,
"-s", keychainService,
"-l", fmt.Sprintf("%s: %s", keychainAccess, name),
"-w", hex.EncodeToString(data),
"-T", "", // Allow access without confirmation
)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("security add-generic-password failed: %s: %w", string(output), err)
}
return nil
}
func (*KeychainBackend) readFromKeychain(name string) ([]byte, error) {
account := fmt.Sprintf("lux-key-%s", name)
cmd := exec.Command("security", "find-generic-password", //nolint:gosec // G204: Intentional keychain command
"-a", account,
"-s", keychainService,
"-w", // Output password only
)
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("security find-generic-password failed: %w", err)
}
// Decode hex
hexData := strings.TrimSpace(string(output))
return hex.DecodeString(hexData)
}
func (*KeychainBackend) deleteFromKeychain(name string) error {
account := fmt.Sprintf("lux-key-%s", name)
cmd := exec.Command("security", "delete-generic-password", //nolint:gosec // G204: Intentional keychain command
"-a", account,
"-s", keychainService,
)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("security delete-generic-password failed: %s: %w", string(output), err)
}
return nil
}
func (*KeychainBackend) loadFromSession(_ string, data []byte) (*HDKeySet, error) {
return parseKeySetJSON(append([]byte{}, data...))
}
func (b *KeychainBackend) GetKeyChecksum(name string) (string, error) {
ks, err := b.LoadKey(context.Background(), name, "")
if err != nil {
return "", err
}
h := sha256.New()
h.Write(ks.ECPrivateKey)
h.Write(ks.BLSPrivateKey)
return hex.EncodeToString(h.Sum(nil)[:8]), nil
}
func init() {
RegisterBackend(NewKeychainBackend())
}