Scaffold noah cli (#10)

* CLI skeleton

* noah CLI: send flags

* add cypher.go file

* fix .PHONY

* add password_hash in state.json

* encode public key using common pkg

* use common.DecodeUrl

* remove cli.Exit calls

* redeem command: make --amount flag optional only if --force is not set

* remove validateURL func

* chmod +x scripts/build-noah

* Update cmd/noah/redeem.go

Co-authored-by: João Bordalo <bordalix@users.noreply.github.com>

* Update cmd/noah/redeem.go

Co-authored-by: João Bordalo <bordalix@users.noreply.github.com>

* Update cmd/noah/init.go

Co-authored-by: João Bordalo <bordalix@users.noreply.github.com>

* Update cmd/noah/main.go

Co-authored-by: João Bordalo <bordalix@users.noreply.github.com>

* Update cmd/noah/send.go

Co-authored-by: João Bordalo <bordalix@users.noreply.github.com>

* rework receive and send

* Update cmd/noah/send.go

Co-authored-by: João Bordalo <bordalix@users.noreply.github.com>

* Update cmd/noah/send.go

Co-authored-by: João Bordalo <bordalix@users.noreply.github.com>

* Update cmd/noah/redeem.go

Co-authored-by: João Bordalo <bordalix@users.noreply.github.com>

* receive command: return ark address

---------

Co-authored-by: bordalix <joao.bordalo@gmail.com>
Co-authored-by: João Bordalo <bordalix@users.noreply.github.com>
This commit is contained in:
Louis Singer
2023-11-23 13:53:19 +01:00
committed by GitHub
parent 27b54f4c41
commit 20bc94087a
13 changed files with 742 additions and 3 deletions

18
cmd/noah/balance.go Normal file
View File

@@ -0,0 +1,18 @@
package main
import (
"fmt"
"github.com/urfave/cli/v2"
)
var balanceCommand = cli.Command{
Name: "balance",
Usage: "Print balance of the Noah wallet",
Action: balanceAction,
}
func balanceAction(ctx *cli.Context) error {
fmt.Println("balance is not implemented yet")
return nil
}

114
cmd/noah/common.go Normal file
View File

@@ -0,0 +1,114 @@
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"syscall"
"github.com/ark-network/ark/common"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"golang.org/x/term"
)
func hashPassword(password []byte) []byte {
hash := sha256.Sum256(password)
return hash[:]
}
func verifyPassword(password []byte) error {
state, err := getState()
if err != nil {
return err
}
passwordHashString, ok := state["password_hash"]
if !ok {
return fmt.Errorf("password hash not found")
}
passwordHash, err := hex.DecodeString(passwordHashString)
if err != nil {
return err
}
currentPassHash := hashPassword(password)
if !bytes.Equal(passwordHash, currentPassHash) {
return fmt.Errorf("invalid password")
}
return nil
}
func readPassword() ([]byte, error) {
fmt.Print("password: ")
passwordInput, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println() // new line
if err != nil {
return nil, err
}
err = verifyPassword(passwordInput)
if err != nil {
return nil, err
}
return passwordInput, nil
}
func privateKeyFromPassword() (*secp256k1.PrivateKey, error) {
state, err := getState()
if err != nil {
return nil, err
}
encryptedPrivateKeyString, ok := state["encrypted_private_key"]
if !ok {
return nil, fmt.Errorf("encrypted private key not found")
}
encryptedPrivateKey, err := hex.DecodeString(encryptedPrivateKeyString)
if err != nil {
return nil, err
}
password, err := readPassword()
if err != nil {
return nil, err
}
cypher := NewAES128Cypher()
privateKeyBytes, err := cypher.Decrypt(encryptedPrivateKey, password)
if err != nil {
return nil, err
}
privateKey := secp256k1.PrivKeyFromBytes(privateKeyBytes)
return privateKey, nil
}
func getServiceProviderPublicKey() (*secp256k1.PublicKey, error) {
state, err := getState()
if err != nil {
return nil, err
}
arkURL, ok := state["ark_url"]
if !ok {
return nil, fmt.Errorf("ark url not found")
}
arkPubKey, _, err := common.DecodeUrl(arkURL)
if err != nil {
return nil, err
}
_, publicKey, err := common.DecodePubKey(arkPubKey)
if err != nil {
return nil, err
}
return publicKey, nil
}

54
cmd/noah/config.go Normal file
View File

