Skip to content

Commit

Permalink
transitapi: http handler dec/enc
Browse files Browse the repository at this point in the history
  • Loading branch information
jmxnzo committed Feb 4, 2025
1 parent 661ea36 commit 17b276b
Show file tree
Hide file tree
Showing 3 changed files with 337 additions and 0 deletions.
114 changes: 114 additions & 0 deletions coordinator/internal/transitengine/crypto.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright 2024 Edgeless Systems GmbH
// SPDX-License-Identifier: AGPL-3.0-only

package transitengine

import (
"crypto/aes"
"crypto/cipher"
"encoding/json"
"strings"

"github.com/edgelesssys/contrast/internal/crypto"

b64 "encoding/base64"
)

const (
// aesGCMNonceSize specifies the default nonce size in bytes used in AES GCM.
aesGCMNonceSize = 12
// aesGCMKeySize specifies the default key size in bytes AES GCM.
aesGCMKeySize = 16
)

// symOpts holds parameters related to the performed symmetric encryption, specifyable as http request parameters.
type symOpts struct {
// 1=encryption, 0=decryption
encrypt bool
// Convergent TODO(jmxzo): add parameter support
convergent bool
// Nonce
nonce []byte
// AdditionalData
additionalData []byte
}

type (
// b64Plaintext describes a base64-encoded plaintext.
b64Plaintext []byte
// prefixedb64Ciphertext describes a base64-encoded ciphertext with prefix 'vault:v1:'.
prefixedb64Ciphertext []byte
)

type encryptionMap struct {
Plaintext b64Plaintext `json:"plaintext"`
Ciphertext prefixedb64Ciphertext `json:"ciphertext"`
key []byte
symOpts symOpts
}

// encryptionFunc derscribes both subjacent functions: encryption and decryption, performing it's operation on an encryptionMap and returning an error on failure.
// Note: The corresponding http error on failure is InternalServerError with status code 500.
type encryptionFunc func(encryptionMap *encryptionMap) error

// symmetricEncrypt returns the encrypted plaintext based on the symmetric options and encryption key handed in.
func symmetricEncrypt(encryptionMap *encryptionMap) error {
aesCipher, err := aes.NewCipher(encryptionMap.key)
if err != nil {
return err
}
gcm, err := cipher.NewGCM(aesCipher)
if err != nil {
return err
}
nonce, err := crypto.GenerateRandomBytes(aesGCMNonceSize)
if err != nil {
return err
}
ciphertext := gcm.Seal(nil, nonce, encryptionMap.Plaintext, nil)
encryptionMap.Ciphertext = append(nonce, ciphertext...)
return nil
}

// symmetricDecrypt returns the decrypted ciphertext based on the symmetric options and encryption keys handed in.
func symmetricDecrypt(encryptionMap *encryptionMap) error {
aesCipher, err := aes.NewCipher(encryptionMap.key)
if err != nil {
return err
}
gcm, err := cipher.NewGCM(aesCipher)
if err != nil {
return err
}
encryptionMap.Plaintext, err = gcm.Open(nil, encryptionMap.symOpts.nonce, encryptionMap.Ciphertext, nil)
return err
}

// UnmarshalJSON implements the json.Unmarshaler interface to automatically decode b64Plaintext into.
func (b *b64Plaintext) UnmarshalJSON(data []byte) error {
var encoded string
if err := json.Unmarshal(data, &encoded); err != nil {
return err
}
decoded, err := b64.StdEncoding.DecodeString(encoded)
if err != nil {
return err
}
*b = decoded
return nil
}

// UnmarshalJSON implements the json.Unmarshaler interface to automatically decode prefixedb64Ciphertext .
func (p *prefixedb64Ciphertext) UnmarshalJSON(data []byte) error {
var encoded string
if err := json.Unmarshal(data, &encoded); err != nil {
return err
}
encoded = strings.TrimPrefix(encoded, "vault:v1:")
decoded, err := b64.StdEncoding.DecodeString(encoded)
if err != nil {
return err
}
*p = decoded
return nil
}
93 changes: 93 additions & 0 deletions coordinator/internal/transitengine/crypto_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright 2024 Edgeless Systems GmbH
// SPDX-License-Identifier: AGPL-3.0-only

