@@ -2,17 +2,23 @@ package hermes
22
33import (
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.
188291func FindInstancesWithWallets (cfg * config.Config ) []string {
0 commit comments