@@ -0,0 +1,54 @@
package main
import (
"fmt"
"github.com/ark-network/ark/common"
"github.com/urfave/cli/v2"
)
var configCommand = cli.Command{
Name: "config",
Usage: "Print local configuration of the Noah CLI",
Action: printConfigAction,
Subcommands: []*cli.Command{
{
Name: "connect",
Usage: "connect <ARK_URL>",
Action: connectAction,
},
},
}
func printConfigAction(ctx *cli.Context) error {
state, err := getState()
if err != nil {
return err
}
for key, value := range state {
fmt.Println(key + ": " + value)
}
return nil
}
func connectAction(ctx *cli.Context) error {
if ctx.NArg() != 1 {
return fmt.Errorf("missing ark URL")
}
url := ctx.Args().Get(0)
_, _, err := common.DecodeUrl(url)
if err != nil {
return err
}
if err := setState(map[string]string{"ark_url": url}); err != nil {
return err
}
fmt.Println("Connected to " + url)
return nil
}

106
cmd/noah/cypher.go Normal file
View File

@@ -0,0 +1,106 @@
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"fmt"
"runtime/debug"
"golang.org/x/crypto/scrypt"
)
type Cypher struct{}
func NewAES128Cypher() *Cypher {
return &Cypher{}
}
func (c *Cypher) Encrypt(privateKey, password []byte) ([]byte, error) {
// Due to https://github.com/golang/go/issues/7168.
// This call makes sure that memory is freed in case the GC doesn't do that
// right after the encryption/decryption.
defer debug.FreeOSMemory()
if len(privateKey) == 0 {
return nil, fmt.Errorf("missing plaintext private key")
}
if len(password) == 0 {
return nil, fmt.Errorf("missing encryption password")
}
key, salt, err := deriveKey(password, nil)
if err != nil {
return nil, err
}
blockCipher, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(blockCipher)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = rand.Read(nonce); err != nil {
return nil, err
}
ciphertext := gcm.Seal(nonce, nonce, privateKey, nil)
ciphertext = append(ciphertext, salt...)
return ciphertext, nil
}
func (c *Cypher) Decrypt(encrypted, password []byte) ([]byte, error) {
defer debug.FreeOSMemory()
if len(encrypted) == 0 {
return nil, fmt.Errorf("missing encrypted mnemonic")
}
if len(password) == 0 {
return nil, fmt.Errorf("missing decryption password")
}
salt := encrypted[len(encrypted)-32:]
data := encrypted[:len(encrypted)-32]
key, _, err := deriveKey(password, salt)
if err != nil {
return nil, err
}
blockCipher, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(blockCipher)
if err != nil {
return nil, err
}
nonce, text := data[:gcm.NonceSize()], data[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, text, nil)
if err != nil {
return nil, fmt.Errorf("invalid password")
}
return plaintext, nil
}
// deriveKey derives a 32 byte array key from a custom passhprase
func deriveKey(password, salt []byte) ([]byte, []byte, error) {
if salt == nil {
salt = make([]byte, 32)
if _, err := rand.Read(salt); err != nil {
return nil, nil, err
}
}
// 2^20 = 1048576 recommended length for key-stretching
// check the doc for other recommended values:
// https://godoc.org/golang.org/x/crypto/scrypt
key, err := scrypt.Key(password, salt, 1048576, 8, 1, 32)
if err != nil {
return nil, nil, err
}
return key, salt, nil
}

90
cmd/noah/init.go Normal file
View File

@@ -0,0 +1,90 @@
package main
import (
"encoding/hex"
"fmt"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/urfave/cli/v2"
)
var (
passwordFlag = cli.StringFlag{
Name: "password",
Usage: "password to encrypt private key",
Value: "",
Required: true,
}
privateKeyFlag = cli.StringFlag{
Name: "prvkey",
Usage: "optional, private key to encrypt",
Value: "",
Required: false,
}
)
var initCommand = cli.Command{
Name: "init",
Usage: "Initialize Noah wallet private key, encrypted with password",
Action: initAction,
Flags: []cli.Flag{
&passwordFlag,
&privateKeyFlag,
},
}
func initAction(ctx *cli.Context) error {
privateKeyString := ctx.String("prvkey")
password := ctx.String("password")
if len(password) <= 0 {
return fmt.Errorf("missing password flag (--password)")
}
var privateKey *secp256k1.PrivateKey
if len(privateKeyString) <= 0 {
privKey, err := generateRandomPrivateKey()
if err != nil {
return err
}
privateKey = privKey
} else {
privKeyBytes, err := hex.DecodeString(privateKeyString)
if err != nil {
return err
}
privateKey = secp256k1.PrivKeyFromBytes(privKeyBytes)
}
cypher := NewAES128Cypher()
encryptedPrivateKey, err := cypher.Encrypt(privateKey.Serialize(), []byte(password))
if err != nil {
return err
}
passwordHash := hashPassword([]byte(password))
state := map[string]string{
"encrypted_private_key": hex.EncodeToString(encryptedPrivateKey),
"password_hash": hex.EncodeToString(passwordHash),
}
if err := setState(state); err != nil {
return err
}
return nil
}
func generateRandomPrivateKey() (*secp256k1.PrivateKey, error) {
privKey, err := btcec.NewPrivateKey()
if err != nil {
return nil, err
}
return privKey, nil
}

