Skip to content

Commit

Permalink
Merge pull request #42 from tkhq/olivia/enclave-encrypt
Browse files Browse the repository at this point in the history
Add enclave encrypt package and use go 1.21
  • Loading branch information
Olivia Thet authored Mar 11, 2024
2 parents 8991973 + 83c7615 commit 69b2ca0
Show file tree
Hide file tree
Showing 16 changed files with 938 additions and 35 deletions.
6 changes: 3 additions & 3 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ jobs:
- uses: actions/checkout@v2

- name: Set up Go
uses: actions/setup-go@v2
uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0
with:
go-version: 1.19
go-version: '1.21'

- name: Get
run: go get -v
Expand All @@ -25,7 +25,7 @@ jobs:
uses: golangci/[email protected]
with:
args: ./...
version: v1.53.2
version: v1.55.2

- name: Build
run: go build -v ./...
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/tkhq/go-sdk

go 1.20
go 1.21

require (
github.com/go-openapi/errors v0.20.4
Expand Down
6 changes: 6 additions & 0 deletions go.work
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
go 1.21.0

use (
.
./pkg/enclave_encrypt
)
8 changes: 8 additions & 0 deletions go.work.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
go.opentelemetry.io/otel/sdk v1.14.0/go.mod h1:bwIC5TjrNG6QDCHNWvW4HLHtUQ4I+VQDsnjhvyZCALM=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
28 changes: 14 additions & 14 deletions pkg/apikey/apikey_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"testing"

"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/tkhq/go-sdk/pkg/apikey"
)
Expand All @@ -33,7 +33,7 @@ func Test_FromTkPrivateKey(t *testing.T) {
// NIST CURVE: P-256
privateKeyFromOpenSSL := "487f361ddfd73440e707f4daa6775b376859e8a3c9f29b3bb694a12927c0213c"
apiKey, err := apikey.FromTurnkeyPrivateKey(privateKeyFromOpenSSL)
assert.Nil(t, err)
require.NoError(t, err)

// This value was computed based on an openssl-generated PEM file:
// $ openssl ec -in docs/fixtures/private_key.pem -pubout -conv_form compressed -outform der | tail -c 33 | xxd -p -c 33
Expand All @@ -48,32 +48,32 @@ func Test_Sign(t *testing.T) {
tkPrivateKey := "487f361ddfd73440e707f4daa6775b376859e8a3c9f29b3bb694a12927c0213c"

apiKey, err := apikey.FromTurnkeyPrivateKey(tkPrivateKey)
assert.Nil(t, err)
require.NoError(t, err)

stampHeader, err := apikey.Stamp([]byte("hello"), apiKey)
assert.Nil(t, err)
require.NoError(t, err)

testStamp, err := base64.RawURLEncoding.DecodeString(stampHeader)
assert.Nil(t, err)
require.NoError(t, err)

var stamp *apikey.APIStamp

assert.Nil(t, json.Unmarshal(testStamp, &stamp))
require.NoError(t, json.Unmarshal(testStamp, &stamp))

assert.Equal(t, stamp.PublicKey, "02f739f8c77b32f4d5f13265861febd76e7a9c61a1140d296b8c16302508870316")
assert.Equal(t, stamp.Scheme, "SIGNATURE_SCHEME_TK_API_P256")
assert.Equal(t, "02f739f8c77b32f4d5f13265861febd76e7a9c61a1140d296b8c16302508870316", stamp.PublicKey)
assert.Equal(t, "SIGNATURE_SCHEME_TK_API_P256", stamp.Scheme)

sigBytes, err := hex.DecodeString(stamp.Signature)
assert.Nil(t, err)
require.NoError(t, err)

publicKey, err := apikey.DecodeTurnkeyPublicKey(stamp.PublicKey)
assert.Nil(t, err)
require.NoError(t, err)

// Verify the soundness of the hash:
// $ echo -n 'hello' | shasum -a256
// 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 -
msgHash := sha256.Sum256([]byte("hello"))
assert.Equal(t, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", fmt.Sprintf("%x", msgHash))
assert.Equal(t, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", hex.EncodeToString(msgHash[:]))

// Finally, check the signature itself
verifiedSig := ecdsa.VerifyASN1(publicKey, msgHash[:], sigBytes)
Expand All @@ -83,9 +83,9 @@ func Test_Sign(t *testing.T) {
func Test_EncodedKeySizeIsFixed(t *testing.T) {
for i := 0; i < 1000; i++ {
apiKey, err := apikey.New(uuid.NewString())
assert.Nil(t, err)
require.NoError(t, err)

assert.Equal(t, 66, len(apiKey.TkPublicKey), "attempt %d: expected 66 characters for public key %s", i, apiKey.TkPublicKey)
assert.Equal(t, 64, len(apiKey.TkPrivateKey), "attempt %d: expected 64 characters for private key %s", i, apiKey.TkPrivateKey)
assert.Len(t, apiKey.TkPublicKey, 66, "attempt %d: expected 66 characters for public key %s", i, apiKey.TkPublicKey)
assert.Len(t, apiKey.TkPrivateKey, 64, "attempt %d: expected 64 characters for private key %s", i, apiKey.TkPrivateKey)
}
}
3 changes: 3 additions & 0 deletions pkg/enclave_encrypt/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.PHONY: test
test:
go test ./...
57 changes: 57 additions & 0 deletions pkg/enclave_encrypt/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Enclave One Shot Encryption

This package hosts a set of primitives for sending and encrypting message from a user to an enclave or vice versa.

N.B. Neither the client or the server should ever be reused to send/receive more then one message. We want to avoid the recipient target key being used more then once in order to improve forward secrecy; see [security profile](#security-profile) section for more details.

## Terms

- Encapsulated ("Encapped") Key - the public key of the sender used for ECDH.
- Target Key Pair - the key pair of the receiver that the sender encrypts to the public key of. Only one message should ever be encrypted to the public key.
- Server - a server inside of the enclave; normally an enclave application.
- Client - a client outside of the enclave; normally a turnkey end user.
- Enclave Auth Key Pair - a key pair derived from the quorum master seed specifically for the purpose of authentication with clients.

## Overview

This protocol builds on top of the HPKE standard ([RFC 9180](https://datatracker.ietf.org/doc/html/rfc9180)) by adding recipient pre-flight authentication so the client can verify it is sending ciphertext to a turnkey controlled enclave and the enclave can verify its sending ciphertext to the correct client. See the [security profile](#security-profile) section more details.

## HPKE Configuration

KEM: KEM_P256_HKDF_SHA256
KDF: KDF_HKDF_SHA256
AEAD: AEAD_AES256GCM
INFO: b"turnkey_hpke"
ADDITIONAL ASSOCIATED DATA: EncappedPublicKey||ReceiverPublicKey

## Protocol

### Server to Client

1. Client generates target pair and sends clientTargetPub key to server. The authenticity of the clientTargetPub is assumed to have been verified by the Ump policy engine.
1. Server computes ciphertext, serverEncappedPub = ENCRYPT(plaintext, clientTargetPub) and clears clientTargetPub from memory.
1. Server computes serverEncappedPub_sig_enclaveAuthPriv = SIGN(serverEncappedPub, enclaveAuthPriv).
1. Server sends (ciphertext, serverEncappedPub, serverEncappedPub_sig_enclaveAuthPriv) to client.
1. Client runs VERIFY(serverEncappedPub, serverEncappedPub_sig_enclaveAuthPriv).
1. Client recovers plaintext by computing DECRYPT(ciphertext, serverEncappedPub, clientTargetPriv) and the client target pair is cleared from memory. If the target pair is used multiple times we increase the count of messages that an attacker with the compromised target private key can decrypt.

Note there is no mechanism to prevent a faulty client from resubmitting the same target public key.

### Client to Server

1. Client sends request to server for target key.
1. Server generates server target pair and computes serverTargetPub_sig_enclaveAuthPriv = SIGN(serverTargetPub, enclaveAuthPriv).
1. Server sends (serverTargetPub, serverTargetPub_sig_enclaveAuthPriv) to client.
1. Client runs VERIFY(serverTargetPub, serverTargetPub_sig_enclaveAuthPriv).
1. Client computes ciphertext, clientEncappedPub = ENCRYPT(plaintext, serverTargetPub) and clears serverTargetPub from memory.
1. Client sends (ciphertext, clientEncappedPub) to server and the client is cleared from memory.
1. Server assumes the authenticity of clientEncappedPub has been verified by the Ump policy engine.
1. Server recovers plaintext by computing DECRYPT(ciphertext, clientEncappedPub, clientTargetPriv) and server target pair is cleared from memory. If the target pair is used multiple times we increase the count of messages that an attacker with the compromised target private key can decrypt.

## Security profile

- Receiver pre-flight authentication: we achieve recipient authentication for both the server and client:
- Client to Server: client verifies that the server's target key is signed by the enclaveAuth key.
- Server to Client: server relies on upstream checks by Ump + activity signing scheme to enforce rules that guarantee authenticity of the clients target key. Specifically, when the client "sends" clientTargetPub it actually submits a signed payload (activity), and that payload must be signed with an existing credential persisted in org data.
- Forward secrecy: the underlying HPKE spec does not provide forward secrecy on the recipient side since the target key can be long lived. To improve forward secrecy we specify that the a target key should only be used once by the sender and receiver.
- Sender authentication: we use OpMode Base and forgo authentication that the sender possessed a given KEM private key. In order for this to be taken advantage of, an attacker would need to compromise the receivers target private key, intercept the message, decrypt it, and then re-encrypt with different plaintext. In our use case, if the attacker intercepts the receivers target private key, everything is already broken so the extra level of authentication is not necessary. Read more about HPKE asymmetric authentication [here](https://datatracker.ietf.org/doc/html/rfc9180#name-authentication-using-an-asy).
136 changes: 136 additions & 0 deletions pkg/enclave_encrypt/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package enclave_encrypt

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/sha256"
"errors"
"fmt"
"reflect"

"github.com/btcsuite/btcutil/base58"
"github.com/cloudflare/circl/kem"
)

// An instance of the client side for enclave encrypt protocol. This should only be used for either
// a SINGLE send or a single receive.
type EnclaveEncryptClient struct {
enclaveAuthKey *ecdsa.PublicKey
targetPrivate kem.PrivateKey
}

// Create a client from the quorum public key.
func NewEnclaveEncryptClient(enclaveAuthKey *ecdsa.PublicKey) (*EnclaveEncryptClient, error) {
_, targetPrivate, err := KemId.Scheme().GenerateKeyPair()
if err != nil {
return nil, err
}

return &EnclaveEncryptClient{
enclaveAuthKey,
targetPrivate,
}, nil
}

// Create a client from the quorum public key and target key pair.
func NewEnclaveEncryptClientFromTargetKey(enclaveAuthKey *ecdsa.PublicKey, targetPrivateKey *kem.PrivateKey) (*EnclaveEncryptClient, error) {
return &EnclaveEncryptClient{
enclaveAuthKey,
*targetPrivateKey,
}, nil
}

// Encrypt some plaintext to the given server target key.
func (c *EnclaveEncryptClient) Encrypt(plaintext Bytes, msg ServerTargetMsg) (*ClientSendMsg, error) {
if !P256Verify(c.enclaveAuthKey, *msg.TargetPublic, *msg.TargetPublicSignature) {
return nil, errors.New("invalid enclave auth key signature")
}
targetPublic, err := KemId.Scheme().UnmarshalBinaryPublicKey((*msg.TargetPublic)[:])
if err != nil {
return nil, err
}

ciphertext, encappedPublic, err := encrypt(
&targetPublic,
plaintext,
)
if err != nil {
return nil, err
}

enc := Bytes(encappedPublic)
ciph := Bytes(ciphertext)
return &ClientSendMsg{
EncappedPublic: &enc,
Ciphertext: &ciph,
}, nil
}

// Decrypt a message from the server. This is used in private key and wallet export flows.
func (c *EnclaveEncryptClient) Decrypt(msg ServerSendMsg) (plaintext []byte, err error) {
if !P256Verify(c.enclaveAuthKey, *msg.EncappedPublic, *msg.EncappedPublicSignature) {
return nil, errors.New("invalid enclave auth key signature")
}

return decrypt(
*msg.EncappedPublic,
c.targetPrivate,
*msg.Ciphertext,
)
}

// Get this clients target public key.
func (c *EnclaveEncryptClient) TargetPublic() ([]byte, error) {
return c.targetPrivate.Public().MarshalBinary()
}

// Decrypt a base58-encoded payload from the server. This is used in email authentication and email recovery flows.
func (c *EnclaveEncryptClient) AuthDecrypt(payload string) (plaintext []byte, err error) {
payloadBytes := base58.Decode(payload)
err = ValidateChecksum(payloadBytes)
if err != nil {
return nil, err
}
// Trim the checksum
payloadBytes = payloadBytes[:len(payloadBytes)-4]

if len(payloadBytes) < 33 {
return nil, errors.New("payload is less then 33 bytes, the length of the expected public key")
}
compressedKey := payloadBytes[0:33]
ciphertext := payloadBytes[33:]

x, y := elliptic.UnmarshalCompressed(elliptic.P256(), compressedKey)

// FIXME: `elliptic.Unmarshal` is deprecated, but scm does not know how to replace it.
// nolint:staticcheck
encappedPublic := elliptic.Marshal(elliptic.P256(), x, y)

return decrypt(
encappedPublic,
c.targetPrivate,
ciphertext,
)
}

// Validates that a payload has a valid checksum in the last four bytes.
func ValidateChecksum(payload []byte) error {
if len(payload) < 5 {
return fmt.Errorf("payload length is < 5 (length: %d)", len(payload))
}
expected := checksum(payload[:len(payload)-4])
if !reflect.DeepEqual(expected[:], payload[len(payload)-4:]) {
return fmt.Errorf("checksum mismatch for payload %02x: %v (computed) != %v (last four bytes)", payload, expected, payload[len(payload)-4:])
}
return nil
}

// Takes a payload and return a checksum (4 bytes)
// The double-hash operation is dictated by the base58check standard
// See https://en.bitcoin.it/wiki/Base58Check_encoding#Creating_a_Base58Check_string
func checksum(payload []byte) (checkSum [4]byte) {
h := sha256.Sum256(payload)
h2 := sha256.Sum256(h[:])
copy(checkSum[:], h2[:4])
return checkSum
}
Loading

0 comments on commit 69b2ca0

Please sign in to comment.