diff --git a/Makefile b/Makefile index 7b94467..f33a0bf 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ local-release: test: build/turnkey go test ./... -build/turnkey: +build/turnkey: main.go internal/ go build -o build/turnkey main.go .PHONY: clean diff --git a/README.md b/README.md index b6b0f19..faf6221 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ To release: # Generate a Github token with "write:packages" # ==> https://github.com/settings/tokens $ export GITHUB_TOKEN= -$ git tag -a vx.y.z -m "New Release: x.y.z" +$ git tag -a vx.y.z -m "New release: x.y.z" $ git push origin vx.y.z $ goreleaser release --rm-dist ``` @@ -38,19 +38,20 @@ brew install turnkey Create a new API key: ```sh -turnkey gen --name rno -Creating /Users/rno/.tk/rno.public -Creating /Users/rno/.tk/rno.private +$ turnkey gen --name my-test-key +{ + "privateKeyFile": "/Users/rno/.config/turnkey/keys/my-test-key.private", + "publicKeyFile": "/Users/rno/.config/turnkey/keys/my-test-key.public" +} ``` Sign a request: ```sh -turnkey approve-request --method POST --path /api/v1/sign --body '{"payload": "hello from TKHQ"}' --key=rno -Raw signature: 3046022100a99781a6b1d7ff7c4ce3951ded09a7757c74f1c6d7c7e1a2e617ac2921d74674022100f75d167abe426eb8f89884afe5e864cb965c6370611566f50b46690209b3a95b -Approval header: X-Approved-By-035acbc8b7751b7703736ae16cb22112451372f7b77717bbecdfa8300d4038432: 3046022100a99781a6b1d7ff7c4ce3951ded09a7757c74f1c6d7c7e1a2e617ac2921d74674022100f75d167abe426eb8f89884afe5e864cb965c6370611566f50b46690209b3a95b --------- -To make this request with curl: - curl -X POST -d {"payload": "hello from TKHQ"} -H'X-Approved-By-035acbc8b7751b7703736ae16cb22112451372f7b77717bbecdfa8300d4038432: 3046022100a99781a6b1d7ff7c4ce3951ded09a7757c74f1c6d7c7e1a2e617ac2921d74674022100f75d167abe426eb8f89884afe5e864cb965c6370611566f50b46690209b3a95b' -v 'https://api.turnkey.io/api/v1/sign' --------- +$ turnkey approve-request --host api.turnkey.io --path /api/v1/sign --body '{"payload": "hello from TKHQ"}' --key=my-test-key +{ + "curlCommand": "curl -X POST -d'{\"payload\": \"hello from TKHQ\"}' -H'X-Stamp: eyJwdWJsaWNLZXkiOiIwM2JmMTYyNTc2ZWI4ZGZlY2YzM2Q5Mjc1ZDA5NTk1Mjg0ZjZjNGRmMGRiNjE1NmMzYzU4Mjc3Nzg4NmEwZWUwYWMiLCJzaWduYXR1cmUiOiIzMDQ0MDIyMDZiMmRlYmIwYjA3YmYwMDJlMjI1ZmQ4NTgzZjZmNGUxNGE5YTUxYWRiYWJjNDAyYzY5YTZlN2Q4N2ViNWNjMDgwMjIwMjE0ZTdkMGJlODFjMGYyNDEyOWE0MmNkZGFlOTUxYTBmZTViMGM1Mzc3YjM2NzZiOTUyNDgyNmYwODdhMWU4ZiIsInNjaGVtZSI6IlNJR05BVFVSRV9TQ0hFTUVfVEtfQVBJX1AyNTYifQ' -v 'https://api.turnkey.io/api/v1/sign'", + "message": "{\"payload\": \"hello from TKHQ\"}", + "stamp": "eyJwdWJsaWNLZXkiOiIwM2JmMTYyNTc2ZWI4ZGZlY2YzM2Q5Mjc1ZDA5NTk1Mjg0ZjZjNGRmMGRiNjE1NmMzYzU4Mjc3Nzg4NmEwZWUwYWMiLCJzaWduYXR1cmUiOiIzMDQ0MDIyMDZiMmRlYmIwYjA3YmYwMDJlMjI1ZmQ4NTgzZjZmNGUxNGE5YTUxYWRiYWJjNDAyYzY5YTZlN2Q4N2ViNWNjMDgwMjIwMjE0ZTdkMGJlODFjMGYyNDEyOWE0MmNkZGFlOTUxYTBmZTViMGM1Mzc3YjM2NzZiOTUyNDgyNmYwODdhMWU4ZiIsInNjaGVtZSI6IlNJR05BVFVSRV9TQ0hFTUVfVEtfQVBJX1AyNTYifQ" +} ``` diff --git a/fixtures/testkey.public b/fixtures/testkey.public index a312068..a0d11cb 100755 --- a/fixtures/testkey.public +++ b/fixtures/testkey.public @@ -1 +1 @@ -035acbc8b7751b7703736ae16cb22112451372f7b77717bbecdfa8300d4038432 \ No newline at end of file +0305acbc8b7751b7703736ae16cb22112451372f7b77717bbecdfa8300d4038432 \ No newline at end of file diff --git a/internal/apikey/apikey.go b/internal/apikey/apikey.go index 1cf1c60..0065bb2 100644 --- a/internal/apikey/apikey.go +++ b/internal/apikey/apikey.go @@ -5,9 +5,13 @@ import ( "crypto/elliptic" "crypto/rand" "crypto/sha256" + "encoding/base64" "encoding/hex" + "encoding/json" "fmt" "math/big" + + "github.com/pkg/errors" ) // Struct to hold both serialized and ecdsa lib friendly version of a public/private key pair @@ -19,6 +23,17 @@ type ApiKey struct { publicKey *ecdsa.PublicKey } +const TURNKEY_API_SIGNATURE_SCHEME = "SIGNATURE_SCHEME_TK_API_P256" + +type ApiStamp struct { + // API public key, hex-encoded + PublicKey string `json:"publicKey"` + // P-256 signature bytes, hex-coded + Signature string `json:"signature"` + // Signature scheme. Must be set to "SIGNATURE_SCHEME_TK_API_P256" + Scheme string `json:"scheme"` +} + // Create a new Turnkey API key func NewTkApiKey() (*ApiKey, error) { privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) @@ -126,12 +141,26 @@ func DecodeTKPublicKey(encodedPublicKey string) (*ecdsa.PublicKey, error) { return publicKey, nil } -func Sign(message string, apiKey *ApiKey) (string, error) { +// / Takes a message and returns the proper API stamp +// / This value should be inserted in a "X-Stamp" header +func Stamp(message string, apiKey *ApiKey) (string, error) { hash := sha256.Sum256([]byte(message)) sigBytes, err := ecdsa.SignASN1(rand.Reader, apiKey.privateKey, hash[:]) if err != nil { return "", err } - return hex.EncodeToString(sigBytes), nil + sigHex := hex.EncodeToString(sigBytes) + + stamp := ApiStamp{ + PublicKey: apiKey.TkPublicKey, + Signature: sigHex, + Scheme: TURNKEY_API_SIGNATURE_SCHEME, + } + jsonStamp, err := json.Marshal(stamp) + if err != nil { + return "", errors.Wrap(err, "cannot marshall API stamp to JSON") + } + encodedStamp := base64.RawURLEncoding.EncodeToString(jsonStamp) + return encodedStamp, nil } diff --git a/internal/apikey/apikey_test.go b/internal/apikey/apikey_test.go index 8cd3c11..eb05a5e 100644 --- a/internal/apikey/apikey_test.go +++ b/internal/apikey/apikey_test.go @@ -3,7 +3,9 @@ package apikey_test import ( "crypto/ecdsa" "crypto/sha256" + "encoding/base64" "encoding/hex" + "encoding/json" "fmt" "testing" @@ -42,17 +44,26 @@ func Test_FromTkPrivateKey(t *testing.T) { func Test_Sign(t *testing.T) { tkPrivateKey := "487f361ddfd73440e707f4daa6775b376859e8a3c9f29b3bb694a12927c0213c" - tkPublicKey := "02f739f8c77b32f4d5f13265861febd76e7a9c61a1140d296b8c16302508870316" apiKey, err := apikey.FromTkPrivateKey(tkPrivateKey) assert.Nil(t, err) - sig, err := apikey.Sign("hello", apiKey) + stampHeader, err := apikey.Stamp("hello", apiKey) assert.Nil(t, err) - sigBytes, err := hex.DecodeString(sig) + + decodedHeaderBytes, err := base64.RawURLEncoding.DecodeString(stampHeader) + assert.Nil(t, err) + + var stamp *apikey.ApiStamp + err = json.Unmarshal(decodedHeaderBytes, &stamp) + assert.Nil(t, err) + assert.Equal(t, stamp.PublicKey, "02f739f8c77b32f4d5f13265861febd76e7a9c61a1140d296b8c16302508870316") + assert.Equal(t, stamp.Scheme, "SIGNATURE_SCHEME_TK_API_P256") + + sigBytes, err := hex.DecodeString(stamp.Signature) assert.Nil(t, err) - publicKey, err := apikey.DecodeTKPublicKey(tkPublicKey) + publicKey, err := apikey.DecodeTKPublicKey(stamp.PublicKey) assert.Nil(t, err) // Verify the soundness of the hash: diff --git a/internal/apikey/serialize.go b/internal/apikey/serialize.go deleted file mode 100644 index c614e12..0000000 --- a/internal/apikey/serialize.go +++ /dev/null @@ -1,5 +0,0 @@ -package apikey - -func SerializeRequest(method, host, path, body string) string { - return method + "\n" + host + "\n" + path + "\n" + body -} diff --git a/internal/flags/flags.go b/internal/flags/flags.go index db05db5..7f48d23 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -43,14 +43,6 @@ func Host() *cli.StringFlag { } } -func Method() *cli.StringFlag { - return &cli.StringFlag{ - Name: "method", - Usage: "HTTP Method. Should be \"GET\" or \"POST\"", - Required: true, - } -} - func Path() *cli.StringFlag { return &cli.StringFlag{ Name: "path", diff --git a/main.go b/main.go index 0251ec3..73f0172 100644 --- a/main.go +++ b/main.go @@ -90,13 +90,11 @@ func main() { UsageText: "generate an approval and make an HTTP(s) request", Flags: []cli.Flag{ flags.Host(), - flags.Method(), flags.Path(), flags.Body(), flags.Key(), }, Action: func(cCtx *cli.Context) error { - method := cCtx.String("method") host := cCtx.String("host") path := cCtx.String("path") body := cCtx.String("body") @@ -106,33 +104,21 @@ func main() { protocol = "http" } - signaturePayload := apikey.SerializeRequest(method, host, path, body) - key := cCtx.String("key") apiKey, err := clifs.GetApiKey(key) if err != nil { log.Fatalf("Unable to retrieve API key: %v", err) } - signature, err := apikey.Sign(signaturePayload, apiKey) + stamp, err := apikey.Stamp(body, apiKey) if err != nil { log.Fatalln(err) - return cli.Exit("Failed to produce a valid signature", 1) + return cli.Exit("Failed to produce a valid API stamp", 1) } - var response *http.Response - if method == "GET" { - response, err = get(apiKey, protocol, host, path, signature) - if err != nil { - log.Fatalln(err) - } - } else if method == "POST" { - response, err = post(apiKey, protocol, host, path, body, signature) - if err != nil { - log.Fatalln(err) - } - } else { - return cli.Exit("Invalid method", 1) + response, err := post(protocol, host, path, body, stamp) + if err != nil { + log.Fatalln(err) } displayResponse, err := display.DisplayResponse(response) @@ -150,37 +136,32 @@ func main() { Usage: "approve a request", UsageText: "generate an approval over an HTTP request", Flags: []cli.Flag{ + flags.Key(), flags.Host(), - flags.Method(), flags.Path(), flags.Body(), - flags.KeysFolder(), }, Action: func(cCtx *cli.Context) error { - method := cCtx.String("method") host := cCtx.String("host") path := cCtx.String("path") body := cCtx.String("body") - signaturePayload := apikey.SerializeRequest(method, host, path, body) - key := cCtx.String("key") apiKey, err := clifs.GetApiKey(key) if err != nil { log.Fatalf("Unable to retrieve API key: %v", err) } - signature, err := apikey.Sign(signaturePayload, apiKey) + stamp, err := apikey.Stamp(body, apiKey) if err != nil { log.Fatalln(err) - return cli.Exit("Failed to produce a valid signature", 1) + return cli.Exit("Failed to produce a valid stamp", 1) } jsonBytes, err := json.MarshalIndent(map[string]interface{}{ - "message": fmt.Sprintf("%q", signaturePayload), - "signature": signature, - "approvalHeader": approvalHeader(apiKey, signature), - "curlCommand": generateCurlCommand(apiKey, method, host, path, body, signature), + "message": body, + "stamp": stamp, + "curlCommand": generateCurlCommand(host, path, body, stamp), }, "", " ") if err != nil { log.Fatalf("Unable to serialize output to JSON: %v", err) @@ -191,9 +172,9 @@ func main() { }, }, { - Name: "sign", + Name: "stamp", Aliases: []string{"s"}, - Usage: "sign an arbitrary message", + Usage: "sign an arbitrary message and produce a valid API Stamp", Flags: []cli.Flag{ flags.Message(), flags.Key(), @@ -226,15 +207,15 @@ func main() { log.Fatalln(err) return cli.Exit("Could recover API key from private key file content", 1) } - signature, err := apikey.Sign(message, apiKey) + stamp, err := apikey.Stamp(message, apiKey) if err != nil { log.Fatalln(err) - return cli.Exit("Failed to produce a valid signature", 1) + return cli.Exit("Failed to produce a valid stamp", 1) } jsonBytes, err := json.MarshalIndent(map[string]interface{}{ - "message": fmt.Sprintf("%q", message), - "signature": signature, + "message": fmt.Sprintf("%q", message), + "stamp": stamp, }, "", " ") if err != nil { log.Fatalf("Unable to serialize output to JSON: %v", err) @@ -252,42 +233,22 @@ func main() { } } -func generateCurlCommand(apiKey *apikey.ApiKey, method, host, path, body, signature string) string { - if method == "POST" { - return fmt.Sprintf("curl -X POST -d'%s' -H'%s' -v 'https://%s%s'", body, approvalHeader(apiKey, signature), host, path) - } else { - return fmt.Sprintf("curl -H'%s' -v 'https://%s%s'", approvalHeader(apiKey, signature), host, path) - } -} - -func approvalHeader(apiKey *apikey.ApiKey, signature string) string { - return fmt.Sprintf("X-Approved-By-%s: %s", apiKey.TkPublicKey, signature) +func generateCurlCommand(host, path, body, stamp string) string { + return fmt.Sprintf("curl -X POST -d'%s' -H'%s' -v 'https://%s%s'", body, stampHeader(stamp), host, path) } -func get(key *apikey.ApiKey, protocol string, host string, path string, signature string) (*http.Response, error) { - url := fmt.Sprintf("%s://%s%s", protocol, host, path) - req, _ := http.NewRequest("GET", url, nil) - - headerName := fmt.Sprintf("X-Approved-By-%s", key.TkPublicKey) - req.Header.Set(headerName, signature) - - client := http.Client{} - response, err := client.Do(req) - if err != nil { - return nil, err - } - return response, nil +func stampHeader(stamp string) string { + return fmt.Sprintf("X-Stamp: %s", stamp) } -func post(key *apikey.ApiKey, protocol string, host string, path string, body string, signature string) (*http.Response, error) { +func post(protocol string, host string, path string, body string, stamp string) (*http.Response, error) { url := fmt.Sprintf("%s://%s%s", protocol, host, path) req, err := http.NewRequest("POST", url, strings.NewReader(body)) if err != nil { return nil, errors.Wrap(err, "error while creating HTTP POST request") } - headerName := fmt.Sprintf("X-Approved-By-%s", key.TkPublicKey) - req.Header.Set(headerName, signature) + req.Header.Set("X-Stamp", stamp) client := http.Client{} response, err := client.Do(req) if err != nil { diff --git a/main_test.go b/main_test.go index 2e33485..fd6da14 100644 --- a/main_test.go +++ b/main_test.go @@ -1,8 +1,10 @@ package main_test import ( + "encoding/base64" "encoding/hex" "encoding/json" + "fmt" "io/ioutil" "os" "os/exec" @@ -11,6 +13,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/tkhq/tkcli/internal/apikey" ) const TURNKEY_BINARY_NAME = "turnkey" @@ -60,20 +63,53 @@ func TestKeygenInTmpFolder(t *testing.T) { assert.Equal(t, parsedOut["privateKeyFile"], tmpDir+"/mykey.private") } -func TestSign(t *testing.T) { +func TestStamp(t *testing.T) { + out, err := RunCliWithArgs(t, []string{"stamp", "--key", "fixtures/testkey.private", "--message", "hello!"}) + assert.Nil(t, err) - out, err := RunCliWithArgs(t, []string{"sign", "--key", "fixtures/testkey.private", "--message", "hello!"}) + var parsedOut map[string]string + err = json.Unmarshal([]byte(out), &parsedOut) + assert.Nil(t, err) + stamp := parsedOut["stamp"] + + pubkeyBytes, err := os.ReadFile("fixtures/testkey.public") + assert.Nil(t, err) + ensureValidStamp(t, stamp, string(pubkeyBytes)) +} + +func TestApproveRequest(t *testing.T) { + out, err := RunCliWithArgs(t, []string{"approve-request", "--host", "api.turnkey.io", "--key", "fixtures/testkey.private", "--body", "{\"some\": \"field\"}", "--path", "/some/endpoint"}) assert.Nil(t, err) var parsedOut map[string]string err = json.Unmarshal([]byte(out), &parsedOut) assert.Nil(t, err) - signature := parsedOut["signature"] + + stamp := parsedOut["stamp"] + pubkeyBytes, err := os.ReadFile("fixtures/testkey.public") + assert.Nil(t, err) + ensureValidStamp(t, stamp, string(pubkeyBytes)) + + assert.Equal(t, "{\"some\": \"field\"}", parsedOut["message"]) + + assert.Contains(t, parsedOut["curlCommand"], "curl -X POST -d'{\"some\": \"field\"}'") + assert.Contains(t, parsedOut["curlCommand"], fmt.Sprintf("-H'X-Stamp: %s'", stamp)) + assert.Contains(t, parsedOut["curlCommand"], "https://api.turnkey.io/some/endpoint") +} + +func ensureValidStamp(t *testing.T, stamp string, expectedPublicKey string) { + stampBytes, err := base64.RawURLEncoding.DecodeString(stamp) + assert.Nil(t, err) + + var parsedStamp *apikey.ApiStamp + json.Unmarshal(stampBytes, &parsedStamp) + + assert.Equal(t, expectedPublicKey, parsedStamp.PublicKey) // All signatures start with 30.... - assert.True(t, strings.HasPrefix(signature, "30")) + assert.True(t, strings.HasPrefix(parsedStamp.Signature, "30")) - _, err = hex.DecodeString(signature) + _, err = hex.DecodeString(parsedStamp.Signature) // Ensure there is no issue decoding the signature as a hexadecimal string assert.Nil(t, err) }