Light client implementation with get-info and list-buckets commands (#2)

* Light client implementation with get-info and list-buckets commands

* Fix client package name

* Fix go.mod to work with vgo

* Use single `fmt.Printf` for `get-info` output

* Use unnamed import to client package

* Simplify usage of sha256 and sha512 sums

* Remove obsolete test code

* Use helper structs for unmarshalling bridge info

* Remove LGPL license files and adjust copyright headers

* Use github.com/zeebo/errs

* Use httptest for test http server

* Use viper for env var management

* Nested struct for swagger

* Add github.com/zeebo/errs to go.mod

* More bucket tests

* word wrap long line

* Use zeebo/errs for crypto errors
This commit is contained in:
Kaloyan Raev 2018-04-16 16:42:06 +03:00 committed by GitHub
parent c90143a58d
commit 7fde8b908a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 763 additions and 1 deletions

18
cmd/storj/main.go Normal file
View File

@ -0,0 +1,18 @@
// Copyright (C) 2018 Storj Labs, Inc.
// See LICENSE for copying information.
package main
import (
"log"
"os"
"storj.io/storj/internal/app/cli"
)
func main() {
err := cli.New().Run(os.Args)
if err != nil {
log.Fatal(err)
}
}

10
go.mod
View File

@ -1 +1,9 @@
module "github.com/Storj/storj"
module "storj.io/storj"
require (
"github.com/spf13/viper" v1.0.2
"github.com/tyler-smith/go-bip39" v0.0.0-20160629163856-8e7a99b3e716
"github.com/urfave/cli" v1.20.0
"github.com/zeebo/errs" v0.1.0
"golang.org/x/crypto" v0.0.0-20180410182641-f70185d77e82
)

80
internal/app/cli/app.go Normal file
View File

@ -0,0 +1,80 @@
// Copyright (C) 2018 Storj Labs, Inc.
// See LICENSE for copying information.
package cli
import (
"fmt"
"os"
"github.com/urfave/cli"
"storj.io/storj/pkg/client"
)
// New creates a new storj cli application with the respective commands and metainfo.
func New() *cli.App {
app := cli.NewApp()
app.Name = "storj"
app.Version = "0.0.2"
app.Usage = "command line interface to the Storj network"
app.Commands = []cli.Command{
{
Name: "get-info",
Usage: "prints bridge api information",
ArgsUsage: " ", // no args
Category: "bridge api information",
Action: func(c *cli.Context) error {
getInfo()
return nil
},
},
{
Name: "list-buckets",
Usage: "lists the available buckets",
ArgsUsage: " ", // no args
Category: "working with buckets and files",
Action: func(c *cli.Context) error {
listBuckets()
return nil
},
},
}
cli.AppHelpTemplate = fmt.Sprintf(`%s
ENVIRONMENT VARIABLES:
STORJ_BRIDGE the bridge host (e.g. https://api.storj.io)
STORJ_BRIDGE_USER bridge username
STORJ_BRIDGE_PASS bridge password
STORJ_ENCRYPTION_KEY file encryption key
`, cli.AppHelpTemplate)
return app
}
func getInfo() {
env := client.NewEnv()
info, err := client.GetInfo(env)
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
fmt.Printf("Storj bridge: %s\n\n"+
"Title: %s\n"+
"Description: %s\n"+
"Version: %s\n"+
"Host: %s\n",
env.URL, info.Title, info.Description, info.Version, info.Host)
}
func listBuckets() {
buckets, err := client.GetBuckets(client.NewEnv())
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
for _, b := range buckets {
fmt.Printf("ID: %s\tDecrypted: %t\t\tCreated: %s\tName: %s\n",
b.ID, b.Decrypted, b.Created, b.Name)
}
}

57
pkg/client/buckets.go Normal file
View File

@ -0,0 +1,57 @@
// Copyright (C) 2018 Storj Labs, Inc.
// See LICENSE for copying information.
package client
import (
"encoding/json"
"io/ioutil"
"log"
"net/http"
"strconv"
)
// Bucket metdata
type Bucket struct {
ID string
Name string
Created string
Decrypted bool
}
// GetBuckets returns the list of buckets
func GetBuckets(env Env) ([]Bucket, error) {
client := &http.Client{}
req, err := http.NewRequest("GET", env.URL+"/buckets", nil)
if err != nil {
return []Bucket{}, err
}
req.SetBasicAuth(env.User, env.Password)
resp, err := client.Do(req)
if err != nil {
return []Bucket{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return []Bucket{}, UnexpectedStatusCode.New(strconv.Itoa(resp.StatusCode))
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return []Bucket{}, err
}
var buckets []Bucket
err = json.Unmarshal(b, &buckets)
if err != nil {
return buckets, err
}
for i, b := range buckets {
decryptedName, err := decryptBucketName(b.Name, env.Mnemonic)
if err != nil {
log.Printf("Could not decrypt bucket name %s: %s\n", b.Name, err)
continue
}
buckets[i].Name = decryptedName
buckets[i].Decrypted = true
}
return buckets, err
}

130
pkg/client/buckets_test.go Normal file
View File

@ -0,0 +1,130 @@
// Copyright (C) 2018 Storj Labs, Inc.
// See LICENSE for copying information.
package client
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/julienschmidt/httprouter"
"github.com/stretchr/testify/assert"
)
const (
testEncryptedBucketName = "cqiNhd3Y16uXRBpRKbcGdrhVvouLRFlBM5O1jMUOr6OKJUVGpvv0LLaBv+6kqzyVvp5jFw=="
testEncryptedBucketNameDiffMnemonic = "Yq3Ky6jJ7dwWiC9MEcb5nhAl5P0xfYe6jCwwwlzd1a1kZxKYLcft/WkOC8dhcwLb3Ka9xA=="
testDecryptedBucketName = "test"
)
func TestGetBuckets(t *testing.T) {
var tsResponse string
router := httprouter.New()
router.GET("/buckets", basicAuth(func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
fmt.Fprintf(w, tsResponse)
}, testBridgeUser, testBridgePass))
ts := httptest.NewServer(router)
defer ts.Close()
for i, tt := range []struct {
env Env
response string
buckets []Bucket
errString string
}{
{NewNoAuthTestEnv(ts), "", nil, "unexpected status code: 401"},
{NewBadPassTestEnv(ts), "", nil, "unexpected status code: 401"},
{NewTestEnv(ts), "", []Bucket{}, "unexpected end of JSON input"},
{NewTestEnv(ts), "{", []Bucket{}, "unexpected end of JSON input"},
{NewTestEnv(ts), "{}", []Bucket{}, "json: cannot unmarshal object into Go value of type []client.Bucket"},
{NewTestEnv(ts), "[]", []Bucket{}, ""},
{NewTestEnv(ts),
`[
{
"id": "e3eca45f4d294132c07b49f4",
"name": "cqiNhd3Y16uXRBpRKbcGdrhVvouLRFlBM5O1jMUOr6OKJUVGpvv0LLaBv+6kqzyVvp5jFw==",
"created": "2016-10-12T14:40:21.259Z"
}
]`, []Bucket{
Bucket{
ID: "e3eca45f4d294132c07b49f4",
Name: testDecryptedBucketName,
Created: "2016-10-12T14:40:21.259Z",
Decrypted: true,
},
}, ""},
{NewNoMnemonicTestEnv(ts),
`[
{
"id": "e3eca45f4d294132c07b49f4",
"name": "cqiNhd3Y16uXRBpRKbcGdrhVvouLRFlBM5O1jMUOr6OKJUVGpvv0LLaBv+6kqzyVvp5jFw==",
"created": "2016-10-12T14:40:21.259Z"
}
]`, []Bucket{
Bucket{
ID: "e3eca45f4d294132c07b49f4",
Name: testEncryptedBucketName,
Created: "2016-10-12T14:40:21.259Z",
Decrypted: false,
},
}, ""},
{NewTestEnv(ts),
`[
{
"id": "e3eca45f4d294132c07b49f4",
"name": "Yq3Ky6jJ7dwWiC9MEcb5nhAl5P0xfYe6jCwwwlzd1a1kZxKYLcft/WkOC8dhcwLb3Ka9xA==",
"created": "2016-10-12T14:40:21.259Z"
}
]`, []Bucket{
Bucket{
ID: "e3eca45f4d294132c07b49f4",
Name: testEncryptedBucketNameDiffMnemonic,
Created: "2016-10-12T14:40:21.259Z",
Decrypted: false,
},
}, ""},
{NewTestEnv(ts),
`[
{
"id": "e3eca45f4d294132c07b49f4",
"name": "test",
"created": "2016-10-12T14:40:21.259Z"
}
]`, []Bucket{
Bucket{
ID: "e3eca45f4d294132c07b49f4",
Name: testDecryptedBucketName,
Created: "2016-10-12T14:40:21.259Z",
Decrypted: false,
},
}, ""},
{NewNoMnemonicTestEnv(ts),
`[
{
"id": "e3eca45f4d294132c07b49f4",
"name": "test",
"created": "2016-10-12T14:40:21.259Z"
}
]`, []Bucket{
Bucket{
ID: "e3eca45f4d294132c07b49f4",
Name: testDecryptedBucketName,
Created: "2016-10-12T14:40:21.259Z",
Decrypted: false,
},
}, ""},
} {
tsResponse = tt.response
buckets, err := GetBuckets(tt.env)
errTag := fmt.Sprintf("Test case #%d", i)
if tt.errString != "" {
assert.EqualError(t, err, tt.errString, errTag)
continue
}
if assert.NoError(t, err, errTag) {
assert.Equal(t, tt.buckets, buckets, errTag)
}
}
}

