From 4d0f2ee30eed4bec67f5fa95c0c580c7100ba748 Mon Sep 17 00:00:00 2001 From: Philipp Winter Date: Wed, 2 Aug 2023 10:44:29 -0500 Subject: [PATCH] Support loading an enclave application at runtime. This PR makes it possible to load the enclave application at runtime. When enabled, nitriding maintains an in-memory transparency log that allows users to verify the evolution of enclave applications. This fixes https://github.com/Amnesic-Systems/nitriding/issues/12. --- app_loader.go | 84 +++++++++++++++++++++++++++++++++++++++++++ app_loader_test.go | 27 ++++++++++++++ app_loader_webapi.go | 33 +++++++++++++++++ internal/enclave.go | 34 ++++++++++++------ internal/handlers.go | 8 +++++ internal/util.go | 7 ++++ internal/util_test.go | 10 +++++- main.go | 5 ++- memory_log.go | 76 +++++++++++++++++++++++++++++++++++++++ memory_log_test.go | 57 +++++++++++++++++++++++++++++ 10 files changed, 329 insertions(+), 12 deletions(-) create mode 100644 app_loader.go create mode 100644 app_loader_test.go create mode 100644 app_loader_webapi.go create mode 100644 memory_log.go create mode 100644 memory_log_test.go diff --git a/app_loader.go b/app_loader.go new file mode 100644 index 0000000..1159c75 --- /dev/null +++ b/app_loader.go @@ -0,0 +1,84 @@ +package main + +import ( + "log" + "net/http" + "os/exec" + "time" +) + +const ( + appPath = "/tmp/enclave-application" // Where to store the enclave application. +) + +// appRetriever implements an interface that retrieves an enclave application at +// runtime. This allows us to retrieve the application via various mechanisms, +// like a Web API or a Docker registry. +type appRetriever interface { + retrieve(*http.Server, chan []byte) error +} + +// appLoader implements a mechanism that can retrieve and execute an enclave +// application at runtime. +type appLoader struct { + srv *http.Server + log transparencyLog + app chan []byte + appExited chan struct{} + appRetriever +} + +// newAppLoader returns a new appLoader object. +func newAppLoader(srv *http.Server, r appRetriever) *appLoader { + return &appLoader{ + srv: srv, + log: new(memLog), + app: make(chan []byte), + appExited: make(chan struct{}), + appRetriever: r, + } +} + +// runCmd runs the enclave application. The function blocks for as long as the +// application is running. +func (l *appLoader) runCmd() { + cmd := exec.Command(appPath) + err := cmd.Run() + elog.Printf("Enclave application exited: %v", err) + l.appExited <- struct{}{} +} + +// appendToLog appends the given digest to our append-only log. +func (l *appLoader) appendToLog(app []byte) { + l.log.append(newSHA256LogRecord(app)) +} + +// start executes the enclave application. +func (l *appLoader) start(stop chan struct{}) { + var ( + err error + ) + elog.Println("Starting app loader event loop.") + defer elog.Println("Stopping app loader event loop.") + + go l.retrieve(l.srv, l.app) + go func() { + for { + select { + case <-stop: + return + case <-l.appExited: + time.Sleep(time.Second) + elog.Println(l.log) + go l.runCmd() + + case app := <-l.app: + if err = writeToDisk(app); err != nil { + log.Fatalf("Error writing enclave application to disk: %v", err) + } + l.appendToLog(app) + go l.runCmd() + } + } + }() +} diff --git a/app_loader_test.go b/app_loader_test.go new file mode 100644 index 0000000..1321dca --- /dev/null +++ b/app_loader_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "net/http" + "testing" +) + +type appRetrieverDummy struct{} + +func (d *appRetrieverDummy) retrieve(srv *http.Server, appChan chan []byte) error { + //var once sync.Once + go func(appChan chan []byte) { + // once.Do() + // sync.Once() + appChan <- []byte("") // Dummy application. + }(appChan) + return nil +} + +func TestStartStop(t *testing.T) { + var ( + loader = newAppLoader(nil, new(appRetrieverDummy)) + stop = make(chan struct{}) + ) + defer close(stop) + loader.start(stop) +} diff --git a/app_loader_webapi.go b/app_loader_webapi.go new file mode 100644 index 0000000..0ecbf4b --- /dev/null +++ b/app_loader_webapi.go @@ -0,0 +1,33 @@ +package main + +import ( + "io" + "net/http" + + "github.com/go-chi/chi/v5" +) + +// appRetrieverViaWeb installs a PUT endpoint that is used to upload the enclave +// application at runtime. +type appRetrieverViaWeb struct{} + +func newAppRetrieverViaWeb() *appRetrieverViaWeb { + return new(appRetrieverViaWeb) +} + +func (l *appRetrieverViaWeb) retrieve(srv *http.Server, appChan chan []byte) error { + srv.Handler.(*chi.Mux).Put(pathApp, func(w http.ResponseWriter, r *http.Request) { + const maxAppLen = 1024 * 1024 * 50 // 50 MiB. + + app, err := io.ReadAll(newLimitReader(r.Body, maxAppLen)) + if err != nil { + http.Error(w, errFailedReqBody.Error(), http.StatusInternalServerError) + return + } + elog.Printf("Received %d-byte enclave application.", len(app)) + appChan <- app + w.WriteHeader(http.StatusOK) + }) + elog.Printf("Installed HTTP handler %s to receive enclave application.", pathApp) + return nil +} diff --git a/internal/enclave.go b/internal/enclave.go index e669363..44c3929 100644 --- a/internal/enclave.go +++ b/internal/enclave.go @@ -37,16 +37,18 @@ const ( // https://docs.aws.amazon.com/enclaves/latest/user/nitro-enclave-concepts.html parentCID = 3 // The following paths are handled by nitriding. - pathRoot = "/enclave" - pathAttestation = "/enclave/attestation" - pathState = "/enclave/state" - pathSync = "/enclave/sync" - pathHash = "/enclave/hash" - pathReady = "/enclave/ready" - pathProfiling = "/enclave/debug" - pathConfig = "/enclave/config" - pathLeader = "/enclave/leader" - pathHeartbeat = "/enclave/heartbeat" + pathRoot = "/enclave" + pathAttestation = "/enclave/attestation" + pathState = "/enclave/state" + pathSync = "/enclave/sync" + pathHash = "/enclave/hash" + pathReady = "/enclave/ready" + pathProfiling = "/enclave/debug" + pathConfig = "/enclave/config" + pathLeader = "/enclave/leader" + pathHeartbeat = "/enclave/heartbeat" + pathTransparencyLog = "/enclave/log" + pathApp = "/enclave/app" // All other paths are handled by the enclave application's Web server if // it exists. pathProxy = "/*" @@ -78,11 +80,14 @@ type Enclave struct { workers *workerManager keys *enclaveKeys httpsCert *certRetriever + loader *appLoader ready, stop chan struct{} } // Config represents the configuration of our enclave service. type Config struct { + Loader bool + // FQDN contains the fully qualified domain name that's set in the HTTPS // certificate of the enclave's Web server, e.g. "example.com". This field // is required. @@ -277,12 +282,16 @@ func NewEnclave(cfg *Config) (*Enclave, error) { if cfg.isScalingEnabled() { e.setSyncState(inProgress) } + if cfg.Loader { + e.loader = newAppLoader(e.extPubSrv, newAppRetrieverViaWeb()) + } // Register external public HTTP API. m := e.extPubSrv.Handler.(*chi.Mux) m.Get(pathAttestation, attestationHandler(e.cfg.UseProfiling, e.hashes, e.attester)) m.Get(pathRoot, rootHandler(e.cfg)) m.Get(pathConfig, configHandler(e.cfg)) + m.Get(pathTransparencyLog, transparencyLogHandler(e.loader.log)) // Register external but private HTTP API. m = e.extPrivSrv.Handler.(*chi.Mux) @@ -352,6 +361,11 @@ func (e *Enclave) Start() error { return fmt.Errorf("%s: %w", errPrefix, err) } + // TODO: Does this play well with key synchronization? + if e.cfg.Loader { + e.loader.start(e.stop) + } + if !e.cfg.isScalingEnabled() { return nil } diff --git a/internal/handlers.go b/internal/handlers.go index e6e4a17..b1a6c8e 100644 --- a/internal/handlers.go +++ b/internal/handlers.go @@ -290,3 +290,11 @@ func getLeaderHandler(ourNonce nonce, weAreLeader chan struct{}) http.HandlerFun w.WriteHeader(http.StatusOK) } } + +// transparencyLogHandler prints the transparency log of all previously-deployed +// enclave applications in human-readable form. +func transparencyLogHandler(log transparencyLog) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, log) + } +} diff --git a/internal/util.go b/internal/util.go index d5f767b..4d51f1d 100644 --- a/internal/util.go +++ b/internal/util.go @@ -20,6 +20,8 @@ import ( "os" "strings" "time" + + "golang.org/x/sys/unix" ) const ( @@ -260,3 +262,8 @@ func makeLeaderRequest(leader *url.URL, ourNonce nonce, areWeLeader chan bool, e } errChan <- fmt.Errorf("leader designation endpoint returned %d", resp.StatusCode) } + +func writeToDisk(appBlob []byte) error { + _ = unix.Umask(0) + return os.WriteFile(appPath, appBlob, 0755) +} diff --git a/internal/util_test.go b/internal/util_test.go index 9c13a6b..92e684b 100644 --- a/internal/util_test.go +++ b/internal/util_test.go @@ -1,6 +1,9 @@ package internal -import "testing" +import ( + "os" + "testing" +) func TestSliceToNonce(t *testing.T) { var err error @@ -11,3 +14,8 @@ func TestSliceToNonce(t *testing.T) { _, err = sliceToNonce(make([]byte, nonceLen)) assertEqual(t, err, nil) } + +func TestWriteToDisk(t *testing.T) { + assertEqual(t, writeToDisk([]byte("foo")), nil) + defer os.Remove(appPath) +} diff --git a/main.go b/main.go index f13add3..f56387d 100644 --- a/main.go +++ b/main.go @@ -22,7 +22,7 @@ var ( func main() { var fqdn, fqdnLeader, appURL, appWebSrv, appCmd, prometheusNamespace, mockCertFp string var extPubPort, extPrivPort, intPort, hostProxyPort, prometheusPort uint - var useACME, waitForApp, useProfiling, useVsockForExtPort, disableKeepAlives, debug bool + var useACME, waitForApp, useProfiling, useVsockForExtPort, disableKeepAlives, debug, loader bool var err error flag.StringVar(&fqdn, "fqdn", "", @@ -61,6 +61,8 @@ func main() { "Print extra debug messages and use dummy attester for testing outside enclaves.") flag.StringVar(&mockCertFp, "mock-cert-fp", "", "Mock certificate fingerprint to use in attestation documents (hexadecimal)") + flag.BoolVar(&loader, "loader", false, + "Dynamically load enclave application.") flag.Parse() if fqdn == "" { @@ -101,6 +103,7 @@ func main() { UseProfiling: useProfiling, MockCertFp: mockCertFp, Debug: debug, + Loader: loader, } if appURL != "" { u, err := url.Parse(appURL) diff --git a/memory_log.go b/memory_log.go new file mode 100644 index 0000000..787ac9e --- /dev/null +++ b/memory_log.go @@ -0,0 +1,76 @@ +package main + +import ( + "crypto/sha256" + "fmt" + "sync" + "time" +) + +// transparencyLog implements an interface for an append-only data structure +// that serves as a transparency log for enclave image IDs. +type transparencyLog interface { + append(*logRecord) error + String() string // human-readable representation +} + +// logRecord represents a single record in our transparency log. +type logRecord struct { + digestType byte + digestSize byte + digest []byte + time time.Time +} + +// newSHA256LogRecord creates a new logRecord that uses SHA-2-256 for the given +// byte blob. +func newSHA256LogRecord(blob []byte) *logRecord { + digest := sha256.Sum256(blob) + return &logRecord{ + digestType: 0x12, // SHA-2-256. + digestSize: 0x20, // 32 bytes, in the "variable integer" multiformat. + digest: digest[:], + time: time.Now(), + } +} + +// String returns a string representation of the log record. +func (r *logRecord) String() string { + return fmt.Sprintf("%s: %x (type=%x)\n", r.time.Format(time.RFC3339), r.digest, r.digestType) +} + +// memLog implements a transparencyLog in memory. +type memLog struct { + sync.Mutex + log []*logRecord +} + +// append appends the given logRecord to the memory log. +func (m *memLog) append(r *logRecord) error { + m.Lock() + defer m.Unlock() + + m.log = append(m.log, r) + elog.Printf("Appended %s to transparency log of new size %d.", r, len(m.log)) + return nil +} + +// size returns the memory log's size. +func (m *memLog) size() int { + m.Lock() + defer m.Unlock() + + return len(m.log) +} + +// String returns a string representation of the memory log. +func (m *memLog) String() string { + m.Lock() + defer m.Unlock() + + var s string + for _, r := range m.log { + s += r.String() + } + return s +} diff --git a/memory_log_test.go b/memory_log_test.go new file mode 100644 index 0000000..77f4f20 --- /dev/null +++ b/memory_log_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "bytes" + "testing" +) + +func TestSHA256RecordCreation(t *testing.T) { + var ( + r1 = newSHA256LogRecord([]byte("foo")) + r2 = newSHA256LogRecord([]byte("foo")) + r3 = newSHA256LogRecord([]byte("bar")) + ) + + // Check if the digests match. + hasSameDigest := func(r1, r2 *logRecord) bool { + if r1.digestType != r2.digestType { + return false + } + if r1.digestSize != r2.digestSize { + return false + } + return bytes.Equal(r1.digest, r2.digest) + } + assertEqual(t, hasSameDigest(r1, r2), true) + assertEqual(t, hasSameDigest(r1, r3), false) + assertEqual(t, hasSameDigest(r2, r3), false) + + // Check if the timestamps match. + assertEqual(t, r1.time == r2.time, false) + assertEqual(t, r1.time == r3.time, false) + assertEqual(t, r2.time == r3.time, false) + + // Check if the string representations match. + // We're not testing r1 against r2 because the result is not deterministic + // due to time being part of the string representation. + assertEqual(t, r1.String() == r3.String(), false) + assertEqual(t, r2.String() == r3.String(), false) +} + +func TestMemLog(t *testing.T) { + var ( + m = new(memLog) + r1 = newSHA256LogRecord([]byte("foo")) + r2 = newSHA256LogRecord([]byte("bar")) + ) + + // Check if appending works correctly. + assertEqual(t, m.size(), 0) + m.append(r1) + assertEqual(t, m.size(), 1) + m.append(r2) + assertEqual(t, m.size(), 2) + + // Check that we get some sort of string representation. + assertEqual(t, len(m.String()) > 0, true) +}