Skip to content

Commit

Permalink
go/common/crypto/sakg: Add ADR 0008 implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
tjanez committed May 19, 2021
1 parent cb3b430 commit 9e1ebce
Show file tree
Hide file tree
Showing 9 changed files with 354 additions and 2 deletions.
3 changes: 3 additions & 0 deletions .changelog/3918.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
go/common/crypto/sakg: Add [ADR 0008] implementation

[ADR 0008]: docs/adr/0008-standard-account-key-generation.md
6 changes: 6 additions & 0 deletions docs/consensus/staking.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ account signer's public key (e.g. entity id).

For more details, see the [`NewAddress` function].

{% hint style="info" %}
When generating an account's private/public key pair, follow [ADR 0008:
Standard Account Key Generation][ADR 0008].
{% endhint %}

### Runtime Accounts

In case of runtime accounts, the `<ctx-version>` and `<ctx-identifier>` are as
Expand Down Expand Up @@ -110,6 +115,7 @@ Currently, they are:
https://pkg.go.dev/github.com/oasisprotocol/oasis-core/go/staking/api?tab=doc#pkg-variables
[`GovernanceDeposits` variable]:
https://pkg.go.dev/github.com/oasisprotocol/oasis-core/go/staking/api?tab=doc#pkg-variables
[ADR 0008]: ../adr/0008-standard-account-key-generation.md
<!-- markdownlint-enable line-length -->

### General
Expand Down
13 changes: 11 additions & 2 deletions docs/crypto.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,17 @@ messages:
The envelopes are themselves CBOR-encoded. While no separate test vectors are
provided, [those used for transactions] can be used as a reference.

## Standard Account Key Generation

When generating an [account]'s private/public key pair, follow [ADR 0008:
Standard Account Key Generation][ADR 0008].

<!-- markdownlint-disable line-length -->
[Single signature envelope (`Signed`)]: https://pkg.go.dev/github.com/oasisprotocol/oasis-core/go/common/crypto/signature?tab=doc#Signed
[Multiple signature envelope (`MultiSigned`)]: https://pkg.go.dev/github.com/oasisprotocol/oasis-core/go/common/crypto/signature?tab=doc#MultiSigned
[Single signature envelope (`Signed`)]:
https://pkg.go.dev/github.com/oasisprotocol/oasis-core/go/common/crypto/signature?tab=doc#Signed
[Multiple signature envelope (`MultiSigned`)]:
https://pkg.go.dev/github.com/oasisprotocol/oasis-core/go/common/crypto/signature?tab=doc#MultiSigned
[those used for transactions]: consensus/test-vectors.md
[account]: consensus/staking.md#accounts
[ADR 0008]: adr/0008-standard-account-key-generation.md
<!-- markdownlint-enable line-length -->
106 changes: 106 additions & 0 deletions go/common/crypto/sakg/bip32.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package sakg

import (
"fmt"
"strconv"
"strings"
)

// HardenedKeysIndexStart is the index of the first hardened BIP-0032 key.
const HardenedKeysIndexStart = uint32(0x80000000)

const (
// BIP32PathMnemonicComponent is the string representing the mnemonic
// (first) component of a BIP-0032 path.
BIP32PathMnemonicComponent = "m"
// BIP32HardenedComponentSuffix is the string representing the suffix of a
// hardened component of a BIP-00032 path.
BIP32HardenedComponentSuffix = "'"
)

// BIP32Path represents a BIP-0032 path.
type BIP32Path []uint32

// String returns the string representation of a BIP-0032 path.
//
// NOTE: Hardened paths are marked with BIP32HardenedComponentSuffix.
func (path BIP32Path) String() string {
pathStr := BIP32PathMnemonicComponent
for _, component := range path {
if component >= HardenedKeysIndexStart {
component -= HardenedKeysIndexStart
pathStr += fmt.Sprintf("/%d%s", component, BIP32HardenedComponentSuffix)
} else {
pathStr += fmt.Sprintf("/%d", component)
}
}
return pathStr
}

// MarshallText encodes a BIP-0032 path into text form.
func (path BIP32Path) MarshalText() ([]byte, error) {
return []byte(path.String()), nil
}

// UnmarshalText decodes a text marshaled BIP-0032 path.
func (path *BIP32Path) UnmarshalText(text []byte) error {
components := strings.Split(string(text), "/")
// NOTE: The first component should be the mnemonic component which doesn't
// have a corresponding element in BIP32Path's slice.
n := len(components) - 1

rawPath := make([]uint32, 0, n)

if components[0] != BIP32PathMnemonicComponent {
return fmt.Errorf(
"invalid BIP-0032 path's mnemonic component: %s (expected: %s)",
components[0],
BIP32PathMnemonicComponent,
)
}

if len(components) > 1 {
for i, component := range components[1:] {
// Use 1-based component indexing. First component is the mnemonic.
componentIndex := i + 2

hardened := strings.HasSuffix(component, BIP32HardenedComponentSuffix)
if hardened {
component = strings.TrimSuffix(component, BIP32HardenedComponentSuffix)
}
comp64, err := strconv.ParseUint(component, 10, 32)
if err != nil {
return fmt.Errorf("invalid BIP-0032 path's %d. component: %w",
componentIndex,
err,
)
}
comp32 := uint32(comp64)
if comp32 >= HardenedKeysIndexStart {
return fmt.Errorf(
"invalid BIP-0032 path's %d. component: maximum value of %d exceeded (got: %d)",
componentIndex,
HardenedKeysIndexStart-1,
comp32,
)
}
if hardened {
comp32 |= HardenedKeysIndexStart
}
rawPath = append(rawPath, comp32)
}
}

*path = BIP32Path(rawPath)
return nil
}

