Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions internal/hermes/hermes.go
Original file line number Diff line number Diff line change
Expand Up @@ -1236,29 +1236,38 @@ 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)
quoted := "'" + strings.ReplaceAll(nodePath, "'", "'\"'\"'") + "'"
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) {
Expand Down
123 changes: 113 additions & 10 deletions internal/hermes/wallet_backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
118 changes: 118 additions & 0 deletions internal/hermes/wallet_backup_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package hermes

import (
"encoding/hex"
"encoding/json"
"io/fs"
"os"
"path/filepath"
"reflect"
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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"

Expand Down
1 change: 1 addition & 0 deletions internal/hermes/wallet_import.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ var (
ensureVolumeWritableFn = ensureVolumeWritable
fixRuntimeVolumeOwnershipFn = fixRuntimeVolumeOwnership
applyWalletMetadataConfigMapFn = applyWalletMetadataConfigMap
k3dNodeExecOutputFn = k3dNodeExecOutput
)

// ImportPrivateKeyWalletCmd imports an existing private key as the
Expand Down
Loading