├── LICENSE.md ├── README.md └── dist ├── client.js └── sw ├── github.js └── serviceworker-stub.js /LICENSE.md: -------------------------------------------------------------------------------- 1 | This library is MIT licensed. 2 | 3 | Copyright 2018 Daniel Huigens 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Signed Web Apps 2 | 3 | **Note:** This library isn't finished yet. Please check back later. 4 | 5 | ## What is it? 6 | 7 | This is a JavaScript library to protect the HTML, JS and CSS in web apps 8 | from tampering by malicious servers or developers. It does this by 9 | installing some code in a [Service Worker][SW], which checks the code 10 | every time you open the web app. In effect, this makes it [Trust on 11 | First Use][TOFU]. 12 | 13 | ## What web apps is this for? 14 | 15 | Most web apps inherently require you to trust the servers and developers 16 | (for example, because they send your data to the server). However, some 17 | do not. This can either be because they store and process your data 18 | entirely on the client, in JavaScript, or because data is encrypted on 19 | the client before it is sent to the server. This library is meant for 20 | those web apps. 21 | 22 | ## So what's the problem this solves? 23 | 24 | When you open a web app, all of its code is delivered by its web server. 25 | It is fairly trivial for the operators of that server to one day decide 26 | to serve you code that *does* send your data to the server, or *doesn't* 27 | encrypt it before doing so. Similarly, a hacker which compromised the 28 | server can do the same. Even worse, a malicious developer could target 29 | *just one or a few* users, and serve them malicious code. That would be 30 | almost impossible to detect. 31 | 32 | ## Why check GitHub? Why not just sign the code using public key crypto? 33 | 34 | Merely signing the code, and delivering public key signatures together 35 | with the code which are checked against a public key, does not solve the 36 | last attack mentioned in the previous paragraph. After all, the 37 | developers could write some malicious code, sign it, and then deliver 38 | both to a target user. 39 | 40 | ## How do I use it? 41 | 42 | This library is a building block, just as encryption is a building 43 | block. It does not, by itself, "make your web app secure". In 44 | particular, it does not attempt to verify that all code in the web app 45 | is checked, and that it does not `eval()` other, untrusted code, etc. To 46 | check that, you should make use of a [Content Security Policy][CSP]. 47 | 48 | As a general rule, if all code in your web app comes from your own 49 | server, or from a third-party server while using [Subresource 50 | Integrity][SRI], and you use an appropriate [Content Security 51 | Policy][CSP] to verify all that, *and* all the client-side code from 52 | your server is on GitHub and verified by this library, then you're set. 53 | 54 | **Note:** This library is experimental and subject to change (as is its 55 | API). To a lesser extent, so is the Service Worker API and its support 56 | by browsers. Therefore, if you decide to use it, update this library 57 | often to stay up-to-date with security patches. 58 | 59 | **Example app:** 60 | 61 | [Example app running on Heroku][swa-example] ([code on 62 | GitHub][swa-example-gh]). 63 | 64 | **Installation:** 65 | 66 | 1. Include this repository under your project and copy 67 | `serviceworker-stub.js` to the root of your project: 68 | 69 | git submodule add -b master https://github.com/airbornio/signed-web-apps.git 70 | cp signed-web-apps/dist/sw/serviceworker-stub.js . 71 | 72 | 2. The following code should be included on **every page** of your web 73 | app (even 404 and other error pages). If you don't, an attacker 74 | could send users to a page without it, and the library would have no 75 | way of warning users of any malicious code on the page. 76 | 77 | ```html 78 | 79 | 97 | ``` 98 | 99 | 3. Create a file called `serviceworker-import.js` in the root of your 100 | domain. This file will (1) import other parts of the library and 101 | (2) tell the library where on GitHub to find your files. 102 | 103 | [Generate your configuration code here][generate-config] and 104 | copy+paste it to that file. 105 | 106 | 4. Make sure that your server serves `Last-Modified` headers that 107 | correspond to either the date when you last pushed files to your 108 | server, or the date when the specific file changed on your server 109 | (the latter may lead to an increase in GitHub API requests in some 110 | cases, though). 111 | 112 | The default configuration from the previous step assumes that you: 113 | 114 | - Push to GitHub *before* you push to your server, and that the 115 | date in the `Last-Modified` header is later than when you pushed 116 | to GitHub. 117 | - Always push to your server within a day of pushing to GitHub. 118 | It's probably a good idea to set up a `production` branch for 119 | this purpose. 120 | - Don't push an old commit to your server. If you want to rollback 121 | your server to an older version, it's probably best to create a 122 | revert commit and push it to GitHub and your server. 123 | 124 | 5. Update often. (Please see the note above the installation 125 | instructions for the reasons why.) Preferably add this to your 126 | install or build script: 127 | 128 | git submodule update --remote 129 | cp signed-web-apps/dist/sw/serviceworker-stub.js . 130 | 131 | 132 | [SW]: https://developer.mozilla.org/docs/Web/API/Service_Worker_API 133 | [TOFU]: https://en.wikipedia.org/wiki/Trust_on_first_use 134 | [CSP]: https://developer.mozilla.org/docs/Web/HTTP/CSP 135 | [SRI]: https://developer.mozilla.org/docs/Web/Security/Subresource_Integrity 136 | [swa-example]: https://signed-web-apps-example.herokuapp.com/ 137 | [swa-example-gh]: https://github.com/airbornio/signed-web-apps-example 138 | [generate-config]: https://airbornio.github.io/signed-web-apps/generate-config.html -------------------------------------------------------------------------------- /dist/client.js: -------------------------------------------------------------------------------- 1 | class SWA extends EventTarget { 2 | constructor(config) { 3 | super(); 4 | if('serviceWorker' in navigator) { 5 | navigator.serviceWorker.register(config.url, { 6 | scope: '/', 7 | }).then(function(registration) { 8 | navigator.serviceWorker.ready.then(function() { 9 | registration.active.postMessage({ 10 | msg: 'ready', 11 | }); 12 | }); 13 | 14 | registration.addEventListener('updatefound', function(event) { 15 | if(registration.active !== null) { // If there is an active Service Worker... 16 | notifyAboutUpdate('updatefound', 'serviceworker.js'); // ... notify that there's a new one. 17 | } 18 | }); 19 | }).catch(err => { 20 | console.error(err); 21 | let errEvent = new ErrorEvent('error', {message: 'Service Worker failed to install.'}); 22 | errEvent.code = 'sw_failed'; 23 | this.dispatchEvent(errEvent); 24 | }); 25 | 26 | navigator.serviceWorker.addEventListener('message', event => { 27 | this.dispatchEvent(new event.constructor(event.data.action, event)); 28 | }); 29 | } else { 30 | setTimeout(() => { 31 | let errEvent = new ErrorEvent('error', {message: 'Service Workers are not supported in your browser.'}); 32 | errEvent.code = 'sw_not_supported'; 33 | this.dispatchEvent(errEvent); 34 | }); 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /dist/sw/github.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | /* Configurable by /serviceworker-import.js: */ 3 | 4 | self.gitRepository = (req, res) => ''; 5 | 6 | self.gitBranch = (req, res) => 'master'; 7 | 8 | self.gitTreeUrl = (path, ref) => { 9 | return 'https://api.github.com/repos/' + gitRepository(req, res) + '/git/trees/' + (ref || '').replace(/\W+/g, '') + '?recursive=1'; 10 | }; 11 | 12 | self.gitDirectory = (req, res) => ''; 13 | 14 | self.commitsCacheTime = (req, res) => 86400000; 15 | 16 | self.maxCommitAge = (req, res) => 86400000; 17 | 18 | self.gitCommitsUrl = (req, res) => { 19 | return 'https://api.github.com/repos/' + gitRepository(req, res) + '/git/commits?ref=' + gitBranch(req, res); 20 | }; 21 | 22 | self.gitCommits = async (req, res) => { 23 | let commitsUrl = gitCommitsUrl(req, res); 24 | let commitsResponse = await caches.match(commitsUrl); 25 | let commitsResponseDate; 26 | let commits; 27 | if (commitsResponse) { 28 | let commitsResponseDate = new Date(commitsResponse.headers.get('Date')); 29 | if ( 30 | new Date() - commitsResponseDate < commitsCacheTime(req, res) && 31 | new Date(res.headers.get('Last-Modified')) < commitsResponseDate 32 | ) { 33 | commits = await commitsResponse.json(); 34 | } 35 | } 36 | if (!commits) { 37 | commitsResponse = await fetch(commitsUrl); 38 | commitsResponseDate = new Date(commitsResponse.headers.get('Date')); 39 | cachePut(commitsUrl, freshResponse); 40 | commits = await commitsResponse.clone().json(); 41 | } 42 | let maxAge = maxCommitAge(req, res); 43 | let lastCommitIndex = commits.findIndex((commit, i) => { 44 | return i > 0 && commitsResponseDate - new Date(commit.commit.committer.date) > maxAge; 45 | }); 46 | return lastCommitIndex === -1 ? commits : commits.slice(0, lastCommitIndex); 47 | }; 48 | 49 | self.gitCommit = async (req, res) => { 50 | let commits = await gitCommits(req, res); 51 | return commits.find((commit, i) => i === commits.length - 1 || new Date(commit.commit.committer.date) < new Date(response.headers.get('Last-Modified'))); 52 | }; 53 | 54 | self.gitPath = (req, res) => { 55 | let path = new URL(request.url).pathname.substr(1); 56 | if(path === '') path = 'index.html'; 57 | return gitDirectory(req, res) + path; 58 | }; 59 | 60 | self.shouldCheckGit = req => true; 61 | 62 | self.shouldCheckGitSync = (req, res) => true; 63 | 64 | self.shouldCache = (req, res) => false; 65 | 66 | /* End of configuration. */ 67 | 68 | 69 | let CACHE_VERSION = 'swa-v1'; 70 | 71 | var clientReady = {}; 72 | self.addEventListener('fetch', event => { 73 | let req = event.request; 74 | if(req.method === 'GET' && shouldCheckGit(req)) { 75 | var cachedResponse = caches.match(req); 76 | event.respondWith( 77 | cachedResponse.then(cachedResponse => cachedResponse ? cachedResponse.clone() : freshResponse) 78 | ); 79 | var freshResponse = Promise.all([cachedResponse, fetch(req)]).then(async function([cachedResponse, res]) { 80 | if(res.ok) { 81 | let path = self.gitPath(req, res); 82 | let pathInModule = path; 83 | var check = Promise.all([ 84 | cachedResponse && cachedResponse.clone().arrayBuffer(), 85 | res.clone().arrayBuffer(), 86 | gitCommit(req, res), 87 | ]).then(async function([cachedBuffer, freshBuffer, commit]) { 88 | if(cachedBuffer && equal(cachedBuffer, freshBuffer)) { 89 | notifyAboutUpdate(event.clientId, 'response_unchanged', path, commit, true, req, res); 90 | if(shouldCache(req, res)) event.waitUntil(cachePut(req, res)); // Update response headers 91 | return true; 92 | } else { 93 | var treeUrl = gitTreeUrl(pathInModule, commit); 94 | var treeResponse = commit && await getPermanentResponse(treeUrl); 95 | inSubmodule: do { 96 | var tree = treeResponse && (await treeResponse.json()).tree; 97 | if(tree instanceof Array) { 98 | var fileDescr; 99 | for(let descr of tree) { 100 | if(descr.type === 'commit' && pathInModule.startsWith(descr.path)) { 101 | let submoduleContents = await getPermanentResponse('https://api.github.com/repos/' + gitRepository(req, res) + '/contents/' + descr.path + '/?ref=' + (commit || '').replace(/\W+/g, '')); 102 | submoduleContents = await submoduleContents.json(); 103 | treeResponse = await getPermanentResponse(submoduleContents.git_url + '?recursive=1'); 104 | pathInModule = pathInModule.substr(descr.path.length + 1); 105 | continue inSubmodule; 106 | } 107 | if(descr.path === pathInModule) { 108 | fileDescr = descr; 109 | break; 110 | } 111 | } 112 | if(!fileDescr) { 113 | notifyAboutUpdate(event.clientId, 'signature_missing', path, commit, !!cachedResponse, req, res); 114 | return false; 115 | } else if( 116 | fileDescr.size === freshBuffer.byteLength && 117 | fileDescr.sha === await gitSHA(freshBuffer) 118 | ) { 119 | notifyAboutUpdate(event.clientId, 'signature_matches', path, commit, !!cachedResponse, req, res); 120 | if(shouldCache(req, res)) event.waitUntil(cachePut(req, res)); 121 | return true; 122 | } else { 123 | notifyAboutUpdate(event.clientId, 'signature_mismatch', path, commit, !!cachedBuffer, req, res); 124 | return false; 125 | } 126 | } else { 127 | var client_error = !commit || treeResponse && treeResponse.status >= 400 && treeResponse.status < 500; 128 | notifyAboutUpdate(event.clientId, client_error ? 'signature_mismatch' : 'network_error', path, commit, !!cachedBuffer, req, res); 129 | return !client_error; 130 | } 131 | } while(true); 132 | } 133 | }); 134 | event.waitUntil(check); 135 | if(shouldCheckGitSync(req, res) && !await check) { 136 | return new Response(INVALID_SIG_RESPONSE, {status: 500, statusText: 'Did not match signature'}); 137 | } 138 | return res.clone(); 139 | } 140 | return res; 141 | }); 142 | event.waitUntil(freshResponse); 143 | } 144 | BEFORE_FIRST_FETCH = false; 145 | }); 146 | 147 | var clientReady = {}; 148 | var onClientReady = {}; 149 | self.addEventListener('message', event => { 150 | if(event.data.msg === 'ready') { 151 | if(onClientReady[event.source.id]) { 152 | onClientReady[event.source.id](); 153 | } else { 154 | clientReady[event.source.id] = Promise.resolve(); 155 | } 156 | } 157 | }); 158 | 159 | async function getPermanentResponse(permaUrl) { 160 | var response = await caches.match(permaUrl); 161 | if(!response) { 162 | response = await fetch(permaUrl); 163 | if(response.ok) { 164 | cachePut(permaUrl, response.clone()); 165 | } 166 | } 167 | return response; 168 | } 169 | 170 | async function notifyAboutUpdate(clientId, msg, path, commit, inCache, req, res) { 171 | var clientList = clientId ? [await clients.get(clientId)] : await clients.matchAll({ 172 | includeUncontrolled: true, 173 | type: 'window', 174 | }); 175 | clientList.forEach(async function(client) { 176 | // For the first few requests (e.g. the html file and the first css 177 | // file) the client might not be ready for messages yet (no message 178 | // event handler installed yet). Therefore, we wait until we get a 179 | // message that it's ready. 180 | await (clientReady[client.id] || (clientReady[client.id] = new Promise(function(resolve) { 181 | onClientReady[client.id] = resolve; 182 | }))); 183 | client.postMessage({ 184 | action: 'urlChecked', 185 | msg, 186 | path, 187 | commit, 188 | inCache, 189 | gitRepository: gitRepository(req, res), 190 | gitDirectory: gitDirectory(req, res), 191 | }); 192 | }); 193 | } 194 | 195 | function cachePut(request, response) { 196 | return caches.open(CACHE_VERSION).then(cache => cache.put(request, response)); 197 | } 198 | 199 | // https://stackoverflow.com/questions/460297/git-finding-the-sha1-of-an-individual-file-in-the-index/24283352 200 | async function gitSHA(buffer) { 201 | var prefix = 'blob ' + buffer.byteLength + '\0'; 202 | var prefixLen = prefix.length; 203 | var newBuffer = new ArrayBuffer(buffer.byteLength + prefixLen); 204 | var view = new Uint8Array(newBuffer); 205 | for(var i = 0; i < prefixLen; i++) { 206 | view[i] = prefix.charCodeAt(i); 207 | } 208 | view.set(new Uint8Array(buffer), prefixLen); 209 | return hex(await crypto.subtle.digest('sha-1', newBuffer)); 210 | } 211 | 212 | // https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest 213 | function hex(buffer) { 214 | var view = new DataView(buffer); 215 | var hexParts = []; 216 | for(var i = 0; i < view.byteLength; i += 4) { 217 | hexParts.push(('00000000' + view.getUint32(i).toString(16)).slice(-8)); 218 | } 219 | return hexParts.join(''); 220 | } 221 | 222 | // https://stackoverflow.com/questions/21553528/how-can-i-test-if-two-arraybuffers-in-javascript-are-equal 223 | function equal(buf1, buf2) { 224 | if(buf1.byteLength !== buf2.byteLength) return false; 225 | var dv1 = new Int8Array(buf1); 226 | var dv2 = new Int8Array(buf2); 227 | for(var i = 0; i !== buf1.byteLength; i++) { 228 | if(dv1[i] !== dv2[i]) return false; 229 | } 230 | return true; 231 | } 232 | 233 | var BEFORE_FIRST_FETCH = true; 234 | registration.addEventListener('updatefound', function(event) { 235 | // When the service worker gets updated, there may not necessarily be a 236 | // client that can show a message for us (e.g., it may be triggered by a 404 237 | // page). Therefore, we show a web notification. 238 | if(!BEFORE_FIRST_FETCH) { 239 | self.registration.showNotification('Airborn OS has been updated.', { 240 | body: "We can't be sure that it's an update that's publicly available on GitHub. Please check that you trust this update or stop using this version of Airborn OS.", 241 | icon: 'images/logo-mark.png' 242 | }); 243 | } 244 | }); 245 | })(); -------------------------------------------------------------------------------- /dist/sw/serviceworker-stub.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | const CACHE_VERSION_STUB = 'swa-stub-v1'; 3 | const CACHE_VERSION_IMPORTS = 'swa-imports-v1'; 4 | 5 | const importUrl = absoluteUrl('serviceworker-import.js'); 6 | 7 | const eventTarget = new EventTarget(); 8 | 9 | const eventNames = ['install', 'activate', 'fetch', 'message']; 10 | 11 | let importDone; 12 | 13 | self.addEventListener('install', event => { 14 | console.log('install handler #1'); 15 | event.waitUntil((async () => { 16 | await importDone; 17 | let newImport = await fetchFromSW(importUrl); 18 | if(!newImport.ok) { 19 | // Cancel installation. 20 | throw new Error('Fetching Service Worker import failed.'); 21 | } 22 | let cache = await caches.open(CACHE_VERSION_STUB); 23 | await cache.put(importUrl, newImport); 24 | console.log('added'); 25 | 26 | // Run new import. 27 | await (importDone = runImport()); 28 | })()); 29 | }); 30 | 31 | let updateImports = false; 32 | self.addEventListener('activate', event => { 33 | // let cache = await caches.open(CACHE_VERSION_IMPORTS); 34 | // cache.clear(); 35 | 36 | updateImports = true; 37 | }); 38 | 39 | for(let eventName of eventNames) { 40 | self.addEventListener(eventName, event => { 41 | console.log(eventName, 'event'); 42 | event.waitUntil((async () => { 43 | await importDone; 44 | let eventClone = new event.constructor(event.type, event); 45 | ['waitUntil', 'replyWith'].forEach(fn => { 46 | eventClone[fn] = (...args) => event[fn](...args); 47 | }); 48 | eventTarget.dispatchEvent(eventClone); 49 | })()); 50 | }); 51 | } 52 | 53 | ['addEventListener', 'removeEventListener'].forEach(fn => { 54 | self[fn] = (...args) => eventTarget[fn](...args); 55 | }); 56 | 57 | self.eventTarget = eventTarget; 58 | 59 | console.log('addEventListener set'); 60 | 61 | importDone = runImport(); 62 | 63 | async function runImport() { 64 | let response = await caches.match(importUrl); 65 | if(response) await self.eval(await response.text() + '\n//# sourceURL=' + importUrl); 66 | return response; 67 | } 68 | 69 | async function fetchFromSW(url) { 70 | let request = new Request(url); 71 | return await new Promise(resolve => { 72 | let event = new FetchEvent('fetch', {request}); 73 | let resolved = false; 74 | event.respondWith = response => { 75 | console.log('respondWith called'); 76 | resolve(response); 77 | resolved = true; 78 | }; 79 | event.waitUntil = () => {}; 80 | eventTarget.dispatchEvent(event); 81 | console.log('dispatched. resolved: ' + resolved); 82 | setTimeout(() => { 83 | console.log('resolved: ' + resolved); 84 | if(!resolved) { 85 | resolve(fetch(request)); 86 | } 87 | }); 88 | }); 89 | } 90 | 91 | self.importScriptsFromSW = async function(...scripts) { 92 | let cache = await caches.open(CACHE_VERSION_IMPORTS); 93 | await Promise.all(scripts.map(async script => { 94 | script = absoluteUrl(script); 95 | let response = await cache.match(script); 96 | if(!response || updateImports) { 97 | try { 98 | let newImport = await fetchFromSW(script); 99 | if(newImport.ok) { 100 | cache.put(script, newImport.clone()); 101 | response = newImport; 102 | } 103 | } catch(e) {} 104 | } 105 | await self.eval(await response.text() + '\n//# sourceURL=' + script); 106 | })); 107 | }; 108 | 109 | function absoluteUrl(url) { 110 | return new URL(url, registration.scope).href; 111 | } 112 | })(); --------------------------------------------------------------------------------