diff --git a/cmd/vaults/commands.go b/cmd/vaults/commands.go index be8f5c0..38ac8cc 100644 --- a/cmd/vaults/commands.go +++ b/cmd/vaults/commands.go @@ -3,6 +3,7 @@ package main import ( "context" "crypto/ecdsa" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -22,6 +23,7 @@ import ( "github.com/schollz/progressbar/v3" "github.com/tablelandnetwork/basin-cli/internal/app" "github.com/tablelandnetwork/basin-cli/pkg/pgrepl" + "github.com/tablelandnetwork/basin-cli/pkg/signing" "github.com/tablelandnetwork/basin-cli/pkg/vaultsprovider" "github.com/urfave/cli/v2" "gopkg.in/yaml.v3" @@ -162,7 +164,7 @@ func newStreamCommand() *cli.Command { ArgsUsage: "", Description: "The daemon will continuously stream database changes (except deletions) \n" + "to the vault, as long as the daemon is actively running.\n\n" + - "EXAMPLE:\n\nvaults stream --vault my.vault --private-key 0x1234abcd", + "EXAMPLE:\n\nvaults stream --private-key 0x1234abcd my.vault", Flags: []cli.Flag{ &cli.StringFlag{ @@ -588,6 +590,50 @@ func newListEventsCommand() *cli.Command { } } +func newSignCommand() *cli.Command { + var privateKey string + + return &cli.Command{ + Name: "sign", + Usage: "Sign a file with a private key", + ArgsUsage: "", + Description: "Signing a file with take a provide key and a path to the desired file\n" + + "to produce a hex encoded string (e.g., can be used in the HTTP API).\n\n" + + "EXAMPLE:\n\nvaults sign --private-key 0x1234abcd /path/to/file", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "private-key", + Aliases: []string{"k"}, + Category: "REQUIRED:", + Usage: "Ethereum wallet private key", + Destination: &privateKey, + Required: true, + }, + }, + Action: func(cCtx *cli.Context) error { + if cCtx.NArg() != 1 { + return errors.New("must provide a file path") + } + filepath := cCtx.Args().First() + + privateKey, err := crypto.HexToECDSA(privateKey) + if err != nil { + return err + } + + signer := signing.NewSigner(privateKey) + signatureBytes, err := signer.SignFile(filepath) + if err != nil { + return fmt.Errorf("failed to sign file: %s", err) + } + signature := hex.EncodeToString(signatureBytes) + fmt.Println(signature) + + return nil + }, + } +} + func newRetrieveCommand() *cli.Command { var output, provider string var timeout int64 @@ -649,6 +695,8 @@ func newRetrieveCommand() *cli.Command { } func newWalletCommand() *cli.Command { + var pkString string + return &cli.Command{ Name: "account", Usage: "Account management for an Ethereum-style wallet", @@ -687,17 +735,32 @@ func newWalletCommand() *cli.Command { { Name: "address", Usage: "Print the public key for an account's private key", - UsageText: "vaults account address ", - Description: "The result of the `vaults account create` command will write a private key to a file, \n" + - "and this lets you retrieve the public key value for use in other commands.\n\n" + - "EXAMPLE:\n\nvaults account address /path/to/file", + UsageText: "vaults account address [command options] ", + Description: "The result of the `vaults account create` command will write a private key to a file, and \n" + + "this lets you retrieve the public key value for the file, or a private key hex string.\n" + + "If no `--string` flag is provided, then the presumption is the argument is a filepath.\n\n" + + "EXAMPLES:\n\nvaults account address /path/to/file\nvaults account address --string abcd1234", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "string", + Category: "OPTIONAL:", + Usage: "Specify if the argument is a hex string", + Destination: &pkString, + }, + }, Action: func(cCtx *cli.Context) error { - filename := cCtx.Args().Get(0) - if filename == "" { - return errors.New("filename is empty") + pkFile := cCtx.Args().Get(0) + if pkFile == "" && pkString == "" { + return errors.New("no argument provided") } - privateKey, err := crypto.LoadECDSA(filename) + var privateKey *ecdsa.PrivateKey + var err error + if pkString == "" { + privateKey, err = crypto.LoadECDSA(pkFile) + } else { + privateKey, err = crypto.HexToECDSA(pkString) + } if err != nil { return fmt.Errorf("loading key: %s", err) } diff --git a/cmd/vaults/main.go b/cmd/vaults/main.go index e4dc078..e94640e 100644 --- a/cmd/vaults/main.go +++ b/cmd/vaults/main.go @@ -36,6 +36,7 @@ func main() { newWriteCommand(), newListCommand(), newListEventsCommand(), + newSignCommand(), newRetrieveCommand(), newWalletCommand(), }, diff --git a/internal/app/uploader.go b/internal/app/uploader.go index 078a736..e6a6620 100644 --- a/internal/app/uploader.go +++ b/internal/app/uploader.go @@ -1,19 +1,15 @@ package app import ( - "bufio" "context" "crypto/ecdsa" "encoding/hex" "fmt" "io" - "log" "os" "strings" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/crypto" - "golang.org/x/crypto/sha3" + "github.com/tablelandnetwork/basin-cli/pkg/signing" ) // VaultsUploader contains logic of uploading Parquet files to Vaults Provider. @@ -48,11 +44,12 @@ func (bu *VaultsUploader) Upload( _ = f.Close() }() - signer := NewSigner(bu.privateKey) - signature, err := signer.SignFile(filepath) + signer := signing.NewSigner(bu.privateKey) + signatureBytes, err := signer.SignFile(filepath) if err != nil { return fmt.Errorf("signing the file: %s", err) } + signature := hex.EncodeToString(signatureBytes) filename := filepath if strings.Contains(filepath, "/") { @@ -66,7 +63,7 @@ func (bu *VaultsUploader) Upload( Content: f, Filename: filename, ProgressBar: progress, - Signature: hex.EncodeToString(signature), + Signature: signature, Size: sz, } @@ -76,78 +73,3 @@ func (bu *VaultsUploader) Upload( return nil } - -// Signer allows you to sign a big stream of bytes by calling Sum multiple times, then Sign. -type Signer struct { - state crypto.KeccakState - privateKey *ecdsa.PrivateKey -} - -// NewSigner creates a new signer. -func NewSigner(pk *ecdsa.PrivateKey) *Signer { - return &Signer{ - state: sha3.NewLegacyKeccak256().(crypto.KeccakState), - privateKey: pk, - } -} - -// Sum updates the hash state with a new chunk. -func (s *Signer) Sum(chunk []byte) { - s.state.Write(chunk) -} - -// Sign signs the internal state. -func (s *Signer) Sign() ([]byte, error) { - var h common.Hash - _, _ = s.state.Read(h[:]) - signature, err := crypto.Sign(h.Bytes(), s.privateKey) - if err != nil { - return []byte{}, fmt.Errorf("sign: %s", err) - } - - return signature, nil -} - -// SignFile signs an entire file. -func (s *Signer) SignFile(filename string) ([]byte, error) { - f, err := os.Open(filename) - if err != nil { - return []byte{}, fmt.Errorf("error to read [file=%v]: %v", filename, err.Error()) - } - - defer func() { - _ = f.Close() - }() - - nBytes, nChunks := int64(0), int64(0) - r := bufio.NewReader(f) - buf := make([]byte, 0, 4*1024) - for { - n, err := r.Read(buf[:cap(buf)]) - buf = buf[:n] - if n == 0 { - if err == nil { - continue - } - if err == io.EOF { - break - } - log.Fatal(err) - } - nChunks++ - nBytes += int64(len(buf)) - - s.Sum(buf) - - if err != nil && err != io.EOF { - log.Fatal(err) - } - } - - signature, err := s.Sign() - if err != nil { - log.Fatal("failed to sign") - } - - return signature, nil -} diff --git a/pkg/signing/signing.go b/pkg/signing/signing.go new file mode 100644 index 0000000..1e369f6 --- /dev/null +++ b/pkg/signing/signing.go @@ -0,0 +1,124 @@ +package signing + +import ( + "bufio" + "crypto/ecdsa" + "fmt" + "io" + "os" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "golang.org/x/crypto/sha3" +) + +// Signer allows you to sign a big stream of bytes by calling Sum multiple times, then Sign. +type Signer struct { + state crypto.KeccakState + privateKey *ecdsa.PrivateKey +} + +// HexToECDSA parses a hex-encoded secp256k1 private key string to an ECDSA +// private key. +func HexToECDSA(hexKey string) (*ecdsa.PrivateKey, error) { + return crypto.HexToECDSA(hexKey) +} + +// FileToECDSA parses a file path to a hex-encoded secp256k1 private key to an +// ECDSA private key. +func FileToECDSA(hexPath string) (*ecdsa.PrivateKey, error) { + return crypto.LoadECDSA(hexPath) +} + +// NewSigner creates a new signer. +func NewSigner(pk *ecdsa.PrivateKey) *Signer { + return &Signer{ + state: sha3.NewLegacyKeccak256().(crypto.KeccakState), + privateKey: pk, + } +} + +// Sum updates the hash state with a new chunk. +func (s *Signer) Sum(chunk []byte) { + s.state.Write(chunk) +} + +// Sign signs the internal state. +func (s *Signer) Sign() ([]byte, error) { + var h common.Hash + _, _ = s.state.Read(h[:]) + signature, err := crypto.Sign(h.Bytes(), s.privateKey) + if err != nil { + return []byte{}, fmt.Errorf("sign: %s", err) + } + + return signature, nil +} + +// SignFile signs an entire file, returning the signature as a byte slice. +func (s *Signer) SignFile(filename string) ([]byte, error) { + f, err := os.Open(filename) + if err != nil { + return []byte{}, fmt.Errorf("error reading [file=%v]: %v", filename, err.Error()) + } + defer func() { + _ = f.Close() + }() + + // Check if the file is empty and return an error if it is + info, err := f.Stat() + if err != nil { + return []byte{}, fmt.Errorf("failed to get file info: %s", err.Error()) + } + if info.Size() == 0 { + return []byte{}, fmt.Errorf("error with file: content is empty") + } + + nBytes, nChunks := int64(0), int64(0) + r := bufio.NewReader(f) + buf := make([]byte, 0, 4*1024) // 4KB buffer + for { + n, err := r.Read(buf[:cap(buf)]) + buf = buf[:n] + if n == 0 { + if err == nil { + continue + } + if err == io.EOF { + break + } + return []byte{}, fmt.Errorf("unexpected error reading file: %s", err.Error()) + } + nChunks++ + nBytes += int64(len(buf)) + + s.Sum(buf) + + if err != nil && err != io.EOF { + return []byte{}, fmt.Errorf("error in buffer: %s", err.Error()) + } + } + + signature, err := s.Sign() + if err != nil { + return []byte{}, fmt.Errorf("failed to sign [file=%v]: %s", filename, err.Error()) + } + + return signature, nil +} + +// SignBytes signs the provided bytes, returning the signature as a byte slice. +func (s *Signer) SignBytes(data []byte) ([]byte, error) { + if len(data) == 0 { + return []byte{}, fmt.Errorf("error with data: content is empty") + } + + s.Sum(data) + + signature, err := s.Sign() + if err != nil { + return []byte{}, fmt.Errorf("failed to sign data: %s", err.Error()) + } + + return signature, nil +} diff --git a/pkg/signing/signing_test.go b/pkg/signing/signing_test.go new file mode 100644 index 0000000..f779751 --- /dev/null +++ b/pkg/signing/signing_test.go @@ -0,0 +1,181 @@ +package signing + +import ( + "crypto/ecdsa" + "encoding/hex" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSignFile(t *testing.T) { + privateKey, _ := HexToECDSA("59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d") + signer := NewSigner(privateKey) + + testCases := []struct { + name string + setup func() (filename string, cleanup func()) + wantErr string + expectedSignature string + }{ + { + name: "should sign file with content", + setup: func() (filename string, cleanup func()) { + tmpFile, _ := os.CreateTemp("", "test_file") + name := tmpFile.Name() + content := []byte("data to be signed") + _, err := tmpFile.Write(content) + require.NoError(t, err, "Error writing to file") + err = tmpFile.Close() + require.NoError(t, err, "Error closing file") + return name, func() { + err = os.Remove(name) + require.NoError(t, err, "Error removing file") + } + }, + wantErr: "", + expectedSignature: "6ddb61a19b9df71136b48c80b2e86e7e20313d5eec0de9210802335b3" + + "00ba8df6c332d35a5d753a028d703769fd9b66d7ce5902d80369750cf55118b1679d84900", + }, + { + name: "should fail with empty file", + setup: func() (filename string, cleanup func()) { + tmpFile, _ := os.CreateTemp("", "test_file") + name := tmpFile.Name() + err := tmpFile.Close() + require.NoError(t, err, "Error closing file") + return name, func() { + err = os.Remove(name) + require.NoError(t, err, "Error removing file") + } + }, + wantErr: "error with file: content is empty", + }, + { + name: "should fail with non-existent file", + setup: func() (filename string, cleanup func()) { + tmpFile, _ := os.CreateTemp("", "test_file") + name := tmpFile.Name() + err := tmpFile.Close() + require.NoError(t, err, "Error closing file") + err = os.Remove(name) + require.NoError(t, err, "Error removing file") + return name, func() {} + }, + wantErr: "error reading [file=", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + filename, cleanup := tc.setup() + defer cleanup() + + signatureBytes, err := signer.SignFile(filename) + signature := hex.EncodeToString(signatureBytes) + if tc.wantErr != "" { + require.Error(t, err, "Expected an error for %v", tc.name) + require.Contains(t, err.Error(), tc.wantErr, "SignFile() error = %v, wantErr %v", err, tc.wantErr) + } else { + require.NoError(t, err, "SignFile() unexpected error = %v", err) + require.Equal(t, tc.expectedSignature, signature, "Signature mismatch") + } + }) + } +} + +func TestSignBytes(t *testing.T) { + privateKey, _ := HexToECDSA("59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d") + signer := NewSigner(privateKey) + + testCases := []struct { + name string + content []byte + wantErr string + expectedSignature string + }{ + { + name: "should sign bytes", + content: []byte("data to be signed"), + wantErr: "", + expectedSignature: "6ddb61a19b9df71136b48c80b2e86e7e20313d5eec0de9210802335b3" + + "00ba8df6c332d35a5d753a028d703769fd9b66d7ce5902d80369750cf55118b1679d84900", + }, + { + name: "should fail with empty bytes", + content: []byte(""), + wantErr: "error with data: content is empty", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + signatureBytes, err := signer.SignBytes(tc.content) + signature := hex.EncodeToString(signatureBytes) + if tc.wantErr != "" { + require.Error(t, err, "Expected an error for %v", tc.name) + require.Contains(t, err.Error(), tc.wantErr, "SignBytes() error = %v, wantErr %v", err, tc.wantErr) + } else { + require.NoError(t, err, "SignBytes() unexpected error = %v", err) + require.Equal(t, tc.expectedSignature, signature, "Signature mismatch") + } + }) + } +} + +func TestPrivateKey(t *testing.T) { + testCases := []struct { + name string + setup func() (pk string, filename string, cleanup func()) + wantErr string + }{ + { + name: "should load a private key string", + setup: func() (pk string, filename string, cleanup func()) { + pk = "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" + return pk, "", func() {} + }, + wantErr: "", + }, + { + name: "should load a private key file", + setup: func() (pk string, filename string, cleanup func()) { + tmpFile, _ := os.CreateTemp("", "test_file") + name := tmpFile.Name() + content := []byte("59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d") + _, err := tmpFile.Write(content) + require.NoError(t, err, "Error writing to file") + err = tmpFile.Close() + require.NoError(t, err, "Error closing file") + return pk, name, func() { + err = os.Remove(name) + require.NoError(t, err, "Error removing file") + } + }, + wantErr: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + pk, filename, cleanup := tc.setup() + defer cleanup() + + var hex *ecdsa.PrivateKey + var err error + if filename == "" { + hex, err = HexToECDSA(pk) + } else { + hex, err = FileToECDSA(filename) + } + if tc.wantErr != "" { + require.Error(t, err, "Expected an error for %v", tc.name) + require.EqualErrorf(t, err, tc.wantErr, "HexToECDSA() error = %v, wantErr %v", err, tc.wantErr) + } else { + require.NoError(t, err, "HexToECDSA() unexpected error = %v", err) + require.NotNil(t, hex, "HexToECDSA() returned nil") + } + }) + } +}