packages/fxa-auth-client/lib/crypto.ts (352 lines of code) (raw):
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import {
hexToUint8,
uint8ToBase64Url,
base64UrlToUint8,
uint8ToHex,
xor,
} from './utils';
import { NAMESPACE, createSaltV1, parseSalt } from './salt';
/**
* A credentials model.
*/
export interface Credentials {
/**
* The encrypted password
*/
authPW: string;
/**
* An unwrap key
*/
unwrapBKey: string;
}
const encoder = () => new TextEncoder();
// These functions implement the onepw protocol
// https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol
export async function getCredentials(email: string, password: string) {
const passkey = await crypto.subtle.importKey(
'raw',
encoder().encode(password),
'PBKDF2',
false,
['deriveBits']
);
const salt = createSaltV1(email);
const quickStretchedRaw = await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
salt: encoder().encode(salt),
iterations: 1000,
hash: 'SHA-256',
},
passkey,
256
);
const quickStretchedKey = await crypto.subtle.importKey(
'raw',
quickStretchedRaw,
'HKDF',
false,
['deriveBits']
);
const authPW = await crypto.subtle.deriveBits(
{
name: 'HKDF',
salt: new Uint8Array(0),
// The builtin ts type definition for HKDF was wrong
// at the time this was written, hence the ignore
// @ts-ignore
info: encoder().encode(`${NAMESPACE}authPW`),
hash: 'SHA-256',
},
quickStretchedKey,
256
);
const unwrapBKey = await crypto.subtle.deriveBits(
{
name: 'HKDF',
salt: new Uint8Array(0),
// @ts-ignore
info: encoder().encode(`${NAMESPACE}unwrapBkey`),
hash: 'SHA-256',
},
quickStretchedKey,
256
);
return {
authPW: uint8ToHex(new Uint8Array(authPW)),
unwrapBKey: uint8ToHex(new Uint8Array(unwrapBKey)),
};
}
export async function getCredentialsV2({password,clientSalt}:{password: string, clientSalt: string}) {
const result = parseSalt(clientSalt);
if (result.version !== 2) {
throw new Error('Invalid v2 clientSalt')
}
const passkey = await crypto.subtle.importKey(
'raw',
encoder().encode(password),
'PBKDF2',
false,
['deriveBits']
);
const quickStretchedRaw = await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
salt: encoder().encode(`${NAMESPACE}quickStretchV2:${result.value}`),
iterations: 650000,
hash: 'SHA-256',
},
passkey,
256
);
const quickStretchedKey = await crypto.subtle.importKey(
'raw',
quickStretchedRaw,
'HKDF',
false,
['deriveBits']
);
const authPW = await crypto.subtle.deriveBits(
{
name: 'HKDF',
salt: new Uint8Array(0),
// The builtin ts type definition for HKDF was wrong
// at the time this was written, hence the ignore
// @ts-ignore
info: encoder().encode(`${NAMESPACE}authPW`),
hash: 'SHA-256',
},
quickStretchedKey,
256
);
const unwrapBKey = await crypto.subtle.deriveBits(
{
name: 'HKDF',
salt: new Uint8Array(0),
// @ts-ignore
info: encoder().encode(`${NAMESPACE}unwrapBkey`),
hash: 'SHA-256',
},
quickStretchedKey,
256
);
return {
clientSalt,
authPW: uint8ToHex(new Uint8Array(authPW)),
unwrapBKey: uint8ToHex(new Uint8Array(unwrapBKey)),
};
}
export async function deriveBundleKeys(
key: hexstring,
keyInfo: string,
payloadBytes: number = 64
) {
const baseKey = await crypto.subtle.importKey(
'raw',
hexToUint8(key),
'HKDF',
false,
['deriveBits']
);
const keyMaterial = await crypto.subtle.deriveBits(
{
name: 'HKDF',
salt: new Uint8Array(0),
// @ts-ignore
info: encoder().encode(`${NAMESPACE}${keyInfo}`),
hash: 'SHA-256',
},
baseKey,
(32 + payloadBytes) * 8
);
const hmacKey = await crypto.subtle.importKey(
'raw',
new Uint8Array(keyMaterial.slice(0, 32)),
{
name: 'HMAC',
hash: 'SHA-256',
length: 256,
},
true,
['verify']
);
const xorKey = new Uint8Array(keyMaterial.slice(32));
return {
hmacKey,
xorKey,
};
}
export async function unbundleKeyFetchResponse(
key: hexstring,
bundle: hexstring
) {
const b = hexToUint8(bundle);
const keys = await deriveBundleKeys(key, 'account/keys');
const ciphertext = b.subarray(0, 64);
const expectedHmac = b.subarray(b.byteLength - 32);
const valid = await crypto.subtle.verify(
'HMAC',
keys.hmacKey,
expectedHmac,
ciphertext
);
if (!valid) {
throw new Error('Bad HMac');
}
const keyAWrapB = xor(ciphertext, keys.xorKey);
return {
kA: uint8ToHex(keyAWrapB.subarray(0, 32)),
wrapKB: uint8ToHex(keyAWrapB.subarray(32)),
};
}
export function unwrapKB(wrapKB: hexstring, unwrapBKey: hexstring) {
return uint8ToHex(xor(hexToUint8(wrapKB), hexToUint8(unwrapBKey)));
}
export async function hkdf(
keyMaterial: Uint8Array,
salt: Uint8Array,
info: Uint8Array,
bytes: number
) {
const key = await crypto.subtle.importKey('raw', keyMaterial, 'HKDF', false, [
'deriveBits',
]);
const result = await crypto.subtle.deriveBits(
{
name: 'HKDF',
salt,
// @ts-ignore
info,
hash: 'SHA-256',
},
key,
bytes * 8
);
return new Uint8Array(result);
}
export async function jweEncrypt(
keyMaterial: Uint8Array,
kid: hexstring,
data: Uint8Array,
forTestingOnly?: { testIV: Uint8Array }
) {
const key = await crypto.subtle.importKey(
'raw',
keyMaterial,
{
name: 'AES-GCM',
},
false,
['encrypt']
);
const jweHeader = uint8ToBase64Url(
encoder().encode(
JSON.stringify({
enc: 'A256GCM',
alg: 'dir',
kid,
})
)
);
const iv =
forTestingOnly?.testIV || crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv,
additionalData: encoder().encode(jweHeader),
tagLength: 128,
},
key,
data
);
const ciphertext = new Uint8Array(
encrypted.slice(0, encrypted.byteLength - 16)
);
const authenticationTag = new Uint8Array(
encrypted.slice(encrypted.byteLength - 16)
);
// prettier-ignore
const compactJWE = `${jweHeader
}..${uint8ToBase64Url(iv)
}.${uint8ToBase64Url(ciphertext)
}.${uint8ToBase64Url(authenticationTag)}`;
return compactJWE;
}
/**
* Decrypt a JWE token using provided key material
* @param keyMaterial Key material
* @param jwe JWE token
*/
export async function jweDecrypt(
keyMaterial: Uint8Array,
jwe: string
): Promise<string> {
const encoder = new TextEncoder();
const [header, , iv, ciphertext, authenticationTag] = jwe.split('.');
const key = await crypto.subtle.importKey(
'raw',
keyMaterial,
{
name: 'AES-GCM',
},
false,
['decrypt']
);
const ciphertextBuf = base64UrlToUint8(ciphertext);
const authenticationTagBuf = base64UrlToUint8(authenticationTag);
const dataBuf = new Uint8Array([...ciphertextBuf, ...authenticationTagBuf]);
const decrypted = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: base64UrlToUint8(iv),
additionalData: encoder.encode(header),
tagLength: 128,
},
key,
dataBuf
);
const decoder = new TextDecoder();
return decoder.decode(decrypted);
}
export async function checkWebCrypto() {
try {
await crypto.subtle.importKey(
'raw',
crypto.getRandomValues(new Uint8Array(16)),
'PBKDF2',
false,
['deriveKey']
);
await crypto.subtle.importKey(
'raw',
crypto.getRandomValues(new Uint8Array(32)),
'HKDF',
false,
['deriveKey']
);
await crypto.subtle.importKey(
'raw',
crypto.getRandomValues(new Uint8Array(32)),
{
name: 'HMAC',
hash: 'SHA-256',
length: 256,
},
false,
['sign']
);
await crypto.subtle.importKey(
'raw',
crypto.getRandomValues(new Uint8Array(32)),
{
name: 'AES-GCM',
},
false,
['encrypt']
);
await crypto.subtle.digest(
'SHA-256',
crypto.getRandomValues(new Uint8Array(16))
);
return true;
} catch (err) {
try {
console.warn('loading webcrypto shim', err);
// prettier-ignore
// @ts-ignore
window.asmCrypto = await import(/* webpackChunkName: "asmcrypto.js" */ 'asmcrypto.js');
// prettier-ignore
// @ts-ignore
await import(/* webpackChunkName: "webcrypto-liner" */ 'webcrypto-liner/build/webcrypto-liner.shim.min');
return true;
} catch (e) {
return false;
}
}
}
export async function getKeysV2({kB, v1, v2}:{
kB?:string,
v1: Credentials,
v2: Credentials,
}) {
if (!kB) {
kB = uint8ToHex(crypto.getRandomValues(new Uint8Array(32)));
}
const wrapKb = xor(hexToUint8(kB), hexToUint8(v1.unwrapBKey));
const wrapKbVersion2 = xor(hexToUint8(kB), hexToUint8(v2.unwrapBKey));
return {
kB,
wrapKb: uint8ToHex(wrapKb),
wrapKbVersion2: uint8ToHex(wrapKbVersion2)
}
}