158
cmd/noah/main.go Normal file
View File

@@ -0,0 +1,158 @@
package main
import (
"encoding/json"
"fmt"
"os"
"os/user"
"path/filepath"
"strings"
"github.com/ark-network/ark/common"
"github.com/urfave/cli/v2"
)
const (
DATADIR_ENVVAR = "NOAH_DATADIR"
STATE_FILE = "state.json"
defaultArkURL = "ark://apub1qgvdtj5ttpuhkldavhq8thtm5auyk0ec4dcmrfdgu0u5hgp9we22v3hrs4x?relays=arelay1qt6f8p7h5f6tm7fv2z5wg92sz92rn9desfhd5733se4lkrptqtdrq65987l-arelay1qt6f8p7h5f6tm7fv2z5wg92sz92rn9desfhd5733se4lkrptqtdrq65987l"
)
var (
version = "alpha"
noahDataDirectory = common.AppDataDir("noah", false)
statePath = filepath.Join(noahDataDirectory, STATE_FILE)
initialState = map[string]string{
"ark_url": defaultArkURL,
"encrypted_private_key": "",
"password_hash": "",
}
)
func initCLIEnv() {
dataDir := cleanAndExpandPath(os.Getenv(DATADIR_ENVVAR))
if len(dataDir) <= 0 {
return
}
noahDataDirectory = dataDir
statePath = filepath.Join(noahDataDirectory, STATE_FILE)
}
func main() {
initCLIEnv()
app := cli.NewApp()
app.Version = version
app.Name = "noah CLI"
app.Usage = "Command line interface for Ark wallet"
app.Commands = append(
app.Commands,
&balanceCommand,
&configCommand,
&initCommand,
&receiveCommand,
&redeemCommand,
&sendCommand,
)
app.Before = func(ctx *cli.Context) error {
if _, err := os.Stat(noahDataDirectory); os.IsNotExist(err) {
return os.Mkdir(noahDataDirectory, os.ModeDir|0755)
}
return nil
}
err := app.Run(os.Args)
if err != nil {
fmt.Println(fmt.Errorf("error: %v", err))
os.Exit(1)
}
}
// cleanAndExpandPath expands environment variables and leading ~ in the
// passed path, cleans the result, and returns it.
// This function is taken from https://github.com/btcsuite/btcd
func cleanAndExpandPath(path string) string {
if path == "" {
return ""
}
// Expand initial ~ to OS specific home directory.
if strings.HasPrefix(path, "~") {
var homeDir string
u, err := user.Current()
if err == nil {
homeDir = u.HomeDir
} else {
homeDir = os.Getenv("HOME")
}
path = strings.Replace(path, "~", homeDir, 1)
}
// NOTE: The os.ExpandEnv doesn't work with Windows-style %VARIABLE%,
// but the variables can still be expanded via POSIX-style $VARIABLE.
return filepath.Clean(os.ExpandEnv(path))
}
func getState() (map[string]string, error) {
file, err := os.ReadFile(statePath)
if err != nil {
if !os.IsNotExist(err) {
return nil, err
}
if err := setInitialState(); err != nil {
return nil, err
}
return initialState, nil
}
data := map[string]string{}
if err := json.Unmarshal(file, &data); err != nil {
return nil, err
}
return data, nil
}
func setInitialState() error {
jsonString, err := json.Marshal(initialState)
if err != nil {
return err
}
return os.WriteFile(statePath, jsonString, 0755)
}
func setState(data map[string]string) error {
currentData, err := getState()
if err != nil {
return err
}
mergedData := merge(currentData, data)
jsonString, err := json.Marshal(mergedData)
if err != nil {
return err
}
err = os.WriteFile(statePath, jsonString, 0755)
if err != nil {
return fmt.Errorf("writing to file: %w", err)
}
return nil
}
func merge(maps ...map[string]string) map[string]string {
merge := make(map[string]string, 0)
for _, m := range maps {
for k, v := range m {
merge[k] = v
}
}
return merge
}

