Browse Source

1.1.0: mobile device support, hack to reload when appcache changes, refactor storage and http into separate services, extract constants

forest johnson 2 years ago
parent
commit
df9fea02b0
6 changed files with 194 additions and 117 deletions
  1. 1 1
      ReadMe.md
  2. 1 1
      build.sh
  3. 6 5
      index.html.gotemplate
  4. 4 0
      server.go
  5. 23 2
      static/application.css
  6. 159 108
      static/application.js

+ 1 - 1
ReadMe.md

@ -22,7 +22,7 @@ docker run \
22 22
  -e SEQUENTIAL_READ_PWM_AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY \
23 23
  -e SEQUENTIAL_READ_PWM_S3_BUCKET_NAME=my-encrypted-password-bucket \
24 24
  -e SEQUENTIAL_READ_PWM_S3_BUCKET_REGION=us-west-2 \
25
  sequentialread/sequentialread-password-manager:1.0.0
25
  sequentialread/sequentialread-password-manager:1.1.0
26 26
```
27 27
28 28
See "Hosting it yourself" for more information.

+ 1 - 1
build.sh

@ -1,4 +1,4 @@
1 1
#!/bin/bash
2 2
3 3
GOOS=linux GOARCH=amd64 go build -v -o sequentialread-password-manager server.go \
4
  && docker build -t sequentialread/sequentialread-password-manager:1.0.0 .
4
  && docker build -t sequentialread/sequentialread-password-manager:1.1.0 .

+ 6 - 5
index.html.gotemplate

@ -2,6 +2,7 @@
2 2
<html manifest="index.appcache?v={{.Version}}">
3 3
<head>
4 4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1"/>
5 6
  <title>pwm</title>
6 7
  <script>
7 8
    // global namespace where we will be putting everything
@ -25,20 +26,20 @@
25 26
  <div class="header">
26 27
    <h3>Sequential Read Password Manager</h3>
27 28
    <div style="float:right; padding-right:20px;">
28
      <a href="https://git.sequentialread.com/forest/sequentialread-password-manager">source code and documentation</a>
29
      <a class="source-link-responsive" href="https://git.sequentialread.com/forest/sequentialread-password-manager">source code and documentation</a>
29 30
      <span id="logout-link-container" style="display:none;">
30
        | <a href="#" id="logout-link">log out</a>
31
        <span class="source-link-responsive">|</span> <a href="#" id="logout-link">log out</a>
31 32
      </span>
32 33
    </div>
33 34
  </div>
34 35
  <div class="header-shadow"></div>
35 36
  <div class="splash content" id="splash-content">
36 37
    <h3>Enter/Generate your Secret Encryption Key</h3>
37
    <div class="progress-bar-holder">
38
      <span id="move-mouse-instruction" style="visibility: hidden;">Move your mouse around to generate entropy</span>
38
    <div class="progress-bar-holder" id="progress-bar-holder" style="display: none;">
39
      <span>Move your mouse around to generate entropy (touch and drag on mobile)</span>
39 40
      <div class="progress-bar" id="entropy-progress-bar" style="width:0;"></div>
40 41
    </div>
41
    <input id="encryption-secret" type="password" style="width:calc(100% - 20px);"></input>
42
    <input id="encryption-secret" type="password" autofocus style="width:calc(100% - 20px);"></input>
42 43
    <button id="generate-encryption-secret-button">
43 44
      Generate new Encryption Secret
44 45
    </button>

+ 4 - 0
server.go

@ -180,6 +180,10 @@ func main() {
180 180
	reloadStaticFiles()
181 181
182 182
	http.HandleFunc("/", indexHtml)
183
	http.HandleFunc("/version", func(response http.ResponseWriter, request *http.Request) {
184
		response.WriteHeader(200)
185
		fmt.Fprint(response, application.Version)
186
	})
183 187
	http.HandleFunc("/index.appcache", cacheManifest)
184 188
185 189
	http.HandleFunc("/storage/", storage)

+ 23 - 2
static/application.css

@ -5,6 +5,8 @@ body {
5 5
  margin:0;
6 6
  font-family: 'Ubuntu','Roboto','Open Sans',sans-serif;
7 7
}
8
9
8 10
a {
9 11
  color: #9359fa;
10 12
}
@ -13,7 +15,7 @@ a:visited {
13 15
}
14 16
h3 {
15 17
  margin-top:0px;
16
  margin-bottom: 15px;
18
  margin-bottom: 5px;
17 19
  color: #333333;
18 20
}
19 21
input, button {
@ -76,6 +78,18 @@ u {
76 78
  padding: 0;
77 79
  margin: 0;
78 80
}
81
@media only screen and (max-width: 679px) {
82
  html .header {
83
    height: 16px;
84
  }
85
86
  html .header h3 {
87
    font-size:14px;
88
  }
89
  html .header .source-link-responsive {
90
    display: none;
91
  }
92
}
79 93
.header a {
80 94
  color: white;
81 95
}
@ -89,6 +103,14 @@ u {
89 103
  border-bottom: 1px solid #bbbbbb;
90 104
  margin-bottom: 20px;
91 105
}
106
@media only screen and (max-width: 840px) {
107
  html body {
108
    background: white;
109
  }
110
  html .content {
111
    border: none;
112
  }
113
}
92 114
.content {
93 115
  background: white;
94 116
  color: #333333;
@ -107,7 +129,6 @@ u {
107 129
.progress-bar-holder {
108 130
  margin-top: 15px;
109 131
  margin-bottom: 5px;
110
  height: 25px;
111 132
  width: 100%;
112 133
}
113 134
.progress-bar {

+ 159 - 108
static/application.js

@ -1,5 +1,16 @@
1 1
'use strict';
2 2
3
4
(function(window, undefined){
5
6
  window.sequentialReadPasswordManager = window.sequentialReadPasswordManager || {};
7
  window.sequentialReadPasswordManager.localStorageKeyPrefix = "sequentialread-pwm:";
8
  window.sequentialReadPasswordManager.s3InterceptorSymbol =  "/awsS3/";
9
  window.sequentialReadPasswordManager.storageBaseUrl = "/storage";
10
11
})(window);
12
13
3 14
(function(app, window, document, undefined){
4 15
  var numberOfWordsInPhrase = 9;
5 16
  var lengthOfKeySegment = 4;
@ -14,14 +25,24 @@
14 25
    var distanceOffsetY = 0;
15 26
    var hashCount = 0;
16 27
    var lastTimeStamp = 0;
17
    document.onmousemove = (mouseEvent) => {
18
      distanceOffsetX += Math.abs(lastKnownOffsetX - mouseEvent.offsetX);
19
      distanceOffsetY += Math.abs(lastKnownOffsetY - mouseEvent.offsetY);
20
      lastKnownOffsetX = mouseEvent.offsetX;
21
      lastKnownOffsetY = mouseEvent.offsetY;
22
      lastTimeStamp = mouseEvent.timeStamp;
28
29
    var mouseOrTouchMoved = (mouseOrTouchEvent) => {
30
      if(!mouseOrTouchEvent.offsetX && (!mouseOrTouchEvent.touches || mouseOrTouchEvent.touches.length == 0)) {
31
        return;
32
      }
33
      var hasTouches = mouseOrTouchEvent.touches && mouseOrTouchEvent.touches[0];
34
      var offsetX = hasTouches ? mouseOrTouchEvent.touches[0].screenX : mouseOrTouchEvent.offsetX;
35
      var offsetY = hasTouches ? mouseOrTouchEvent.touches[0].screenY : mouseOrTouchEvent.offsetY;
36
      distanceOffsetX += Math.abs(lastKnownOffsetX - offsetX);
37
      distanceOffsetY += Math.abs(lastKnownOffsetY - offsetY);
38
      lastKnownOffsetX = offsetX;
39
      lastKnownOffsetY = offsetY;
40
      lastTimeStamp = new Date().getTime();
23 41
    };
24 42
43
    document.addEventListener('mousemove', mouseOrTouchMoved, false);
44
    document.body.addEventListener('touchmove', mouseOrTouchMoved, false);
45
25 46
    var currentUserSecret = null;
26 47
    var currentUserSecretId = null;
27 48
    this.setSecret = (secret) => {
@ -85,27 +106,101 @@
85 106
  })();
86 107
})(window.sequentialReadPasswordManager, window, document);
87 108
109
(function(app, window, document, undefined) {
110
111
  app.http = (method, url, headers, content) =>
112
    new Promise((resolve, reject) => {
113
      headers = headers || {};
114
      var httpRequest = new XMLHttpRequest();
115
      httpRequest.onloadend = () => {
116
        //console.log(`httpRequest.onloadend: ${httpRequest.status} ${url}`);
117
        if (httpRequest.status < 300) {
118
          if(httpRequest.responseText.length == 0) {
119
            resolve();
120
          } else {
121
            // this can happen sometimes with our Application Cache fallback -- treat it as 404
122
            if(httpRequest.responseText.indexOf("<!DOCTYPE HTML>") == 0 || httpRequest.responseText.indexOf("<html>") == 0) {
123
              reject(false);
124
              return;
125
            }
88 126
89
(function(app, window, document, undefined){
90
  app.storageService = new (function StorageService(cryptoService, awsClient, awsS3BucketName, awsS3BucketRegion) {
127
            // Does it look like a sjcl ciphertext json blob ?
128
            if(httpRequest.responseText.indexOf("\"iv\"") != -1 && httpRequest.responseText.indexOf("\"ks\"") != -1 && httpRequest.responseText.indexOf("\"cipher\"") != -1) {
129
              var jsonFailed = false;
130
              try {
131
                resolve(JSON.parse(app.cryptoService.decrypt(httpRequest.responseText)));
132
              } catch (err) {
133
                jsonFailed  = true;
134
              }
135
              if(jsonFailed) {
136
                try {
137
                  resolve(app.cryptoService.decrypt(httpRequest.responseText));
138
                } catch (err) {
139
                  window.onerror(`unable to decrypt '${url}': ${err.message} `, null, null, null, err);
140
                  reject(false);
141
                }
142
              }
143
            } else {
144
              resolve(httpRequest.responseText);
145
            }
146
          }
147
        } else {
148
          reject(false);
149
        }
150
      };
151
      //httpRequest.onerror = () => {
152
      //  console.log(`httpRequest.onerror: ${httpRequest.status} ${url}`);
153
      //  reject(false);
154
      //};
155
      httpRequest.ontimeout = () => {
156
        //console.log(`httpRequest.ontimeout: ${httpRequest.status} ${url}`);
157
        reject(true);
158
      };
159
160
      // Encrypt Content first
161
      if(content) {
162
        if(typeof content == "object") {
163
          content = JSON.stringify(content, 0, 2);
164
        }
165
        content = app.cryptoService.encrypt(content);
166
      }
91 167
92
    var baseUrl = "/storage";
168
      // AWS S3 request interceptor
169
      if(url.startsWith(app.s3InterceptorSymbol)) {
170
        var path = url.replace(app.s3InterceptorSymbol, '');
171
        var s3Request = app.awsClient.s3Request(method, app.S3BucketRegion, app.S3BucketName, path, content);
172
        headers = s3Request.headers;
173
        url = s3Request.endpointUri;
174
      }
93 175
94
    var s3InterceptorSymbol = "/awsS3/";
176
      httpRequest.open(method, url);
177
      httpRequest.timeout = 2000;
95 178
96
    var localStorageKeyPrefix = "sequentialread-pwm:";
179
      Object.keys(headers)
180
        .filter(key => key.toLowerCase() != 'host' && key.toLowerCase() != 'content-length')
181
        .forEach(key => httpRequest.setRequestHeader(key, headers[key]));
182
183
      if(content) {
184
        httpRequest.send(content);
185
      } else {
186
        httpRequest.send();
187
      }
188
    });
189
190
})(window.sequentialReadPasswordManager, window, document);
97 191
98
    var requestsCurrentlyInFlight = 0;
99 192
100
    function RequestFailure(httpRequest, isTimeout) {
101
      this.httpRequest = httpRequest;
102
      this.isTimeout = isTimeout
193
(function(app, window, document, undefined){
194
  app.storageService = new (function StorageService(localStorageKeyPrefix, s3InterceptorSymbol, storageBaseUrl, http, cryptoService) {
195
196
    function RequestFailure(isTimeout) {
197
      this.isTimeout = isTimeout;
103 198
    }
104 199
105
    // request ALWAYS resolves, if it fails it will resolve a RequestFailure.
106
    var request = (method, url, headers, content) =>
107
      new Promise((resolve, reject) => {
200
    var requestsCurrentlyInFlight = 0;
108 201
202
    var httpButAlwaysResolves = (method, url, headers, content) =>
203
      new Promise((resolve, reject) => {
109 204
        requestsCurrentlyInFlight += 1;
110 205
        document.getElementById('progress-container').style.display = 'block';
111 206
@ -117,83 +212,18 @@
117 212
          resolve(result);
118 213
        };
119 214
120
        headers = headers || {};
121
        var httpRequest = new XMLHttpRequest();
122
        httpRequest.onloadend = () => {
123
          //console.log(`httpRequest.onloadend: ${httpRequest.status} ${url}`);
124
          if (httpRequest.status === 200) {
125
            if(httpRequest.responseText.length == 0) {
126
              resolveAndPopInFlight();
127
            } else {
128
              // this can happen sometimes with our Application Cache fallback -- treat it as 404
129
              if(httpRequest.responseText.indexOf("<!DOCTYPE HTML>") == 0 || httpRequest.responseText.indexOf("<html>") == 0) {
130
                resolveAndPopInFlight(new RequestFailure(httpRequest, false));
131
                return;
132
              }
215
        http(method, url, headers, content)
216
        .then(
217
          (result) => resolveAndPopInFlight(result),
218
          (isTimeout) => resolveAndPopInFlight(new RequestFailure(isTimeout)),
219
        );
133 220
134
              var jsonFailed = false;
135
              try {
136
                resolveAndPopInFlight(JSON.parse(cryptoService.decrypt(httpRequest.responseText)));
137
              } catch (err) {
138
                jsonFailed  = true;
139
              }
140
              if(jsonFailed) {
141
                try {
142
                  resolveAndPopInFlight(cryptoService.decrypt(httpRequest.responseText));
143
                } catch (err) {
144
                  window.onerror(`unable to decrypt '${url}': ${err.message} `, null, null, null, err);
145
                  resolveAndPopInFlight(new RequestFailure(httpRequest, false));
146
                }
147
              }
148
            }
149
          } else {
150
            resolveAndPopInFlight(new RequestFailure(httpRequest, false));
151
          }
152
        };
153
        //httpRequest.onerror = () => {
154
        //  console.log(`httpRequest.onerror: ${httpRequest.status} ${url}`);
155
        //  resolveAndPopInFlight(new RequestFailure(httpRequest, false));
156
        //};
157
        httpRequest.ontimeout = () => {
158
          //console.log(`httpRequest.ontimeout: ${httpRequest.status} ${url}`);
159
          resolveAndPopInFlight(new RequestFailure(httpRequest, true));
160
        };
161
162
        // Encrypt Content first
163
        if(content) {
164
          if(typeof content == "object") {
165
            content = JSON.stringify(content, 0, 2);
166
          }
167
          content = cryptoService.encrypt(content);
168
        }
169
170
        // AWS S3 request interceptor
171
        if(url.startsWith(s3InterceptorSymbol)) {
172
          var path = url.replace(s3InterceptorSymbol, '');
173
          var s3Request = awsClient.s3Request(method, awsS3BucketRegion, awsS3BucketName, path, content);
174
          headers = s3Request.headers;
175
          url = s3Request.endpointUri;
176
        }
177
178
        httpRequest.open(method, url);
179
        httpRequest.timeout = 2000;
180
181
        Object.keys(headers)
182
          .filter(key => key.toLowerCase() != 'host' && key.toLowerCase() != 'content-length')
183
          .forEach(key => httpRequest.setRequestHeader(key, headers[key]));
184
185
        if(content) {
186
          httpRequest.send(content);
187
        } else {
188
          httpRequest.send();
189
        }
190 221
      });
191 222
192 223
    this.get = (id) => {
193
      // request() ALWAYS resolves, if it fails it will resolve a RequestFailure.
194 224
      return Promise.all([
195
        request('GET', `${baseUrl}/${id}`, {'Accept': 'application/json'}),
196
        request('GET', `${s3InterceptorSymbol}${id}`, {'Accept': 'application/json'})
225
        httpButAlwaysResolves('GET', `${storageBaseUrl}/${id}`, {'Accept': 'application/json'}),
226
        httpButAlwaysResolves('GET', `${s3InterceptorSymbol}${id}`, {'Accept': 'application/json'})
197 227
      ]).then((results) => {
198 228
        return new Promise((resolve, reject) => {
199 229
          var localCopyCiphertext = window.localStorage[`${localStorageKeyPrefix}${id}`];
@ -209,10 +239,10 @@
209 239
          if(localCopy) {
210 240
            allCopies.push(localCopy);
211 241
          }
212
          if(!(sequentialreadCopy instanceof RequestFailure)) {
242
          if(!(sequentialreadCopy instanceof RequestFailure) && sequentialreadCopy && sequentialreadCopy.lastUpdated) {
213 243
            allCopies.push(sequentialreadCopy);
214 244
          }
215
          if(!(s3Copy instanceof RequestFailure)) {
245
          if(!(s3Copy instanceof RequestFailure) && s3Copy && s3Copy.lastUpdated) {
216 246
            allCopies.push(s3Copy);
217 247
          }
218 248
          if(allCopies.length == 0) {
@ -242,21 +272,20 @@
242 272
    };
243 273
    this.put = (id, content) => {
244 274
      content.lastUpdated = new Date().getTime();
245
      // request() ALWAYS resolves, if it fails it will resolve a RequestFailure.
246 275
      window.localStorage[`${localStorageKeyPrefix}${id}`] = cryptoService.encrypt(JSON.stringify(content));
247 276
      return Promise.all([
248
        request('PUT', `${baseUrl}/${id}`, {'Content-Type': 'application/json'}, content),
249
        request('PUT', `${s3InterceptorSymbol}${id}`, {'Content-Type': 'application/json'}, content)
277
        httpButAlwaysResolves('PUT', `${storageBaseUrl}/${id}`, {'Content-Type': 'application/json'}, content),
278
        httpButAlwaysResolves('PUT', `${s3InterceptorSymbol}${id}`, {'Content-Type': 'application/json'}, content)
250 279
      ]).then(() => content)
251 280
    };
252 281
    this.delete = (id) => {
253 282
      window.localStorage.removeItem(`${localStorageKeyPrefix}${id}`);
254 283
      return Promise.all([
255
        request('DELETE', `${baseUrl}/${id}`),
256
        request('DELETE', `${s3InterceptorSymbol}${id}`)
284
        httpButAlwaysResolves('DELETE', `${storageBaseUrl}/${id}`),
285
        httpButAlwaysResolves('DELETE', `${s3InterceptorSymbol}${id}`)
257 286
      ]).then(() => null);
258 287
    };
259
  })(app.cryptoService, app.awsClient, app.S3BucketName, app.S3BucketRegion);
288
  })(app.localStorageKeyPrefix, app.s3InterceptorSymbol, app.storageBaseUrl, app.http, app.cryptoService);
260 289
})(window.sequentialReadPasswordManager, window, document);
261 290
262 291
(function(app, document, window, undefined){
@ -341,6 +370,7 @@
341 370
    this.onError = (message, fileName, lineNumber, column, err) => {
342 371
343 372
      this.errorContent += `<p>${message || err.message} at ${fileName || ""}:${lineNumber || ""}</p>`;
373
      document.getElementById('progress-container').style.display = 'none';
344 374
      console.log(message, fileName, lineNumber, column, err);
345 375
      modalService.open(
346 376
        "JavaScript Error",
@ -372,7 +402,7 @@
372 402
      }
373 403
    });
374 404
  })(app.modalService);
375
})(window.sequentialReadPasswordManager, window);
405
})(window.sequentialReadPasswordManager, window, document);
376 406
377 407
(function(app, window, document, undefined){
378 408
  app.navController = new (function NavController() {
@ -529,14 +559,13 @@
529 559
            "Are you sure you want to make a new index file?",
530 560
            (resolve, reject) => {},
531 561
            [{
532
              innerHTML: "Cancel",
562
              innerHTML: "Log Out",
533 563
              escapeKey: true,
534 564
              onclick: (resolve, reject) => reject()
535 565
            },
536 566
            {
537
              id: "new-file-create-button",
538
              innerHTML: "Create",
539
              enterKey: true,
567
              innerHTML: "Create & Potentially Overwrite",
568
              enterKey: false,
540 569
              onclick: (resolve, reject) => resolve()
541 570
            }]
542 571
          )
@ -548,7 +577,10 @@
548 577
                () => null //TODO error handler
549 578
              );
550 579
            },
551
            () => null
580
            () => {
581
              window.location = window.location.origin;
582
              return null;
583
            }
552 584
          );
553 585
        }
554 586
      );
@ -594,10 +626,11 @@
594 626
})(window.sequentialReadPasswordManager, document);
595 627
596 628
(function(app, window, document, undefined){
597
  app.splashController = new (function SplashController(cryptoService, navController, fileListController) {
629
630
  app.splashController = new (function SplashController(localStorageKeyPrefix, http, cryptoService, navController, fileListController) {
598 631
599 632
    document.getElementById('generate-encryption-secret-button').onclick = () => {
600
      document.getElementById('move-mouse-instruction').style.visibility = 'visible';
633
      document.getElementById('progress-bar-holder').style.display = 'block';
601 634
602 635
      var entropizer = cryptoService.getEntropizer();
603 636
@ -607,7 +640,7 @@
607 640
          document.getElementById('encryption-secret').value = entropizer.passphrase;
608 641
          document.getElementById('encryption-secret').type = 'text';
609 642
          window.clearInterval(checkInterval);
610
          document.getElementById('move-mouse-instruction').style.visibility = 'hidden';
643
          document.getElementById('progress-bar-holder').style.display = 'none';
611 644
          document.getElementById('entropy-progress-bar').style.width = '0';
612 645
        }
613 646
      }, 100);
@ -631,5 +664,23 @@
631 664
632 665
    document.getElementById('splash-continue-button').onclick = onContinueClicked;
633 666
634
  })(app.cryptoService, app.navController, app.fileListController);
667
    document.getElementById('encryption-secret')
668
669
    // Force a reload if the version changed (gets around issues with Application Cache)
670
    http('GET', 'version', {}, null)
671
    .then(
672
      (currentVersion) => {
673
        var lastVersion = window.localStorage[`${localStorageKeyPrefix}version`];
674
        if(currentVersion != lastVersion) {
675
          console.log(`reloading in 1 second due to new app version: ${currentVersion}`)
676
          window.localStorage[`${localStorageKeyPrefix}version`] = currentVersion;
677
          window.setTimeout(function(){
678
            window.location = window.location.origin;
679
          }, 1000);
680
        }
681
      },
682
      () => {}
683
    )
684
685
  })(app.localStorageKeyPrefix, app.http, app.cryptoService, app.navController, app.fileListController);
635 686
})(window.sequentialReadPasswordManager, window, document);