diff --git a/coordinator/internal/transitengine/crypto.go b/coordinator/internal/transitengine/crypto.go new file mode 100644 index 000000000..47ce6e359 --- /dev/null +++ b/coordinator/internal/transitengine/crypto.go @@ -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 +} diff --git a/coordinator/internal/transitengine/crypto_test.go b/coordinator/internal/transitengine/crypto_test.go new file mode 100644 index 000000000..2b6d8d929 --- /dev/null +++ b/coordinator/internal/transitengine/crypto_test.go @@ -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 +} diff --git a/coordinator/internal/transitengine/transitengine.go b/coordinator/internal/transitengine/transitengine.go new file mode 100644 index 000000000..1d217c119 --- /dev/null +++ b/coordinator/internal/transitengine/transitengine.go @@ -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:"} + 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 +}