package transitengine

import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"

"github.com/edgelesssys/contrast/coordinator/internal/seedengine"
"github.com/stretchr/testify/require"
)

func TestCryptoAPICyclic(t *testing.T) {
data := []map[string]string{
{"Plaintext": "vpIhKQhFuGwLv5B/XLYr960uZQ==", "Ciphertext": "vault:v1:d01M+wZYaG9LFnuc18s8oh6PuVFw3+7DBX4LkXXQ6d64DvmQt6qwjj1MHmA88UE="},
{"Plaintext": "O/XShnapt5hNMCZnP+4BZjH84CcWAwhxOUnGwKH7ja1ZYsZdyZrGeLT4EZtA1vWey04bAsi+viGmpYO98YbkCvSn7HZvglLh2DMv3Ach9SP2qjWw0NBa2rrfToI1dsE=", "Ciphertext": "vault:v1:onRtviw8oJv5cY4EngPYySbYlAYgEqWXk5WbD2X+jpRDR5d87Y2qE0Otc4KUKja8LYVb1zB1P40yWTgNb7srG4D7kuxlRFFbtkYaCAC3bLbz+QuumLFQd9RbsN7avbLtEmT8nay/1qvvp2e/MDv5oPoDIT0vzHorVI40"},
{"Plaintext": "lT3rQGMlxq680DdSKfIYYcfyCfMnP9ikxaO5b0mGRKRl4qNL3W9xkSW3QmaMwozCRfNMZhhDCbYokn6KEiGotlVInKt66QjBgXR2Nk9hIcez0LYt8W5pxD0lwTxC", "Ciphertext": "vault:v1:taQ++Amvoj0G1V+OQb0PjldLh5BRXmAhwlO38LRjajVIuHTEa69kfytU3mMaFEpG5JNVg3Cq6vSWH58n1NEmM6WDV79q/hxzaji68joq9uQeoyH3To9iBoRHE2T7jbxUvYLeBwgzFvM/YNk5EaFpNkfIwwxzNZ5gmQ=="},
{"Plaintext": "WC0sC1KtNw76hGVQTpeFNtPg94tJc64dE3rf0mhENsBMLhWmYinA99YbIGx0gSQEOkOsR1sPgSnEmxTvycQdNA==", "Ciphertext": "vault:v1:vjeAbnpFxh+atxQsk6OeumH6irWPAuRbim2UQ8ggNGQFpB4wnYxjUiydGgYixZ6x1Ad+STfbjwxLvij5ZpmyMxFsmrYZIQKCYE48mVUNI+VJa87zuMabQCMClOs="},
{"Plaintext": "iZo0IezTmQ6Ms1GJUbbY4nrsRydO31b6xuqlJwi+R9xLt1K9uaI8ZiuInXc92qpulYNaAWAiBmNNghKM0dpAPdSXwc93+YCT1Zm2i1cuW7H6Uz5tL7E=", "Ciphertext": "vault:v1:qqjtmjUKfzoAb3VeUgWjbRDdYu3K/cdmi7sEVcKPRiOdbY5OyuQrNQHtYZ/mje8hcPyHnmgDJEDOpjLhQgUG6yoYamqksut//lv7DDbKYbzroro5BRiCqQjhfqnhmna79maV5okq8zI5YZBoSSn36ivu"},
{"Plaintext": "FLiOXWBy8oVETtiNzfw0rbMgCfa3DVSfKL4GhR86EcluUe78nLiDxt0HtP5vTwaaz7mvLXu2nOtsdlz8kYY1YrZCLLNlYzjm/vYe++CcH/x+fDKepJem5le0BCsdog==", "Ciphertext": "vault:v1:0qp8vsJ+JFf5m5HekxQeUw0+gj/NdoDcmy7ExSw0G7PB1RBQ80T+TUMjSvmmgu02eQ2oCKEkfFMolfNt1zq+sZkLQuTLpbW8p+Vd8ALPGdyyD20MIb2ez6dm9nMM4jiXL2FkfARuHcHoY3/LCBQVLE+kkJLAdwze/Z4="},
{"Plaintext": "Xqv9SzcpNK99JF1I7xRAJ0FOkzka", "Ciphertext": "vault:v1:N431rZS6bcuGDDJ8Jh93yvih4oc5wHGtz02M2vFQ5IioIlZFqfv29nDImWNaZUW7Zw=="},
{"Plaintext": "6HQ035OxE30=", "Ciphertext": "vault:v1:ZUuU9vMiCa3CE7PvmYlJYhHQOJm2/Rk6xuUA7DSJu4ExttPl"},
{"Plaintext": "yqUBQzznRjbXMxhQkwo5q2Az3/6nvgRQ86uffx8ZqT7rufplfhJz+xDfvi4EOw==", "Ciphertext": "vault:v1:fpjOfUK8hZEk6DmcH715zdyTMAqyW9ymAMxQFgoMR2Q5639NYCA3rbrR4OKfGwCqO7UrBg3LeFksYBPhiO4pbX6dHXGYIfjDdgo="},
}
t.Run("encrypt-decrypt handler", func(t *testing.T) {
for _, entry := range data {
t.Run("cyclic handler function testing", func(t *testing.T) {
var ciphertext, receivedPlaintext string
t.Run("encryption request handling", func(t *testing.T) {
require := require.New(t)
jsonBody, err := createReqBodyJSON("plaintext", entry["Plaintext"])
require.NoError(err)
req := httptest.NewRequest(http.MethodPut, "/v1/transit/encrypt/autounseal", bytes.NewReader(jsonBody))
req.Header.Set("Content-Type", "application/json")
require.NoError(err)
encRespBody := receiveResponseBody(t, *req)
require.NoError(err)
data, _ := encRespBody["data"].(map[string]any)
ciphertext, _ = data["ciphertext"].(string)
})

t.Run("decryption request handling", func(t *testing.T) {
require := require.New(t)
decryptReqBody, err := createReqBodyJSON("ciphertext", ciphertext)
require.NoError(err)
decryptReq := httptest.NewRequest(http.MethodPut, "/v1/transit/decrypt/autounseal", bytes.NewReader(decryptReqBody))
decryptReq.Header.Set("Content-Type", "application/json")
decRespBody := receiveResponseBody(t, *decryptReq)
data, _ := decRespBody["data"].(map[string]any)
receivedPlaintext, _ = data["plaintext"].(string)
require.Equal(entry["Plaintext"], receivedPlaintext, "Unexpected received plaintext after cycling handler functions")
})
})
}
})
}

