Getting Started With SGX: Seal and Unseal

Introduction

In my last post I detailed the installation process for the SGX sdk and driver. This post will explore the basics of sealing and unsealing data using an enclave. This won’t represent the best practices, or even reasonable security practices. The goal here is to demonstrate the mechanics of shipping data to and from an enclave and performing encrypt and decrypt operations.

The complete example is available in the abedra/sgx_bootstrapping repository.

Defining Behavior with EDL

SGX uses a custom language called Enclave Definition Language (EDL) to describe the interface between your program and the enclave. EDL is a C like language with some additional semantics for describing the directionality and nature of the data marshalled in and out of the enclave.

Given these describe the nature and purpose of the enclave in this application, it seems fitting to start here. Our example will pass an unencrypted integer to the enclave and expect encrypted data to be returned. This encrypted data will be the sealed version of the integer. We will also want to decrypt or unseal the data returned by the enclave so it can be used. This will take the sealed data, perform the decrypt operation, and return the originally supplied integer back to the caller.

enclave {
  include "sgx_tseal.h"

  trusted {
    public sgx_status_t seal(
      [in, size=plaintext_len]uint8_t* plaintext, size_t plaintext_len,
      [out, size=sealed_size]sgx_sealed_data_t* sealed_data, size_t sealed_size
    );

    public sgx_status_t unseal(
      [in, size=sealed_size]sgx_sealed_data_t* sealed_data, size_t sealed_size,
      [out, size=plaintext_len]uint8_t* plaintext, uint32_t plaintext_len
    );
  };
};

The enclave stanza includes the entire definition. All definitions must be put inside this block. The enclave stanza encloses three sections, includes, trusted interface definitions, and untrusted interface definitions. Includes are straight forward, and function as any C style include does. The trusted section defines calls that will be made to the enclave by the program. The untrusted section defines calls that will be made by the enclave to the program. This program does not contain any untrusted functions, so no stanza is defined. We will further refer to trusted calls as ECALL and untrusted calls as OCALL. They are named appropriately and the design of your software should consider them carefully. Data marshalled to an OCALL should only ever include data that is acceptable to be viewed by anyone outside the enclave.

You probably noticed the bracketed arguments before some of the variables. These indicate the direction of the data being marshalled. When providing passing a reference to an enclave function, you have to give it explicit instructions on how to handle the reference. This includes the direction as well as the size. For an intro and brief explanation to how the enclave description language works, you can take a look at Intel’s video on the subject. For a deeper dive a tutorial site is provided. I highly recommend working through it to build foundational knowledge on SGX.

The Enclave Code

With our interface defined, we need to actually implement the code that will run when we make our ECALLs. When we compile, we will invoke the edger8r will generate headers based on our EDL, so we can skip directly to implementation. Our example is simple enough to require only a single SGX api call in each case. Each function will mirror what was defined in the EDL without the special syntax.

#include "sgx_trts.h"
#include "sgx_tseal.h"
#include "string.h"
#include "enclave_t.h"

sgx_status_t seal(uint8_t* plaintext, size_t plaintext_len, sgx_sealed_data_t* sealed_data, size_t sealed_size) {
  return sgx_seal_data(0, NULL, plaintext_len, plaintext, sealed_size, sealed_data);
}

sgx_status_t unseal(sgx_sealed_data_t* sealed_data, size_t sealed_size, uint8_t* plaintext, uint32_t plaintext_len) {
  return sgx_unseal_data(sealed_data, NULL, NULL, (uint8_t*)plaintext, &plaintext_len);
}

The implementation of sgx_seal_data and sgx_unseal_data is provided by the SGX api. More advanced use cases will have custom code alongside proxy calls, but this example makes it clear what the minimal surface area looks like. In terms of writing the enclave supported portion of our code, we’re all done. The rest of the application will contain the untrusted part of the program.

Initializing the Enclave

