Transparent Web Apps using Service Workers
By Daniel Huigens on Thursday, August 3 2017
TLDR: As has been written before, writing web applications that don't trust the server (e.g. by adding client-side encryption, or storing all data in local storage and never sending it to the server) wasn't possible before, because the browser requests the code of the web application from the server every time you open it and executes it trustingly. We propose a solution, analogous to Binary Transparency on the desktop, using Service Workers, and have implemented it for Airborn OS.
Web applications, unlike desktop and mobile applications, aren't normally installed: the browser downloads the application from the server every time you open it. As well as advantages, this has some disadvantages: for example, they don't normally work offline. I say "normally" because in modern browsers, web applications can partially "install" themselves with Service Workers. Service Workers sit between the web application and the server and cache responses so that the app keeps working offline, among other things.
There's also a security disadvantage to downloading the web app every time, if you want to verify whether you trust its source code: even if you read all the source code on GitHub, the server could send you a new version tomorrow with no easy way to notice.
Furthermore, the server could send just you a different version of the web application tomorrow. There's no way to check that you're getting the same version as everyone else. This also applies to desktop applications, and the solution is Binary Transparency, such as Firefox is planning to implement. Basically, every release will be put in a public log, and the Firefox Updater will check that the new version matches the version in the log.
Now that we know all the pieces of the puzzle, the question arises: can we use Service Workers to achieve Binary Transparency? It turns out we can, with some limitations.
When the browser requests a file (/ or /main.js, say) the request goes to the Service Worker. If the SW has a response in cache, it responds with that immediately. It also requests an up-to-date version from the server. If it differs from the version in cache (i.e. the web app has been updated), it sends a request to the GitHub API for a list of files with sizes and hashes on GitHub. If the response matches, the cached version is updated and the user is shown this message:
That link to GitHub is not just a generic link to the repository: it's a link to the specific commit with the same code that we received from the server (the Service Worker verified that). This makes it easy to check whether you trust the code of the web application.
If the response doesn't match, the user is shown this message:
Note that it's not a warning because the user isn't really at risk, because we still have the old version in cache. If that's not the case (this is the first time the user opens the web app) this error is shown instead:
Note that while we chose GitHub as a public log, this is not a requirement. You could check the files against another (possibly cryptographic) log, or check that they are signed with a given public key, etc.
What does this guarantee?
If you trust or verify the Service Worker the first time you get it from the server, this guarantees that:
- Unless you've gotten a message saying Airborn OS has been updated*, you're still running the same code as when you first opened Airborn OS
- Unless you've gotten a message saying you should check your trust in Airborn OS*, you're running the code on GitHub, and therefore the same code everyone else is running
This makes it possible for a security researcher to read the code on GitHub, publish their results (with inspected GitHub commit) and for everyone to verify that they're running that same code.
(*) Or a warning, error, or your computer has been hacked, or (for the second guarantee) if both the server and GitHub have been hacked. However, notably, the guarantees hold true if the server has been hacked or gone rogue. If the server has been hacked, the hacker also needs access to GitHub to update the code for existing users. And if the developer has gone rogue and pushes malicious code to the server, he also has to push that same code to GitHub, which makes it possible for observers to detect his rogueness.
Furthermore, we would like to guarantee that a security researcher can independently verify that all users are running the same code, as long as the server was not malicious when the users first opened the web app.
However, currently, the server tells the Service Worker which commit on GitHub each file is coming from. This is necessary, because it's hard to guarantee that the browser always gets the latest version on GitHub, due to caches and using a CDN. However, an attacker could hide a commit on GitHub, for example in an old branch. To solve this, the Service Worker could check that the commit is in a specific 'release' branch, and either the latest commit or no more than 24 hours old. We plan to implement this but haven't done so yet.
Service Worker lifecycle
The designers of the Service Worker specification have taken great care to make sure that a web application cannot permanently break itself using Service Workers: the browser checks for an update to the SW on every page load, and we can't prevent the SW from being updated if it has been changed on the server. Fortunately, both the old Service Worker and the web app get notified when that happens, so we can notify the user of the update.
However, we can't actually check that the new Service Worker matches the version on GitHub. We can request the new Service Worker file, of course, but (at least in Chrome) that issues a separate network request from the request that Chrome itself used to get the Service Worker, so we can't be sure that the server replied with the same file.
It would be nice if Chrome could put the Service Worker file it got in network cache, so that the old Service Worker can request it from there. Then, we can stop showing the relatively unhelpful warning above.
Service Workers also update on Push Notification and Background Sync events. We currently use neither, but it's worth verifying that browsers starts the old Service Worker for the event (while updating asynchronously) and sends it an update event, which is what we want. It's also worth verifying that there's no other way to trigger an update, especially without starting and notifying the old Service Worker, since that would be a vulnerability for our use case.
Previous approaches to Transparent Web Apps
Previously, some (including Airborn OS) have been using browser addons to increase the security of their web apps. The obvious disadvantage of this is that users have to install an addon. Also, you can't install addons on mobile Chrome and Safari.
I've previously written about another approach using Certificate Transparency. Basically, the idea is to put checksums of the files of the web app in the website's certificate. Thanks to Certificate Transparency, we can check that there's only one certificate for the website (and by extension, only one version of the code).
The advantage of that approach is that it's less trust-on-first-use: it can protect you even the first time you open the web app on a computer. The disadvantages are that it's less flexible (because it's implemented in the browser or an addon instead of in the web app), requires more work or tooling to update the web app, and requires work on the part of browser makers to implement. The solution using Service Worker, in contrast, can be deployed today.
There are many web apps that would benefit from being made transparent: encrypted chat apps, BitCoin wallets, but also client-side tools such as word counters and photo editors. Let us know if you're a web app developer and need help implementing something like this. We would also like to create a library (maybe with the help from other web app developers) to make that easy. Let us know if you want to help or have any feedback!