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 <charon@lethean.io>
This commit is contained in:
parent
ff97d51550
commit
f66ef2e61d
5 changed files with 377 additions and 0 deletions
|
|
@ -8,6 +8,7 @@
|
|||
#include <vector>
|
||||
#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<char*>(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;
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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])))
|
||||
}
|
||||
|
|
|
|||
211
wallet/account.go
Normal file
211
wallet/account.go
Normal file
|
|
@ -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
|
||||
}
|
||||
149
wallet/account_test.go
Normal file
149
wallet/account_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue