diff --git a/.gitignore b/.gitignore index 5693db3..300b491 100644 --- a/.gitignore +++ b/.gitignore @@ -53,5 +53,9 @@ temp/ bin obol-agent + +# Wallet backups (contain keystore passwords) +obol-wallet-backup-*.json +obol-wallet-backup-*.enc .private_keys/ -.claude/worktrees/ \ No newline at end of file +.claude/worktrees/ diff --git a/cmd/obol/openclaw.go b/cmd/obol/openclaw.go index 8238194..e403f8d 100644 --- a/cmd/obol/openclaw.go +++ b/cmd/obol/openclaw.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/ObolNetwork/obol-stack/internal/kubectl" "github.com/ObolNetwork/obol-stack/internal/openclaw" "github.com/urfave/cli/v3" ) @@ -150,6 +151,7 @@ func openclawCommand(cfg *config.Config) *cli.Command { }, }, openclawSkillsCommand(cfg), + openclawWalletCommand(cfg), { Name: "cli", Usage: "Run openclaw CLI commands against a deployed instance", @@ -186,6 +188,97 @@ func openclawCommand(cfg *config.Config) *cli.Command { } } +// openclawWalletCommand builds the "obol openclaw wallet" subcommand group. +func openclawWalletCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "wallet", + Usage: "Manage OpenClaw instance wallets", + Commands: []*cli.Command{ + { + Name: "backup", + Usage: "Back up wallet keys for an OpenClaw instance", + ArgsUsage: "[instance-name]", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "output", + Usage: "Output file path", + }, + &cli.StringFlag{ + Name: "passphrase", + Usage: "Encryption passphrase (empty string = no encryption)", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + if err := kubectl.EnsureCluster(cfg); err != nil { + return err + } + id, _, err := openclaw.ResolveInstance(cfg, cmd.Args().Slice()) + if err != nil { + return err + } + return openclaw.BackupWalletCmd(cfg, id, openclaw.BackupWalletOptions{ + Output: cmd.String("output"), + Passphrase: cmd.String("passphrase"), + HasPassFlag: cmd.IsSet("passphrase"), + }, getUI(cmd)) + }, + }, + { + Name: "restore", + Usage: "Restore wallet keys from a backup file", + ArgsUsage: "[instance-name]", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "input", + Usage: "Backup file path", + Required: true, + }, + &cli.StringFlag{ + Name: "passphrase", + Usage: "Decryption passphrase", + }, + &cli.BoolFlag{ + Name: "force", + Usage: "Overwrite existing wallet", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + if err := kubectl.EnsureCluster(cfg); err != nil { + return err + } + id, _, err := openclaw.ResolveInstance(cfg, cmd.Args().Slice()) + if err != nil { + return err + } + return openclaw.RestoreWalletCmd(cfg, id, openclaw.RestoreWalletOptions{ + Input: cmd.String("input"), + Passphrase: cmd.String("passphrase"), + HasPassFlag: cmd.IsSet("passphrase"), + Force: cmd.Bool("force"), + }, getUI(cmd)) + }, + }, + { + Name: "list", + Usage: "List wallets for OpenClaw instances", + ArgsUsage: "[instance-name]", + Action: func(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + var id string + if len(args) > 0 { + var err error + id, _, err = openclaw.ResolveInstance(cfg, args) + if err != nil { + return err + } + } + return openclaw.ListWallets(cfg, id, getUI(cmd)) + }, + }, + }, + } +} + // openclawSkillsCommand builds the "obol openclaw skills" subcommand group. func openclawSkillsCommand(cfg *config.Config) *cli.Command { return &cli.Command{ diff --git a/internal/openclaw/integration_test.go b/internal/openclaw/integration_test.go index ec116f8..fc1c61f 100644 --- a/internal/openclaw/integration_test.go +++ b/internal/openclaw/integration_test.go @@ -178,7 +178,7 @@ func requireLiteLLMReady(t *testing.T, cfg *config.Config) { // cluster is done separately via `obol openclaw sync`. func scaffoldInstance(t *testing.T, cfg *config.Config, id string, ollamaModels []string) { t.Helper() - deploymentDir := deploymentPath(cfg, id) + deploymentDir := DeploymentPath(cfg, id) if err := os.MkdirAll(deploymentDir, 0755); err != nil { t.Fatalf("failed to create deployment dir: %v", err) } @@ -205,7 +205,7 @@ func scaffoldCloudInstance(t *testing.T, cfg *config.Config, id string, cloud *C hostname := fmt.Sprintf("openclaw-%s.%s", id, defaultDomain) namespace := fmt.Sprintf("%s-%s", appName, id) - deploymentDir := deploymentPath(cfg, id) + deploymentDir := DeploymentPath(cfg, id) if err := os.MkdirAll(deploymentDir, 0755); err != nil { t.Fatalf("failed to create deployment dir: %v", err) } @@ -687,7 +687,7 @@ func TestIntegration_SkillsStagedOnSync(t *testing.T) { obolRun(t, cfg, "openclaw", "sync", id) // 1. Verify skills were staged in the deployment directory - deployDir := deploymentPath(cfg, id) + deployDir := DeploymentPath(cfg, id) skillsDir := filepath.Join(deployDir, "skills") expectedSkills := []string{"distributed-validators", "ethereum-networks", "obol-stack", "addresses", "wallets"} @@ -848,7 +848,7 @@ func TestIntegration_SkillsIdempotentSync(t *testing.T) { obolRun(t, cfg, "openclaw", "sync", id) // Add a custom file to the staged skills directory (simulating user customisation) - deployDir := deploymentPath(cfg, id) + deployDir := DeploymentPath(cfg, id) marker := filepath.Join(deployDir, "skills", "custom-user-skill", "SKILL.md") if err := os.MkdirAll(filepath.Dir(marker), 0755); err != nil { t.Fatalf("mkdir: %v", err) diff --git a/internal/openclaw/openclaw.go b/internal/openclaw/openclaw.go index 228269b..ae19d2a 100644 --- a/internal/openclaw/openclaw.go +++ b/internal/openclaw/openclaw.go @@ -74,7 +74,7 @@ type OnboardOptions struct { func SetupDefault(cfg *config.Config, u *ui.UI) error { // Check whether the default deployment already exists (re-sync path). // If it does, proceed unconditionally — the overlay was already written. - deploymentDir := deploymentPath(cfg, "default") + deploymentDir := DeploymentPath(cfg, "default") if _, err := os.Stat(deploymentDir); err == nil { // Existing deployment — always re-sync regardless of Ollama status. return Onboard(cfg, OnboardOptions{ @@ -134,7 +134,7 @@ func Onboard(cfg *config.Config, opts OnboardOptions, u *ui.UI) error { u.Infof("Using deployment ID: %s", id) } - deploymentDir := deploymentPath(cfg, id) + deploymentDir := DeploymentPath(cfg, id) // Idempotent re-run for default deployment: just re-sync if opts.IsDefault && !opts.Force { @@ -264,7 +264,7 @@ agents: os.RemoveAll(deploymentDir) return fmt.Errorf("failed to write remote-signer values: %w", err) } - if err := writeWalletMetadata(deploymentDir, wallet); err != nil { + if err := WriteWalletMetadata(deploymentDir, wallet); err != nil { os.RemoveAll(deploymentDir) return fmt.Errorf("failed to write wallet metadata: %w", err) } @@ -294,7 +294,7 @@ agents: } u.Blank() u.Print(" Back up your signing key:") - u.Printf(" cp -r %s ~/obol-wallet-backup/", keystoreVolumePath(cfg, id)) + u.Printf(" cp -r %s ~/obol-wallet-backup/", KeystoreVolumePath(cfg, id)) // Stage default skills to deployment directory (immediate, no cluster needed) u.Blank() @@ -334,7 +334,7 @@ func Sync(cfg *config.Config, id string, u *ui.UI) error { } func doSync(cfg *config.Config, id string, u *ui.UI) error { - deploymentDir := deploymentPath(cfg, id) + deploymentDir := DeploymentPath(cfg, id) if _, err := os.Stat(deploymentDir); os.IsNotExist(err) { return fmt.Errorf("deployment not found: %s/%s\nDirectory: %s", appName, id, deploymentDir) } @@ -912,7 +912,7 @@ type SetupOptions struct { // It runs the interactive provider prompt, regenerates the overlay values, // and syncs via helmfile so the pod picks up the new configuration. func Setup(cfg *config.Config, id string, _ SetupOptions, u *ui.UI) error { - deploymentDir := deploymentPath(cfg, id) + deploymentDir := DeploymentPath(cfg, id) if _, err := os.Stat(deploymentDir); os.IsNotExist(err) { return fmt.Errorf("deployment not found: %s/%s\nRun 'obol openclaw onboard' first", appName, id) } @@ -983,7 +983,7 @@ type DashboardOptions struct { // The onReady callback is invoked with the dashboard URL; the CLI layer uses it // to open a browser. func Dashboard(cfg *config.Config, id string, opts DashboardOptions, onReady func(url string), u *ui.UI) error { - deploymentDir := deploymentPath(cfg, id) + deploymentDir := DeploymentPath(cfg, id) if _, err := os.Stat(deploymentDir); os.IsNotExist(err) { return fmt.Errorf("deployment not found: %s/%s\nRun 'obol openclaw up' first", appName, id) } @@ -1076,7 +1076,7 @@ func List(cfg *config.Config, u *ui.UI) error { // Delete removes an OpenClaw instance func Delete(cfg *config.Config, id string, force bool, u *ui.UI) error { namespace := fmt.Sprintf("%s-%s", appName, id) - deploymentDir := deploymentPath(cfg, id) + deploymentDir := DeploymentPath(cfg, id) u.Infof("Deleting OpenClaw: %s/%s", appName, id) u.Detail("Namespace", namespace) @@ -1245,7 +1245,7 @@ var remoteCapableCommands = map[string]bool{ // others are executed via kubectl exec into the pod. func CLI(cfg *config.Config, id string, args []string, u *ui.UI) error { _ = u // interactive passthrough — subprocess owns stdout/stderr - deploymentDir := deploymentPath(cfg, id) + deploymentDir := DeploymentPath(cfg, id) if _, err := os.Stat(deploymentDir); os.IsNotExist(err) { return fmt.Errorf("deployment not found: %s/%s\nRun 'obol openclaw up' first", appName, id) } @@ -1371,7 +1371,7 @@ func SyncOverlayModels(cfg *config.Config, models []string, u *ui.UI) error { masterKey := litellmMasterKey(cfg) for _, id := range ids { - overlayPath := filepath.Join(deploymentPath(cfg, id), "values-obol.yaml") + overlayPath := filepath.Join(DeploymentPath(cfg, id), "values-obol.yaml") data, err := os.ReadFile(overlayPath) if err != nil { continue @@ -1653,8 +1653,8 @@ func patchOverlayModelList(content string, models []string) (string, bool) { return strings.Join(result, "\n"), true } -// deploymentPath returns the path to a deployment directory -func deploymentPath(cfg *config.Config, id string) string { +// DeploymentPath returns the path to a deployment directory. +func DeploymentPath(cfg *config.Config, id string) string { return filepath.Join(cfg.ConfigDir, "applications", appName, id) } diff --git a/internal/openclaw/wallet.go b/internal/openclaw/wallet.go index 9d0fa1c..c707f35 100644 --- a/internal/openclaw/wallet.go +++ b/internal/openclaw/wallet.go @@ -312,10 +312,10 @@ func generateRandomPassword(length int) (string, error) { return string(result), nil } -// keystoreVolumePath returns the host-side path where the remote-signer's +// KeystoreVolumePath returns the host-side path where the remote-signer's // PVC stores keystores. This follows the local-path-provisioner pattern: // $DATA_DIR/// -func keystoreVolumePath(cfg *config.Config, id string) string { +func KeystoreVolumePath(cfg *config.Config, id string) string { namespace := fmt.Sprintf("%s-%s", appName, id) return filepath.Join(cfg.DataDir, namespace, "remote-signer-keystores") } @@ -324,7 +324,7 @@ func keystoreVolumePath(cfg *config.Config, id string) string { // path before the remote-signer pod starts. Returns the absolute path to the // written keystore file. func provisionKeystoreToVolume(cfg *config.Config, id, keystoreID string, keystoreJSON []byte) (string, error) { - dir := keystoreVolumePath(cfg, id) + dir := KeystoreVolumePath(cfg, id) if err := os.MkdirAll(dir, 0700); err != nil { return "", fmt.Errorf("create keystore directory: %w", err) } @@ -359,9 +359,9 @@ func walletMetadataPath(deploymentDir string) string { return filepath.Join(deploymentDir, "wallet.json") } -// writeWalletMetadata writes the wallet address and UUID to a JSON file +// WriteWalletMetadata writes the wallet address and UUID to a JSON file // in the deployment directory for re-sync and display purposes. -func writeWalletMetadata(deploymentDir string, wallet *WalletInfo) error { +func WriteWalletMetadata(deploymentDir string, wallet *WalletInfo) error { data, err := json.MarshalIndent(wallet, "", " ") if err != nil { return fmt.Errorf("marshal wallet metadata: %w", err) @@ -369,8 +369,8 @@ func writeWalletMetadata(deploymentDir string, wallet *WalletInfo) error { return os.WriteFile(walletMetadataPath(deploymentDir), data, 0644) } -// readWalletMetadata reads existing wallet metadata from the deployment directory. -func readWalletMetadata(deploymentDir string) (*WalletInfo, error) { +// ReadWalletMetadata reads existing wallet metadata from the deployment directory. +func ReadWalletMetadata(deploymentDir string) (*WalletInfo, error) { data, err := os.ReadFile(walletMetadataPath(deploymentDir)) if err != nil { return nil, err @@ -411,7 +411,7 @@ func ensureWallet(cfg *config.Config, id, deploymentDir string) { return } - if err := writeWalletMetadata(deploymentDir, wallet); err != nil { + if err := WriteWalletMetadata(deploymentDir, wallet); err != nil { fmt.Printf("Warning: could not write wallet metadata: %v\n", err) return } @@ -423,7 +423,7 @@ func ensureWallet(cfg *config.Config, id, deploymentDir string) { // in the instance namespace. The frontend reads this to display wallet addresses. // Must be called after helmfile sync (namespace must exist). func applyWalletMetadataConfigMap(cfg *config.Config, id, deploymentDir string) { - wallet, err := readWalletMetadata(deploymentDir) + wallet, err := ReadWalletMetadata(deploymentDir) if err != nil { return // no wallet metadata, nothing to apply } diff --git a/internal/openclaw/wallet_backup.go b/internal/openclaw/wallet_backup.go new file mode 100644 index 0000000..14f1fc6 --- /dev/null +++ b/internal/openclaw/wallet_backup.go @@ -0,0 +1,536 @@ +package openclaw + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/json" + "fmt" + "os" + "path/filepath" + "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/ObolNetwork/obol-stack/internal/kubectl" + "github.com/ObolNetwork/obol-stack/internal/ui" + "golang.org/x/crypto/scrypt" + "gopkg.in/yaml.v3" +) + +// backupMagic is the first 4 bytes of an encrypted backup file. +var backupMagic = []byte("OBOL") + +const backupVersion byte = 1 + +// BackupFile is the JSON structure of a wallet backup. +type BackupFile struct { + Version int `json:"version"` + Instance string `json:"instance"` + Wallets []BackupWallet `json:"wallets"` +} + +// BackupWallet holds a single wallet's backup data. +type BackupWallet struct { + Address string `json:"address"` + PublicKey string `json:"publicKey"` + KeystoreUUID string `json:"keystoreUUID"` + CreatedAt string `json:"createdAt"` + Keystore json.RawMessage `json:"keystore"` + KeystorePassword string `json:"keystorePassword"` +} + +// BackupWalletOptions holds options for the backup command. +type BackupWalletOptions struct { + Output string // Output file path (empty = auto-generate) + Passphrase string // Encryption passphrase (empty = no encryption) + HasPassFlag bool // Whether --passphrase was explicitly set +} + +// RestoreWalletOptions holds options for the restore command. +type RestoreWalletOptions struct { + Input string // Input file path + Passphrase string // Decryption passphrase + HasPassFlag bool // Whether --passphrase was explicitly set + Force bool // Overwrite existing wallet +} + +// BackupWallet creates a backup of the wallet for the given instance. +func BackupWalletCmd(cfg *config.Config, id string, opts BackupWalletOptions, u *ui.UI) error { + deployDir := DeploymentPath(cfg, id) + + // Read wallet metadata. + wallet, err := ReadWalletMetadata(deployDir) + if err != nil { + return fmt.Errorf("no wallet found for instance %q: %w", id, err) + } + + // Read keystore JSON. + keystorePath := filepath.Join(KeystoreVolumePath(cfg, id), wallet.KeystoreUUID+".json") + keystoreData, err := os.ReadFile(keystorePath) + if err != nil { + return fmt.Errorf("failed to read keystore file: %w", err) + } + + // Read keystore password from values-remote-signer.yaml. + password, err := readKeystorePassword(deployDir) + if err != nil { + return fmt.Errorf("failed to read keystore password: %w", err) + } + + // Build backup structure. + backup := BackupFile{ + Version: 1, + Instance: id, + Wallets: []BackupWallet{ + { + Address: wallet.Address, + PublicKey: wallet.PublicKey, + KeystoreUUID: wallet.KeystoreUUID, + CreatedAt: wallet.CreatedAt, + Keystore: json.RawMessage(keystoreData), + KeystorePassword: password, + }, + }, + } + + backupJSON, err := json.MarshalIndent(backup, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal backup: %w", err) + } + + // Determine passphrase. + passphrase, err := resolvePassphrase(opts.Passphrase, opts.HasPassFlag, u) + if err != nil { + return err + } + + // Determine output path and write. + addrSuffix := wallet.Address + if len(addrSuffix) > 8 { + addrSuffix = addrSuffix[len(addrSuffix)-8:] + } + + outputPath := opts.Output + encrypted := passphrase != "" + + if outputPath == "" { + if encrypted { + outputPath = fmt.Sprintf("obol-wallet-backup-%s.enc", addrSuffix) + } else { + outputPath = fmt.Sprintf("obol-wallet-backup-%s.json", addrSuffix) + } + } + + if encrypted { + ciphertext, err := encryptBackup(backupJSON, passphrase) + if err != nil { + return fmt.Errorf("encryption failed: %w", err) + } + if err := os.WriteFile(outputPath, ciphertext, 0600); err != nil { + return fmt.Errorf("failed to write backup: %w", err) + } + } else { + if err := os.WriteFile(outputPath, backupJSON, 0600); err != nil { + return fmt.Errorf("failed to write backup: %w", err) + } + } + + u.Success("Wallet backup created") + u.Detail("Address", wallet.Address) + u.Detail("Output", outputPath) + if encrypted { + u.Detail("Encrypted", "yes (AES-256-GCM)") + } else { + u.Detail("Encrypted", "no") + u.Warn("Backup contains unencrypted keystore password — store securely") + } + + return nil +} + +// RestoreWalletCmd restores a wallet from a backup file. +func RestoreWalletCmd(cfg *config.Config, id string, opts RestoreWalletOptions, u *ui.UI) error { + // Read backup file. + raw, err := os.ReadFile(opts.Input) + if err != nil { + return fmt.Errorf("failed to read backup file: %w", err) + } + + // Detect format and decrypt if needed. + var backupJSON []byte + if isEncryptedBackup(raw) { + passphrase := opts.Passphrase + if !opts.HasPassFlag { + passphrase, err = u.SecretInput("Backup passphrase") + if err != nil { + return fmt.Errorf("failed to read passphrase: %w", err) + } + } + if passphrase == "" { + return fmt.Errorf("passphrase required for encrypted backup") + } + backupJSON, err = decryptBackup(raw, passphrase) + if err != nil { + return fmt.Errorf("decryption failed (wrong passphrase?): %w", err) + } + } else { + backupJSON = raw + } + + // Parse backup. + var backup BackupFile + if err := json.Unmarshal(backupJSON, &backup); err != nil { + return fmt.Errorf("invalid backup file: %w", err) + } + if backup.Version != 1 { + return fmt.Errorf("unsupported backup version %d (expected 1)", backup.Version) + } + if len(backup.Wallets) == 0 { + return fmt.Errorf("backup contains no wallets") + } + + w := backup.Wallets[0] + + // Verify deployment dir exists. + deployDir := DeploymentPath(cfg, id) + if _, err := os.Stat(deployDir); os.IsNotExist(err) { + return fmt.Errorf("instance %q not found — run 'obol openclaw onboard --id %s' first", id, id) + } + + // Check for existing wallet. + existingWallet, _ := ReadWalletMetadata(deployDir) + if existingWallet != nil && !opts.Force { + return fmt.Errorf("instance %q already has a wallet (address: %s)\nUse --force to overwrite", id, existingWallet.Address) + } + + // Write keystore file. + keystoreDir := KeystoreVolumePath(cfg, id) + if err := os.MkdirAll(keystoreDir, 0700); err != nil { + return fmt.Errorf("failed to create keystore directory: %w", err) + } + keystorePath := filepath.Join(keystoreDir, w.KeystoreUUID+".json") + if err := os.WriteFile(keystorePath, []byte(w.Keystore), 0600); err != nil { + return fmt.Errorf("failed to write keystore: %w", err) + } + + // Update values-remote-signer.yaml with restored password. + if err := writeKeystorePassword(deployDir, w.KeystorePassword); err != nil { + return fmt.Errorf("failed to write keystore password: %w", err) + } + + // Update wallet.json metadata. + walletInfo := &WalletInfo{ + Address: w.Address, + PublicKey: w.PublicKey, + KeystoreUUID: w.KeystoreUUID, + KeystorePath: keystorePath, + CreatedAt: w.CreatedAt, + } + if err := WriteWalletMetadata(deployDir, walletInfo); err != nil { + return fmt.Errorf("failed to write wallet metadata: %w", err) + } + + u.Success("Wallet restored") + u.Detail("Address", w.Address) + u.Detail("Instance", id) + + // Restart the remote-signer so it picks up the new keystore. + // Best-effort: cluster may not be running. + namespace := fmt.Sprintf("%s-%s", appName, id) + kubectlBin, kubeconfig := kubectl.Paths(cfg) + if err := kubectl.RunSilent(kubectlBin, kubeconfig, + "rollout", "restart", "deployment/remote-signer", "-n", namespace, + ); err != nil { + u.Blank() + u.Warnf("Could not restart remote-signer (cluster may not be running)") + u.Printf("Run 'obol openclaw sync %s' to apply changes to the cluster.", id) + } else { + u.Success("Remote-signer restarted") + } + + return nil +} + +// ListWallets displays wallet information for one or all instances. +func ListWallets(cfg *config.Config, id string, u *ui.UI) error { + var ids []string + + if id != "" { + ids = []string{id} + } else { + var err error + ids, err = ListInstanceIDs(cfg) + if err != nil { + return err + } + if len(ids) == 0 { + u.Info("No OpenClaw instances found") + return nil + } + } + + found := false + for _, instanceID := range ids { + deployDir := DeploymentPath(cfg, instanceID) + wallet, err := ReadWalletMetadata(deployDir) + if err != nil { + continue + } + found = true + u.Detail("Instance", instanceID) + u.Detail(" Address", wallet.Address) + u.Detail(" Keystore UUID", wallet.KeystoreUUID) + if wallet.CreatedAt != "" { + u.Detail(" Created", wallet.CreatedAt) + } + u.Blank() + } + + if !found { + u.Info("No wallets found") + } + return nil +} + +// FindInstancesWithWallets returns instance IDs that have wallet metadata. +func FindInstancesWithWallets(cfg *config.Config) []string { + ids, err := ListInstanceIDs(cfg) + if err != nil { + return nil + } + var result []string + for _, id := range ids { + deployDir := DeploymentPath(cfg, id) + if _, err := ReadWalletMetadata(deployDir); err == nil { + result = append(result, id) + } + } + return result +} + +// resolvePassphrase determines the passphrase via flag or interactive prompt. +func resolvePassphrase(flagValue string, hasFlag bool, u *ui.UI) (string, error) { + if hasFlag { + return flagValue, nil + } + + passphrase, err := u.SecretInput("Backup passphrase (empty for no encryption)") + if err != nil { + return "", fmt.Errorf("failed to read passphrase: %w", err) + } + + if passphrase != "" { + confirm, err := u.SecretInput("Confirm passphrase") + if err != nil { + return "", fmt.Errorf("failed to read confirmation: %w", err) + } + if passphrase != confirm { + return "", fmt.Errorf("passphrases do not match") + } + } + + return passphrase, nil +} + +// readKeystorePassword extracts the keystore password from values-remote-signer.yaml. +func readKeystorePassword(deployDir string) (string, error) { + data, err := os.ReadFile(filepath.Join(deployDir, "values-remote-signer.yaml")) + if err != nil { + return "", err + } + + var values struct { + KeystorePassword struct { + Value string `yaml:"value"` + } `yaml:"keystorePassword"` + } + if err := yaml.Unmarshal(data, &values); err != nil { + return "", fmt.Errorf("failed to parse values-remote-signer.yaml: %w", err) + } + if values.KeystorePassword.Value == "" { + return "", fmt.Errorf("keystorePassword.value not found in values-remote-signer.yaml") + } + return values.KeystorePassword.Value, nil +} + +// writeKeystorePassword writes the remote-signer values YAML with the given password. +func writeKeystorePassword(deployDir, password string) error { + content := fmt.Sprintf(`# Remote-signer configuration +# Managed by obol openclaw — do not edit manually. + +keystorePassword: + value: %q + +persistence: + enabled: true + size: 100Mi +`, password) + return os.WriteFile(filepath.Join(deployDir, "values-remote-signer.yaml"), []byte(content), 0644) +} + +// encryptBackup encrypts plaintext using AES-256-GCM with a scrypt-derived key. +// Format: magic(4) || version(1) || salt(32) || nonce(12) || ciphertext+tag +func encryptBackup(plaintext []byte, passphrase string) ([]byte, error) { + salt := make([]byte, 32) + if _, err := rand.Read(salt); err != nil { + return nil, fmt.Errorf("salt generation: %w", err) + } + + key, err := scrypt.Key([]byte(passphrase), salt, scryptN, scryptR, scryptP, scryptDKLen) + if err != nil { + return nil, fmt.Errorf("scrypt key derivation: %w", err) + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("aes cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("gcm: %w", err) + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, fmt.Errorf("nonce generation: %w", err) + } + + ciphertext := gcm.Seal(nil, nonce, plaintext, nil) + + // Assemble: magic || version || salt || nonce || ciphertext + result := make([]byte, 0, len(backupMagic)+1+len(salt)+len(nonce)+len(ciphertext)) + result = append(result, backupMagic...) + result = append(result, backupVersion) + result = append(result, salt...) + result = append(result, nonce...) + result = append(result, ciphertext...) + + return result, nil +} + +// decryptBackup decrypts an encrypted backup file. +func decryptBackup(data []byte, passphrase string) ([]byte, error) { + minLen := len(backupMagic) + 1 + 32 + 12 // magic + version + salt + nonce + if len(data) < minLen { + return nil, fmt.Errorf("encrypted file too short") + } + + offset := 0 + + // Verify magic. + if string(data[offset:offset+len(backupMagic)]) != string(backupMagic) { + return nil, fmt.Errorf("not an encrypted backup file") + } + offset += len(backupMagic) + + // Check version. + version := data[offset] + offset++ + if version != backupVersion { + return nil, fmt.Errorf("unsupported encryption version %d", version) + } + + // Extract salt. + salt := data[offset : offset+32] + offset += 32 + + // Derive key. + key, err := scrypt.Key([]byte(passphrase), salt, scryptN, scryptR, scryptP, scryptDKLen) + if err != nil { + return nil, fmt.Errorf("scrypt key derivation: %w", err) + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("aes cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("gcm: %w", err) + } + + // Extract nonce. + nonceSize := gcm.NonceSize() + if len(data) < offset+nonceSize { + return nil, fmt.Errorf("encrypted file too short for nonce") + } + nonce := data[offset : offset+nonceSize] + offset += nonceSize + + // Decrypt. + ciphertext := data[offset:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, fmt.Errorf("decryption failed: %w", err) + } + + return plaintext, nil +} + +// isEncryptedBackup checks if data starts with the OBOL magic bytes. +func isEncryptedBackup(data []byte) bool { + if len(data) < len(backupMagic) { + return false + } + return string(data[:len(backupMagic)]) == string(backupMagic) +} + +// walletAddressesForPurgeWarning returns addresses of wallets that would be lost. +func walletAddressesForPurgeWarning(cfg *config.Config) []string { + ids := FindInstancesWithWallets(cfg) + var addresses []string + for _, id := range ids { + deployDir := DeploymentPath(cfg, id) + w, err := ReadWalletMetadata(deployDir) + if err == nil { + addresses = append(addresses, fmt.Sprintf(" %s (instance: %s)", w.Address, id)) + } + } + return addresses +} + +// PromptBackupBeforePurge checks for wallets and offers to back them up. +// Non-interactive (piped/scripted): prints a warning but does not block. +func PromptBackupBeforePurge(cfg *config.Config, u *ui.UI) { + ids := FindInstancesWithWallets(cfg) + if len(ids) == 0 { + return + } + + addresses := walletAddressesForPurgeWarning(cfg) + u.Warn("The following wallets will be destroyed:") + for _, a := range addresses { + u.Print(a) + } + + // Non-interactive: warn but don't block scripts. + if !u.IsTTY() { + u.Warn("Run 'obol openclaw wallet backup' first to save wallet keys") + return + } + + u.Blank() + if !u.Confirm("Back up wallets before purging?", true) { + return + } + + // Get a single passphrase for all backups. + passphrase, err := resolvePassphrase("", false, u) + if err != nil { + u.Warnf("Failed to get passphrase: %v", err) + return + } + + for _, id := range ids { + err := BackupWalletCmd(cfg, id, BackupWalletOptions{ + Passphrase: passphrase, + HasPassFlag: true, + }, u) + if err != nil { + u.Warnf("Failed to backup wallet for instance %s: %v", id, err) + } + } + + u.Blank() +} + diff --git a/internal/openclaw/wallet_backup_test.go b/internal/openclaw/wallet_backup_test.go new file mode 100644 index 0000000..eab4f81 --- /dev/null +++ b/internal/openclaw/wallet_backup_test.go @@ -0,0 +1,384 @@ +package openclaw + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/ObolNetwork/obol-stack/internal/ui" +) + +// setupTestInstance creates a minimal instance structure for testing. +func setupTestInstance(t *testing.T) (*config.Config, string, *WalletInfo) { + t.Helper() + + tmpDir := t.TempDir() + cfg := &config.Config{ + ConfigDir: filepath.Join(tmpDir, "config"), + DataDir: filepath.Join(tmpDir, "data"), + } + + id := "test-instance" + deployDir := DeploymentPath(cfg, id) + if err := os.MkdirAll(deployDir, 0755); err != nil { + t.Fatal(err) + } + + // Generate a wallet. + wallet, err := GenerateWallet(cfg, id) + if err != nil { + t.Fatal(err) + } + + // Write metadata. + if err := WriteWalletMetadata(deployDir, wallet); err != nil { + t.Fatal(err) + } + + // Write values-remote-signer.yaml. + values := generateRemoteSignerValues(wallet) + if err := os.WriteFile(filepath.Join(deployDir, "values-remote-signer.yaml"), []byte(values), 0644); err != nil { + t.Fatal(err) + } + + return cfg, id, wallet +} + +func TestBackupRestorePlainRoundTrip(t *testing.T) { + cfg, id, origWallet := setupTestInstance(t) + + // Backup (no encryption). + backupPath := filepath.Join(t.TempDir(), "backup.json") + u := testUI() + err := BackupWalletCmd(cfg, id, BackupWalletOptions{ + Output: backupPath, + Passphrase: "", + HasPassFlag: true, // skip prompt + }, u) + if err != nil { + t.Fatalf("backup: %v", err) + } + + // Verify backup file is valid JSON. + data, err := os.ReadFile(backupPath) + if err != nil { + t.Fatal(err) + } + var backup BackupFile + if err := json.Unmarshal(data, &backup); err != nil { + t.Fatalf("backup is not valid JSON: %v", err) + } + if backup.Version != 1 { + t.Errorf("version = %d, want 1", backup.Version) + } + if len(backup.Wallets) != 1 { + t.Fatalf("wallets count = %d, want 1", len(backup.Wallets)) + } + if backup.Wallets[0].Address != origWallet.Address { + t.Errorf("address = %q, want %q", backup.Wallets[0].Address, origWallet.Address) + } + + // Create a new instance to restore into. + restoreID := "restore-instance" + restoreDir := DeploymentPath(cfg, restoreID) + if err := os.MkdirAll(restoreDir, 0755); err != nil { + t.Fatal(err) + } + // Write a dummy values-remote-signer.yaml so deployment looks valid. + if err := os.WriteFile(filepath.Join(restoreDir, "values-remote-signer.yaml"), []byte("dummy: true\n"), 0644); err != nil { + t.Fatal(err) + } + + // Restore. + err = RestoreWalletCmd(cfg, restoreID, RestoreWalletOptions{ + Input: backupPath, + Passphrase: "", + HasPassFlag: true, + Force: false, + }, u) + if err != nil { + t.Fatalf("restore: %v", err) + } + + // Verify restored wallet metadata. + restored, err := ReadWalletMetadata(restoreDir) + if err != nil { + t.Fatal(err) + } + if restored.Address != origWallet.Address { + t.Errorf("restored address = %q, want %q", restored.Address, origWallet.Address) + } + if restored.KeystoreUUID != origWallet.KeystoreUUID { + t.Errorf("restored UUID = %q, want %q", restored.KeystoreUUID, origWallet.KeystoreUUID) + } + + // Verify restored keystore file exists. + keystorePath := filepath.Join(KeystoreVolumePath(cfg, restoreID), origWallet.KeystoreUUID+".json") + if _, err := os.Stat(keystorePath); os.IsNotExist(err) { + t.Error("restored keystore file does not exist") + } + + // Verify restored password. + restoredPwd, err := readKeystorePassword(restoreDir) + if err != nil { + t.Fatal(err) + } + if restoredPwd != origWallet.Password { + t.Errorf("restored password = %q, want %q", restoredPwd, origWallet.Password) + } +} + +func TestBackupRestoreEncryptedRoundTrip(t *testing.T) { + cfg, id, origWallet := setupTestInstance(t) + + backupPath := filepath.Join(t.TempDir(), "backup.enc") + passphrase := "test-secure-passphrase-123" + u := testUI() + + // Backup with encryption. + err := BackupWalletCmd(cfg, id, BackupWalletOptions{ + Output: backupPath, + Passphrase: passphrase, + HasPassFlag: true, + }, u) + if err != nil { + t.Fatalf("encrypted backup: %v", err) + } + + // Verify the file is encrypted (starts with OBOL magic). + data, err := os.ReadFile(backupPath) + if err != nil { + t.Fatal(err) + } + if !isEncryptedBackup(data) { + t.Error("backup file should be encrypted") + } + + // Restore. + restoreID := "restore-enc" + restoreDir := DeploymentPath(cfg, restoreID) + if err := os.MkdirAll(restoreDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(restoreDir, "values-remote-signer.yaml"), []byte("dummy: true\n"), 0644); err != nil { + t.Fatal(err) + } + + err = RestoreWalletCmd(cfg, restoreID, RestoreWalletOptions{ + Input: backupPath, + Passphrase: passphrase, + HasPassFlag: true, + }, u) + if err != nil { + t.Fatalf("encrypted restore: %v", err) + } + + // Verify. + restored, err := ReadWalletMetadata(restoreDir) + if err != nil { + t.Fatal(err) + } + if restored.Address != origWallet.Address { + t.Errorf("address = %q, want %q", restored.Address, origWallet.Address) + } +} + +func TestDecryptWrongPassphrase(t *testing.T) { + plaintext := []byte(`{"version":1,"instance":"test","wallets":[]}`) + encrypted, err := encryptBackup(plaintext, "correct-passphrase") + if err != nil { + t.Fatal(err) + } + + _, err = decryptBackup(encrypted, "wrong-passphrase") + if err == nil { + t.Error("expected error with wrong passphrase") + } +} + +func TestEncryptDecryptBackupRoundTrip(t *testing.T) { + plaintext := []byte(`{"version":1,"instance":"test","wallets":[{"address":"0x1234"}]}`) + passphrase := "my-secret" + + encrypted, err := encryptBackup(plaintext, passphrase) + if err != nil { + t.Fatal(err) + } + + if !isEncryptedBackup(encrypted) { + t.Error("encrypted data should start with OBOL magic") + } + + decrypted, err := decryptBackup(encrypted, passphrase) + if err != nil { + t.Fatalf("decrypt: %v", err) + } + + if string(decrypted) != string(plaintext) { + t.Errorf("decrypted = %q, want %q", decrypted, plaintext) + } +} + +func TestIsEncryptedBackup(t *testing.T) { + tests := []struct { + name string + data []byte + want bool + }{ + {"encrypted", []byte("OBOL\x01rest-of-data"), true}, + {"plain json", []byte(`{"version": 1}`), false}, + {"empty", []byte{}, false}, + {"short", []byte("OBO"), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isEncryptedBackup(tt.data); got != tt.want { + t.Errorf("isEncryptedBackup = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCorruptEncryptedFile(t *testing.T) { + plaintext := []byte(`{"version":1}`) + encrypted, err := encryptBackup(plaintext, "pass") + if err != nil { + t.Fatal(err) + } + + // Corrupt the ciphertext (last byte). + encrypted[len(encrypted)-1] ^= 0xFF + + _, err = decryptBackup(encrypted, "pass") + if err == nil { + t.Error("expected error with corrupted ciphertext") + } +} + +func TestRestoreRequiresForceForExisting(t *testing.T) { + cfg, id, _ := setupTestInstance(t) + + // Create a backup first. + backupPath := filepath.Join(t.TempDir(), "backup.json") + u := testUI() + if err := BackupWalletCmd(cfg, id, BackupWalletOptions{ + Output: backupPath, + Passphrase: "", + HasPassFlag: true, + }, u); err != nil { + t.Fatal(err) + } + + // Try to restore over existing wallet without --force. + err := RestoreWalletCmd(cfg, id, RestoreWalletOptions{ + Input: backupPath, + Passphrase: "", + HasPassFlag: true, + Force: false, + }, u) + if err == nil { + t.Error("expected error when restoring over existing wallet without --force") + } + + // With --force should succeed. + err = RestoreWalletCmd(cfg, id, RestoreWalletOptions{ + Input: backupPath, + Passphrase: "", + HasPassFlag: true, + Force: true, + }, u) + if err != nil { + t.Fatalf("restore with --force: %v", err) + } +} + +func TestRestoreInvalidVersion(t *testing.T) { + backup := `{"version":99,"instance":"test","wallets":[{"address":"0x1234"}]}` + backupPath := filepath.Join(t.TempDir(), "backup.json") + if err := os.WriteFile(backupPath, []byte(backup), 0644); err != nil { + t.Fatal(err) + } + + tmpDir := t.TempDir() + cfg := &config.Config{ + ConfigDir: filepath.Join(tmpDir, "config"), + DataDir: filepath.Join(tmpDir, "data"), + } + deployDir := DeploymentPath(cfg, "test") + if err := os.MkdirAll(deployDir, 0755); err != nil { + t.Fatal(err) + } + + u := testUI() + err := RestoreWalletCmd(cfg, "test", RestoreWalletOptions{ + Input: backupPath, + Passphrase: "", + HasPassFlag: true, + }, u) + if err == nil { + t.Error("expected error for unsupported version") + } +} + +func TestReadKeystorePassword(t *testing.T) { + tmpDir := t.TempDir() + + yaml := `# Remote-signer configuration +keystorePassword: + value: "my-password-123" + +persistence: + enabled: true +` + if err := os.WriteFile(filepath.Join(tmpDir, "values-remote-signer.yaml"), []byte(yaml), 0644); err != nil { + t.Fatal(err) + } + + pwd, err := readKeystorePassword(tmpDir) + if err != nil { + t.Fatal(err) + } + if pwd != "my-password-123" { + t.Errorf("password = %q, want %q", pwd, "my-password-123") + } +} + +func TestFindInstancesWithWallets(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + ConfigDir: filepath.Join(tmpDir, "config"), + DataDir: filepath.Join(tmpDir, "data"), + } + + // Create two instances, one with wallet, one without. + for _, id := range []string{"with-wallet", "no-wallet"} { + dir := DeploymentPath(cfg, id) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + } + + wallet := &WalletInfo{ + Address: "0x1234", + KeystoreUUID: "uuid-123", + } + if err := WriteWalletMetadata(DeploymentPath(cfg, "with-wallet"), wallet); err != nil { + t.Fatal(err) + } + + ids := FindInstancesWithWallets(cfg) + if len(ids) != 1 { + t.Fatalf("expected 1 instance with wallet, got %d", len(ids)) + } + if ids[0] != "with-wallet" { + t.Errorf("instance = %q, want %q", ids[0], "with-wallet") + } +} + +// testUI creates a UI for testing. +func testUI() *ui.UI { + return ui.New(false) +} diff --git a/internal/openclaw/wallet_test.go b/internal/openclaw/wallet_test.go index 62d3caa..8f12c77 100644 --- a/internal/openclaw/wallet_test.go +++ b/internal/openclaw/wallet_test.go @@ -216,7 +216,7 @@ func TestKeystoreVolumePath(t *testing.T) { cfg := &config.Config{ DataDir: "/test/data", } - path := keystoreVolumePath(cfg, "my-agent") + path := KeystoreVolumePath(cfg, "my-agent") want := "/test/data/openclaw-my-agent/remote-signer-keystores" if path != want { t.Errorf("keystoreVolumePath = %q, want %q", path, want) @@ -287,11 +287,11 @@ func TestWalletMetadataRoundTrip(t *testing.T) { Password: "should-not-serialize", } - if err := writeWalletMetadata(tmpDir, wallet); err != nil { + if err := WriteWalletMetadata(tmpDir, wallet); err != nil { t.Fatal(err) } - recovered, err := readWalletMetadata(tmpDir) + recovered, err := ReadWalletMetadata(tmpDir) if err != nil { t.Fatal(err) } diff --git a/internal/stack/stack.go b/internal/stack/stack.go index 37c3c40..71897e8 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -319,6 +319,11 @@ func Down(cfg *config.Config, u *ui.UI) error { // Purge deletes the cluster config and optionally data func Purge(cfg *config.Config, u *ui.UI, force bool) error { + // When --force is set, data dir will be deleted — offer wallet backup. + if force { + openclaw.PromptBackupBeforePurge(cfg, u) + } + stackID := getStackID(cfg) backend, err := LoadBackend(cfg)