From ccbbbdcfb45b6aff834ab0aa1a81533c10287155 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Wed, 27 May 2026 14:25:46 +0400 Subject: [PATCH] fix(hermes): make wallet backup portable on k3d --- internal/hermes/hermes.go | 17 +++- internal/hermes/wallet_backup.go | 123 +++++++++++++++++++++++--- internal/hermes/wallet_backup_test.go | 118 ++++++++++++++++++++++++ internal/hermes/wallet_import.go | 1 + 4 files changed, 245 insertions(+), 14 deletions(-) diff --git a/internal/hermes/hermes.go b/internal/hermes/hermes.go index 041f9a9b..1f49fa8d 100644 --- a/internal/hermes/hermes.go +++ b/internal/hermes/hermes.go @@ -1236,21 +1236,26 @@ func rankModels(models []string) (primary string, fallbacks []string) { } func k3dNodeExec(cfg *config.Config, hostPath, shellCmd string) error { + _, err := k3dNodeExecOutput(cfg, hostPath, shellCmd) + return err +} + +func k3dNodeExecOutput(cfg *config.Config, hostPath, shellCmd string) ([]byte, error) { stackID := "" if data, err := os.ReadFile(filepath.Join(cfg.ConfigDir, ".stack-id")); err == nil { stackID = strings.TrimSpace(string(data)) } if stackID == "" { - return fmt.Errorf("stack ID not found") + return nil, fmt.Errorf("stack ID not found") } container := fmt.Sprintf("k3d-obol-stack-%s-server-0", stackID) relPath, err := filepath.Rel(cfg.DataDir, hostPath) if err != nil { - return fmt.Errorf("cannot compute relative path from %s to %s: %w", cfg.DataDir, hostPath, err) + return nil, fmt.Errorf("cannot compute relative path from %s to %s: %w", cfg.DataDir, hostPath, err) } if strings.HasPrefix(relPath, "..") { - return fmt.Errorf("path %s is not under DataDir %s", hostPath, cfg.DataDir) + return nil, fmt.Errorf("path %s is not under DataDir %s", hostPath, cfg.DataDir) } nodePath := filepath.Join("/data", relPath) @@ -1258,7 +1263,11 @@ func k3dNodeExec(cfg *config.Config, hostPath, shellCmd string) error { expanded := strings.ReplaceAll(shellCmd, "{}", quoted) cmd := exec.Command("docker", "exec", container, "sh", "-c", expanded) - return cmd.Run() + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("docker exec %s: %w: %s", container, err, strings.TrimSpace(string(out))) + } + return out, nil } func ensureVolumeWritable(cfg *config.Config, hostPath string, u *ui.UI) { diff --git a/internal/hermes/wallet_backup.go b/internal/hermes/wallet_backup.go index 50820501..85f42912 100644 --- a/internal/hermes/wallet_backup.go +++ b/internal/hermes/wallet_backup.go @@ -2,17 +2,23 @@ package hermes import ( "bytes" + "encoding/hex" "encoding/json" + "errors" "fmt" "os" "os/exec" "path/filepath" + "strings" + "time" "github.com/ObolNetwork/obol-stack/internal/agentruntime" "github.com/ObolNetwork/obol-stack/internal/config" "github.com/ObolNetwork/obol-stack/internal/kubectl" "github.com/ObolNetwork/obol-stack/internal/ui" "github.com/ObolNetwork/obol-stack/internal/walletbackup" + gethkeystore "github.com/ethereum/go-ethereum/accounts/keystore" + ethcrypto "github.com/ethereum/go-ethereum/crypto" ) // BackupWalletOptions holds options for `obol agent wallet backup`. @@ -31,6 +37,8 @@ type RestoreWalletOptions struct { ApplyCluster bool } +var readHostKeystoreFileFn = os.ReadFile + // BackupWalletCmd creates a backup of the Hermes instance's remote-signer // wallet. The on-disk format is identical to OpenClaw's, so a Hermes backup // can be restored into an OpenClaw instance and vice versa — instance @@ -44,7 +52,7 @@ func BackupWalletCmd(cfg *config.Config, id string, opts BackupWalletOptions, u } keystorePath := filepath.Join(agentruntime.KeystoreVolumePath(cfg, agentruntime.Hermes, id), wallet.KeystoreUUID+".json") - keystoreData, err := os.ReadFile(keystorePath) + keystoreData, err := readKeystoreFileForBackup(cfg, keystorePath) if err != nil { return fmt.Errorf("failed to read keystore file: %w", err) } @@ -114,15 +122,7 @@ func RestoreWalletCmd(cfg *config.Config, id string, opts RestoreWalletOptions, return fmt.Errorf("failed to read backup file: %w", err) } - passphrase := opts.Passphrase - if walletbackup.IsEncrypted(raw) && !opts.HasPassFlag { - passphrase, err = u.SecretInput("Backup passphrase") - if err != nil { - return fmt.Errorf("failed to read passphrase: %w", err) - } - } - - backup, err := walletbackup.Decode(raw, passphrase) + backup, err := decodeHermesWalletRestoreInput(raw, opts, id, u) if err != nil { return err } @@ -183,6 +183,109 @@ func RestoreWalletCmd(cfg *config.Config, id string, opts RestoreWalletOptions, return nil } +func decodeHermesWalletRestoreInput(raw []byte, opts RestoreWalletOptions, id string, u *ui.UI) (*walletbackup.File, error) { + passphrase := opts.Passphrase + if walletbackup.IsEncrypted(raw) && !opts.HasPassFlag { + var err error + passphrase, err = u.SecretInput("Backup passphrase") + if err != nil { + return nil, fmt.Errorf("failed to read passphrase: %w", err) + } + } + + backup, err := walletbackup.Decode(raw, passphrase) + if err == nil { + return backup, nil + } + if !isRawV3Keystore(raw) { + return nil, err + } + + keystorePassword := opts.Passphrase + if !opts.HasPassFlag { + keystorePassword, err = u.SecretInput("Ethereum keystore password") + if err != nil { + return nil, fmt.Errorf("failed to read Ethereum keystore password: %w", err) + } + } + return backupFromRawV3Keystore(raw, keystorePassword, id) +} + +func readKeystoreFileForBackup(cfg *config.Config, keystorePath string) ([]byte, error) { + data, err := readHostKeystoreFileFn(keystorePath) + if err == nil { + return data, nil + } + if !os.IsPermission(err) || !isK3dBackend(cfg) { + return nil, err + } + + data, fallbackErr := k3dNodeExecOutputFn(cfg, keystorePath, "cat {}") + if fallbackErr != nil { + return nil, fmt.Errorf("%w; k3d node read fallback failed: %v", err, fallbackErr) + } + return data, nil +} + +func isK3dBackend(cfg *config.Config) bool { + data, err := os.ReadFile(filepath.Join(cfg.ConfigDir, ".stack-backend")) + if err != nil { + return true + } + return strings.TrimSpace(string(data)) == "k3d" +} + +func isRawV3Keystore(raw []byte) bool { + var probe struct { + Version int `json:"version"` + Crypto json.RawMessage `json:"crypto"` + } + if err := json.Unmarshal(raw, &probe); err != nil { + return false + } + return probe.Version == 3 && len(probe.Crypto) > 0 +} + +func backupFromRawV3Keystore(raw []byte, pw, instanceID string) (*walletbackup.File, error) { + var meta struct { + ID string `json:"id"` + } + if err := json.Unmarshal(raw, &meta); err != nil { + return nil, fmt.Errorf("parse raw Ethereum V3 keystore metadata: %w", err) + } + + key, err := gethkeystore.DecryptKey(raw, pw) + if err != nil { + return nil, fmt.Errorf("decrypt raw Ethereum V3 keystore: %w", err) + } + + keystoreID := strings.TrimSpace(meta.ID) + if keystoreID == "" { + keystoreID = key.Id.String() + } + if keystoreID == "" { + return nil, errors.New("raw Ethereum V3 keystore missing id") + } + + publicKey := ethcrypto.FromECDSAPub(&key.PrivateKey.PublicKey) + if len(publicKey) != 65 || publicKey[0] != 0x04 { + return nil, errors.New("raw Ethereum V3 keystore produced invalid public key") + } + + return &walletbackup.File{ + Version: walletbackup.Version, + Instance: instanceID, + Wallets: []walletbackup.Wallet{{ + Address: key.Address.Hex(), + PublicKey: "0x" + hex.EncodeToString(publicKey), + KeystoreUUID: keystoreID, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + Keystore: json.RawMessage(raw), + KeystorePassword: pw, + }}, + }, nil +} + // FindInstancesWithWallets returns Hermes instance IDs that have wallet // metadata on disk. Used by purge prompts. func FindInstancesWithWallets(cfg *config.Config) []string { diff --git a/internal/hermes/wallet_backup_test.go b/internal/hermes/wallet_backup_test.go index beb3b6d3..7a67e91d 100644 --- a/internal/hermes/wallet_backup_test.go +++ b/internal/hermes/wallet_backup_test.go @@ -1,7 +1,9 @@ package hermes import ( + "encoding/hex" "encoding/json" + "io/fs" "os" "path/filepath" "reflect" @@ -127,6 +129,63 @@ func TestBackupRestoreWalletCmd_HermesRoundTrip(t *testing.T) { } } +func TestBackupWalletCmd_K3dPermissionFallbackReadsFromNode(t *testing.T) { + cfg, id, original := setupHermesBackupInstance(t, "k3d-fallback") + if err := os.WriteFile(filepath.Join(cfg.ConfigDir, ".stack-backend"), []byte("k3d"), 0o600); err != nil { + t.Fatalf("write stack backend: %v", err) + } + if err := os.WriteFile(filepath.Join(cfg.ConfigDir, ".stack-id"), []byte("test-stack"), 0o600); err != nil { + t.Fatalf("write stack id: %v", err) + } + + origRead := readHostKeystoreFileFn + origNode := k3dNodeExecOutputFn + t.Cleanup(func() { + readHostKeystoreFileFn = origRead + k3dNodeExecOutputFn = origNode + }) + + readHostKeystoreFileFn = func(path string) ([]byte, error) { + if path == original.KeystorePath { + return nil, &os.PathError{Op: "open", Path: path, Err: fs.ErrPermission} + } + return os.ReadFile(path) + } + k3dNodeExecOutputFn = func(_ *config.Config, hostPath, shellCmd string) ([]byte, error) { + if hostPath != original.KeystorePath { + t.Fatalf("k3d fallback hostPath = %q, want %q", hostPath, original.KeystorePath) + } + if shellCmd != "cat {}" { + t.Fatalf("k3d fallback shellCmd = %q, want cat {}", shellCmd) + } + return []byte(`{"version":3,"from":"k3d-node"}`), nil + } + + backupPath := filepath.Join(t.TempDir(), "backup.json") + if err := BackupWalletCmd(cfg, id, BackupWalletOptions{ + Output: backupPath, + HasPassFlag: true, + }, newTestUI()); err != nil { + t.Fatalf("backup: %v", err) + } + + payload, err := os.ReadFile(backupPath) + if err != nil { + t.Fatalf("read backup: %v", err) + } + decoded, err := walletbackup.Decode(payload, "") + if err != nil { + t.Fatalf("decode backup: %v", err) + } + var keystore map[string]any + if err := json.Unmarshal(decoded.Wallets[0].Keystore, &keystore); err != nil { + t.Fatalf("unmarshal backup keystore: %v", err) + } + if got := keystore["from"]; got != "k3d-node" { + t.Fatalf("backup keystore from = %v, want k3d-node", got) + } +} + func TestRestoreWalletCmd_HermesRequiresForceForExistingWallet(t *testing.T) { cfg, id, _ := setupHermesBackupInstance(t, "existing") @@ -155,6 +214,65 @@ func TestRestoreWalletCmd_HermesRequiresForceForExistingWallet(t *testing.T) { } } +func TestRestoreWalletCmd_HermesAcceptsRawEthereumV3Keystore(t *testing.T) { + const id = "raw-v3" + + cfg, deployDir := walletImportTestConfig(t, id) + stubVolumeOwnership(t) + + privKey, pubKey, err := generateKeypair() + if err != nil { + t.Fatalf("generate keypair: %v", err) + } + password := "raw-v3-password" + keystoreJSON, keystoreID, err := encryptToV3Keystore(privKey, pubKey, password) + if err != nil { + t.Fatalf("encrypt keystore: %v", err) + } + input := filepath.Join(t.TempDir(), "raw-v3.json") + if err := os.WriteFile(input, keystoreJSON, 0o600); err != nil { + t.Fatalf("write raw v3: %v", err) + } + + if err := RestoreWalletCmd(cfg, id, RestoreWalletOptions{ + Input: input, + Passphrase: password, + HasPassFlag: true, + }, newTestUI()); err != nil { + t.Fatalf("restore raw v3: %v", err) + } + + restored, err := ReadWalletMetadata(deployDir) + if err != nil { + t.Fatalf("read restored metadata: %v", err) + } + if restored.Address != addressFromPublicKey(pubKey) { + t.Fatalf("restored address = %q, want %q", restored.Address, addressFromPublicKey(pubKey)) + } + if restored.PublicKey != "0x04"+hex.EncodeToString(pubKey) { + t.Fatalf("restored public key = %q", restored.PublicKey) + } + if restored.KeystoreUUID != keystoreID { + t.Fatalf("restored keystore UUID = %q, want %q", restored.KeystoreUUID, keystoreID) + } + + passwordFromValues, err := walletbackup.ReadKeystorePassword(deployDir) + if err != nil { + t.Fatalf("read restored password: %v", err) + } + if passwordFromValues != password { + t.Fatalf("restored password = %q, want raw V3 password", passwordFromValues) + } + + restoredKeystore, err := os.ReadFile(filepath.Join(agentruntime.KeystoreVolumePath(cfg, agentruntime.Hermes, id), keystoreID+".json")) + if err != nil { + t.Fatalf("read restored keystore: %v", err) + } + if string(restoredKeystore) != string(keystoreJSON) { + t.Fatal("restored raw V3 keystore changed") + } +} + func TestRestoreWalletCmd_ApplyClusterPublishesWalletMetadataBeforeRestart(t *testing.T) { const id = "obol-agent" diff --git a/internal/hermes/wallet_import.go b/internal/hermes/wallet_import.go index 91a9acb6..7d842418 100644 --- a/internal/hermes/wallet_import.go +++ b/internal/hermes/wallet_import.go @@ -33,6 +33,7 @@ var ( ensureVolumeWritableFn = ensureVolumeWritable fixRuntimeVolumeOwnershipFn = fixRuntimeVolumeOwnership applyWalletMetadataConfigMapFn = applyWalletMetadataConfigMap + k3dNodeExecOutputFn = k3dNodeExecOutput ) // ImportPrivateKeyWalletCmd imports an existing private key as the