102
pkg/client/crypto.go Normal file
View File

@ -0,0 +1,102 @@
// Copyright (C) 2018 Storj Labs, Inc.
// See LICENSE for copying information.
package client
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"encoding/hex"
bip39 "github.com/tyler-smith/go-bip39"
)
const (
bucketNameMagic = "398734aab3c4c30c9f22590e83a95f7e43556a45fc2b3060e0c39fde31f50272"
gcmDigestSize = 16
ivSize = 32
)
var bucketMetaMagic = []byte{
66, 150, 71, 16, 50, 114, 88, 160, 163, 35, 154, 65, 162, 213, 226, 215,
70, 138, 57, 61, 52, 19, 210, 170, 38, 164, 162, 200, 86, 201, 2, 81}
func sha256Sum(str string) string {
checksum := sha256.Sum256([]byte(str))
return hex.EncodeToString(checksum[:])
}
func mnemonicToSeed(mnemonic string) (string, error) {
seed, err := bip39.NewSeedWithErrorChecking(mnemonic, "")
if err != nil {
return "", err
}
return hex.EncodeToString(seed), nil
}
func getDeterministicKey(key, id string) (string, error) {
input, err := hex.DecodeString(key + id)
if err != nil {
return "", err
}
checksum := sha512.Sum512(input)
str := hex.EncodeToString(checksum[:])
return str[0 : len(str)/2], nil
}
func generateBucketKey(mnemonic, bucketID string) (string, error) {
seed, err := mnemonicToSeed(mnemonic)
if err != nil {
return "", err
}
return getDeterministicKey(seed, bucketNameMagic)
}
func decryptMeta(encryptedName string, key []byte) ([]byte, error) {
nameBase64, err := base64.StdEncoding.DecodeString(encryptedName)
if err != nil {
return []byte{}, err
}
if len(nameBase64) <= gcmDigestSize+ivSize {
return []byte{}, CryptoError.New("Invalid encrypted name")
}
block, err := aes.NewCipher(key)
if err != nil {
return []byte{}, err
}
aesgcm, err := cipher.NewGCMWithNonceSize(block, ivSize)
if err != nil {
return []byte{}, err
}
digest := nameBase64[:gcmDigestSize]
iv := nameBase64[gcmDigestSize : gcmDigestSize+ivSize]
cipherText := nameBase64[gcmDigestSize+ivSize:]
return aesgcm.Open(nil, iv, append(cipherText, digest...), nil)
}
func decryptBucketName(name, mnemonic string) (string, error) {
bucketKey, err := generateBucketKey(mnemonic, bucketNameMagic)
if err != nil {
return "", err
}
bucketKeyHex, err := hex.DecodeString(bucketKey)
if err != nil {
return "", err
}
sig := hmac.New(sha512.New, bucketKeyHex)
_, err = sig.Write(bucketMetaMagic)
if err != nil {
return "", err
}
hmacSHA512 := sig.Sum(nil)
key := hmacSHA512[0 : len(hmacSHA512)/2]
decryptedName, err := decryptMeta(name, key)
if err != nil {
return "", err
}
return string(decryptedName), nil
}

