diff --git a/key/key.go b/key/key.go new file mode 100644 index 0000000..e62b31d --- /dev/null +++ b/key/key.go @@ -0,0 +1,116 @@ +// Copyright (C) 2019-2022, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +// Package key implements key manager and helper functions. +package key + +import ( + "bytes" + "errors" + "sort" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/vms/components/avax" + "github.com/ava-labs/avalanchego/vms/platformvm/txs" + "github.com/ava-labs/avalanchego/vms/secp256k1fx" +) + +var ( + ErrInvalidType = errors.New("invalid type") + ErrCantSpend = errors.New("can't spend") +) + +// Key defines methods for key manager interface. +type Key interface { + // P returns all formatted P-Chain addresses. + P(string) (string, error) + // C returns the C-Chain address in Ethereum format + C() string + // Addresses returns the all raw ids.ShortID address. + Addresses() []ids.ShortID + // Match attempts to match a list of addresses up to the provided threshold. + Match(owners *secp256k1fx.OutputOwners, time uint64) ([]uint32, []ids.ShortID, bool) + // Spend attempts to spend all specified UTXOs (outputs) + // and returns the new UTXO inputs. + // + // If target amount is specified, it only uses the + // outputs until the total spending is below the target + // amount. + Spends(outputs []*avax.UTXO, opts ...OpOption) ( + totalBalanceToSpend uint64, + inputs []*avax.TransferableInput, + signers [][]ids.ShortID, + ) + // Sign generates [numSigs] signatures and attaches them to [pTx]. + Sign(pTx *txs.Tx, signers [][]ids.ShortID) error +} + +type Op struct { + time uint64 + targetAmount uint64 + feeDeduct uint64 +} + +type OpOption func(*Op) + +func (op *Op) applyOpts(opts []OpOption) { + for _, opt := range opts { + opt(op) + } +} + +func WithTime(t uint64) OpOption { + return func(op *Op) { + op.time = t + } +} + +func WithTargetAmount(ta uint64) OpOption { + return func(op *Op) { + op.targetAmount = ta + } +} + +// To deduct transfer fee from total spend (output). +// e.g., "units.MilliAvax" for X/P-Chain transfer. +func WithFeeDeduct(fee uint64) OpOption { + return func(op *Op) { + op.feeDeduct = fee + } +} + +type innerSortTransferableInputsWithSigners struct { + ins []*avax.TransferableInput + signers [][]ids.ShortID +} + +func (ins *innerSortTransferableInputsWithSigners) Less(i, j int) bool { + iID, iIndex := ins.ins[i].InputSource() + jID, jIndex := ins.ins[j].InputSource() + + switch bytes.Compare(iID[:], jID[:]) { + case -1: + return true + case 0: + return iIndex < jIndex + default: + return false + } +} + +func (ins *innerSortTransferableInputsWithSigners) Len() int { + return len(ins.ins) +} + +func (ins *innerSortTransferableInputsWithSigners) Swap(i, j int) { + ins.ins[j], ins.ins[i] = ins.ins[i], ins.ins[j] + ins.signers[j], ins.signers[i] = ins.signers[i], ins.signers[j] +} + +// SortTransferableInputsWithSigners sorts the inputs and signers based on the +// input's utxo ID. +// +// This is based off of (generics?): https://github.com/ava-labs/avalanchego/blob/224c9fd23d41839201dd0275ac864a845de6e93e/vms/components/avax/transferables.go#L202 +func SortTransferableInputsWithSigners(ins []*avax.TransferableInput, signers [][]ids.ShortID) { + sort.Sort(&innerSortTransferableInputsWithSigners{ins: ins, signers: signers}) +} diff --git a/key/key_test.go b/key/key_test.go new file mode 100644 index 0000000..f7ce514 --- /dev/null +++ b/key/key_test.go @@ -0,0 +1,115 @@ +// Copyright (C) 2019-2022, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package key + +import ( + "bytes" + "errors" + "path/filepath" + "testing" + + "github.com/ava-labs/avalanchego/utils/cb58" + "github.com/ava-labs/avalanchego/utils/crypto/secp256k1" +) + +const ewoqPChainAddr = "P-custom18jma8ppw3nhx5r4ap8clazz0dps7rv5u9xde7p" + +func TestNewKeyEwoq(t *testing.T) { + t.Parallel() + + m, err := NewSoft( + WithPrivateKeyEncoded(EwoqPrivateKey), + ) + if err != nil { + t.Fatal(err) + } + + pAddr, err := m.P("custom") + if err != nil { + t.Fatal(err) + } + if pAddr != ewoqPChainAddr { + t.Fatalf("unexpected P-Chain address %q, expected %q", pAddr, ewoqPChainAddr) + } + + keyPath := filepath.Join(t.TempDir(), "key.pk") + if err := m.Save(keyPath); err != nil { + t.Fatal(err) + } + + m2, err := LoadSoft(keyPath) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(m.PrivKeyRaw(), m2.PrivKeyRaw()) { + t.Fatalf("loaded key unexpected %v, expected %v", m2.PrivKeyRaw(), m.PrivKeyRaw()) + } +} + +func TestNewKey(t *testing.T) { + t.Parallel() + + skBytes, err := cb58.Decode(rawEwoqPk) + if err != nil { + t.Fatal(err) + } + ewoqPk, err := secp256k1.ToPrivateKey(skBytes) + if err != nil { + t.Fatal(err) + } + + privKey2, err := secp256k1.NewPrivateKey() + if err != nil { + t.Fatal(err) + } + + tt := []struct { + name string + opts []SOpOption + expErr error + }{ + { + name: "test", + opts: nil, + expErr: nil, + }, + { + name: "ewop with WithPrivateKey", + opts: []SOpOption{ + WithPrivateKey(ewoqPk), + }, + expErr: nil, + }, + { + name: "ewop with WithPrivateKeyEncoded", + opts: []SOpOption{ + WithPrivateKeyEncoded(EwoqPrivateKey), + }, + expErr: nil, + }, + { + name: "ewop with WithPrivateKey/WithPrivateKeyEncoded", + opts: []SOpOption{ + WithPrivateKey(ewoqPk), + WithPrivateKeyEncoded(EwoqPrivateKey), + }, + expErr: nil, + }, + { + name: "ewop with invalid WithPrivateKey", + opts: []SOpOption{ + WithPrivateKey(privKey2), + WithPrivateKeyEncoded(EwoqPrivateKey), + }, + expErr: ErrInvalidPrivateKey, + }, + } + for i, tv := range tt { + _, err := NewSoft(tv.opts...) + if !errors.Is(err, tv.expErr) { + t.Fatalf("#%d(%s): unexpected error %v, expected %v", i, tv.name, err, tv.expErr) + } + } +} diff --git a/key/ledger_key.go b/key/ledger_key.go new file mode 100644 index 0000000..702ccfb --- /dev/null +++ b/key/ledger_key.go @@ -0,0 +1,78 @@ +// Copyright (C) 2019-2022, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package key + +import ( + "fmt" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/vms/components/avax" + "github.com/ava-labs/avalanchego/vms/platformvm/txs" + "github.com/ava-labs/avalanchego/vms/secp256k1fx" +) + +var _ Key = &LedgerKey{} + +type LedgerKey struct { + index uint32 +} + +// ledger device should be connected +func NewLedger(index uint32) LedgerKey { + return LedgerKey{ + index: index, + } +} + +// LoadLedger loads the ledger key info from disk and creates the corresponding LedgerKey. +func LoadLedger(_ string) (*LedgerKey, error) { + return nil, fmt.Errorf("not implemented") +} + +// LoadLedgerFromBytes loads the ledger key info from bytes and creates the corresponding LedgerKey. +func LoadLedgerFromBytes(_ []byte) (*SoftKey, error) { + return nil, fmt.Errorf("not implemented") +} + +func (*LedgerKey) C() string { + return "" +} + +// Returns the KeyChain +func (*LedgerKey) KeyChain() *secp256k1fx.Keychain { + return nil +} + +// Saves the key info to disk +func (*LedgerKey) Save(_ string) error { + return fmt.Errorf("not implemented") +} + +func (*LedgerKey) P(_ string) (string, error) { + return "", fmt.Errorf("not implemented") +} + +func (*LedgerKey) X(_ string) (string, error) { + return "", fmt.Errorf("not implemented") +} + +func (*LedgerKey) Spends(_ []*avax.UTXO, _ ...OpOption) ( + totalBalanceToSpend uint64, + inputs []*avax.TransferableInput, + signers [][]ids.ShortID, +) { + return 0, nil, nil +} + +func (*LedgerKey) Addresses() []ids.ShortID { + return nil +} + +func (*LedgerKey) Sign(_ *txs.Tx, _ [][]ids.ShortID) error { + return fmt.Errorf("not implemented") +} + +func (*LedgerKey) Match(_ *secp256k1fx.OutputOwners, _ uint64) ([]uint32, []ids.ShortID, bool) { + return nil, nil, false +} diff --git a/key/soft_key.go b/key/soft_key.go new file mode 100644 index 0000000..454ef4c --- /dev/null +++ b/key/soft_key.go @@ -0,0 +1,380 @@ +// Copyright (C) 2019-2022, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package key + +import ( + "bufio" + "bytes" + "encoding/hex" + "errors" + "io" + "os" + "strings" + + "avalanche-tooling-sdk-go/utils" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/cb58" + "github.com/ava-labs/avalanchego/utils/crypto/secp256k1" + "github.com/ava-labs/avalanchego/utils/formatting/address" + "github.com/ava-labs/avalanchego/vms/components/avax" + "github.com/ava-labs/avalanchego/vms/platformvm/txs" + "github.com/ava-labs/avalanchego/vms/secp256k1fx" + + eth_crypto "github.com/ethereum/go-ethereum/crypto" + "go.uber.org/zap" +) + +var ( + ErrInvalidPrivateKey = errors.New("invalid private key") + ErrInvalidPrivateKeyLen = errors.New("invalid private key length (expect 64 bytes in hex)") + ErrInvalidPrivateKeyEnding = errors.New("invalid private key ending") + ErrInvalidPrivateKeyEncoding = errors.New("invalid private key encoding") +) + +var _ Key = &SoftKey{} + +type SoftKey struct { + privKey *secp256k1.PrivateKey + privKeyRaw []byte + privKeyEncoded string + + keyChain *secp256k1fx.Keychain +} + +const ( + privKeyEncPfx = "PrivateKey-" + privKeySize = 64 + + rawEwoqPk = "ewoqjP7PxY4yr3iLTpLisriqt94hdyDFNgchSxGGztUrTXtNN" + EwoqPrivateKey = privKeyEncPfx + rawEwoqPk +) + +var ewoqKeyBytes = []byte("56289e99c94b6912bfc12adc093c9b51124f0dc54ac7a766b2bc5ccf558d8027") + +type SOp struct { + privKey *secp256k1.PrivateKey + privKeyEncoded string +} + +type SOpOption func(*SOp) + +func (sop *SOp) applyOpts(opts []SOpOption) { + for _, opt := range opts { + opt(sop) + } +} + +// To create a new key SoftKey with a pre-loaded private key. +func WithPrivateKey(privKey *secp256k1.PrivateKey) SOpOption { + return func(sop *SOp) { + sop.privKey = privKey + } +} + +// To create a new key SoftKey with a pre-defined private key. +func WithPrivateKeyEncoded(privKey string) SOpOption { + return func(sop *SOp) { + sop.privKeyEncoded = privKey + } +} + +func NewSoft(opts ...SOpOption) (*SoftKey, error) { + ret := &SOp{} + ret.applyOpts(opts) + + // set via "WithPrivateKeyEncoded" + if len(ret.privKeyEncoded) > 0 { + privKey, err := decodePrivateKey(ret.privKeyEncoded) + if err != nil { + return nil, err + } + // to not overwrite + if ret.privKey != nil && + !bytes.Equal(ret.privKey.Bytes(), privKey.Bytes()) { + return nil, ErrInvalidPrivateKey + } + ret.privKey = privKey + } + + // generate a new one + if ret.privKey == nil { + var err error + ret.privKey, err = secp256k1.NewPrivateKey() + if err != nil { + return nil, err + } + } + + privKey := ret.privKey + privKeyEncoded, err := encodePrivateKey(ret.privKey) + if err != nil { + return nil, err + } + + // double-check encoding is consistent + if ret.privKeyEncoded != "" && + ret.privKeyEncoded != privKeyEncoded { + return nil, ErrInvalidPrivateKeyEncoding + } + + keyChain := secp256k1fx.NewKeychain() + keyChain.Add(privKey) + + m := &SoftKey{ + privKey: privKey, + privKeyRaw: privKey.Bytes(), + privKeyEncoded: privKeyEncoded, + + keyChain: keyChain, + } + + return m, nil +} + +// LoadSoft loads the private key from disk and creates the corresponding SoftKey. +func LoadSoft(keyPath string) (*SoftKey, error) { + kb, err := os.ReadFile(keyPath) + if err != nil { + return nil, err + } + return LoadSoftFromBytes(kb) +} + +func LoadSoftOrCreate(keyPath string) (*SoftKey, error) { + if utils.FileExists(keyPath) { + return LoadSoft(keyPath) + } else { + k, err := NewSoft() + if err != nil { + return nil, err + } + if err := k.Save(keyPath); err != nil { + return nil, err + } + return k, nil + } +} + +func LoadEwoq() (*SoftKey, error) { + return LoadSoftFromBytes(ewoqKeyBytes) +} + +// LoadSoftFromBytes loads the private key from bytes and creates the corresponding SoftKey. +func LoadSoftFromBytes(kb []byte) (*SoftKey, error) { + // in case, it's already encoded + k, err := NewSoft(WithPrivateKeyEncoded(string(kb))) + if err == nil { + return k, nil + } + + r := bufio.NewReader(bytes.NewBuffer(kb)) + buf := make([]byte, privKeySize) + n, err := readASCII(buf, r) + if err != nil { + return nil, err + } + if n != len(buf) { + return nil, ErrInvalidPrivateKeyLen + } + if err := checkKeyFileEnd(r); err != nil { + return nil, err + } + + skBytes, err := hex.DecodeString(string(buf)) + if err != nil { + return nil, err + } + privKey, err := secp256k1.ToPrivateKey(skBytes) + if err != nil { + return nil, err + } + + return NewSoft(WithPrivateKey(privKey)) +} + +// readASCII reads into 'buf', stopping when the buffer is full or +// when a non-printable control character is encountered. +func readASCII(buf []byte, r io.ByteReader) (n int, err error) { + for ; n < len(buf); n++ { + buf[n], err = r.ReadByte() + switch { + case errors.Is(err, io.EOF) || buf[n] < '!': + return n, nil + case err != nil: + return n, err + } + } + return n, nil +} + +const fileEndLimit = 1 + +// checkKeyFileEnd skips over additional newlines at the end of a key file. +func checkKeyFileEnd(r io.ByteReader) error { + for idx := 0; ; idx++ { + b, err := r.ReadByte() + switch { + case errors.Is(err, io.EOF): + return nil + case err != nil: + return err + case b != '\n' && b != '\r': + return ErrInvalidPrivateKeyEnding + case idx > fileEndLimit: + return ErrInvalidPrivateKeyLen + } + } +} + +func encodePrivateKey(pk *secp256k1.PrivateKey) (string, error) { + privKeyRaw := pk.Bytes() + enc, err := cb58.Encode(privKeyRaw) + if err != nil { + return "", err + } + return privKeyEncPfx + enc, nil +} + +func decodePrivateKey(enc string) (*secp256k1.PrivateKey, error) { + rawPk := strings.Replace(enc, privKeyEncPfx, "", 1) + skBytes, err := cb58.Decode(rawPk) + if err != nil { + return nil, err + } + privKey, err := secp256k1.ToPrivateKey(skBytes) + if err != nil { + return nil, err + } + return privKey, nil +} + +func (m *SoftKey) C() string { + ecdsaPrv := m.privKey.ToECDSA() + pub := ecdsaPrv.PublicKey + + addr := eth_crypto.PubkeyToAddress(pub) + return addr.String() +} + +// Returns the KeyChain +func (m *SoftKey) KeyChain() *secp256k1fx.Keychain { + return m.keyChain +} + +// Returns the private key. +func (m *SoftKey) PrivKey() *secp256k1.PrivateKey { + return m.privKey +} + +// Returns the private key in raw bytes. +func (m *SoftKey) PrivKeyRaw() []byte { + return m.privKeyRaw +} + +// Returns the private key encoded in CB58 and "PrivateKey-" prefix. +func (m *SoftKey) PrivKeyCB58() string { + return m.privKeyEncoded +} + +// Returns the private key encoded hex +func (m *SoftKey) PrivKeyHex() string { + return hex.EncodeToString(m.privKeyRaw) +} + +// Saves the private key to disk with hex encoding. +func (m *SoftKey) Save(p string) error { + return os.WriteFile(p, []byte(m.PrivKeyHex()), utils.WriteReadUserOnlyPerms) +} + +func (m *SoftKey) P(networkHRP string) (string, error) { + return address.Format("P", networkHRP, m.privKey.PublicKey().Address().Bytes()) +} + +func (m *SoftKey) X(networkHRP string) (string, error) { + return address.Format("X", networkHRP, m.privKey.PublicKey().Address().Bytes()) +} + +func (m *SoftKey) Spends(outputs []*avax.UTXO, opts ...OpOption) ( + totalBalanceToSpend uint64, + inputs []*avax.TransferableInput, + signers [][]ids.ShortID, +) { + ret := &Op{} + ret.applyOpts(opts) + + for _, out := range outputs { + input, psigners, err := m.spend(out, ret.time) + if err != nil { + zap.L().Warn("cannot spend with current key", zap.Error(err)) + continue + } + totalBalanceToSpend += input.Amount() + inputs = append(inputs, &avax.TransferableInput{ + UTXOID: out.UTXOID, + Asset: out.Asset, + In: input, + }) + // Convert to ids.ShortID to adhere with interface + pksigners := make([]ids.ShortID, len(psigners)) + for i, psigner := range psigners { + pksigners[i] = psigner.PublicKey().Address() + } + signers = append(signers, pksigners) + if ret.targetAmount > 0 && + totalBalanceToSpend > ret.targetAmount+ret.feeDeduct { + break + } + } + SortTransferableInputsWithSigners(inputs, signers) + return totalBalanceToSpend, inputs, signers +} + +func (m *SoftKey) spend(output *avax.UTXO, time uint64) ( + input avax.TransferableIn, + signers []*secp256k1.PrivateKey, + err error, +) { + // "time" is used to check whether the key owner + // is still within the lock time (thus can't spend). + inputf, psigners, err := m.keyChain.Spend(output.Out, time) + if err != nil { + return nil, nil, err + } + var ok bool + input, ok = inputf.(avax.TransferableIn) + if !ok { + return nil, nil, ErrInvalidType + } + return input, psigners, nil +} + +func (m *SoftKey) Addresses() []ids.ShortID { + return []ids.ShortID{m.privKey.PublicKey().Address()} +} + +func (m *SoftKey) Sign(pTx *txs.Tx, signers [][]ids.ShortID) error { + privsigners := make([][]*secp256k1.PrivateKey, len(signers)) + for i, inputSigners := range signers { + privsigners[i] = make([]*secp256k1.PrivateKey, len(inputSigners)) + for j, signer := range inputSigners { + if signer != m.privKey.PublicKey().Address() { + // Should never happen + return ErrCantSpend + } + privsigners[i][j] = m.privKey + } + } + + return pTx.Sign(txs.Codec, privsigners) +} + +func (m *SoftKey) Match(owners *secp256k1fx.OutputOwners, time uint64) ([]uint32, []ids.ShortID, bool) { + indices, privs, ok := m.keyChain.Match(owners, time) + pks := make([]ids.ShortID, len(privs)) + for i, priv := range privs { + pks[i] = priv.PublicKey().Address() + } + return indices, pks, ok +}