Browse Source

implemented PKCE auth (#921)

* implemented PKCE auth

* removed node-jose

* added PKCE tests
Danny Coates 6 years ago
parent
commit
7ccf462bf8
10 changed files with 250 additions and 129 deletions
  1. 1 1
      android/stores/state.js
  2. 2 4
      app/fileManager.js
  3. 143 11
      app/fxa.js
  4. 2 6
      app/main.js
  5. 8 3
      app/routes/index.js
  6. 48 7
      app/user.js
  7. 0 91
      package-lock.json
  8. 0 1
      package.json
  9. 0 5
      server/config.js
  10. 46 0
      server/fxa.js

+ 1 - 1
android/stores/state.js

@@ -8,7 +8,7 @@ export default function initialState(state, emitter) {
 
   Object.assign(state, {
     prefix: '/android_asset',
-    user: new User(undefined, storage),
+    user: new User(storage),
     getAsset(name) {
       return `${state.prefix}/${name}`;
     },

+ 2 - 4
app/fileManager.js

@@ -5,7 +5,6 @@ import { copyToClipboard, delay, openLinksInNewTab, percent } from './utils';
 import * as metrics from './metrics';
 import Archive from './archive';
 import { bytes } from './utils';
-import { prepareWrapKey } from './fxa';
 
 export default function(state, emitter) {
   let lastRender = 0;
@@ -45,9 +44,8 @@ export default function(state, emitter) {
     lastRender = Date.now();
   });
 
-  emitter.on('login', async () => {
-    const k = await prepareWrapKey(state.storage);
-    location.assign(`/api/fxa/login?keys_jwk=${k}`);
+  emitter.on('login', () => {
+    state.user.login();
   });
 
   emitter.on('logout', () => {

+ 143 - 11
app/fxa.js

@@ -1,21 +1,153 @@
-import jose from 'node-jose';
 import { arrayToB64, b64ToArray } from './utils';
 
 const encoder = new TextEncoder();
+const decoder = new TextDecoder();
 
-export async function prepareWrapKey(storage) {
-  const keystore = jose.JWK.createKeyStore();
-  const keypair = await keystore.generate('EC', 'P-256');
-  storage.set('fxaWrapKey', JSON.stringify(keystore.toJSON(true)));
-  return jose.util.base64url.encode(JSON.stringify(keypair.toJSON()));
+function getOtherInfo(enc) {
+  const name = encoder.encode(enc);
+  const length = 256;
+  const buffer = new ArrayBuffer(name.length + 16);
+  const dv = new DataView(buffer);
+  const result = new Uint8Array(buffer);
+  let i = 0;
+  dv.setUint32(i, name.length);
+  i += 4;
+  result.set(name, i);
+  i += name.length;
+  dv.setUint32(i, 0);
+  i += 4;
+  dv.setUint32(i, 0);
+  i += 4;
+  dv.setUint32(i, length);
+  return result;
 }
 
-export async function getFileListKey(storage, bundle) {
-  const keystore = await jose.JWK.asKeyStore(
-    JSON.parse(storage.get('fxaWrapKey'))
+function concat(b1, b2) {
+  const result = new Uint8Array(b1.length + b2.length);
+  result.set(b1, 0);
+  result.set(b2, b1.length);
+  return result;
+}
+
+async function concatKdf(key, enc) {
+  if (key.length !== 32) {
+    throw new Error('unsupported key length');
+  }
+  const otherInfo = getOtherInfo(enc);
+  const buffer = new ArrayBuffer(4 + key.length + otherInfo.length);
+  const dv = new DataView(buffer);
+  const concat = new Uint8Array(buffer);
+  dv.setUint32(0, 1);
+  concat.set(key, 4);
+  concat.set(otherInfo, key.length + 4);
+  const result = await crypto.subtle.digest('SHA-256', concat);
+  return new Uint8Array(result);
+}
+
+export async function prepareScopedBundleKey(storage) {
+  const keys = await crypto.subtle.generateKey(
+    {
+      name: 'ECDH',
+      namedCurve: 'P-256'
+    },
+    true,
+    ['deriveBits']
+  );
+  const privateJwk = await crypto.subtle.exportKey('jwk', keys.privateKey);
+  const publicJwk = await crypto.subtle.exportKey('jwk', keys.publicKey);
+  const kid = await crypto.subtle.digest(
+    'SHA-256',
+    encoder.encode(JSON.stringify(publicJwk))
+  );
+  privateJwk.kid = kid;
+  publicJwk.kid = kid;
+  storage.set('scopedBundlePrivateKey', JSON.stringify(privateJwk));
+  return arrayToB64(encoder.encode(JSON.stringify(publicJwk)));
+}
+
+export async function decryptBundle(storage, bundle) {
+  const privateJwk = JSON.parse(storage.get('scopedBundlePrivateKey'));
+  storage.remove('scopedBundlePrivateKey');
+  const privateKey = await crypto.subtle.importKey(
+    'jwk',
+    privateJwk,
+    {
+      name: 'ECDH',
+      namedCurve: 'P-256'
+    },
+    false,
+    ['deriveBits']
+  );
+  const jweParts = bundle.split('.');
+  if (jweParts.length !== 5) {
+    throw new Error('invalid jwe');
+  }
+  const header = JSON.parse(decoder.decode(b64ToArray(jweParts[0])));
+  const additionalData = encoder.encode(jweParts[0]);
+  const iv = b64ToArray(jweParts[2]);
+  const ciphertext = b64ToArray(jweParts[3]);
+  const tag = b64ToArray(jweParts[4]);
+
+  if (header.alg !== 'ECDH-ES' || header.enc !== 'A256GCM') {
+    throw new Error('unsupported jwe type');
+  }
+
+  const publicKey = await crypto.subtle.importKey(
+    'jwk',
+    header.epk,
+    {
+      name: 'ECDH',
+      namedCurve: 'P-256'
+    },
+    false,
+    []
   );
-  const result = await jose.JWE.createDecrypt(keystore).decrypt(bundle);
-  const jwks = JSON.parse(jose.util.utf8.encode(result.plaintext));
+  const sharedBits = await crypto.subtle.deriveBits(
+    {
+      name: 'ECDH',
+      public: publicKey
+    },
+    privateKey,
+    256
+  );
+
+  const rawSharedKey = await concatKdf(new Uint8Array(sharedBits), header.enc);
+  const sharedKey = await crypto.subtle.importKey(
+    'raw',
+    rawSharedKey,
+    {
+      name: 'AES-GCM'
+    },
+    false,
+    ['decrypt']
+  );
+
+  const plaintext = await crypto.subtle.decrypt(
+    {
+      name: 'AES-GCM',
+      iv: iv,
+      additionalData: additionalData,
+      tagLength: tag.length * 8
+    },
+    sharedKey,
+    concat(ciphertext, tag)
+  );
+
+  return JSON.parse(decoder.decode(plaintext));
+}
+
+export async function preparePkce(storage) {
+  const verifier = arrayToB64(crypto.getRandomValues(new Uint8Array(64)));
+  storage.set('pkceVerifier', verifier);
+  const challenge = await crypto.subtle.digest(
+    'SHA-256',
+    encoder.encode(verifier)
+  );
+  return arrayToB64(new Uint8Array(challenge));
+}
+
+export async function getFileListKey(storage, bundle) {
+  const jwks = await decryptBundle(storage, bundle);
   const jwk = jwks['https://identity.mozilla.com/apps/send'];
   const baseKey = await crypto.subtle.importKey(
     'raw',

+ 2 - 6
app/main.js

@@ -1,4 +1,3 @@
-/* global userInfo */
 import 'fast-text-encoding'; // MS Edge support
 import 'fluent-intl-polyfill';
 import app from './routes';
@@ -13,7 +12,6 @@ import experiments from './experiments';
 import Raven from 'raven-js';
 import './main.css';
 import User from './user';
-import { getFileListKey } from './fxa';
 
 (async function start() {
   if (navigator.doNotTrack !== '1' && window.RAVEN_CONFIG) {
@@ -23,9 +21,7 @@ import { getFileListKey } from './fxa';
   if (capa.streamDownload) {
     navigator.serviceWorker.register('/serviceWorker.js');
   }
-  if (userInfo && userInfo.keys_jwe) {
-    userInfo.fileListKey = await getFileListKey(storage, userInfo.keys_jwe);
-  }
+
   app.use((state, emitter) => {
     state.capabilities = capa;
     state.transfer = null;
@@ -33,7 +29,7 @@ import { getFileListKey } from './fxa';
     state.translate = locale.getTranslator();
     state.storage = storage;
     state.raven = Raven;
-    state.user = new User(userInfo, storage);
+    state.user = new User(storage);
     window.appState = state;
     let unsupportedReason = null;
     if (

+ 8 - 3
app/routes/index.js

@@ -68,9 +68,14 @@ app.route('/legal', body(require('../pages/legal')));
 app.route('/error', body(require('../pages/error')));
 app.route('/blank', body(require('../pages/blank')));
 app.route('/signin', body(require('../pages/signin')));
-app.route('/api/fxa/oauth', function(state, emit) {
-  emit('replaceState', '/');
-  setTimeout(() => emit('render'));
+app.route('/api/fxa/oauth', async function(state, emit) {
+  try {
+    await state.user.finishLogin(state.query.code);
+    emit('replaceState', '/');
+  } catch (e) {
+    emit('replaceState', '/error');
+    setTimeout(() => emit('render'));
+  }
 });
 app.route('*', body(require('../pages/notFound')));
 

+ 48 - 7
app/user.js

@@ -1,20 +1,18 @@
-/* global LIMITS */
+/* global LIMITS AUTH_CONFIG */
 import assets from '../common/assets';
 import { getFileList, setFileList } from './api';
 import { encryptStream, decryptStream } from './ece';
 import { b64ToArray, streamToArrayBuffer } from './utils';
 import { blobStream } from './streams';
+import { getFileListKey, prepareScopedBundleKey, preparePkce } from './fxa';
 
 const textEncoder = new TextEncoder();
 const textDecoder = new TextDecoder();
 
 export default class User {
-  constructor(info, storage) {
-    if (info && storage) {
-      storage.user = info;
-    }
+  constructor(storage) {
     this.storage = storage;
-    this.data = info || storage.user || {};
+    this.data = storage.user || {};
   }
 
   get avatar() {
@@ -55,7 +53,50 @@ export default class User {
     return this.loggedIn ? LIMITS.MAX_DOWNLOADS : LIMITS.ANON.MAX_DOWNLOADS;
   }
 
-  login() {}
+  async login() {
+    const keys_jwk = await prepareScopedBundleKey(this.storage);
+    const code_challenge = await preparePkce(this.storage);
+    const params = new URLSearchParams({
+      client_id: AUTH_CONFIG.client_id,
+      code_challenge,
+      code_challenge_method: 'S256',
+      response_type: 'code',
+      scope: 'profile https://identity.mozilla.com/apps/send', //TODO param
+      state: 'todo',
+      keys_jwk
+    });
+    location.assign(
+      `${AUTH_CONFIG.authorization_endpoint}?${params.toString()}`
+    );
+  }
+
+  async finishLogin(code) {
+    const tokenResponse = await fetch(AUTH_CONFIG.token_endpoint, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json'
+      },
+      body: JSON.stringify({
+        code,
+        client_id: AUTH_CONFIG.client_id,
+        code_verifier: this.storage.get('pkceVerifier')
+      })
+    });
+    const auth = await tokenResponse.json();
+    const infoResponse = await fetch(AUTH_CONFIG.userinfo_endpoint, {
+      method: 'GET',
+      headers: {
+        Authorization: `Bearer ${auth.access_token}`
+      }
+    });
+    const userInfo = await infoResponse.json();
+    userInfo.keys_jwe = auth.keys_jwe;
+    userInfo.access_token = auth.access_token;
+    userInfo.fileListKey = await getFileListKey(this.storage, auth.keys_jwe);
+    this.storage.user = userInfo;
+    this.data = userInfo;
+    this.storage.remove('pkceVerifier');
+  }
 
   logout() {
     this.storage.user = null;

+ 0 - 91
package-lock.json

@@ -3921,12 +3921,6 @@
       "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz",
       "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw=="
     },
-    "base64url": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.0.tgz",
-      "integrity": "sha512-LIVmqIrIWuiqTvn4RzcrwCOuHo2DD6tKmKBPXXlr4p4n4l6BZBkwFTIa3zu1XkX5MbZgro4a6BvPi+n2Mns5Gg==",
-      "dev": true
-    },
     "basic-auth": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.0.tgz",
@@ -9587,24 +9581,12 @@
       "integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=",
       "dev": true
     },
-    "lodash.assign": {
-      "version": "4.2.0",
-      "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz",
-      "integrity": "sha1-DZnzzNem0mHRm9rrkkUAXShYCOc=",
-      "dev": true
-    },
     "lodash.camelcase": {
       "version": "4.3.0",
       "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
       "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=",
       "dev": true
     },
-    "lodash.clone": {
-      "version": "4.5.0",
-      "resolved": "https://registry.npmjs.org/lodash.clone/-/lodash.clone-4.5.0.tgz",
-      "integrity": "sha1-GVhwRQ9aExkkeN9Lw9I9LeoZB7Y=",
-      "dev": true
-    },
     "lodash.clonedeep": {
       "version": "4.5.0",
       "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
@@ -9616,60 +9598,18 @@
       "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=",
       "dev": true
     },
-    "lodash.fill": {
-      "version": "3.4.0",
-      "resolved": "https://registry.npmjs.org/lodash.fill/-/lodash.fill-3.4.0.tgz",
-      "integrity": "sha1-o8dK5kDQU63w3CB5+HIHiOi/74U=",
-      "dev": true
-    },
-    "lodash.flatten": {
-      "version": "4.4.0",
-      "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
-      "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=",
-      "dev": true
-    },
     "lodash.get": {
       "version": "4.4.2",
       "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
       "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=",
       "dev": true
     },
-    "lodash.intersection": {
-      "version": "4.4.0",
-      "resolved": "https://registry.npmjs.org/lodash.intersection/-/lodash.intersection-4.4.0.tgz",
-      "integrity": "sha1-ChG6Yx0OlcI8fy9Mu5ppLtF45wU=",
-      "dev": true
-    },
     "lodash.memoize": {
       "version": "4.1.2",
       "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
       "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=",
       "dev": true
     },
-    "lodash.merge": {
-      "version": "4.6.1",
-      "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.1.tgz",
-      "integrity": "sha512-AOYza4+Hf5z1/0Hztxpm2/xiPZgi/cjMqdnKTUWTBSKchJlxXXuUSxCCl8rJlf4g6yww/j6mA8nC8Hw/EZWxKQ==",
-      "dev": true
-    },
-    "lodash.omit": {
-      "version": "4.5.0",
-      "resolved": "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz",
-      "integrity": "sha1-brGa5aHuHdnfC5aeZs4Lf6MLXmA=",
-      "dev": true
-    },
-    "lodash.partialright": {
-      "version": "4.2.1",
-      "resolved": "https://registry.npmjs.org/lodash.partialright/-/lodash.partialright-4.2.1.tgz",
-      "integrity": "sha1-ATDYDoM2MmTUAHTzKbij56ihzEs=",
-      "dev": true
-    },
-    "lodash.pick": {
-      "version": "4.4.0",
-      "resolved": "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz",
-      "integrity": "sha1-UvBWEP/53tQiYRRB7R/BI6AwAbM=",
-      "dev": true
-    },
     "lodash.template": {
       "version": "4.4.0",
       "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-4.4.0.tgz",
@@ -10560,37 +10500,6 @@
       "integrity": "sha512-MmbQJ2MTESTjt3Gi/3yG1wGpIMhUfcIypUCGtTizFR9IiccFwxSpfp0vtIZlkFclEqERemxfnSdZEMR9VqqEFQ==",
       "dev": true
     },
-    "node-jose": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/node-jose/-/node-jose-1.0.0.tgz",
-      "integrity": "sha512-RE3P8l60Rj9ELrpPmvw6sOQ1hSyYfmQdNUMCa4EN7nCE1ux5JVX+GfXv+mfUTEMhZwNMwxBtI0+X1CKKeukSVQ==",
-      "dev": true,
-      "requires": {
-        "base64url": "^3.0.0",
-        "es6-promise": "^4.0.5",
-        "lodash.assign": "^4.0.8",
-        "lodash.clone": "^4.3.2",
-        "lodash.fill": "^3.2.2",
-        "lodash.flatten": "^4.2.0",
-        "lodash.intersection": "^4.1.2",
-        "lodash.merge": "^4.3.5",
-        "lodash.omit": "^4.2.1",
-        "lodash.partialright": "^4.1.3",
-        "lodash.pick": "^4.2.0",
-        "lodash.uniq": "^4.2.1",
-        "long": "^4.0.0",
-        "node-forge": "^0.7.1",
-        "uuid": "^3.0.1"
-      },
-      "dependencies": {
-        "long": {
-          "version": "4.0.0",
-          "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
-          "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
-          "dev": true
-        }
-      }
-    },
     "node-libs-browser": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.1.0.tgz",

+ 0 - 1
package.json

@@ -89,7 +89,6 @@
     "morgan": "^1.9.0",
     "nanobus": "^4.3.2",
     "nanotiming": "^7.3.1",
-    "node-jose": "^1.0.0",
     "npm-run-all": "^4.1.3",
     "nyc": "^13.0.1",
     "postcss-cssnext": "^3.1.0",

+ 0 - 5
server/config.js

@@ -134,11 +134,6 @@ const conf = convict({
     format: String,
     default: 'b50ec33d3c9beb6d', // localhost
     env: 'FXA_CLIENT_ID'
-  },
-  fxa_client_secret: {
-    format: String,
-    default: '05ac76fbe3e739c9effbaea439bc07d265c613c5e0da9070590a2378377c09d8', // localhost
-    env: 'FXA_CLIENT_SECRET'
   }
 });
 

+ 46 - 0
server/fxa.js

@@ -0,0 +1,46 @@
+const fetch = require('node-fetch');
+const config = require('./config');
+
+const KEY_SCOPE = 'https://identity.mozilla.com/apps/send';
+let fxaConfig = null;
+let lastConfigRefresh = 0;
+
+async function getFxaConfig() {
+  if (fxaConfig && Date.now() - lastConfigRefresh < 1000 * 60 * 5) {
+    return fxaConfig;
+  }
+  const res = await fetch(`${config.fxa_url}/.well-known/openid-configuration`);
+  fxaConfig = await res.json();
+  lastConfigRefresh = Date.now();
+  return fxaConfig;
+}
+
+module.exports = {
+  getFxaConfig,
+  verify: async function(token) {
+    if (!token) {
+      return null;
+    }
+
+    const c = await getFxaConfig();
+    try {
+      const verifyUrl = c.jwks_uri.replace('jwks', 'verify'); //HACK
+      const result = await fetch(verifyUrl, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ token })
+      });
+      const info = await result.json();
+      if (
+        info.scope &&
+        Array.isArray(info.scope) &&
+        info.scope.includes(KEY_SCOPE)
+      ) {
+        return info.user;
+      }
+    } catch (e) {
+      // gulp
+    }
+    return null;
+  }
+};

Some files were not shown because too many files changed in this diff