Skip to content

Commit ccbbbdc

Browse files
committed
fix(hermes): make wallet backup portable on k3d
1 parent 98fa406 commit ccbbbdc

4 files changed

Lines changed: 245 additions & 14 deletions

File tree

internal/hermes/hermes.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1236,29 +1236,38 @@ func rankModels(models []string) (primary string, fallbacks []string) {
12361236
}
12371237

12381238
func k3dNodeExec(cfg *config.Config, hostPath, shellCmd string) error {
1239+
_, err := k3dNodeExecOutput(cfg, hostPath, shellCmd)
1240+
return err
1241+
}
1242+
1243+
func k3dNodeExecOutput(cfg *config.Config, hostPath, shellCmd string) ([]byte, error) {
12391244
stackID := ""
12401245
if data, err := os.ReadFile(filepath.Join(cfg.ConfigDir, ".stack-id")); err == nil {
12411246
stackID = strings.TrimSpace(string(data))
12421247
}
12431248
if stackID == "" {
1244-
return fmt.Errorf("stack ID not found")
1249+
return nil, fmt.Errorf("stack ID not found")
12451250
}
12461251

12471252
container := fmt.Sprintf("k3d-obol-stack-%s-server-0", stackID)
12481253
relPath, err := filepath.Rel(cfg.DataDir, hostPath)
12491254
if err != nil {
1250-
return fmt.Errorf("cannot compute relative path from %s to %s: %w", cfg.DataDir, hostPath, err)
1255+
return nil, fmt.Errorf("cannot compute relative path from %s to %s: %w", cfg.DataDir, hostPath, err)
12511256
}
12521257
if strings.HasPrefix(relPath, "..") {
1253-
return fmt.Errorf("path %s is not under DataDir %s", hostPath, cfg.DataDir)
1258+
return nil, fmt.Errorf("path %s is not under DataDir %s", hostPath, cfg.DataDir)
12541259
}
12551260

12561261
nodePath := filepath.Join("/data", relPath)
12571262
quoted := "'" + strings.ReplaceAll(nodePath, "'", "'\"'\"'") + "'"
12581263
expanded := strings.ReplaceAll(shellCmd, "{}", quoted)
12591264

12601265
cmd := exec.Command("docker", "exec", container, "sh", "-c", expanded)
1261-
return cmd.Run()
1266+
out, err := cmd.CombinedOutput()
1267+
if err != nil {
1268+
return nil, fmt.Errorf("docker exec %s: %w: %s", container, err, strings.TrimSpace(string(out)))
1269+
}
1270+
return out, nil
12621271
}
12631272

12641273
func ensureVolumeWritable(cfg *config.Config, hostPath string, u *ui.UI) {

internal/hermes/wallet_backup.go

Lines changed: 113 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,23 @@ package hermes
22

33
import (
44
"bytes"
5+
"encoding/hex"
56
"encoding/json"
7+
"errors"
68
"fmt"
79
"os"
810
"os/exec"
911
"path/filepath"
12+
"strings"
13+
"time"
1014

1115
"github.com/ObolNetwork/obol-stack/internal/agentruntime"
1216
"github.com/ObolNetwork/obol-stack/internal/config"
1317
"github.com/ObolNetwork/obol-stack/internal/kubectl"
1418
"github.com/ObolNetwork/obol-stack/internal/ui"
1519
"github.com/ObolNetwork/obol-stack/internal/walletbackup"
20+
gethkeystore "github.com/ethereum/go-ethereum/accounts/keystore"
21+
ethcrypto "github.com/ethereum/go-ethereum/crypto"
1622
)
1723

1824
// BackupWalletOptions holds options for `obol agent wallet backup`.
@@ -31,6 +37,8 @@ type RestoreWalletOptions struct {
3137
ApplyCluster bool
3238
}
3339

40+
var readHostKeystoreFileFn = os.ReadFile
41+
3442
// BackupWalletCmd creates a backup of the Hermes instance's remote-signer
3543
// wallet. The on-disk format is identical to OpenClaw's, so a Hermes backup
3644
// 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
4452
}
4553

