From f66ef2e61d1c8867f9b9129e265e3326d1289690 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Feb 2026 23:14:17 +0000 Subject: [PATCH] feat(wallet): account key management with Argon2id encryption GenerateAccount, RestoreFromSeed, RestoreViewOnly with deterministic view key derivation (sc_reduce32(Keccak256(spend_secret))), matching C++ account_base::generate(). Encrypted persistence via Argon2id (time=3, mem=64MB) + AES-256-GCM in go-store. Adds cn_sc_reduce32 to the CGo bridge for scalar reduction mod l, required to convert a hash output into a valid Ed25519 secret key. Co-Authored-By: Charon --- crypto/bridge.cpp | 7 ++ crypto/bridge.h | 4 + crypto/crypto.go | 6 ++ wallet/account.go | 211 +++++++++++++++++++++++++++++++++++++++++ wallet/account_test.go | 149 +++++++++++++++++++++++++++++ 5 files changed, 377 insertions(+) create mode 100644 wallet/account.go create mode 100644 wallet/account_test.go diff --git a/crypto/bridge.cpp b/crypto/bridge.cpp index 76ef5a0..992a975 100644 --- a/crypto/bridge.cpp +++ b/crypto/bridge.cpp @@ -8,6 +8,7 @@ #include #include "crypto.h" #include "crypto-sugar.h" +#include "crypto-ops.h" #include "clsag.h" #include "hash-ops.h" @@ -17,6 +18,12 @@ void bridge_fast_hash(const uint8_t *data, size_t len, uint8_t hash[32]) { crypto::cn_fast_hash(data, len, reinterpret_cast(hash)); } +// ── Scalar Operations ──────────────────────────────────── + +void cn_sc_reduce32(uint8_t key[32]) { + crypto::sc_reduce32(key); +} + int cn_generate_keys(uint8_t pub[32], uint8_t sec[32]) { crypto::public_key pk; crypto::secret_key sk; diff --git a/crypto/bridge.h b/crypto/bridge.h index aee04bf..32fbde9 100644 --- a/crypto/bridge.h +++ b/crypto/bridge.h @@ -14,6 +14,10 @@ extern "C" { // ── Hashing ─────────────────────────────────────────────── void bridge_fast_hash(const uint8_t *data, size_t len, uint8_t hash[32]); +// ── Scalar Operations ──────────────────────────────────── +// Reduce a 32-byte scalar modulo the Ed25519 group order l. +void cn_sc_reduce32(uint8_t key[32]); + // ── Key Operations ──────────────────────────────────────── int cn_generate_keys(uint8_t pub[32], uint8_t sec[32]); int cn_secret_to_public(const uint8_t sec[32], uint8_t pub[32]); diff --git a/crypto/crypto.go b/crypto/crypto.go index 650693f..2edf696 100644 --- a/crypto/crypto.go +++ b/crypto/crypto.go @@ -23,3 +23,9 @@ func FastHash(data []byte) [32]byte { } return hash } + +// ScReduce32 reduces a 32-byte value modulo the Ed25519 group order l. +// This is required when converting a hash output to a valid secret key scalar. +func ScReduce32(key *[32]byte) { + C.cn_sc_reduce32((*C.uint8_t)(unsafe.Pointer(&key[0]))) +} diff --git a/wallet/account.go b/wallet/account.go new file mode 100644 index 0000000..ce1fc1d --- /dev/null +++ b/wallet/account.go @@ -0,0 +1,211 @@ +// 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" + "fmt" + "io" + + "golang.org/x/crypto/argon2" + + store "forge.lthn.ai/core/go-store" + + "forge.lthn.ai/core/go-blockchain/crypto" + "forge.lthn.ai/core/go-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, fmt.Errorf("wallet: generate spend keys: %w", 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, fmt.Errorf("wallet: restore from seed: %w", err) + } + spendPub, err := crypto.SecretToPublic(key) + if err != nil { + return nil, fmt.Errorf("wallet: spend pub from secret: %w", 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, fmt.Errorf("wallet: view pub from secret: %w", 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 fmt.Errorf("wallet: marshal account: %w", err) + } + + salt := make([]byte, saltLen) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return fmt.Errorf("wallet: generate salt: %w", err) + } + + derived := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + + block, err := aes.NewCipher(derived) + if err != nil { + return fmt.Errorf("wallet: aes cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return fmt.Errorf("wallet: gcm: %w", err) + } + + nonce := make([]byte, nonceLen) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return fmt.Errorf("wallet: generate nonce: %w", 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, fmt.Errorf("wallet: load account: %w", err) + } + + blob, err := hex.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("wallet: decode account hex: %w", err) + } + + if len(blob) < saltLen+nonceLen+1 { + return nil, fmt.Errorf("wallet: account data too short") + } + + 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, fmt.Errorf("wallet: aes cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("wallet: gcm: %w", err) + } + + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, fmt.Errorf("wallet: decrypt account: %w", err) + } + + var acc Account + if err := json.Unmarshal(plaintext, &acc); err != nil { + return nil, fmt.Errorf("wallet: unmarshal account: %w", 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, fmt.Errorf("wallet: view pub from secret: %w", err) + } + return &Account{ + SpendPublicKey: types.PublicKey(spendPub), + SpendSecretKey: types.SecretKey(spendSec), + ViewPublicKey: types.PublicKey(viewPub), + ViewSecretKey: types.SecretKey(viewSec), + }, nil +} diff --git a/wallet/account_test.go b/wallet/account_test.go new file mode 100644 index 0000000..059f125 --- /dev/null +++ b/wallet/account_test.go @@ -0,0 +1,149 @@ +// Copyright (c) 2017-2026 Lethean (https://lt.hn) +// +// Licensed under the European Union Public Licence (EUPL) version 1.2. +// SPDX-License-Identifier: EUPL-1.2 + +package wallet + +import ( + "strings" + "testing" +) + +func TestAccountGenerate(t *testing.T) { + acc, err := GenerateAccount() + if err != nil { + t.Fatal(err) + } + + var zero [32]byte + if acc.SpendSecretKey == zero { + t.Fatal("spend secret is zero") + } + if acc.ViewSecretKey == zero { + t.Fatal("view secret is zero") + } + if acc.SpendPublicKey == zero { + t.Fatal("spend public is zero") + } + if acc.ViewPublicKey == zero { + t.Fatal("view public is zero") + } +} + +func TestAccountSeedRoundTrip(t *testing.T) { + acc1, err := GenerateAccount() + if err != nil { + t.Fatal(err) + } + + phrase, err := acc1.ToSeed() + if err != nil { + t.Fatal(err) + } + words := strings.Fields(phrase) + if len(words) != 25 { + t.Fatalf("seed has %d words, want 25", len(words)) + } + + acc2, err := RestoreFromSeed(phrase) + if err != nil { + t.Fatal(err) + } + if acc1.SpendSecretKey != acc2.SpendSecretKey { + t.Fatal("spend secret mismatch") + } + if acc1.ViewSecretKey != acc2.ViewSecretKey { + t.Fatal("view secret mismatch") + } + if acc1.SpendPublicKey != acc2.SpendPublicKey { + t.Fatal("spend public mismatch") + } + if acc1.ViewPublicKey != acc2.ViewPublicKey { + t.Fatal("view public mismatch") + } +} + +func TestAccountViewOnly(t *testing.T) { + acc, err := GenerateAccount() + if err != nil { + t.Fatal(err) + } + + view, err := RestoreViewOnly(acc.ViewSecretKey, acc.SpendPublicKey) + if err != nil { + t.Fatal(err) + } + if view.ViewPublicKey != acc.ViewPublicKey { + t.Fatal("view public mismatch") + } + if view.SpendPublicKey != acc.SpendPublicKey { + t.Fatal("spend public mismatch") + } + + var zero [32]byte + if view.SpendSecretKey != zero { + t.Fatal("view-only should have zero spend secret") + } +} + +func TestAccountSaveLoad(t *testing.T) { + s := newTestStore(t) + + acc1, err := GenerateAccount() + if err != nil { + t.Fatal(err) + } + if err := acc1.Save(s, "test-password-123"); err != nil { + t.Fatal(err) + } + + acc2, err := LoadAccount(s, "test-password-123") + if err != nil { + t.Fatal(err) + } + if acc1.SpendSecretKey != acc2.SpendSecretKey { + t.Fatal("spend secret mismatch") + } + if acc1.ViewSecretKey != acc2.ViewSecretKey { + t.Fatal("view secret mismatch") + } + if acc1.SpendPublicKey != acc2.SpendPublicKey { + t.Fatal("spend public mismatch") + } + if acc1.ViewPublicKey != acc2.ViewPublicKey { + t.Fatal("view public mismatch") + } +} + +func TestAccountLoadWrongPassword(t *testing.T) { + s := newTestStore(t) + + acc, err := GenerateAccount() + if err != nil { + t.Fatal(err) + } + if err := acc.Save(s, "correct"); err != nil { + t.Fatal(err) + } + + _, err = LoadAccount(s, "wrong") + if err == nil { + t.Fatal("expected error with wrong password") + } +} + +func TestAccountAddress(t *testing.T) { + acc, err := GenerateAccount() + if err != nil { + t.Fatal(err) + } + + addr := acc.Address() + if addr.SpendPublicKey != acc.SpendPublicKey { + t.Fatal("address spend public key mismatch") + } + if addr.ViewPublicKey != acc.ViewPublicKey { + t.Fatal("address view public key mismatch") + } +}