diff --git a/go.mod b/go.mod index 6e6f5ae..b36d096 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.0 require ( filippo.io/edwards25519 v1.1.0 + github.com/anyproto/go-slip10 v1.0.1 github.com/aws/aws-sdk-go-v2 v1.41.9 github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.25 github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 @@ -30,6 +31,7 @@ require ( github.com/spaolacci/murmur3 v1.1.0 github.com/spf13/viper v1.7.0 github.com/stretchr/testify v1.8.4 + github.com/tyler-smith/go-bip39 v1.1.0 github.com/ybbus/jsonrpc v2.1.2+incompatible go.uber.org/zap v1.27.1 golang.org/x/image v0.36.0 diff --git a/go.sum b/go.sum index 692cb97..49d2bdf 100644 --- a/go.sum +++ b/go.sum @@ -54,6 +54,8 @@ github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/anyproto/go-slip10 v1.0.1 h1:Pa/OpYoOE668fip4ygAd4T07chLBx4XoBa5fwnGq0/M= +github.com/anyproto/go-slip10 v1.0.1/go.mod h1:BCmIlM1KB8wX6K4/8pOvxPl9oVKfEvZ5vsmO5rkK6vg= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= @@ -468,6 +470,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= +github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= diff --git a/ocp/common/account.go b/ocp/common/account.go index de4113d..545f488 100644 --- a/ocp/common/account.go +++ b/ocp/common/account.go @@ -7,7 +7,9 @@ import ( "fmt" "filippo.io/edwards25519" + slip10 "github.com/anyproto/go-slip10" "github.com/pkg/errors" + bip39 "github.com/tyler-smith/go-bip39" commonpb "github.com/code-payments/ocp-protobuf-api/generated/go/common/v1" @@ -21,6 +23,10 @@ import ( "github.com/code-payments/ocp-server/solana/vm" ) +// solanaDerivationPath is the standard BIP-44 derivation path for Solana +// accounts. All components are hardened per SLIP-0010 for ed25519. +const solanaDerivationPath = "m/44'/501'/0'/0'" + type Account struct { publicKey *Key privateKey *Key // Optional @@ -139,6 +145,41 @@ func NewAccountFromPrivateKeyString(privateKey string) (*Account, error) { return NewAccountFromPrivateKey(key) } +// NewAccountFromEntropy deterministically derives an account from a 16-byte +// BIP-39 entropy value following the standard Solana key derivation used by Code +// and other wallets: +// +// entropy -> BIP-39 mnemonic -> BIP-39 seed (PBKDF2) -> SLIP-0010 ed25519 key +// at m/44'/501'/0'/0' +// +// The same entropy always produces the same account, while different entropy +// values produce unrelated accounts. +func NewAccountFromEntropy(entropy []byte) (*Account, error) { + if len(entropy) != 16 { + return nil, errors.New("entropy must be 16 bytes") + } + + mnemonic, err := bip39.NewMnemonic(entropy) + if err != nil { + return nil, errors.Wrap(err, "error deriving mnemonic from entropy") + } + + seed := bip39.NewSeed(mnemonic, "") + + node, err := slip10.DeriveForPath(solanaDerivationPath, seed) + if err != nil { + return nil, errors.Wrap(err, "error deriving key from seed") + } + _, privateKey := node.Keypair() + + derivedKey, err := NewKeyFromBytes(privateKey) + if err != nil { + return nil, errors.Wrap(err, "error creating private key from entropy") + } + + return NewAccountFromPrivateKey(derivedKey) +} + func NewAccountFromProto(proto *commonpb.SolanaAccountId) (*Account, error) { publicKey, err := NewKeyFromBytes(proto.Value) if err != nil { diff --git a/ocp/common/account_test.go b/ocp/common/account_test.go index 3d27fd2..f024a60 100644 --- a/ocp/common/account_test.go +++ b/ocp/common/account_test.go @@ -3,11 +3,14 @@ package common import ( "context" "crypto/ed25519" + "encoding/hex" "testing" + slip10 "github.com/anyproto/go-slip10" "github.com/mr-tron/base58/base58" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + bip39 "github.com/tyler-smith/go-bip39" commonpb "github.com/code-payments/ocp-protobuf-api/generated/go/common/v1" @@ -79,6 +82,58 @@ func TestAccountWithPrivateKey(t *testing.T) { } } +func TestNewAccountFromEntropy(t *testing.T) { + // All-zeros BIP-39 entropy maps to the canonical "abandon ... about" + // mnemonic, whose Solana account at m/44'/501'/0'/0' (empty passphrase) is a + // well-known address shared with standard wallets (Phantom, Solana CLI). + entropy := make([]byte, 16) + + account, err := NewAccountFromEntropy(entropy) + require.NoError(t, err) + require.NotNil(t, account.PrivateKey()) + assert.Equal(t, "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk", account.PublicKey().ToBase58()) + + // The same entropy is deterministic; different entropy is unrelated. + again, err := NewAccountFromEntropy(entropy) + require.NoError(t, err) + assert.Equal(t, account.PublicKey().ToBase58(), again.PublicKey().ToBase58()) + assert.Equal(t, account.PrivateKey().ToBase58(), again.PrivateKey().ToBase58()) + + otherEntropy := make([]byte, 16) + otherEntropy[15] = 1 + other, err := NewAccountFromEntropy(otherEntropy) + require.NoError(t, err) + assert.NotEqual(t, account.PublicKey().ToBase58(), other.PublicKey().ToBase58()) + + // Only 16-byte entropy is accepted. + for _, badLen := range []int{0, 15, 17, 32} { + _, err := NewAccountFromEntropy(make([]byte, badLen)) + assert.Error(t, err) + } +} + +// TestNewAccountFromEntropy_SpecVectors anchors each stage of the derivation +// pipeline to its canonical specification test vector, guarding against a +// dependency behaving non-standardly. +func TestNewAccountFromEntropy_SpecVectors(t *testing.T) { + // BIP-39: zero entropy -> mnemonic -> seed. + mnemonic, err := bip39.NewMnemonic(make([]byte, 16)) + require.NoError(t, err) + assert.Equal(t, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", mnemonic) + assert.Equal(t, + "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4", + hex.EncodeToString(bip39.NewSeed(mnemonic, "")), + ) + + // SLIP-0010 ed25519 test vector 1 (seed 000102...0f), deepest path. + seed, err := hex.DecodeString("000102030405060708090a0b0c0d0e0f") + require.NoError(t, err) + node, err := slip10.DeriveForPath("m/0'/1'/2'/2'/1000000000'", seed) + require.NoError(t, err) + _, privateKey := node.Keypair() + assert.Equal(t, "8f94d394a8e8fd6b1bc2f3f49f5c47e385281d5c17e65324b0f62483e37e8793", hex.EncodeToString(privateKey.Seed())) +} + func TestInvalidAccount(t *testing.T) { stringValue := "invalid-account" bytesValue := []byte(stringValue)