October 7, 2019

Load Encrypted Model Data with WebAssembly and Rust

Security and intellectual property protection has arguably been a high priority concern for Forge and BIM developers at large. With Forge our strengthening measures include the enforcement of TLS 1.2 for all Forge endpoints as well as the proxy service approach discussed here. But what if the token itself becomes part of the attack surface when it gets stolen, or even the client agent is compromised? As a countermeasure to these extreme (but quite likely) scenarios, is it possible to secure the derivatives (translated model files) during their entire transmission cycle with encryption, decrypt securely on the client side (yes the browsers) and load them with Viewer? The answer is yes thanks to the emergence of WebAssembly so we can rest assured that your model files are safe against token theft or client side breach - let’s see how you can encrypt your local model files or the ones on Forge using the proxy approach and load them as usual on Viewer.

Architectural Overview

We are going to implement server side encryption with streamed cipher to serve and encrypt protected resources on the fly, and then decipher in real time with client side decryption in WebAssembly. Once again our workflow today will be marshaled by our recurring guest star - Service Worker - so you might want to read up on these relevant topics (Disconnected Workflow and Advanced Service Worker with Viewer) if you haven’t before continuing.

Sb233

Design Rationale

Service Worker is without a doubt indispensable as it is necessary to intercept Viewer’s load requests and to achieve code-splitting by injecting the decipher in a non-intrusive manner w/o impacting the rest of our app. 

However would it be an overkill to involve WebAssembly since it is entirely possible to decipher almost every cryptographic algorithm in pure JavaScript? Programmatically probably yes - but if we did so all the code would be exposed and the implementation details of the decipher would be inevitably transparent to the attackers and hence become an attack vector itself. With WebAssembly our decipher would be compiled to assembly level code which would be reasonably complex to reverse engineer. And performance wise our JavaScript decipher might lag behind WebAssembly by some distance as well.

WebAssembly - A Game Changer for Web Apps

WebAssembly (WASM), in a nutshell, is new type of code that can be run in modern web browsers and other platforms (Node.js etc) — it is a low-level assembly-like language with a compact binary format that runs with near-native performance. See browser support as of 2019.10 below and we can see its production readiness is a lot better if compared year over and that attests to momentum that WASM is gaining of late:

233

WebAssembly provides languages such as C/C++ and Rust with a compilation target so that they can run on the web. It is also designed to run alongside JavaScript, allowing both to work together. 

As of 2019.10 we can use compilers (Emscripten etc.) to compile the following languages to WebAssembly:

233

Rust - Blazingly Fast and Memory-efficient: Without Runtime or Garbage Collector

Rust is a multi-paradigm system programming language focused on safety, especially safe concurrency. Rust’s rich type system and ownership model guarantee memory-safety and thread-safety and enable you to eliminate many classes of bugs at compile-time.

233

While it is conceivable that one day people will be writing the latest 3D video games in Rust — an area where high performance has historically been critical — it is unlikely ever to have a web framework that will go toe-to-toe with Ruby on Rails.

Rust is our language of choice to implement the client side cipher because WebaAssembly is among its default compilation targets and we get to benefit from its memory features, sleek syntax and impressive ecosystem with powerful tools like cargo and wasm-pack.

Server Side Encryption - Node.js Crpto

In our backend we are using the crypto module of the Node.js standard library to create a stream cypher to encrypt on the fly files under the model folders as we serve them - just so the model files themselves can be stored as is w/o physical encryption, giving us the flexibility to change salt, keys and even the encryption algorithm when we want to and keep the files directly accessible internally to other modules or apps all the while. To do this our route handler looks like:
 

//src/server/index.js
    server.route({
    method: 'GET',
    path: '/models/{file*}',
    handler: (request, h) => {

        const filepath = path.join(__dirname,'../../public/models',request.params.file);
        if(!fs.existsSync(filepath)) return h.response().code(404);
      const  cipher = crypto.createCipheriv('aes-192-ctr', key, Buffer.alloc(16, 0));
      return h.response(fs.createReadStream(filepath).pipe(cipher))
    }});

 

And let's also set up a route to proxy requests to Forge endpoints and inject OAuth tokens into the outbound requests so the tokens stay behind the backend at all times - similar to local model files we apply a cipher to encrypt the proxy response, supposing traffic between Forge and your backend is secure doing so reduces the attack surface drastically:

server.route({
    method: 'GET',
    path: '/proxy/{path*}',
    handler: { proxy: {
      mapUri: request => ({
        headers: { Authorization: 'Bearer ' + token },
        uri: 'https:///developer.api.autodesk.com/' + request.params.path }),
      onResponse: function (err, res, request, h, settings, ttl) {
        const cipher = crypto.createCipheriv('aes-192-ctr', key, Buffer.alloc(16, 0))
        return h.response(res.pipe(cipher))
      }
    } }
  })

Client Side Decryption - Rust WASM