func receiveResponseBody(t *testing.T, req http.Request) map[string]any {
require := require.New(t)
rec := httptest.NewRecorder()
salt := make([]byte, 32) // 32-byte salt initialized with zeros
secretSeed := make([]byte, 32) // 32-byte secret seed initialized with zeros
seedEngine, err := seedengine.New(secretSeed, salt)
require.NoError(err)
httpHandlerFunc := customWrapperHandler(seedEngine, slog.Default())
httpHandlerFunc(rec, &req)
res := rec.Result()
defer res.Body.Close()
require.Equal(http.StatusOK, res.StatusCode)
respBody, err := io.ReadAll(res.Body)
require.NoError(err)
var respData map[string]any
err = json.Unmarshal(respBody, &respData)
require.NoError(err)
return respData
}

func createReqBodyJSON(bodyParameter, value string) ([]byte, error) {
body := map[string]string{
bodyParameter: value,
}
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, err
}
return jsonBody, nil
}
130 changes: 130 additions & 0 deletions coordinator/internal/transitengine/transitengine.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Copyright 2024 Edgeless Systems GmbH
// SPDX-License-Identifier: AGPL-3.0-only

package transitengine

import (
b64 "encoding/base64"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"

"github.com/edgelesssys/contrast/coordinator/internal/seedengine"
)

