Skip to content

Commit

Permalink
Merge pull request #130 from replicatedhq/laverya/sign-enterprise-req…
Browse files Browse the repository at this point in the history
…uests

sign enterprise requests, and use ecdsa keys instead
  • Loading branch information
laverya authored Apr 30, 2020
2 parents f3432a8 + df0cbb3 commit b4067de
Show file tree
Hide file tree
Showing 6 changed files with 161 additions and 58 deletions.
2 changes: 1 addition & 1 deletion cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const (

var appSlugOrID string
var apiToken string
var enterprisePrivateKeyPath = filepath.Join(homeDir(), ".replicated", "enterprise", "key")
var enterprisePrivateKeyPath = filepath.Join(homeDir(), ".replicated", "enterprise", "ecdsa")
var platformOrigin = "https://api.replicated.com/vendor"
var graphqlOrigin = "https://g.replicated.com/graphql"
var kurlDotSHOrigin = "https://kurl.sh"
Expand Down
1 change: 1 addition & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMx
github.com/yosssi/ace v0.0.5 h1:tUkIP/BLdKqrlrPwcmH0shwEEhTRHoGnc1wFIWmaBUA=
github.com/yosssi/ace v0.0.5/go.mod h1:ALfIzm2vT7t5ZE7uoIZqF3TQ7SAOyupFZnkrF5id+K0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59 h1:3zb4D3T4G8jdExgVU/95+vQXfpEPiMdCaZgmGVxjNHM=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20170627222143-455220fa52c8/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ=
Expand Down
92 changes: 68 additions & 24 deletions pkg/enterpriseclient/auth.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
package enterpriseclient

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha512"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"io/ioutil"
"math/big"
"os"
"path/filepath"

"github.com/pkg/errors"
"golang.org/x/crypto/ssh"
)

func (c HTTPClient) AuthInit(organizationName string) error {
Expand All @@ -24,8 +29,8 @@ func (c HTTPClient) AuthInit(organizationName string) error {
return errors.Wrap(err, "failed to mkdir")
}
}
pubKeyPath := filepath.Join(homeDir(), ".replicated", "enterprise", "key.pub")
privKeyPath := filepath.Join(homeDir(), ".replicated", "enterprise", "key")
pubKeyPath := filepath.Join(homeDir(), ".replicated", "enterprise", "ecdsa.pub")
privKeyPath := filepath.Join(homeDir(), ".replicated", "enterprise", "ecdsa")

_, pubKeyErr := os.Stat(pubKeyPath)
_, privKeyErr := os.Stat(privKeyPath)
Expand All @@ -52,7 +57,7 @@ func (c HTTPClient) AuthInit(organizationName string) error {
return errors.Wrap(err, "failed to write private key to file")
}