// NewBIP32Path creates a BIP32Path object from the given BIP-0032 path's string
// representation.
func NewBIP32Path(pathStr string) (BIP32Path, error) {
var path BIP32Path
if err := path.UnmarshalText([]byte(pathStr)); err != nil {
return nil, err
}
return path, nil
}
57 changes: 57 additions & 0 deletions go/common/crypto/sakg/bip32_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package sakg

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestBIP32Path(t *testing.T) {
require := require.New(t)

testVectors := []struct {
strPath string
strPathValid bool
path BIP32Path
errMsg string
}{
// Valid.
{"m", true, []uint32{}, ""},
{"m/1/2/3", true, []uint32{1, 2, 3}, ""},
{"m/44'", true, []uint32{0x8000002c}, ""},
{"m/44'/0'", true, []uint32{0x8000002c, 0x80000000}, ""},
{"m/44'/0'/0'", true, []uint32{0x8000002c, 0x80000000, 0x80000000}, ""},
{"m/44'/0'/0'/0", true, []uint32{0x8000002c, 0x80000000, 0x80000000, 0}, ""},
{"m/44'/0'/0'/0/0", true, []uint32{0x8000002c, 0x80000000, 0x80000000, 0, 0}, ""},
{"m/44'/2147483647", true, []uint32{0x8000002c, 0x7fffffff}, ""},
{"m/44'/2147483647'", true, []uint32{0x8000002c, 0xffffffff}, ""},

// Invalid.
{"", false, []uint32{}, "invalid BIP-0032 path's mnemonic component: (expected: m)"},
{"44'/0'", false, []uint32{}, "invalid BIP-0032 path's mnemonic component: 44' (expected: m)"},
{"foo/44'", false, []uint32{}, "invalid BIP-0032 path's mnemonic component: foo (expected: m)"},
{"m/bla'", false, []uint32{}, "invalid BIP-0032 path's 2. component: strconv.ParseUint: parsing \"bla\": invalid syntax"},
{"m/44'/2147483648", false, []uint32{}, "invalid BIP-0032 path's 3. component: maximum value of 2147483647 exceeded (got: 2147483648)"},
{"m/44'/2147483648'", false, []uint32{}, "invalid BIP-0032 path's 3. component: maximum value of 2147483647 exceeded (got: 2147483648)"},
}

for _, v := range testVectors {
var unmarshaledPath BIP32Path
err := unmarshaledPath.UnmarshalText([]byte(v.strPath))
if !v.strPathValid {
require.EqualErrorf(
err,
v.errMsg,
"Unmarshaling invalid BIP-0032 string path: %s should fail with expected error message",
v.strPath,
)
continue
}
require.NoErrorf(err, "Failed to unmarshal a valid BIP-0032 string path: %s", v.strPath)
require.Equal(v.path, unmarshaledPath, "Unmarshaled BIP-0032 path doesn't equal expected path")

textPath, err := unmarshaledPath.MarshalText()
require.NoError(err, "Failed to marshal a valid BIP-0032 path: %s", v.path)
require.Equal(v.strPath, string(textPath), "Marshaled BIP-0032 path doesn't equal expected text BIP-0032 path")
}
}
60 changes: 60 additions & 0 deletions go/common/crypto/sakg/sakg.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Package sakg implements ADR 0008: Standard Account Key Generation.
package sakg

import (
"fmt"

bip39 "github.com/tyler-smith/go-bip39"

"github.com/oasisprotocol/oasis-core/go/common/crypto/signature"
"github.com/oasisprotocol/oasis-core/go/common/crypto/slip10"
)

// MaxAccountKeyNumber is the maximum allowed key number when using ADR 0008.
const MaxAccountKeyNumber = uint32(0x7fffffff)

// BIP32PathPrefix is the Oasis Network's BIP-0032 path prefix as defined by
// ADR 0008.
const BIP32PathPrefix = "m/44'/474'"