// NewTransitEngineAPI starts a transit engine API listening on the given port and is initialized with a seedengine getter for key derivation.
func NewTransitEngineAPI(port int, _ *seedengine.SeedEngine, logger *slog.Logger) error {
salt := make([]byte, 32) // 32-byte salt initialized with zeros
secretSeed := make([]byte, 32) // 32-byte secret seed initialized with zeros
seedEngine, err := seedengine.New(secretSeed, salt)
if err != nil {
return err
}
http.HandleFunc("/", customWrapperHandler(seedEngine, logger))

logger.Info(fmt.Sprintf("Serving transit engine API on port: %d", port))
return http.ListenAndServe(fmt.Sprintf(":%d", port), nil)
}

// customWrapperHandler returns a http.HandlerFunc that wraps error handling and logging of:
// routing the request,
// executing the encryption function on the request-representative encryptionMap and
// returning the response.
func customWrapperHandler(seedEngine *seedengine.SeedEngine, logger *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var encryptionMap *encryptionMap
encryptionFunc, encryptionMap, err := parseEncryptionRequest(r, seedEngine)
if err != nil {
http.Error(w, fmt.Sprint("Parsing request: %w", err), http.StatusBadRequest)
return
}
if err = encryptionFunc(encryptionMap); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
logger.Error("Request failed", "addr", r.RemoteAddr, "method", r.Method, "url", r.URL, "error", err)
return
}
if err = writeJSONResponse(w, encryptionMap); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
logger.Debug("Request successful", "addr", r.RemoteAddr, "method", r.Method, "url", r.URL)
}
}

// deriveEncryptionKey
func deriveEncryptionKey(seedEngine *seedengine.SeedEngine, workloadSecretID string) ([]byte, error) {
// TODO(jmxnzo): authentication of client certs <-> parsed workloadSecretID
derivedWorkloadSecret, err := seedEngine.DeriveWorkloadSecret(workloadSecretID)
if err != nil {
return nil, err
}
return derivedWorkloadSecret[:aesGCMKeySize], nil
}

// parseEncryptionRequest routes the request by determining the encryption/decryption function and parses the request parameters into a representative encryptionMap.
func parseEncryptionRequest(r *http.Request, seedEngine *seedengine.SeedEngine) (encryptionFunc encryptionFunc, encryptionMap *encryptionMap, err error) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) != 4 || parts[1] != "transit" {
return nil, nil, errors.New("Invalid URL format")
}
action, workloadSecretID := parts[2], parts[3]

key, err := deriveEncryptionKey(seedEngine, workloadSecretID)
if err != nil {
return nil, nil, err
}
switch action {
case "encrypt":
encryptionFunc = symmetricEncrypt
case "decrypt":
encryptionFunc = symmetricDecrypt
default:
return nil, nil, errors.New("Invalid URL: operation not existing")

}
defer r.Body.Close()
if err := json.NewDecoder(r.Body).Decode(&encryptionMap); err != nil {
return nil, nil, err
}
encryptionMap.key = key
encryptionMap.symOpts = symOpts{
encrypt: (len(encryptionMap.Ciphertext) == 0),
convergent: false,
additionalData: []byte("test"),
}
if !encryptionMap.symOpts.encrypt {
encryptionMap.symOpts.nonce = encryptionMap.Ciphertext[:aesGCMNonceSize]
encryptionMap.Ciphertext = encryptionMap.Ciphertext[aesGCMNonceSize:]

}
return encryptionFunc, encryptionMap, nil
}

// writeJSONResponse sends a http.StatusOK response, wrapping the provided encryptionMap into a data JSON body.
func writeJSONResponse(w http.ResponseWriter, encryptionMap *encryptionMap) error {
// Formatting: data:{"ciphertext": "vault:v1:<base64encoding>"}
data := map[string]any{
"data": map[string]string{
func() string {
if encryptionMap.symOpts.encrypt {
return "ciphertext"
}
return "plaintext"
}(): func() string {
if encryptionMap.symOpts.encrypt {
return "vault:v1:" + b64.StdEncoding.EncodeToString(encryptionMap.Ciphertext)
}
return b64.StdEncoding.EncodeToString(encryptionMap.Plaintext)
}(),
},
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(data); err != nil {
return err
}
return nil
}

0 comments on commit 17b276b

Please sign in to comment.