implemented PKCE auth (#921)

* implemented PKCE auth

* removed node-jose

* added PKCE tests
This commit is contained in:
Danny Coates 2018-09-14 08:00:33 -07:00 committed by Donovan Preston
parent 20528eb0d1
commit 7ccf462bf8
18 changed files with 331 additions and 263 deletions

View file

@ -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', () => {

View file

@ -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',

View file

@ -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 (

View file

@ -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')));

View file

@ -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;