Problem

Thought I’d share the mental process behind how solving a small everyday problem leads to the reinvention of a design principle that is well established in the industry.

While designing a system for my work, I realised the data I was going to obtain will be quite sensitive. The system was written to work either locally for testing and development or in the cloud for production. If it was only former, I wouldn’t worry: I am the only one who has access to the device and data. Cloud, however, is another thing. Encryption at rest there is underpinned by the same identity and access management as any other component such as the original app: if anyone somehow gets their hands on a valid account, it will not help. That made me uneasy and motivated to introduce another safeguard. “Additional encryption!” I thought, creating a nice swiss-cheese model of a platform (cloud), application (system) and data (yet-to-be written encryption).

Design

XOR and symmetric encryption

My first thought that usually works for malware obfuscation was to throw a quick XOR. Right after I realized that the encryption key would have to be inside the application which, according to my security model, can be considered compromised and the key retrieved. Thus the problem becomes not ‘how to protect the data’ but ‘how to protect the encryption key’.

RSA and asymmetric encryption

Alright, if the key is the issue, why not bypass it with asymmetry and RSA? Generate a key pair, leave the private key at home for personal decryption and embed the public key in the app for encryption. Something PGP-like. Should be simple enough.

Almost got my hands on the keyboard when realization hit: RSA is limited in terms of plaintext size depending on the key length. Definitely not enough for the type of data I needed to process. Trying to solve that would be chunking data akin to block ciphers, encrypting it and hardcoding or having some kind of service information concatenated with ciphertext. Sounds doable but overly complicated and brittle for the task at hand.

Envelope and hybrid encryption

“Why not combine both?” I thought then. I don’t need to process the whole plaintext. I can just solve my key problem and then use the symmetric crypto to safeguard the rest of data. It clicked!

TLS came to mind, although a bit incorrect since TLS uses Diffie-Hellman to get the first set of keys before using internal AES for data security. Later I found out that envelope encryption is a known and established thing, one such example being Google’s Tink. The usual deployment of Tink with KEK (key encryption key - RSA), however, is paired with the cloud KMS service which circles back on my problem of not underpinning the security model of sensitive data on the cloud IAM.

Because of that reason, I went ahead with simple wrapper over existing crypto functions. While we’re at it, we can also quickly upgrade from simple XOR straight to AES. If we’re being fancy, we can even indulge ourselves with AES-GCM for added data authenticity.

Implementation

I’ve decided to separate decryptor from encryptor rather than have a single omni-class since the former has to be initialized with sensitive private RSA key and the latter can be shipped alongside application.

The proposed design is quite simple:

flowchart LR subgraph Encryptor A[Generate random AES key] --> B[RSA-encrypt AES key\nwith public key] B --> E[enc_aes_key] C[Generate random nonce] --> D[AES-GCM encrypt\nplaintext] A --> D D --> F[nonce + ciphertext] F --> G[base64 package] end E --> H G --> H[(storage)] H --> I H --> J subgraph Decryptor I[RSA-decrypt AES key\nwith private key] --> K J[Extract nonce] --> K[AES-GCM decrypt\nciphertext] K --> L[plaintext] end

Encryptor

We’ll start with AES portion: key should be generated randomly on the class instance initialization with initialized AES helper stored for future reference:

class Encryptor:
  def __init__(self):

    aes_key = AESGCM.generate_key(bit_length=256)
    self.aesgcm = AESGCM(aes_key)

To transmit the generated key securely, we need to wrap it in RSA encryption using the public key shipped with application:

class Encryptor:
  def __init__(self, rsa_public_key_path):

    aes_key = AESGCM.generate_key(bit_length=256)
    self.aesgcm = AESGCM(aes_key)

    with open(rsa_public_key_path, "rb") as f:
      self.rsa_key = serialization.load_pem_public_key(f.read())
    
    self.enc_aes_key = self.rsa_key.encrypt(
      aes_key, 
      padding.OAEP(
        mgf=padding.MGF1(algorithm=hashes.SHA256()),
        algorithm=hashes.SHA256(),
        label=None,
      ),
    )

Now that class constructor is ready, let’s write the encryption part. We’ll add optional text-to-byte conversion which should be convenient for aggregate data. For more complex cases, additional serialization to something like JSON would need to be implemented on a separate basis:

def encrypt(plaintext):

  if isinstance(plaintext, str):
    plaintext = plaintext.encode("utf-8")

Now we’ll generate a random nonce and AES-encrypt the data:

class Encryptor:
  def __init__(self, rsa_public_key_path, nonce_len = 12):
    self.nonce_len = nonce_len

<...>

def encrypt(self, plaintext):

  if isinstance(plaintext, str):
    plaintext = plaintext.encode("utf-8")
  
  nonce = os.urandom(self.nonce_len)
  ciphertext = self.aesgcm.encrypt(nonce, plaintext, None)

Working with bytes resulting from encryption directly might be inconvenient, so let’s add another convenience layer would be encoding ciphertext as base64 to be able to copy and paste data if needed:

def encrypt(self, plaintext):

  if isinstance(plaintext, str):
    plaintext = plaintext.encode("utf-8")
  
  nonce = os.urandom(self.nonce_len)
  ciphertext = self.aesgcm.encrypt(nonce, plaintext, None)

  return base64.b64encode(nonce + ciphertext).decode("ascii")

Decryptor

We start with class initialization that takes filepath to private RSA key and encrypted AES which together will “open the envelope”:

class Decryptor:
  def __init__(self, rsa_private_key_path, enc_aes_key, nonce_len=12):
    self.nonce_len = nonce_len
    
    with open(rsa_private_key_path, "rb") as kf:
          rsa_key = serialization.load_pem_private_key(
              kf.read(),
              password=None,
          )
    
    aes_key = rsa_key.decrypt(
          enc_aes_key,
          padding.OAEP(
              mgf=padding.MGF1(algorithm=hashes.SHA256()),
              algorithm=hashes.SHA256(),
              label=None
          )
      )
    
    self.aesgcm = AESGCM(aes_key)

Now that we have the plaintext AES key, plaintext data retrieval is trivial and just a reversal of our package:

  • decode bytes from base64
  • split bytes into prepended nonce and appended ciphertext
  • decrypt
def decrypt(self, envelope):
  envelope = base64.b64decode(envelope)
  nonce = envelope[:self.nonce_len]
  ciphertext = envelope[self.nonce_len:]
  plaintext = self.aesgcm.decrypt(nonce,ciphertext,None)
  return plaintext

Usage

You can find the full script here. Usage is straightforward:

from envelope import Encryptor

secret = "1337"

encryptor = Encryptor("/data/rsa.pub")
print(encryptor.enc_aes_key)
print(encryptor.encrypt(secret))

And on the client/operator side:

from envelope import Decryptor

enc_aes_key = sys.argv[1]
ciphertext = sys.argv[2]

decryptor = Decryptor(".ssh/id_rsa", enc_aes_key)
print(decryptor.decrypt(ciphertext))

Obviously, the example is simplified and in actual implementation you would use an external storage (cloud or local) adapter in both server and client, but I wanted to specifically show the cryptography wrapper.

Hopefully, this article was useful and/or interesting.