Given the limited types available to exchange between WASM and JavaScript we will pass the read chunks to Rust as u8 (8 bit unsigned integer) vector and as Uint8Array from JavaScript. And since there’s no other media than the exchangeable, primitive types and shared memory available to interchange object references or convey and persist application states we will employ a static map (URL as key to perfectly avoid racing conditions) to hold the ciphers and enclose them with mutex to allow themselves to be mutable so their own states can be persisted to decipher the streams properly - this will serve us just fine as the WASM instance is going to live through the entirety of Service Worker life cycle.

//src/wasm/src/lib.rs
lazy_static! {
     static ref cipherMap:Mutex<HashMap<String,Mutex<Aes192Ctr>>> = Mutex::new(HashMap::new());  // URL to cipher map
}

#[wasm_bindgen]
pub fn decrypt(mut buffer: &mut[u8], key: &str) -> Vec<u8> {
    let mut cipherMapLock = cipherMap.lock().unwrap();
    let stringKey = String::from(key);
    if !cipherMapLock.contains_key(&stringKey)
    {
        let cipherKey = hex::decode("6b65796b65796b65796b65796b65796b65796b65796b6579").unwrap(); 
        cipherMapLock.insert(stringKey.to_string(), Mutex::new(Aes192Ctr::new_var(&cipherKey, &[0; 16]).unwrap()));  //create cipher and add to static map
    }

    let mut cipher = cipherMapLock.get(&stringKey).unwrap().lock().unwrap();

    cipher.apply_keystream(&mut buffer);  // decrypt chunk and move the cipher
    buffer[..].to_vec()
}

#[wasm_bindgen]
pub fn finish(key: &str)  {
    cipherMap.lock().unwrap().remove(&String::from(key));
    ()   //returns nothing
}

 

In our Service Worker we load and instantiate the WASM module - note that with the Service Worker Webpack plugin we can happily delegate and abstract that away by importing the WASM module directly into your JS code, but since that’d require a dynamic import that is not exactly supported for now by Service Worker so we’d need to instantiate explicitly with our own code:
 

//src/client/sw.js
WebAssembly.compileStreaming(fetch(`/${wasm_package_name}_bg.wasm`)).then(mod => WebAssembly.instantiate(mod, { imports: {} }).then(instance => {
  self.wasm = instance.exports    //expose WASM exports for the glue code
  self.clients.matchAll().then(clients => clients[0].postMessage('tryinitViewer')) //notify that the WASM is ready
}))

 

Then we set up to intercept responses, apply our own reader and response stream. Here we see one crucial benefit of using wasm-pack to compile our WASM module as it automatically produces JS glue code that enables us to pass and receive chunks to WASM by calling its methods as easily as an ordinary JS function:

 

//src/client/index.js
self.addEventListener('fetch', event => {
  event.respondWith(
    async function () {
      if (/http\:\/\/(\w|\:)+\/models/.test(event.request.url)) {
        const response = await fetch(event.request)
        if (response.status != 200) return response
        const reader = response.body.getReader()
        const url = event.request.url
        const stream = new ReadableStream({
          start (controller) {
            function push () {
              reader.read().then(({ done, value }) => {
                if (done) {
                  controller.close()  //finish reading the response stream
                  finish(url) //remove the cipher when done
                  return
                }
                controller.enqueue(decrypt(value, url)) //decrypt the buffered chunk
                push()  //read and decrypt the response stream recursively 
              })
            };

            push()
          }
        })

        return new Response(stream)  //return the decrypted response
      } else return fetch(event.request) 
    }())
})

 

Testing and Benchmarking

Time to compile and run the code to see it works like a charm:

233
And we can be assured that the model would deterministically fail to load w/o the cipher:

233

See below charts as we go head to head over the load times with and w/o encryption on Chrome 77.

233

233

23

Impressively the overheads of decryption are at a minimum, almost ignorable with subtle differences only in memory usage - looks like Rust kept well its promise about memory efficiency and hats off to the maturity of modern browsers in their support for WebAssembly.

233

Full Working Sample

See repo here - this sample uses AES encryption with 192-bit key and the CTR cipher mode and can be switched to other encryption specs or adopt the assembly for your other workflows that suit your needs.

Remaining Attach Surface - If Any Left at All?

Let’s take a step back and review our current attack window:

  • Files are served as encrypted binary - no vector here
  • Ciphers are either secure behind the backend or compiled to WASM - (almost) no vector here
  • However malicious user can still steal logins from another user and save the decrypted files from the browser - that could only be addressed with a Viewer’s port or binder module to WASM so we can keep the decrypted data entirely and transiently in the WASM memory space - hopefully we can see this wish come true in the not so distant future!
  • Another vector could be exposed if a malicious user gains access to the client app files and runs the WASM locally to decrypt the files

And here’s a few strengthening measures off the top of my head:

 

  • We can change the salt keys periodically without having to downtime to persist the encrypted files again
  • Audit incoming requests to detect attack attempts
  • Fire a homing call from within the WASM to the backend for it to check the origin of the request so the module can verify if it’s running in a permitted context

Can you think of other attack vectors or come up with countermeasures for our existing vectors? Let them be heard by reaching out to forge.help@autodesk.com!

Alright so much for today and hopefully you got to take some heart from reading this article over security strengthening with your Forge workflows. Thanks and until next time!

 

Related Posts