38
pkg/client/crypto_test.go Normal file
View File

@ -0,0 +1,38 @@
// Copyright (C) 2018 Storj Labs, Inc.
// See LICENSE for copying information.
package client
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
const testMnemonic = "uncle obtain april oxygen cover patient layer abuse off text royal normal"
func TestDecryptBucketName(t *testing.T) {
for i, tt := range []struct {
encryptedName string
mnemonic string
decryptedName string
errString string
}{
{"", "", "", "Invalid mnemonic"},
{"", testMnemonic, "", "Invalid encrypted name"},
{testEncryptedBucketName, "", "", "Invalid mnemonic"},
{testEncryptedBucketName, testMnemonic, testDecryptedBucketName, ""},
{testEncryptedBucketNameDiffMnemonic, testMnemonic, "", "cipher: message authentication failed"},
} {
decryptedName, err := decryptBucketName(tt.encryptedName, tt.mnemonic)
errTag := fmt.Sprintf("Test case #%d", i)
if tt.errString != "" {
assert.EqualError(t, err, tt.errString, errTag)
continue
}
if assert.NoError(t, err, errTag) {
assert.Equal(t, tt.decryptedName, decryptedName, errTag)
}
}
}

38
pkg/client/env.go Normal file
View File

@ -0,0 +1,38 @@
// Copyright (C) 2018 Storj Labs, Inc.
// See LICENSE for copying information.
package client
import (
"strings"
"github.com/spf13/viper"
)
// DefaultURL of the Storj Bridge API endpoint
const DefaultURL = "https://api.storj.io"
func init() {
viper.SetEnvPrefix("storj")
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
viper.AutomaticEnv()
viper.SetDefault("bridge", DefaultURL)
}
// Env contains parameters for accessing the Storj network
type Env struct {
URL string
User string
Password string
Mnemonic string
}
// NewEnv creates new Env struct with default values
func NewEnv() Env {
return Env{
URL: viper.GetString("bridge"),
User: viper.GetString("bridge-user"),
Password: sha256Sum(viper.GetString("bridge-pass")),
Mnemonic: viper.GetString("encryption-key"),
}
}

