go-blockchain/wallet/account.go

215 lines
6.6 KiB
Go
Raw Permalink Normal View History

// 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
}