37
cmd/noah/receive.go Normal file
View File

@@ -0,0 +1,37 @@
package main
import (
"fmt"
"github.com/ark-network/ark/common"
"github.com/urfave/cli/v2"
)
var receiveCommand = cli.Command{
Name: "receive",
Usage: "Print the Ark address associated with your wallet and the connected Ark",
Action: receiveAction,
}
func receiveAction(ctx *cli.Context) error {
privateKey, err := privateKeyFromPassword()
if err != nil {
return err
}
publicKey := privateKey.PubKey()
aspPublicKey, err := getServiceProviderPublicKey()
if err != nil {
return err
}
addr, err := common.EncodeAddress(common.MainNet.Addr, publicKey, aspPublicKey)
if err != nil {
return err
}
fmt.Println(addr)
return nil
}

67
cmd/noah/redeem.go Normal file
View File

@@ -0,0 +1,67 @@
package main
import (
"fmt"
"github.com/urfave/cli/v2"
)
var (
addressFlag = cli.StringFlag{
Name: "address",
Usage: "main chain address receiving the redeeemed VTXO",
Value: "",
Required: true,
}
amountToRedeemFlag = cli.Uint64Flag{
Name: "amount",
Usage: "amount to redeem",
Value: 0,
Required: false,
}
forceFlag = cli.BoolFlag{
Name: "force",
Usage: "force redemption without collaborate with the Ark service provider",
Value: false,
Required: false,
}
)
var redeemCommand = cli.Command{
Name: "redeem",
Usage: "Redeem VTXO(s) to onchain",
Flags: []cli.Flag{&addressFlag, &amountToRedeemFlag, &forceFlag},
Action: redeemAction,
}
func redeemAction(ctx *cli.Context) error {
address := ctx.String("address")
amount := ctx.Uint64("amount")
force := ctx.Bool("force")
if len(address) <= 0 {
return fmt.Errorf("missing address flag (--address)")
}
if !force && amount <= 0 {
return fmt.Errorf("missing amount flag (--amount)")
}
if force {
return unilateralRedeem(address)
}
return collaborativeRedeem(address, amount)
}
func collaborativeRedeem(address string, amount uint64) error {
fmt.Println("collaborative redeem is not implemented yet")
return nil
}
func unilateralRedeem(address string) error {
fmt.Println("unilateral redeem is not implemented yet")
return nil
}

60
cmd/noah/send.go Normal file
View File

@@ -0,0 +1,60 @@
package main
import (
"encoding/json"
"fmt"
"github.com/ark-network/ark/common"
"github.com/urfave/cli/v2"
)
var (
receiversFlag = cli.StringFlag{
Name: "receivers",
Usage: "receivers of the send transaction, JSON encoded: '[{\"to\": \"<...>\", \"amount\": <...>}, ...]'",
Value: "",
Required: true,
}
)
var sendCommand = cli.Command{
Name: "send",
Usage: "Send VTXOs to a list of addresses",
Action: sendAction,
Flags: []cli.Flag{&receiversFlag},
}
func sendAction(ctx *cli.Context) error {
receivers := ctx.String("receivers")
// parse json encoded receivers
var receiversJSON []receiverJSON
if err := json.Unmarshal([]byte(receivers), &receiversJSON); err != nil {
return fmt.Errorf("invalid receivers: %s", err)
}
if len(receiversJSON) <= 0 {
return fmt.Errorf("no receivers specified")
}
for _, receiver := range receiversJSON {
// TODO: check if receiver asp public key is valid
_, _, _, err := common.DecodeAddress(receiver.To)
if err != nil {
return fmt.Errorf("invalid receiver address: %s", err)
}
if receiver.Amount <= 0 {
return fmt.Errorf("invalid amount: %d", receiver.Amount)
}
}
fmt.Println("send command is not implemented yet")
return nil
}
type receiverJSON struct {
To string `json:"to"`
Amount int64 `json:"amount"`
}