Update go.mod module line, all require/replace directives, and every .go import path from forge.lthn.ai/core/go-blockchain to dappco.re/go/core/blockchain. Add replace directives to bridge dappco.re paths to existing forge.lthn.ai registry during migration. Update CLAUDE.md, README, and docs to reflect the new module path. Co-Authored-By: Virgil <virgil@lethean.io>
212 lines
6.5 KiB
Go
212 lines
6.5 KiB
Go
// 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/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,
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|