-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
3 changed files
with
337 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |