From 4bfe50247b641194c7fedcc846bdbee01163e4f7 Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Tue, 22 Aug 2017 14:37:19 -0700 Subject: [PATCH 1/5] Update SDK Registration Routine Add support for registration tokens to the SDK and update integration tests: - Registration via a token is explicitly tested - Both the test client and sharing client are dynamically registered --- client.go | 59 ++++++++++++++++++++++++++ client_integration_test.go | 87 +++++++++++++++++++++++++++++++++++--- 2 files changed, 140 insertions(+), 6 deletions(-) diff --git a/client.go b/client.go index 7ba2f75..71c564f 100644 --- a/client.go +++ b/client.go @@ -67,6 +67,25 @@ type ClientInfo struct { Validated bool `json:"validated"` } +// ClientDetails contains information about a newly-registered E3DB client +type ClientDetails struct { + ClientID string `json:"client_id"` + ApiKeyID string `json:"api_key_id"` + ApiSecret string `json:"api_secret"` + PublicKey clientKey `json:"public_key"` + Name string `json:"name"` +} + +type clientRegistrationInfo struct { + Name string `json:"name"` + PublicKey clientKey `json:"public_key"` +} + +type clientRegistrationRequest struct { + Token string `json:"token"` + Client clientRegistrationInfo `json:"client"` +} + // Meta contains meta-information about an E3DB record, such as // who wrote it, when it was written, and the type of the data stored. type Meta struct { @@ -132,6 +151,46 @@ func GetClient(opts ClientOpts) (*Client, error) { }, nil } +// RegisterClient creates a new client for a given InnoVault account +func RegisterClient(registrationToken string, clientName string, publicKey clientKey, apiURL string) (*ClientDetails, error) { + if apiURL == "" { + apiURL = defaultStorageURL + } + + request := &clientRegistrationRequest{ + Token: registrationToken, + Client: clientRegistrationInfo{ + Name: clientName, + PublicKey: publicKey, + }, + } + + buf := new(bytes.Buffer) + json.NewEncoder(buf).Encode(request) + req, err := http.NewRequest("POST", fmt.Sprintf("%s/v1/account/e3db/clients/register", apiURL), buf) + + if err != nil { + return nil, err + } + + client := &http.Client{} + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + defer closeResp(resp) + + var details *ClientDetails + if err := json.NewDecoder(resp.Body).Decode(&details); err != nil { + closeResp(resp) + return nil, err + } + + return details, nil +} + func (c *Client) apiURL() string { if c.Options.APIBaseURL == "" { return defaultStorageURL diff --git a/client_integration_test.go b/client_integration_test.go index d18d212..831a634 100644 --- a/client_integration_test.go +++ b/client_integration_test.go @@ -37,25 +37,61 @@ func dieErr(err error) { } func setup() { - opts, err := GetConfig("integration-test") + apiURL := os.Getenv("API_URL") + token := os.Getenv("REGISTRATION_TOKEN") + + clientName := "test-client-" + base64Encode(randomSecretKey()[:8]) + shareClientName := "share-client-" + base64Encode(randomSecretKey()[:8]) + + pub, priv, err := generateKeyPair() if err != nil { dieErr(err) } + pubKey := clientKey{Curve25519: base64Encode(pub[:])} - opts.Logging = false + pub2, priv2, err := generateKeyPair() + if err != nil { + dieErr(err) + } + pubKey2 := clientKey{Curve25519: base64Encode(pub2[:])} - client, err = GetClient(*opts) + clientDetails, err := RegisterClient(token, clientName, pubKey, apiURL) if err != nil { dieErr(err) } - // Load another client for later sharing tests - opts, err = GetConfig("integration-test-shared") + shareClientDetails, err := RegisterClient(token, shareClientName, pubKey2, apiURL) if err != nil { dieErr(err) } - opts.Logging = false + opts := &ClientOpts{ + ClientID: clientDetails.ClientID, + ClientEmail: "", + APIKeyID: clientDetails.ApiKeyID, + APISecret: clientDetails.ApiSecret, + PublicKey: pub, + PrivateKey: priv, + APIBaseURL: apiURL, + Logging: false, + } + + client, err = GetClient(*opts) + if err != nil { + dieErr(err) + } + + // Load another client for later sharing tests + opts = &ClientOpts{ + ClientID: shareClientDetails.ClientID, + ClientEmail: "", + APIKeyID: shareClientDetails.ApiKeyID, + APISecret: shareClientDetails.ApiSecret, + PublicKey: pub2, + PrivateKey: priv2, + APIBaseURL: apiURL, + Logging: false, + } var client2 *Client client2, err = GetClient(*opts) @@ -70,6 +106,45 @@ func shutdown() { } +func TestRegistration(t *testing.T) { + apiURL := os.Getenv("API_URL") + token := os.Getenv("REGISTRATION_TOKEN") + + pub, _, err := generateKeyPair() + if err != nil { + t.Fatal(err) + } + + pubKey := clientKey{Curve25519: base64Encode(pub[:])} + clientName := "test-client-" + base64Encode(randomSecretKey()[:8]) + + client, err := RegisterClient(token, clientName, pubKey, apiURL) + + if err != nil { + t.Fatal(err) + } + + if clientName != client.Name { + t.Errorf("Client name does not match: %s != %s", clientName, client.Name) + } + + if pubKey.Curve25519 != client.PublicKey.Curve25519 { + t.Errorf("Client keys do not match: %s != %s", pubKey.Curve25519, client.PublicKey.Curve25519) + } + + if client.ClientID == "" { + t.Error("Client ID is not set") + } + + if client.ApiKeyID == "" { + t.Error("API Key ID is not set") + } + + if client.ApiSecret == "" { + t.Error("API Secret is not set") + } +} + func TestGetClientInfo(t *testing.T) { info, err := client.GetClientInfo(context.Background(), client.Options.ClientID) if err != nil { From ff94541a6790c34326e925e72d2d8a3622348731 Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Tue, 22 Aug 2017 16:39:27 -0700 Subject: [PATCH 2/5] Add Registration to the CLI Permit new clients to register by passing an account token to the CLI Fixes E3DB-665 --- client.go | 16 ++++---- client_integration_test.go | 6 +-- cmd/e3db/main.go | 77 ++++++++++++++++++++++++++++++++++++++ crypto.go | 24 ++++++------ 4 files changed, 100 insertions(+), 23 deletions(-) diff --git a/client.go b/client.go index 71c564f..fc96292 100644 --- a/client.go +++ b/client.go @@ -40,8 +40,8 @@ type ClientOpts struct { ClientEmail string APIKeyID string APISecret string - PublicKey publicKey - PrivateKey privateKey + PublicKey PublicKey + PrivateKey PrivateKey APIBaseURL string Logging bool } @@ -55,7 +55,7 @@ type Client struct { akCache map[akCacheKey]secretKey } -type clientKey struct { +type ClientKey struct { Curve25519 string `json:"curve25519"` } @@ -63,7 +63,7 @@ type clientKey struct { // about a client. type ClientInfo struct { ClientID string `json:"client_id"` - PublicKey clientKey `json:"public_key"` + PublicKey ClientKey `json:"public_key"` Validated bool `json:"validated"` } @@ -72,13 +72,13 @@ type ClientDetails struct { ClientID string `json:"client_id"` ApiKeyID string `json:"api_key_id"` ApiSecret string `json:"api_secret"` - PublicKey clientKey `json:"public_key"` + PublicKey ClientKey `json:"public_key"` Name string `json:"name"` } type clientRegistrationInfo struct { Name string `json:"name"` - PublicKey clientKey `json:"public_key"` + PublicKey ClientKey `json:"public_key"` } type clientRegistrationRequest struct { @@ -152,7 +152,7 @@ func GetClient(opts ClientOpts) (*Client, error) { } // RegisterClient creates a new client for a given InnoVault account -func RegisterClient(registrationToken string, clientName string, publicKey clientKey, apiURL string) (*ClientDetails, error) { +func RegisterClient(registrationToken string, clientName string, publicKey ClientKey, apiURL string) (*ClientDetails, error) { if apiURL == "" { apiURL = defaultStorageURL } @@ -304,7 +304,7 @@ func (c *Client) GetClientInfo(ctx context.Context, clientID string) (*ClientInf // getClientKey queries the E3DB server for a client's public key // given its client UUID. (This was exported in the Java SDK but // I'm not sure why since it's rather low level.) -func (c *Client) getClientKey(ctx context.Context, clientID string) (publicKey, error) { +func (c *Client) getClientKey(ctx context.Context, clientID string) (PublicKey, error) { info, err := c.GetClientInfo(ctx, clientID) if err != nil { return nil, err diff --git a/client_integration_test.go b/client_integration_test.go index 831a634..53c5770 100644 --- a/client_integration_test.go +++ b/client_integration_test.go @@ -47,13 +47,13 @@ func setup() { if err != nil { dieErr(err) } - pubKey := clientKey{Curve25519: base64Encode(pub[:])} + pubKey := ClientKey{Curve25519: base64Encode(pub[:])} pub2, priv2, err := generateKeyPair() if err != nil { dieErr(err) } - pubKey2 := clientKey{Curve25519: base64Encode(pub2[:])} + pubKey2 := ClientKey{Curve25519: base64Encode(pub2[:])} clientDetails, err := RegisterClient(token, clientName, pubKey, apiURL) if err != nil { @@ -115,7 +115,7 @@ func TestRegistration(t *testing.T) { t.Fatal(err) } - pubKey := clientKey{Curve25519: base64Encode(pub[:])} + pubKey := ClientKey{Curve25519: base64Encode(pub[:])} clientName := "test-client-" + base64Encode(randomSecretKey()[:8]) client, err := RegisterClient(token, clientName, pubKey, apiURL) diff --git a/cmd/e3db/main.go b/cmd/e3db/main.go index e112868..457f468 100644 --- a/cmd/e3db/main.go +++ b/cmd/e3db/main.go @@ -11,6 +11,7 @@ import ( "bufio" "bytes" "context" + "crypto/rand" "encoding/base64" "encoding/json" "errors" @@ -23,6 +24,8 @@ import ( "strconv" "strings" + "golang.org/x/crypto/nacl/box" + "github.com/jawher/mow.cli" "github.com/tozny/e3db-go" ) @@ -590,6 +593,79 @@ func cmdPolicyIncoming(cmd *cli.Cmd) { } } +func cmdRegister(cmd *cli.Cmd) { + apiBaseURL := cmd.String(cli.StringOpt{ + Name: "api", + Desc: "e3db api base url", + Value: "", + HideValue: true, + }) + + email := cmd.String(cli.StringArg{ + Name: "EMAIL", + Desc: "client e-mail address", + Value: "", + HideValue: true, + }) + + token := cmd.String(cli.StringArg{ + Name: "TOKEN", + Desc: "registration token from the InnoVault admin console", + Value: "", + HideValue: false, + }) + + cmd.Action = func() { + // Preflight check for existing configuration file to prevent a later + // failure writing the file (since we use O_EXCL) after registration. + if e3db.ProfileExists(*options.Profile) { + var name string + if *options.Profile != "" { + name = *options.Profile + } else { + name = "(default)" + } + + dieFmt("register: profile %s already registered", name) + } + + // minimally validate that email looks like an email address + _, err := mail.ParseAddress(*email) + if err != nil { + dieErr(err) + } + + pub, priv, err := box.GenerateKey(rand.Reader) + if err != nil { + dieErr(err) + } + + publicKey := e3db.ClientKey{Curve25519: base64.RawURLEncoding.EncodeToString(pub[:])} + + details, err := e3db.RegisterClient(*token, *email, publicKey, *apiBaseURL) + + if err != nil { + dieErr(err) + } + + info := &e3db.ClientOpts{ + ClientID: details.ClientID, + ClientEmail: details.Name, + APIKeyID: details.ApiKeyID, + APISecret: details.ApiSecret, + PublicKey: pub, + PrivateKey: priv, + APIBaseURL: *apiBaseURL, + Logging: false, + } + + err = e3db.SaveConfig(*options.Profile, info) + if err != nil { + dieErr(err) + } + } +} + func main() { app := cli.App("e3db-cli", "E3DB Command Line Interface") @@ -598,6 +674,7 @@ func main() { options.Logging = app.BoolOpt("d debug", false, "enable debug logging") options.Profile = app.StringOpt("p profile", "", "e3db configuration profile") + app.Command("register", "register a client", cmdRegister) app.Command("info", "get client information", cmdInfo) app.Command("ls", "list records", cmdList) app.Command("write", "write a record", cmdWrite) diff --git a/crypto.go b/crypto.go index 24a5f79..0b534b9 100644 --- a/crypto.go +++ b/crypto.go @@ -123,10 +123,10 @@ func secretBoxDecryptFromBase64(ciphertext, nonce string, key secretKey) ([]byte const publicKeySize = 32 const privateKeySize = 32 -type publicKey *[publicKeySize]byte -type privateKey *[privateKeySize]byte +type PublicKey *[publicKeySize]byte +type PrivateKey *[privateKeySize]byte -func generateKeyPair() (publicKey, privateKey, error) { +func generateKeyPair() (PublicKey, PrivateKey, error) { pub, priv, err := box.GenerateKey(rand.Reader) if err != nil { return nil, nil, err @@ -135,7 +135,7 @@ func generateKeyPair() (publicKey, privateKey, error) { return pub, priv, nil } -func makePublicKey(b []byte) publicKey { +func makePublicKey(b []byte) PublicKey { key := [publicKeySize]byte{} copy(key[:], b) return &key @@ -144,7 +144,7 @@ func makePublicKey(b []byte) publicKey { // decodePublicKey decodes a public key from a Base64URL encoded // string containing a 256-bit Curve25519 public key, returning an // error if the decode operation fails. -func decodePublicKey(s string) (publicKey, error) { +func decodePublicKey(s string) (PublicKey, error) { bytes, err := base64Decode(s) if err != nil { return nil, err @@ -153,11 +153,11 @@ func decodePublicKey(s string) (publicKey, error) { return makePublicKey(bytes), nil } -func encodePublicKey(k publicKey) string { +func encodePublicKey(k PublicKey) string { return base64Encode(k[:]) } -func makePrivateKey(b []byte) privateKey { +func makePrivateKey(b []byte) PrivateKey { key := [privateKeySize]byte{} copy(key[:], b) return &key @@ -166,7 +166,7 @@ func makePrivateKey(b []byte) privateKey { // decodePrivateKey decodes a private key from a Base64URL encoded // string containing a 256-bit Curve25519 private key, returning an // error if the decode operation fails. -func decodePrivateKey(s string) (privateKey, error) { +func decodePrivateKey(s string) (PrivateKey, error) { bytes, err := base64Decode(s) if err != nil { return nil, err @@ -175,17 +175,17 @@ func decodePrivateKey(s string) (privateKey, error) { return makePrivateKey(bytes), nil } -func encodePrivateKey(k privateKey) string { +func encodePrivateKey(k PrivateKey) string { return base64Encode(k[:]) } -func boxEncryptToBase64(data []byte, pubKey publicKey, privKey privateKey) (string, string) { +func boxEncryptToBase64(data []byte, pubKey PublicKey, privKey PrivateKey) (string, string) { n := randomNonce() ciphertext := box.Seal(nil, data, n, pubKey, privKey) return base64Encode(ciphertext), base64Encode(n[:]) } -func boxDecryptFromBase64(ciphertext, nonce string, pubKey publicKey, privKey privateKey) ([]byte, error) { +func boxDecryptFromBase64(ciphertext, nonce string, pubKey PublicKey, privKey PrivateKey) ([]byte, error) { ciphertextBytes, err := base64Decode(ciphertext) if err != nil { return nil, err @@ -225,7 +225,7 @@ func (c *Client) putAKCache(akID akCacheKey, k secretKey) { type getEAKResponse struct { EAK string `json:"eak"` AuthorizerID string `json:"authorizer_id"` - AuthorizerPublicKey clientKey `json:"authorizer_public_key"` + AuthorizerPublicKey ClientKey `json:"authorizer_public_key"` } type putEAKRequest struct { From f165a535770df3f949408cdc2beacdbcc46034d7 Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Thu, 24 Aug 2017 13:32:27 -0700 Subject: [PATCH 3/5] Export GenerateKeyPair for public usage --- client_integration_test.go | 6 +++--- crypto.go | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/client_integration_test.go b/client_integration_test.go index 53c5770..c090233 100644 --- a/client_integration_test.go +++ b/client_integration_test.go @@ -43,13 +43,13 @@ func setup() { clientName := "test-client-" + base64Encode(randomSecretKey()[:8]) shareClientName := "share-client-" + base64Encode(randomSecretKey()[:8]) - pub, priv, err := generateKeyPair() + pub, priv, err := GenerateKeyPair() if err != nil { dieErr(err) } pubKey := ClientKey{Curve25519: base64Encode(pub[:])} - pub2, priv2, err := generateKeyPair() + pub2, priv2, err := GenerateKeyPair() if err != nil { dieErr(err) } @@ -110,7 +110,7 @@ func TestRegistration(t *testing.T) { apiURL := os.Getenv("API_URL") token := os.Getenv("REGISTRATION_TOKEN") - pub, _, err := generateKeyPair() + pub, _, err := GenerateKeyPair() if err != nil { t.Fatal(err) } diff --git a/crypto.go b/crypto.go index 0b534b9..1e2531c 100644 --- a/crypto.go +++ b/crypto.go @@ -126,7 +126,8 @@ const privateKeySize = 32 type PublicKey *[publicKeySize]byte type PrivateKey *[privateKeySize]byte -func generateKeyPair() (PublicKey, PrivateKey, error) { +// GenerateKeyPair creates a new Curve25519 keypair for cryptographic operations +func GenerateKeyPair() (PublicKey, PrivateKey, error) { pub, priv, err := box.GenerateKey(rand.Reader) if err != nil { return nil, nil, err From 0d4e34c8439c381dd39bb310067a479c8d76f782 Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Thu, 24 Aug 2017 14:07:58 -0700 Subject: [PATCH 4/5] Test reads/writes without the AK cache --- client_integration_test.go | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/client_integration_test.go b/client_integration_test.go index c090233..9e2b543 100644 --- a/client_integration_test.go +++ b/client_integration_test.go @@ -21,6 +21,7 @@ import ( ) var client *Client +var altClient *Client var clientSharedWithID string // TestMain bootstraps the environment and sets up our client instance. @@ -80,6 +81,10 @@ func setup() { if err != nil { dieErr(err) } + altClient, err = GetClient(*opts) + if err != nil { + dieErr(err) + } // Load another client for later sharing tests opts = &ClientOpts{ @@ -196,6 +201,37 @@ func TestWriteRead(t *testing.T) { } } +func TestWriteReadNoCache(t *testing.T) { + data := make(map[string]string) + data["message"] = "Hello, world!" + rec1, err := client.Write(context.Background(), "test-data", data, nil) + if err != nil { + t.Fatal(err) + } + recordID := rec1.Meta.RecordID + + rec2, err := altClient.Read(context.Background(), recordID) + if err != nil { + t.Fatal(err) + } + + if rec1.Meta.WriterID != rec2.Meta.WriterID { + t.Errorf("Writer IDs don't match: %s != %s", rec1.Meta.WriterID, rec2.Meta.WriterID) + } + + if rec1.Meta.UserID != rec2.Meta.UserID { + t.Errorf("User IDs don't match: %s != %s", rec1.Meta.UserID, rec2.Meta.UserID) + } + + if rec1.Meta.Type != rec2.Meta.Type { + t.Errorf("Record types don't match: %s != %s", rec1.Meta.Type, rec2.Meta.Type) + } + + if rec1.Data["message"] != rec2.Data["message"] { + t.Errorf("Record field doesn't match: %s != %s", rec1.Data["message"], rec2.Data["message"]) + } +} + // TestWriteThenDelete should delete a record func TestWriteThenDelete(t *testing.T) { data := make(map[string]string) From 722cad23b591dcfb9b20df5ae9b55189350aa927 Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Thu, 24 Aug 2017 14:17:50 -0700 Subject: [PATCH 5/5] Introduce tests on the config routines --- client_integration_test.go | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/client_integration_test.go b/client_integration_test.go index 9e2b543..c65f435 100644 --- a/client_integration_test.go +++ b/client_integration_test.go @@ -21,6 +21,7 @@ import ( ) var client *Client +var clientOpts *ClientOpts var altClient *Client var clientSharedWithID string @@ -66,7 +67,7 @@ func setup() { dieErr(err) } - opts := &ClientOpts{ + clientOpts = &ClientOpts{ ClientID: clientDetails.ClientID, ClientEmail: "", APIKeyID: clientDetails.ApiKeyID, @@ -77,17 +78,17 @@ func setup() { Logging: false, } - client, err = GetClient(*opts) + client, err = GetClient(*clientOpts) if err != nil { dieErr(err) } - altClient, err = GetClient(*opts) + altClient, err = GetClient(*clientOpts) if err != nil { dieErr(err) } // Load another client for later sharing tests - opts = &ClientOpts{ + opts := &ClientOpts{ ClientID: shareClientDetails.ClientID, ClientEmail: "", APIKeyID: shareClientDetails.ApiKeyID, @@ -150,6 +151,28 @@ func TestRegistration(t *testing.T) { } } +func TestConfig(t *testing.T) { + profile := "p_" + base64Encode(randomSecretKey()[:8]) + + if ProfileExists(profile) { + t.Error("Profile already exists") + } + + err := SaveConfig(profile, clientOpts) + if err != nil { + t.Error("Unable to save profile") + } + + newOpts, err := GetConfig(profile) + if err != nil { + t.Error("Unable to re-read profile") + } + + if newOpts.ClientID != clientOpts.ClientID { + t.Error("Invalid profile retrieved") + } +} + func TestGetClientInfo(t *testing.T) { info, err := client.GetClientInfo(context.Background(), client.Options.ClientID) if err != nil {