99
pkg/client/env_test.go Normal file
View File

@ -0,0 +1,99 @@
// Copyright (C) 2018 Storj Labs, Inc.
// See LICENSE for copying information.
package client
import (
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/julienschmidt/httprouter"
"github.com/stretchr/testify/assert"
)
const (
testBridgeURL = "http://example.com"
testBridgeUser = "testuser@storj.io"
testBridgePass = "secret"
)
func NewTestEnv(ts *httptest.Server) Env {
return Env{
URL: ts.URL,
User: testBridgeUser,
Password: testBridgePass,
Mnemonic: testMnemonic,
}
}
func NewNoAuthTestEnv(ts *httptest.Server) Env {
return Env{
URL: ts.URL,
}
}
func NewBadPassTestEnv(ts *httptest.Server) Env {
return Env{
URL: ts.URL,
User: testBridgeUser,
Password: "bad password",
Mnemonic: testMnemonic,
}
}
func NewNoMnemonicTestEnv(ts *httptest.Server) Env {
return Env{
URL: ts.URL,
User: testBridgeUser,
Password: testBridgePass,
}
}
func basicAuth(h httprouter.Handle, requiredUser, requiredPassword string) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
// Get the Basic Authentication credentials
user, password, hasAuth := r.BasicAuth()
if hasAuth && user == requiredUser && password == requiredPassword {
// Delegate request to the given handle
h(w, r, ps)
} else {
// Request Basic Authentication otherwise
w.Header().Set("WWW-Authenticate", "Basic realm=Restricted")
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
}
}
}
func TestNewEnv(t *testing.T) {
for _, tt := range []struct {
env Env
url string
}{
{Env{}, ""},
{NewEnv(), DefaultURL},
{Env{URL: testBridgeURL}, testBridgeURL},
} {
assert.Equal(t, tt.url, tt.env.URL)
}
}
func TestNewEnvVars(t *testing.T) {
os.Setenv("STORJ_BRIDGE", testBridgeURL)
defer os.Unsetenv("STORJ_BRIDGE")
os.Setenv("STORJ_BRIDGE_USER", testBridgeUser)
defer os.Unsetenv("STORJ_BRIDGE_USER")
os.Setenv("STORJ_BRIDGE_PASS", testBridgePass)
defer os.Unsetenv("STORJ_BRIDGE_PASS")
os.Setenv("STORJ_ENCRYPTION_KEY", testMnemonic)
defer os.Unsetenv("STORJ_ENCRYPTION_KEY")
env := NewEnv()
assert.Equal(t, testBridgeURL, env.URL)
assert.Equal(t, testBridgeUser, env.User)
assert.Equal(t, sha256Sum(testBridgePass), env.Password)
assert.Equal(t, testMnemonic, env.Mnemonic)
}

14
pkg/client/errors.go Normal file
View File

@ -0,0 +1,14 @@
// Copyright (C) 2018 Storj Labs, Inc.
// See LICENSE for copying information.
package client
import (
"github.com/zeebo/errs"
)
// UnexpectedStatusCode is an error class for unexpected HTTP response
var UnexpectedStatusCode = errs.Class("unexpected status code")
// CryptoError is an error class for encryption errors
var CryptoError = errs.Class("encryption error")

58
pkg/client/info.go Normal file
View File