4654
keystorePath := filepath.Join(agentruntime.KeystoreVolumePath(cfg, agentruntime.Hermes, id), wallet.KeystoreUUID+".json")
47-
keystoreData, err := os.ReadFile(keystorePath)
55+
keystoreData, err := readKeystoreFileForBackup(cfg, keystorePath)
4856
if err != nil {
4957
return fmt.Errorf("failed to read keystore file: %w", err)
5058
}
@@ -114,15 +122,7 @@ func RestoreWalletCmd(cfg *config.Config, id string, opts RestoreWalletOptions,
114122
return fmt.Errorf("failed to read backup file: %w", err)
115123
}
116124

117-
passphrase := opts.Passphrase
118-
if walletbackup.IsEncrypted(raw) && !opts.HasPassFlag {
119-
passphrase, err = u.SecretInput("Backup passphrase")
120-
if err != nil {
121-
return fmt.Errorf("failed to read passphrase: %w", err)
122-
}
123-
}
124-
125-
backup, err := walletbackup.Decode(raw, passphrase)
125+
backup, err := decodeHermesWalletRestoreInput(raw, opts, id, u)
126126
if err != nil {
127127
return err
128128
}
@@ -183,6 +183,109 @@ func RestoreWalletCmd(cfg *config.Config, id string, opts RestoreWalletOptions,
183183
return nil
184184
}
185185

