├── 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 | })();
--------------------------------------------------------------------------------