Before our program can interact with the enclave, it needs to initialize it. This is done from the untrusted portion of the code and will make sure we are able to communicate with the enclave. If this fails, there’s nothing left for our program to do and it will exit. You may or may not have that invariant in your system, but since this example’s only purpose is enclave interaction, it’s a fatal error.

#pragma once

#include "EnclaveToken.h"

#include <iostream>

struct EnclaveInitializer {
  static int init(sgx_enclave_id_t* eid, EnclaveToken enclave_token, const std::string& enclave_name) {
    sgx_launch_token_t token = {0};
    sgx_status_t status = SGX_ERROR_UNEXPECTED;
    int updated = 0;

    enclave_token.read(&token);

    status = sgx_create_enclave(enclave_name.c_str(), SGX_DEBUG_FLAG, &token, &updated, eid, NULL);

    if (status != SGX_SUCCESS) {
      std::cout << "SGX error code: " << status << std::endl;
      return status;
    }

    if (updated == true) {
      return enclave_token.save(&token);
    }

    return SGX_SUCCESS;
  }
};

We provide our init method an enclave id, an instance of EnclaveToken to inject token load and save functionality, and the name of the signed shared object file that includes the enclave code. We will read our token and populate the sgx_launch_token_t data structure with its contents. Next we make a call to sgx_create_enclave and give it the shared object, a flag to represent debug mode, our token, an integer to determine if the token was updated, and the id of the enclave. We will check the status of the call and save the token to disk if it was updated. The enclave token represents successful initialization of the enclave, which can include attestation guarantees. This allows a program to restart without having to perform the full verification sequence every time.

Provisioning The Secret

Before we can unseal our secret, we have to seal it first. Provisioning this requires using the seal ECALL we defined above. Our method will take a mechanism to save the sealed data, and the integer we want to seal.

static int provision(const Persistence &persistence, int number) {
  size_t sealed_size = sizeof(sgx_sealed_data_t) + sizeof(number);
  uint8_t* sealed_data = (uint8_t*)malloc(sealed_size);
  sgx_status_t ecall_status;
  sgx_status_t status = seal(global_eid, &ecall_status,
                             (uint8_t*)&number, sizeof(number),
                             (sgx_sealed_data_t*)sealed_data, sealed_size);

  int validation_result = EnclaveResult::validate(status, ecall_status);
  if (validation_result != SGX_SUCCESS) {
    std::cout << "Failed to unseal " << persistence.path() << std::endl;
    return validation_result;
  }

  persistence.save(sealed_data, sealed_size);

  std::cout << persistence.path() << " saved with value: " << number << std::endl;

  return SGX_SUCCESS;
}

A quick check to determine if the ECALL succeeded is necessary to ensure you are saving valid sealed data. Once persisted, the program will print back what it sealed. This is definitely not something you want to do in practice. It’s purely to see what was sealed so we can compare it with the result of the unseal program later.

Loading and Unsealing the Secret

With sealed data available, we can now perform the reverse. Our load method only needs the persistence mechanism to allow it to load the data from disk. We will pass that to our ECALL, check the status, and print the result if it was successful.

int load(const Persistence &persistence) {
  size_t sealed_size = sizeof(sgx_sealed_data_t) + sizeof(int);
  uint8_t* sealed_data = (uint8_t*)malloc(sealed_size);
  int load_status = persistence.load(sealed_data, sealed_size);

  if (load_status != SGX_SUCCESS) {
    std::cout << "Could not load " << persistence.path() << std::endl;
    free(sealed_data);
    return load_status;
  }

  int unsealed;
  sgx_status_t ecall_status;
  sgx_status_t status = unseal(global_eid, &ecall_status,
                               (sgx_sealed_data_t*)sealed_data, sealed_size,
                               (uint8_t*)&unsealed, sizeof(unsealed));

  int validation_result = EnclaveResult::validate(status, ecall_status);
  if (validation_result != SGX_SUCCESS) {
    std::cout << "Failed to unseal " << persistence.path() << std::endl;
    return validation_result;
  }

  std::cout << persistence.path() << " unsealed to: " << unsealed << std::endl;

  return SGX_SUCCESS;
}