// Generate signer for the given mnemonic, passphrase and account according to
// ADR 0008.
func GetAccountSigner(
mnemonic string,
passphrase string,
number uint32,
) (signature.Signer, BIP32Path, error) {
if number > MaxAccountKeyNumber {
return nil, nil, fmt.Errorf(
"sakg: invalid key number: %d (maximum: %d)",
number,
MaxAccountKeyNumber,
)
}

if !bip39.IsMnemonicValid(mnemonic) {
return nil, nil, fmt.Errorf("sakg: invalid mnemonic")
}

seed := bip39.NewSeed(mnemonic, passphrase)

signer, chainCode, err := slip10.NewMasterKey(seed)
if err != nil {
return nil, nil, fmt.Errorf("sakg: error deriving master key: %w", err)
}

pathStr := fmt.Sprintf("%s/%d'", BIP32PathPrefix, number)
path, err := NewBIP32Path(pathStr)
if err != nil {
return nil, nil, fmt.Errorf("sakg: error creating BIP-0032 path %s: %w", pathStr, err)
}

for _, index := range path {
signer, chainCode, err = slip10.NewChildKey(signer, chainCode, index)
if err != nil {
return nil, nil, fmt.Errorf("sakg: error deriving child key: %w", err)
}
}

return signer, path, nil
}
108 changes: 108 additions & 0 deletions go/common/crypto/sakg/sakg_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package sakg

import (
"fmt"
"testing"

"github.com/stretchr/testify/require"

"github.com/oasisprotocol/oasis-core/go/common/crypto/signature"
)

func TestGetAccountSigner(t *testing.T) {
require := require.New(t)

testVectors := []struct { // nolint: maligned
mnemonic string
passphrase string
number uint32
expectedPubkeyHex string
expectedBIP32PathStr string
valid bool
errMsg string
}{
// Valid.
{
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"",
0,
"ad55bbb7c192b8ecfeb6ad18bbd7681c0923f472d5b0c212fbde33008005ad61",
"m/44'/474'/0'",
true,
"",
},
{
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"",
1,
"73fd7c51a0f059ea34d8dca305e0fdb21134ca32216ca1681ae1d12b3d350e16",
"m/44'/474'/1'",
true,
"",
},
{
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"",
MaxAccountKeyNumber,
"9e7c2b2d03265ce4ea175e3664a678182548a7fc6db04801513cff7c98c8f151",
fmt.Sprintf("m/44'/474'/%d'", MaxAccountKeyNumber),
true,
"",
},
{
"equip will roof matter pink blind book anxiety banner elbow sun young",
"p4ssphr4se",
1,
"b099f8906467325aa1283590c1bca01e8708d5419557aa7771b826fa02d2abe6",
"m/44'/474'/1'",
true,
"",
},

// Invalid.
{
"foo bar baz",
"",
0,
"",
"",
false,
"sakg: invalid mnemonic",
},
{
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"",
MaxAccountKeyNumber + 1,
"",
"",
false,
"sakg: invalid key number: 2147483648 (maximum: 2147483647)",
},
}

for _, v := range testVectors {
signer, actualBIP32Path, err := GetAccountSigner(v.mnemonic, v.passphrase, v.number)
if !v.valid {
require.EqualError(err, v.errMsg, "Generating signer for invalid inputs should fail with expected error message")
continue
}
require.NoErrorf(
err,
"Failed to generate signer for:\n- mnemonic: '%s'\n- passphrase: '%s'\n- number: %d\n",
v.mnemonic,
v.passphrase,
v.number,
)
// Check generated signer's public key.
var expectedPK signature.PublicKey
_ = expectedPK.UnmarshalHex(v.expectedPubkeyHex)
require.Equal(expectedPK, signer.Public(), "Generated signer's public key doesn't equal expected public key")
// Check generated signer's BIP-0032 path.
actualBIP32PathText, _ := actualBIP32Path.MarshalText()
require.Equal(
v.expectedBIP32PathStr,
string(actualBIP32PathText),
"Generated signer's BIP-0032 path doesn't equal expected BIP-0032 path",
)
}
}
1 change: 1 addition & 0 deletions go/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ require (
github.com/tendermint/tendermint v0.34.9
github.com/tendermint/tm-db v0.6.4
github.com/thepudds/fzgo v0.2.2
github.com/tyler-smith/go-bip39 v1.1.0
github.com/uber/jaeger-client-go v2.25.0+incompatible
github.com/uber/jaeger-lib v2.2.0+incompatible // indirect
github.com/whyrusleeping/go-logging v0.0.1
Expand Down
2 changes: 2 additions & 0 deletions go/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,8 @@ github.com/tinylib/msgp v1.1.0 h1:9fQd+ICuRIu/ue4vxJZu6/LzxN0HwMds2nq/0cFvxHU=
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
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/uber/jaeger-client-go v2.25.0+incompatible h1:IxcNZ7WRY1Y3G4poYlx24szfsn/3LvK9QHCq9oQw8+U=
github.com/uber/jaeger-client-go v2.25.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk=
github.com/uber/jaeger-lib v2.2.0+incompatible h1:MxZXOiR2JuoANZ3J6DE/U0kSFv/eJ/GfSYVCjK7dyaw=
Expand Down

0 comments on commit 9e1ebce

Please sign in to comment.