implemented PKCE auth (#921)
* implemented PKCE auth * removed node-jose * added PKCE tests
This commit is contained in:
parent
20528eb0d1
commit
7ccf462bf8
18 changed files with 331 additions and 263 deletions
|
@ -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', () => {
|
||||
|
|
154
app/fxa.js
154
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',
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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')));
|
||||
|
||||
|
|
55
app/user.js
55
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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue