From 7ccf462bf881c73d724d69aedfde3621f21709cf Mon Sep 17 00:00:00 2001 From: Danny Coates Date: Fri, 14 Sep 2018 08:00:33 -0700 Subject: [PATCH] implemented PKCE auth (#921) * implemented PKCE auth * removed node-jose * added PKCE tests --- android/stores/state.js | 2 +- app/fileManager.js | 6 +- app/fxa.js | 154 +++++++++++++++++++++++++++--- app/main.js | 8 +- app/routes/index.js | 11 ++- app/user.js | 55 +++++++++-- package-lock.json | 91 ------------------ package.json | 1 - server/config.js | 5 - server/fxa.js | 46 +++++++++ server/initScript.js | 3 - server/middleware/auth.js | 2 +- server/routes/fxa.js | 96 ------------------- server/routes/index.js | 6 +- server/routes/jsconfig.js | 57 +++++------ server/routes/ws.js | 2 +- server/state.js | 4 +- test/frontend/tests/auth-tests.js | 45 +++++++++ 18 files changed, 331 insertions(+), 263 deletions(-) create mode 100644 server/fxa.js delete mode 100644 server/routes/fxa.js create mode 100644 test/frontend/tests/auth-tests.js diff --git a/android/stores/state.js b/android/stores/state.js index 4d91a2ad..469fd6cc 100644 --- a/android/stores/state.js +++ b/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}`; }, diff --git a/app/fileManager.js b/app/fileManager.js index 97846f88..3ab7033e 100644 --- a/app/fileManager.js +++ b/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', () => { diff --git a/app/fxa.js b/app/fxa.js index fe6eba38..20ed7762 100644 --- a/app/fxa.js +++ b/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; +} + +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 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 keystore = await jose.JWK.asKeyStore( - JSON.parse(storage.get('fxaWrapKey')) - ); - const result = await jose.JWE.createDecrypt(keystore).decrypt(bundle); - const jwks = JSON.parse(jose.util.utf8.encode(result.plaintext)); + const jwks = await decryptBundle(storage, bundle); const jwk = jwks['https://identity.mozilla.com/apps/send']; const baseKey = await crypto.subtle.importKey( 'raw', diff --git a/app/main.js b/app/main.js index 141299cc..e13c12d8 100644 --- a/app/main.js +++ b/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 ( diff --git a/app/routes/index.js b/app/routes/index.js index d596001f..09551cdf 100644 --- a/app/routes/index.js +++ b/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'))); diff --git a/app/user.js b/app/user.js index 22668dcd..ade660d6 100644 --- a/app/user.js +++ b/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; diff --git a/package-lock.json b/package-lock.json index 05f8adad..181745d0 100644 --- a/package-lock.json +++ b/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", diff --git a/package.json b/package.json index 95b281b2..a52aafab 100644 --- a/package.json +++ b/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", diff --git a/server/config.js b/server/config.js index dcbab8ed..0099e4ce 100644 --- a/server/config.js +++ b/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' } }); diff --git a/server/fxa.js b/server/fxa.js new file mode 100644 index 00000000..9e1b03ed --- /dev/null +++ b/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; + } +}; diff --git a/server/initScript.js b/server/initScript.js index 69ad2db0..599e01ac 100644 --- a/server/initScript.js +++ b/server/initScript.js @@ -6,9 +6,6 @@ module.exports = function(state) { return state.cspNonce ? html`