@ -0,0 +1,58 @@
// Copyright (C) 2018 Storj Labs, Inc.
// See LICENSE for copying information.
package client
import (
"encoding/json"
"io/ioutil"
"net/http"
"strconv"
)
// Info struct of the GetInfo() response
type Info struct {
Title string
Description string
Version string
Host string
}
type swagger struct {
Info struct {
Title string
Description string
Version string
}
Host string
}
// UnmarshalJSON overrides the unmarshalling for Info to correctly extract the data from the Swagger JSON response
func (info *Info) UnmarshalJSON(b []byte) error {
var s swagger
json.Unmarshal(b, &s)
info.Title = s.Info.Title
info.Description = s.Info.Description
info.Version = s.Info.Version
info.Host = s.Host
return nil
}
// GetInfo returns info about the Storj Bridge server
func GetInfo(env Env) (Info, error) {
info := Info{}
resp, err := http.Get(env.URL)
if err != nil {
return info, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return info, UnexpectedStatusCode.New(strconv.Itoa(resp.StatusCode))
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return info, err
}
err = json.Unmarshal(b, &info)
return info, err
}

120
pkg/client/info_test.go Normal file
View File

@ -0,0 +1,120 @@
// Copyright (C) 2018 Storj Labs, Inc.
// See LICENSE for copying information.
package client
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/julienschmidt/httprouter"
"github.com/stretchr/testify/assert"
)
const (
testTitle = "Storj Bridge"
testDescription = "Some description"
testVersion = "1.2.3"
testHost = "1.2.3.4"
)
func TestUnmarshalJSON(t *testing.T) {
for i, tt := range []struct {
json string
info Info
errString string
}{
{"", Info{}, "unexpected end of JSON input"},
{"{", Info{}, "unexpected end of JSON input"},
{"{}", Info{}, ""},
{`{"info":{}}`, Info{}, ""},
{`{"info":10}`, Info{}, ""},
{`{"info":{"title":10,"description":10,"version":10},"host":10}`, Info{}, ""},
{fmt.Sprintf(`{"info":{"description":"%s","version":"%s"},"host":"%s"}`,
testDescription, testVersion, testHost),
Info{
Description: testDescription,
Version: testVersion,
Host: testHost,
},
""},
{fmt.Sprintf(`{"info":{"title":"%s","version":"%s"},"host":"%s"}`,
testTitle, testVersion, testHost),
Info{
Title: testTitle,
Version: testVersion,
Host: testHost,
},
""},
{fmt.Sprintf(`{"info":{"title":"%s","description":"%s"},"host":"%s"}`,
testTitle, testDescription, testHost),
Info{
Title: testTitle,
Description: testDescription,
Host: testHost,
},
""},
{fmt.Sprintf(`{"info":{"title":"%s","description":"%s","version":"%s"}}`,
testTitle, testDescription, testVersion),
Info{
Title: testTitle,
Description: testDescription,
Version: testVersion,
},
""},
{fmt.Sprintf(`{"info":{"title":"%s","description":"%s","version":"%s"},"host":"%s"}`,
testTitle, testDescription, testVersion, testHost),
Info{
Title: testTitle,
Description: testDescription,
Version: testVersion,
Host: testHost,
},
""},
} {
var info Info
err := json.Unmarshal([]byte(tt.json), &info)
errTag := fmt.Sprintf("Test case #%d", i)
if tt.errString != "" {
assert.EqualError(t, err, tt.errString, errTag)
continue
}
if assert.NoError(t, err, errTag) {
assert.Equal(t, tt.info, info, errTag)
}
}
}
func TestGetInfo(t *testing.T) {
router := httprouter.New()
router.GET("/", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
fmt.Fprintf(w, `{"info":{"title":"%s","description":"%s","version":"%s"},"host":"%s"}`,
testTitle, testDescription, testVersion, testHost)
})
ts := httptest.NewServer(router)
defer ts.Close()
for i, tt := range []struct {
env Env
errString string
}{
{NewTestEnv(ts), ""},
{Env{URL: ts.URL + "/info"}, "unexpected status code: 404"},
} {
info, err := GetInfo(tt.env)
errTag := fmt.Sprintf("Test case #%d", i)
if tt.errString != "" {
assert.EqualError(t, err, tt.errString, errTag)
continue
}
if assert.NoError(t, err, errTag) {
assert.Equal(t, testTitle, info.Title, errTag)
assert.Equal(t, testDescription, info.Description, errTag)
assert.Equal(t, testVersion, info.Version, errTag)
assert.Equal(t, testHost, info.Host, errTag)
}
}
}