if err := ioutil.WriteFile(pubKeyPath, encodePublicKeyToPEM(&privateKey.PublicKey), 0600); err != nil {
if err := ioutil.WriteFile(pubKeyPath, encodePublicKey(&privateKey.PublicKey), 0600); err != nil {
return errors.Wrap(err, "failed to write public key to file")
}

Expand All @@ -64,7 +69,7 @@ func (c HTTPClient) AuthInit(organizationName string) error {
OrganizationName string `json:"organizationName"`
}
createOrgRequest := CreateOrgRequest{
PublicKeyBytes: encodePublicKeyToPEM(&privateKey.PublicKey),
PublicKeyBytes: encodePublicKey(&privateKey.PublicKey),
OrganizationName: organizationName,
}

Expand All @@ -86,7 +91,7 @@ func (c HTTPClient) AuthInit(organizationName string) error {
PublicKeyBytes []byte `json:"publicKey"`
}
authRequest := AuthRequest{
PublicKeyBytes: encodePublicKeyToPEM(&privateKey.PublicKey),
PublicKeyBytes: encodePublicKey(&privateKey.PublicKey),
}

type AuthInitResponse struct {
Expand Down Expand Up @@ -122,25 +127,23 @@ func (c HTTPClient) AuthApprove(fingerprint string) error {
return nil
}

func generatePrivateKey() (*rsa.PrivateKey, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, 4096)
func generatePrivateKey() (*ecdsa.PrivateKey, error) {
privateKey, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader)
if err != nil {
return nil, errors.Wrap(err, "failed to generate key")
}

err = privateKey.Validate()
if err != nil {
return nil, errors.Wrap(err, "failed to validate new key")
}

return privateKey, nil
}

func encodePrivateKeyToPEM(privateKey *rsa.PrivateKey) []byte {
privDER := x509.MarshalPKCS1PrivateKey(privateKey)
func encodePrivateKeyToPEM(privateKey *ecdsa.PrivateKey) []byte {
privDER, err := x509.MarshalECPrivateKey(privateKey)
if err != nil {
panic(err) // this should never happen - if it does, that means things are rather broken
}

privBlock := pem.Block{
Type: "RSA PRIVATE KEY",
Type: "EC PRIVATE KEY",
Headers: nil,
Bytes: privDER,
}
Expand All @@ -150,18 +153,35 @@ func encodePrivateKeyToPEM(privateKey *rsa.PrivateKey) []byte {
return privatePEM
}

func encodePublicKeyToPEM(publicKey *rsa.PublicKey) []byte {
pubDER := x509.MarshalPKCS1PublicKey(publicKey)
func decodePrivateKeyPEM(privateBytes []byte) (*ecdsa.PrivateKey, error) {
privBlock, _ := pem.Decode(privateBytes)

pubBlock := pem.Block{
Type: "RSA PUBLIC KEY",
Headers: nil,
Bytes: pubDER,
if privBlock.Type != "EC PRIVATE KEY" {
return nil, fmt.Errorf("private key type is %s, not 'EC PRIVATE KEY'", privBlock.Type)
}

pubPEM := pem.EncodeToMemory(&pubBlock)
key, err := x509.ParseECPrivateKey(privBlock.Bytes)
if err != nil {
return nil, errors.Wrap(err, "decode ec private key")
}
return key, nil
}

func encodePublicKey(publicKey *ecdsa.PublicKey) []byte {
pubKey, err := ssh.NewPublicKey(publicKey)
if err != nil {
panic(errors.Wrap(err, "create ssh pubkey")) // this should never happen - if it does, that means things are rather broken
}

return pubPEM
return pubKey.Marshal()
}

func getFingerprint(publicKey *ecdsa.PublicKey) (string, error) {
pubKey, err := ssh.NewPublicKey(publicKey)
if err != nil {
return "", errors.Wrap(err, "create ssh pubkey")
}
return ssh.FingerprintSHA256(pubKey), nil
}

func homeDir() string {
Expand All @@ -170,3 +190,27 @@ func homeDir() string {
}
return os.Getenv("USERPROFILE")
}

// gets the (base64 encoded) signature and fingerprint for a given key and data
func sigAndFingerprint(privateKey *ecdsa.PrivateKey, data []byte) (string, string, error) {
// hash the body and sign the hash
// store the signature in an ecSig struct and marshal it with the ssh wire format
contentSha := sha512.Sum512(data)
var ecSig struct {
R *big.Int
S *big.Int
}
var err error
ecSig.R, ecSig.S, err = ecdsa.Sign(rand.Reader, privateKey, contentSha[:])
if err != nil {
return "", "", errors.Wrap(err, "failed to sign content sha")
}
signatureString := base64.StdEncoding.EncodeToString(ssh.Marshal(ecSig))

// include the public key fingerprint as a hint to the server
fingerprint, err := getFingerprint(&privateKey.PublicKey)
if err != nil {
return "", "", errors.Wrap(err, "failed to get public key fingerprint")
}
return signatureString, fingerprint, nil
}
53 changes: 53 additions & 0 deletions pkg/enterpriseclient/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package enterpriseclient

import (
"encoding/base64"
"strings"
"testing"

"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)

func Test_sigAndFingerprint(t *testing.T) {
req := require.New(t)

var testData string // just needs to be of sufficient length
for i := 0; i < 100; i++ {
testData = testData + "abcdefghijklmnopqurstuvwxyz123456789"
}

// make a new private key
privateKey, err := generatePrivateKey()
req.NoError(err)

// encode that private key to bytes
privateKeyBytes := encodePrivateKeyToPEM(privateKey)
// decode private key bytes
privateKey, err = decodePrivateKeyPEM(privateKeyBytes)
req.NoError(err)

sig, fingerprint, err := sigAndFingerprint(privateKey, []byte(testData))
req.NoError(err)

pubKey := encodePublicKey(&privateKey.PublicKey)

// everything past this depends only on testData, sig, fingerprint, pubKey and req
// NOT privateKey

sigBytes, err := base64.StdEncoding.DecodeString(sig)
req.NoError(err)

parsedPubKey, err := ssh.ParsePublicKey(pubKey)
req.NoError(err)

req.True(strings.HasPrefix(parsedPubKey.Type(), "ecdsa-sha2-"), parsedPubKey.Type())

err = parsedPubKey.Verify([]byte(testData), &ssh.Signature{
Format: parsedPubKey.Type(),
Blob: sigBytes,
})
req.NoError(err)

req.Equal(fingerprint, ssh.FingerprintSHA256(parsedPubKey))
}
55 changes: 22 additions & 33 deletions pkg/enterpriseclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,8 @@ package enterpriseclient

import (
"bytes"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"crypto/ecdsa"
"encoding/json"
"encoding/pem"
"fmt"
"io/ioutil"
"net/http"
Expand All @@ -18,8 +15,8 @@ const apiOrigin = "https://api.replicated.com/enterprise"

// An HTTPClient communicates with the Replicated Enterprise HTTP API.
type HTTPClient struct {
privateKeyContents []byte
apiOrigin string
privateKey *ecdsa.PrivateKey
apiOrigin string
}

// New returns a new HTTP client.
Expand All @@ -29,50 +26,42 @@ func New(privateKeyContents []byte) *HTTPClient {

func NewHTTPClient(origin string, privateKeyContents []byte) *HTTPClient {
c := &HTTPClient{
privateKeyContents: privateKeyContents,
apiOrigin: origin,
apiOrigin: origin,
}
if privateKeyContents != nil {
privateKey, err := decodePrivateKeyPEM(privateKeyContents)
if err != nil {
privateKey = nil
}
c.privateKey = privateKey
}

return c
}

func (c *HTTPClient) doJSON(method, path string, successStatus int, reqBody interface{}, respBody interface{}) error {
endpoint := fmt.Sprintf("%s%s", c.apiOrigin, path)
var buf bytes.Buffer
var bodyBytes []byte
if reqBody != nil {
if err := json.NewEncoder(&buf).Encode(reqBody); err != nil {
var err error
bodyBytes, err = json.Marshal(reqBody)
if err != nil {
return err
}
}

req, err := http.NewRequest(method, endpoint, &buf)
req, err := http.NewRequest(method, endpoint, bytes.NewBuffer(bodyBytes))
if err != nil {
return err
}

if c.privateKeyContents != nil {
// get the private key id as a hint to the server
var parsedKey interface{}
decodedPEM, _ := pem.Decode(c.privateKeyContents)
if parsedKey, err = x509.ParsePKCS1PrivateKey(decodedPEM.Bytes); err != nil {
return errors.Wrap(err, "failed to parse private key")
}

privateKey, ok := parsedKey.(*rsa.PrivateKey)
if !ok {
return errors.New("failed to cast key")
}

// the key id is the sha256 sum of the public key
pubDER := x509.MarshalPKCS1PublicKey(&privateKey.PublicKey)
pubBlock := pem.Block{
Type: "RSA PUBLIC KEY",
Headers: nil,
Bytes: pubDER,
if c.privateKey != nil {
sig, fingerprint, err := sigAndFingerprint(c.privateKey, bodyBytes)
if err != nil {
return err
}
pubPEM := pem.EncodeToMemory(&pubBlock)

req.Header.Set("Authorization", fmt.Sprintf("%x", sha256.Sum256(pubPEM)))
req.Header.Set("Signature", sig)
req.Header.Set("Authorization", fingerprint)
}

req.Header.Set("Content-Type", "application/json")
Expand Down
16 changes: 16 additions & 0 deletions pkg/enterpriseclient/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package enterpriseclient

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestNewNilHTTPClient(t *testing.T) {
req := require.New(t)
client := NewHTTPClient("origin", nil)
req.Equal(&HTTPClient{
privateKey: nil,
apiOrigin: "origin",
}, client)
}

0 comments on commit b4067de

Please sign in to comment.