The reason an integer was used here was to keep the demonstration easier to follow. Deserializing more complex data into structs carries some overhead that wasn’t necessary to demonstrate how things work.

Putting it Together

Our main method should be straightforward. We simply need to make the various method calls we have built up. We first create an instance of Persistence to handle the sealed data. This is just a wrapper class that makes saving and loading the data from disk more ergonomic. Next we initialize our enclave, and if it’s successful we perform the necessary operations.

int main(int argc, char** argv) {
  Persistence persistence{std::filesystem::path{"persistence.seal"}};

  if (!persistence.exists()) {
    std::cout << persistence.path() << " does not exist, creating" << std::endl;
  }

  if (EnclaveInitializer::init(&global_eid, EnclaveToken{"enclave.token"}, "enclave.signed.so") != SGX_SUCCESS) {
    std::cout << "Failed to initialize enclave." << std::endl;
    return 1;
  }

  // The unseal program will replace all of this with a call to load()
  int number = read_random_number();
  if (number == -1) {
    std::cout << "Failed to read random number from stdin" << std::endl;
    return number;
  } else {
    return provision(persistence, number);
  }
}

For the provision program, we will read a random number from stdin and make a call to provision to seal and save the data. In the case of the unseal program, we will do the same but only make a call to load to unseal and print the result. Compiling and running the program should look similar to the following:

$ make
/home/abedra/src/opensource/sgx_bootstrapping/app
GEN  =>  app/enclave_u.c
CC   <=  app/enclave_u.c
CXX  <=  app/unseal.cpp
LINK =>  unseal
CXX  <=  app/provision.cpp
LINK =>  provision
/home/abedra/src/opensource/sgx_bootstrapping/enclave
GEN  =>  enclave/enclave_t.c
CC   <=  enclave/enclave_t.c
CXX  <=  enclave/enclave.cpp
LINK =>  enclave.so
<!-- Please refer to User's Guide for the explanation of each field -->
<EnclaveConfiguration>
    <ProdID>0</ProdID>
    <ISVSVN>0</ISVSVN>
    <StackMaxSize>0x40000</StackMaxSize>
    <HeapMaxSize>0x100000</HeapMaxSize>
    <TCSNum>10</TCSNum>
    <TCSPolicy>1</TCSPolicy>
    <DisableDebug>0</DisableDebug>
    <MiscSelect>0</MiscSelect>
    <MiscMask>0xFFFFFFFF</MiscMask>
</EnclaveConfiguration>
tcs_num 10, tcs_max_num 10, tcs_min_pool 1
The required memory is 4026368B.
The required memory is 0x3d7000, 3932 KB.
Succeed.
SIGN =>  enclave.signed.so

$ echo $RANDOM | ./provision
persistence.seal saved with value: 12396

$ ./unseal
persistence.seal unsealed to: 12396

This example uses the $RANDOM shell variable available on Linux systems. This is a terrible source of actual randomness, however, it makes for an easy demonstration. Once this completes, you have now sealed and unsealed an integer using SGX.

Wrap-Up

It’s worth noting that pushing a secret out of the enclave isn’t a great idea and this example does just that. While the key material used to encrypt the data is never seen outside the enclave, the secret is pushed out of the boundary. Production implementations should operate on the secret in enclave, producing data that needs the secret without actually exposing it. This is necessary to prevent side channel attacks on the program itself, which is part of the threat model that should be considered when designing software that interacts with hardware enclaves.

You may also wonder why there are separate programs in this example for seal and unseal. If you think about how a real system is written, secrets are typically only provisioned when an application is deployed. The program will run and restart much more often than provisioning. In fact, it may be wise to remove the provision program once the operation is complete to avoid any unwanted invocations.

Hopefully this has been an informative dive into the basic mechanics of interacting with SGX. Stay tuned for more complex examples that push toward production worthy operations and ideas.

comments powered by Disqus