Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
41 changes: 41 additions & 0 deletions ocp/common/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
55 changes: 55 additions & 0 deletions ocp/common/account_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)
Expand Down
Loading