186+
func decodeHermesWalletRestoreInput(raw []byte, opts RestoreWalletOptions, id string, u *ui.UI) (*walletbackup.File, error) {
187+
passphrase := opts.Passphrase
188+
if walletbackup.IsEncrypted(raw) && !opts.HasPassFlag {
189+
var err error
190+
passphrase, err = u.SecretInput("Backup passphrase")
191+
if err != nil {
192+
return nil, fmt.Errorf("failed to read passphrase: %w", err)
193+
}
194+
}
195+
196+
backup, err := walletbackup.Decode(raw, passphrase)
197+
if err == nil {
198+
return backup, nil
199+
}
200+
if !isRawV3Keystore(raw) {
201+
return nil, err
202+
}
203+
204+
keystorePassword := opts.Passphrase
205+
if !opts.HasPassFlag {
206+
keystorePassword, err = u.SecretInput("Ethereum keystore password")
207+
if err != nil {
208+
return nil, fmt.Errorf("failed to read Ethereum keystore password: %w", err)
209+
}
210+
}
211+
return backupFromRawV3Keystore(raw, keystorePassword, id)
212+
}
213+
214+
func readKeystoreFileForBackup(cfg *config.Config, keystorePath string) ([]byte, error) {
215+
data, err := readHostKeystoreFileFn(keystorePath)
216+
if err == nil {
217+
return data, nil
218+
}
219+
if !os.IsPermission(err) || !isK3dBackend(cfg) {
220+
return nil, err
221+
}
222+
223+
data, fallbackErr := k3dNodeExecOutputFn(cfg, keystorePath, "cat {}")
224+
if fallbackErr != nil {
225+
return nil, fmt.Errorf("%w; k3d node read fallback failed: %v", err, fallbackErr)
226+
}
227+
return data, nil
228+
}
229+
230+
func isK3dBackend(cfg *config.Config) bool {
231+
data, err := os.ReadFile(filepath.Join(cfg.ConfigDir, ".stack-backend"))
232+
if err != nil {
233+
return true
234+
}
235+
return strings.TrimSpace(string(data)) == "k3d"
236+
}
237+
238+
func isRawV3Keystore(raw []byte) bool {
239+
var probe struct {
240+
Version int `json:"version"`
241+
Crypto json.RawMessage `json:"crypto"`
242+
}
243+
if err := json.Unmarshal(raw, &probe); err != nil {
244+
return false
245+
}
246+
return probe.Version == 3 && len(probe.Crypto) > 0
247+
}
248+
249+
func backupFromRawV3Keystore(raw []byte, pw, instanceID string) (*walletbackup.File, error) {
250+
var meta struct {
251+
ID string `json:"id"`
252+
}
253+
if err := json.Unmarshal(raw, &meta); err != nil {
254+
return nil, fmt.Errorf("parse raw Ethereum V3 keystore metadata: %w", err)
255+
}
256+
257+
key, err := gethkeystore.DecryptKey(raw, pw)
258+
if err != nil {
259+
return nil, fmt.Errorf("decrypt raw Ethereum V3 keystore: %w", err)
260+
}
261+
262+
keystoreID := strings.TrimSpace(meta.ID)
263+
if keystoreID == "" {
264+
keystoreID = key.Id.String()
265+
}
266+
if keystoreID == "" {
267+
return nil, errors.New("raw Ethereum V3 keystore missing id")
268+
}
269+
270+
publicKey := ethcrypto.FromECDSAPub(&key.PrivateKey.PublicKey)
271+
if len(publicKey) != 65 || publicKey[0] != 0x04 {
272+
return nil, errors.New("raw Ethereum V3 keystore produced invalid public key")
273+
}
274+
275+
return &walletbackup.File{
276+
Version: walletbackup.Version,
277+
Instance: instanceID,
278+
Wallets: []walletbackup.Wallet{{
279+
Address: key.Address.Hex(),
280+
PublicKey: "0x" + hex.EncodeToString(publicKey),
281+
KeystoreUUID: keystoreID,
282+
CreatedAt: time.Now().UTC().Format(time.RFC3339),
283+
Keystore: json.RawMessage(raw),
284+
KeystorePassword: pw,
285+
}},
286+
}, nil
287+
}
288+
186289
// FindInstancesWithWallets returns Hermes instance IDs that have wallet
187290
// metadata on disk. Used by purge prompts.
188291
func FindInstancesWithWallets(cfg *config.Config) []string {

internal/hermes/wallet_backup_test.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package hermes
22

33
import (
4+
"encoding/hex"
45
"encoding/json"
6+
"io/fs"
57
"os"
68
"path/filepath"
79
"reflect"
@@ -127,6 +129,63 @@ func TestBackupRestoreWalletCmd_HermesRoundTrip(t *testing.T) {
127129
}
128130
}
129131

132+
func TestBackupWalletCmd_K3dPermissionFallbackReadsFromNode(t *testing.T) {
133+
cfg, id, original := setupHermesBackupInstance(t, "k3d-fallback")
134+
if err := os.WriteFile(filepath.Join(cfg.ConfigDir, ".stack-backend"), []byte("k3d"), 0o600); err != nil {
135+
t.Fatalf("write stack backend: %v", err)
136+
}
137+
if err := os.WriteFile(filepath.Join(cfg.ConfigDir, ".stack-id"), []byte("test-stack"), 0o600); err != nil {
138+
t.Fatalf("write stack id: %v", err)
139+
}
140+
141+
origRead := readHostKeystoreFileFn
142+
origNode := k3dNodeExecOutputFn
143+
t.Cleanup(func() {
144+
readHostKeystoreFileFn = origRead
145+
k3dNodeExecOutputFn = origNode
146+
})
147+
148+
readHostKeystoreFileFn = func(path string) ([]byte, error) {
149+
if path == original.KeystorePath {
150+
return nil, &os.PathError{Op: "open", Path: path, Err: fs.ErrPermission}
151+
}
152+
return os.ReadFile(path)
153+
}
154+
k3dNodeExecOutputFn = func(_ *config.Config, hostPath, shellCmd string) ([]byte, error) {
155+
if hostPath != original.KeystorePath {
156+
t.Fatalf("k3d fallback hostPath = %q, want %q", hostPath, original.KeystorePath)
157+
}
158+
if shellCmd != "cat {}" {
159+
t.Fatalf("k3d fallback shellCmd = %q, want cat {}", shellCmd)
160+
}
161+
return []byte(`{"version":3,"from":"k3d-node"}`), nil
162+
}
163+
164+
backupPath := filepath.Join(t.TempDir(), "backup.json")
165+
if err := BackupWalletCmd(cfg, id, BackupWalletOptions{
166+
Output: backupPath,
167+
HasPassFlag: true,
168+
}, newTestUI()); err != nil {
169+
t.Fatalf("backup: %v", err)
170+
}
171+
172+
payload, err := os.ReadFile(backupPath)
173+
if err != nil {
174+
t.Fatalf("read backup: %v", err)
175+
}
176+
decoded, err := walletbackup.Decode(payload, "")
177+
if err != nil {
178+
t.Fatalf("decode backup: %v", err)
179+
}
180+
var keystore map[string]any
181+
if err := json.Unmarshal(decoded.Wallets[0].Keystore, &keystore); err != nil {
182+
t.Fatalf("unmarshal backup keystore: %v", err)
183+
}
184+
if got := keystore["from"]; got != "k3d-node" {
185+
t.Fatalf("backup keystore from = %v, want k3d-node", got)
186+
}
187+
}
188+
130189
func TestRestoreWalletCmd_HermesRequiresForceForExistingWallet(t *testing.T) {
131190
cfg, id, _ := setupHermesBackupInstance(t, "existing")
132191

@@ -155,6 +214,65 @@ func TestRestoreWalletCmd_HermesRequiresForceForExistingWallet(t *testing.T) {
155214
}
156215
}
157216

217+
func TestRestoreWalletCmd_HermesAcceptsRawEthereumV3Keystore(t *testing.T) {
218+
const id = "raw-v3"
219+
220+
cfg, deployDir := walletImportTestConfig(t, id)
221+
stubVolumeOwnership(t)
222+
223+
privKey, pubKey, err := generateKeypair()
224+
if err != nil {
225+
t.Fatalf("generate keypair: %v", err)
226+
}
227+
password := "raw-v3-password"
228+
keystoreJSON, keystoreID, err := encryptToV3Keystore(privKey, pubKey, password)
229+
if err != nil {
230+
t.Fatalf("encrypt keystore: %v", err)
231+
}
232+
input := filepath.Join(t.TempDir(), "raw-v3.json")
233+
if err := os.WriteFile(input, keystoreJSON, 0o600); err != nil {
234+
t.Fatalf("write raw v3: %v", err)
235+
}
236+
237+
if err := RestoreWalletCmd(cfg, id, RestoreWalletOptions{
238+
Input: input,
239+
Passphrase: password,
240+
HasPassFlag: true,
241+
}, newTestUI()); err != nil {
242+
t.Fatalf("restore raw v3: %v", err)
243+
}
244+
245+
restored, err := ReadWalletMetadata(deployDir)
246+
if err != nil {
247+
t.Fatalf("read restored metadata: %v", err)
248+
}
249+
if restored.Address != addressFromPublicKey(pubKey) {
250+
t.Fatalf("restored address = %q, want %q", restored.Address, addressFromPublicKey(pubKey))
251+
}
252+
if restored.PublicKey != "0x04"+hex.EncodeToString(pubKey) {
253+
t.Fatalf("restored public key = %q", restored.PublicKey)
254+
}
255+
if restored.KeystoreUUID != keystoreID {
256+
t.Fatalf("restored keystore UUID = %q, want %q", restored.KeystoreUUID, keystoreID)
257+
}
258+
259+
passwordFromValues, err := walletbackup.ReadKeystorePassword(deployDir)
260+
if err != nil {
261+
t.Fatalf("read restored password: %v", err)
262+
}
263+
if passwordFromValues != password {
264+
t.Fatalf("restored password = %q, want raw V3 password", passwordFromValues)
265+
}
266+
267+
restoredKeystore, err := os.ReadFile(filepath.Join(agentruntime.KeystoreVolumePath(cfg, agentruntime.Hermes, id), keystoreID+".json"))
268+
if err != nil {
269+
t.Fatalf("read restored keystore: %v", err)
270+
}
271+
if string(restoredKeystore) != string(keystoreJSON) {
272+
t.Fatal("restored raw V3 keystore changed")
273+
}
274+
}
275+
158276
func TestRestoreWalletCmd_ApplyClusterPublishesWalletMetadataBeforeRestart(t *testing.T) {
159277
const id = "obol-agent"
160278

internal/hermes/wallet_import.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ var (
3333
ensureVolumeWritableFn = ensureVolumeWritable
3434
fixRuntimeVolumeOwnershipFn = fixRuntimeVolumeOwnership
3535
applyWalletMetadataConfigMapFn = applyWalletMetadataConfigMap
36+
k3dNodeExecOutputFn = k3dNodeExecOutput
3637
)
3738

3839
// ImportPrivateKeyWalletCmd imports an existing private key as the

0 commit comments

Comments
 (0)