// Copyright (c) 2017-2026 Lethean (https://lt.hn) // // Licensed under the European Union Public Licence (EUPL) version 1.2. // You may obtain a copy of the licence at: // // https://joinup.ec.europa.eu/software/page/eupl/licence-eupl // // SPDX-License-Identifier: EUPL-1.2 package wallet import ( "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/hex" "encoding/json" "io" coreerr "dappco.re/go/core/log" "golang.org/x/crypto/argon2" store "dappco.re/go/core/store" "dappco.re/go/core/blockchain/config" "dappco.re/go/core/blockchain/crypto" "dappco.re/go/core/blockchain/types" ) // Store group and key for the encrypted account blob. const ( groupAccount = "wallet" keyAccount = "account" ) // Argon2id parameters for key derivation. const ( argonTime = 3 argonMemory = 64 * 1024 argonThreads = 4 argonKeyLen = 32 ) // Encryption envelope sizes. const ( saltLen = 16 nonceLen = 12 ) // Account holds the spend and view key pairs for a wallet. The spend secret // key is the master key; the view secret key is deterministically derived as // Keccak256(spend_secret_key), matching the C++ account_base::generate(). type Account struct { SpendPublicKey types.PublicKey `json:"spend_public_key"` SpendSecretKey types.SecretKey `json:"spend_secret_key"` ViewPublicKey types.PublicKey `json:"view_public_key"` ViewSecretKey types.SecretKey `json:"view_secret_key"` CreatedAt uint64 `json:"created_at"` Flags uint8 `json:"flags"` } // GenerateAccount creates a new account with random spend keys and a // deterministically derived view key pair. func GenerateAccount() (*Account, error) { spendPub, spendSec, err := crypto.GenerateKeys() if err != nil { return nil, coreerr.E("GenerateAccount", "wallet: generate spend keys", err) } return accountFromSpendKey(spendSec, spendPub) } // RestoreFromSeed reconstructs an account from a 25-word mnemonic phrase. // The spend secret is decoded from the phrase; all other keys are derived. func RestoreFromSeed(phrase string) (*Account, error) { key, err := MnemonicDecode(phrase) if err != nil { return nil, coreerr.E("RestoreFromSeed", "wallet: restore from seed", err) } spendPub, err := crypto.SecretToPublic(key) if err != nil { return nil, coreerr.E("RestoreFromSeed", "wallet: spend pub from secret", err) } return accountFromSpendKey(key, spendPub) } // RestoreViewOnly creates a view-only account that can scan incoming // transactions but cannot spend. The spend secret key is left zeroed. func RestoreViewOnly(viewSecret types.SecretKey, spendPublic types.PublicKey) (*Account, error) { viewPub, err := crypto.SecretToPublic([32]byte(viewSecret)) if err != nil { return nil, coreerr.E("RestoreViewOnly", "wallet: view pub from secret", err) } return &Account{ SpendPublicKey: spendPublic, ViewPublicKey: types.PublicKey(viewPub), ViewSecretKey: viewSecret, }, nil } // ToSeed encodes the spend secret key as a 25-word mnemonic phrase. func (a *Account) ToSeed() (string, error) { return MnemonicEncode(a.SpendSecretKey[:]) } // Address returns the public address derived from the account's public keys. func (a *Account) Address() types.Address { return types.Address{ SpendPublicKey: a.SpendPublicKey, ViewPublicKey: a.ViewPublicKey, Prefix: config.AddressPrefix, } } // Save encrypts the account with Argon2id + AES-256-GCM and persists it to // the given store. The stored blob layout is: salt (16) | nonce (12) | ciphertext. func (a *Account) Save(s *store.Store, password string) error { plaintext, err := json.Marshal(a) if err != nil { return coreerr.E("Account.Save", "wallet: marshal account", err) } salt := make([]byte, saltLen) if _, err := io.ReadFull(rand.Reader, salt); err != nil { return coreerr.E("Account.Save", "wallet: generate salt", err) } derived := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) block, err := aes.NewCipher(derived) if err != nil { return coreerr.E("Account.Save", "wallet: aes cipher", err) } gcm, err := cipher.NewGCM(block) if err != nil { return coreerr.E("Account.Save", "wallet: gcm", err) } nonce := make([]byte, nonceLen) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return coreerr.E("Account.Save", "wallet: generate nonce", err) } ciphertext := gcm.Seal(nil, nonce, plaintext, nil) blob := make([]byte, 0, saltLen+nonceLen+len(ciphertext)) blob = append(blob, salt...) blob = append(blob, nonce...) blob = append(blob, ciphertext...) return s.Set(groupAccount, keyAccount, hex.EncodeToString(blob)) } // LoadAccount decrypts and returns the account stored in the given store. // Returns an error if the password is incorrect or no account exists. func LoadAccount(s *store.Store, password string) (*Account, error) { encoded, err := s.Get(groupAccount, keyAccount) if err != nil { return nil, coreerr.E("LoadAccount", "wallet: load account", err) } blob, err := hex.DecodeString(encoded) if err != nil { return nil, coreerr.E("LoadAccount", "wallet: decode account hex", err) } if len(blob) < saltLen+nonceLen+1 { return nil, coreerr.E("LoadAccount", "wallet: account data too short", nil) } salt := blob[:saltLen] nonce := blob[saltLen : saltLen+nonceLen] ciphertext := blob[saltLen+nonceLen:] derived := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) block, err := aes.NewCipher(derived) if err != nil { return nil, coreerr.E("LoadAccount", "wallet: aes cipher", err) } gcm, err := cipher.NewGCM(block) if err != nil { return nil, coreerr.E("LoadAccount", "wallet: gcm", err) } plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) if err != nil { return nil, coreerr.E("LoadAccount", "wallet: decrypt account", err) } var acc Account if err := json.Unmarshal(plaintext, &acc); err != nil { return nil, coreerr.E("LoadAccount", "wallet: unmarshal account", err) } return &acc, nil } // accountFromSpendKey derives the full key set from a spend key pair. The // view secret is computed as sc_reduce32(Keccak256(spendSec)), matching the // C++ account_base::generate() derivation. func accountFromSpendKey(spendSec, spendPub [32]byte) (*Account, error) { viewSec := crypto.FastHash(spendSec[:]) crypto.ScReduce32(&viewSec) viewPub, err := crypto.SecretToPublic(viewSec) if err != nil { return nil, coreerr.E("accountFromSpendKey", "wallet: view pub from secret", err) } return &Account{ SpendPublicKey: types.PublicKey(spendPub), SpendSecretKey: types.SecretKey(spendSec), ViewPublicKey: types.PublicKey(viewPub), ViewSecretKey: types.SecretKey(viewSec), }, nil }