Merge pull request #15078 from mozilla/fxa-7044

feat(password): Add ability to decrypt a user's `recoveryKeyData`
This commit is contained in:
Vijay Budhram 2023-03-27 15:00:36 -04:00 коммит произвёл GitHub
Родитель 928dc4647b 2c471c00b1
Коммит 2358852361
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
14 изменённых файлов: 253 добавлений и 31 удалений

Просмотреть файл

@ -651,7 +651,7 @@ jobs:
default: large
parallelism:
type: integer
default: 6
default: 8
executor: functional-test-executor
resource_class: << parameters.resource_class >>
parallelism: << parameters.parallelism >>

Просмотреть файл

@ -367,11 +367,11 @@ export class LoginPage extends BaseLayout {
}
setRecoveryKey(key: string) {
return this.page.fill(selectors.RECOVERY_KEY_TEXT_INPUT, key);
return this.page.locator(selectors.RECOVERY_KEY_TEXT_INPUT).fill(key);
}
setAge(age: string) {
return this.page.fill(selectors.AGE, age);
return this.page.locator(selectors.AGE).fill(age);
}
async setNewPassword(password: string) {

Просмотреть файл

@ -0,0 +1,109 @@
import { test, expect } from '../../lib/fixtures/standard';
import { EmailHeader, EmailType } from '../../lib/email';
import { BaseTarget } from '../../lib/targets/base';
let key;
let originalEncryptionKeys;
const NEW_PASSWORD = 'notYourAveragePassW0Rd';
function getReactFeatureFlagUrl(target: BaseTarget, path: string) {
return `${target.contentServerUrl}${path}?showReactApp=true`;
}
test.describe('recovery key', () => {
test.beforeEach(
async ({ target, credentials, pages: { settings, recoveryKey } }) => {
// Generating and consuming recovery keys is a slow process
test.slow();
await settings.goto();
let status = await settings.recoveryKey.statusText();
expect(status).toEqual('Not Set');
await settings.recoveryKey.clickCreate();
await recoveryKey.setPassword(credentials.password);
await recoveryKey.submit();
// Store key to be used later
key = await recoveryKey.getKey();
await recoveryKey.clickClose();
// Verify status as 'enabled'
status = await settings.recoveryKey.statusText();
expect(status).toEqual('Enabled');
// Stash original encryption keys to be verified later
const res = await target.auth.sessionReauth(
credentials.sessionToken,
credentials.email,
credentials.password,
{
keys: true,
reason: 'recovery_key',
}
)
originalEncryptionKeys = await target.auth.accountKeys(res.keyFetchToken, res.unwrapBKey);
}
);
test('can reset password with recovery key', async ({
credentials,
target,
page,
}) => {
await page.goto(getReactFeatureFlagUrl(target, '/reset_password'));
// Verify react page has been loaded
expect(await page.locator('#root').isEnabled()).toBeTruthy();
await page.locator('input').fill(credentials.email);
await page.locator('text="Begin reset"').click();
await page.waitForURL(/confirm_reset_password/);
// We need to append `&showReactApp=true` to reset link inorder to enroll in reset password experiment
let link = await target.email.waitForEmail(
credentials.email,
EmailType.recovery,
EmailHeader.link
);
link = `${link}&showReactApp=true`;
// Loads the React version
await page.goto(link);
expect(await page.locator('#root').isEnabled()).toBeTruthy();
expect(
await page.locator('text="Enter account recovery key"').isVisible()
).toBeTruthy();
await page.locator('input').fill(key);
await page.locator('text="Confirm account recovery key"').click();
await page.waitForURL(/account_recovery_reset_password/);
await page.locator('input[name="newPassword"]').fill(NEW_PASSWORD);
await page.locator('input[name="confirmPassword"]').fill(NEW_PASSWORD);
await page.locator('text="Reset password"').click();
await page.waitForURL(/reset_password_with_recovery_key_verified/);
// Attempt to login with new password
const { sessionToken } = await target.auth.signIn(
credentials.email,
NEW_PASSWORD
);
const res = await target.auth.sessionReauth(
sessionToken,
credentials.email,
NEW_PASSWORD,
{
keys: true,
reason: 'recovery_key',
}
)
const newEncryptionKeys = await target.auth.accountKeys(res.keyFetchToken, res.unwrapBKey);
expect(originalEncryptionKeys).toEqual(newEncryptionKeys);
// Cleanup requires setting this value to correct password
credentials.password = NEW_PASSWORD;
});
});

Просмотреть файл

@ -17,7 +17,7 @@ test.describe('reset password', () => {
test.slow(project.name !== 'local', 'email delivery can be slow');
});
test.skip('can reset password', async ({
test('can reset password', async ({
page,
target,
credentials,

Просмотреть файл

@ -7,6 +7,9 @@ let key;
test.describe('recovery key test', () => {
test.beforeEach(
async ({ credentials, page, pages: { settings, recoveryKey } }) => {
// Generating and consuming recovery keys is a slow process
test.slow();
await settings.goto();
let status = await settings.recoveryKey.statusText();
expect(status).toEqual('Not Set');
@ -73,9 +76,7 @@ test.describe('recovery key test', () => {
await recoveryKey.confirmRecoveryKey();
// Verify the error
expect(await recoveryKey.invalidRecoveryKeyError()).toMatch(
'Invalid account recovery key'
);
expect(await recoveryKey.invalidRecoveryKeyError()).toContain('Invalid account recovery key');
// Enter new recovery key
await login.setRecoveryKey(secondKey);

Просмотреть файл

@ -8,7 +8,7 @@ import { EmailHeader, EmailType } from '../../lib/email';
const password = 'passwordzxcv';
let email;
test.describe('Firefox Desktop Sync v3 sign in', () => {
test.describe.skip('Firefox Desktop Sync v3 sign in', () => {
test.beforeEach(async ({ pages: { login } }) => {
test.slow();
email = login.createEmail('sync{id}');

Просмотреть файл

@ -8,7 +8,7 @@ const password = 'passwordzxcv';
const incorrectPassword = 'password123';
let email;
test.describe('Firefox Desktop Sync v3 sign up', () => {
test.describe.skip('Firefox Desktop Sync v3 sign up', () => {
test.beforeEach(async ({ pages: { login } }) => {
test.slow();
email = login.createEmail('sync{id}');
@ -34,7 +34,7 @@ test.describe('Firefox Desktop Sync v3 sign up', () => {
await signinTokenCode.clickSubmitButton();
// Verify the error message
expect(await login.getTooltipError()).toMatch('Passwords do not match');
expect(await login.getTooltipError()).toContain('Passwords do not match');
// Fix the error
await login.confirmPassword(password);
@ -67,7 +67,7 @@ test.describe('Firefox Desktop Sync v3 sign up', () => {
// Age textbox is not on the page and click submit
await login.submit();
await login.fillOutSignUpCode(email);
expect(await connectAnotherDevice.fxaConnected.isVisible()).toBeTruthy();
expect(await connectAnotherDevice.fxaConnected.isEnabled()).toBeTruthy();
await login.page.close();
await page.close();
@ -84,7 +84,7 @@ test.describe('Firefox Desktop Sync v3 sign up', () => {
target.contentServerUrl
}?context=fx_desktop_v3&service=sync&action=email&${queryParam.toString()}`
);
expect(await login.getTooltipError()).toMatch('Valid email required');
expect(await login.getTooltipError()).toContain('Valid email required');
await login.page.close();
await page.close();
@ -100,7 +100,7 @@ test.describe('Firefox Desktop Sync v3 sign up', () => {
target.contentServerUrl
}?context=fx_desktop_v3&service=sync&action=email&${queryParam.toString()}`
);
expect(await login.getTooltipError()).toMatch('Valid email required');
expect(await login.getTooltipError()).toContain('Valid email required');
await login.page.close();
await page.close();
@ -143,7 +143,7 @@ test.describe('Firefox Desktop Sync v3 sign up', () => {
expect(await login.isPasswordHeader()).toBe(true);
// Verify the correct email is displayed
expect(await login.getPrefilledEmail()).toMatch(credentials.email);
expect(await login.getPrefilledEmail()).toContain(credentials.email);
await login.page.close();
await page.close();

Просмотреть файл

@ -1,7 +1,7 @@
/* 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, uint8ToHex, xor } from './utils';
import { hexToUint8, uint8ToBase64Url, base64UrlToUint8, uint8ToHex, xor } from './utils';
const encoder = () => new TextEncoder();
const NAMESPACE = 'identity.mozilla.com/picl/v1/';
@ -206,6 +206,39 @@ export async function jweEncrypt(
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, empty, 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(

Просмотреть файл

@ -2,10 +2,14 @@
* 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 { jweEncrypt, hkdf } from './crypto';
import { jweEncrypt, jweDecrypt, hkdf } from './crypto';
import { hexToUint8, uint8ToHex } from './utils';
async function randomKey() {
export type DecryptedRecoveryKeyData = {
kA: string;
kB: string;
}
export async function randomKey() {
// The key is displayed in base32 'Crockford' so the length should be
// divisible by (5 bits per character) and (8 bits per byte).
// 20 bytes == 160 bits == 32 base32 characters
@ -74,3 +78,24 @@ export async function generateRecoveryKey(
recoveryData,
};
}
/**
* Decrypts a user's recoveryKeyData. This data contains the user's encryption
* keys (kA, kB) used in OnePW protocol.
*
* @param recoveryKey
* @param recoveryKeyId
* @param recoveryData encrypted user recovery data
*/
export async function decryptRecoveryKeyData(recoveryKey:Uint8Array, recoveryKeyId:string, recoveryData:string, uid:hexstring): Promise<DecryptedRecoveryKeyData> {
const encoder = new TextEncoder();
const salt = hexToUint8(uid);
const encryptionKey = await hkdf(
recoveryKey,
salt,
encoder.encode('fxa recovery encrypt key'),
32
);
return JSON.parse(await jweDecrypt(encryptionKey, recoveryData));
}

Просмотреть файл

@ -29,6 +29,14 @@ export function uint8ToBase64Url(array: Uint8Array) {
.replace(/\//g, '_');
}
export function base64UrlToUint8(value: string): Uint8Array {
const m = value.length % 4;
return Uint8Array.from(atob(
value.replace(/-/g, '+')
.replace(/_/g, '/')
), c => c.charCodeAt(0))
}
export function xor(array1: Uint8Array, array2: Uint8Array) {
return new Uint8Array(array1.map((byte, i) => byte ^ array2[i]));
}

Просмотреть файл

@ -1,6 +1,10 @@
import assert from 'assert';
import * as assert from 'assert';
import '../server'; // must import this to run with nodejs
import { getCredentials, unbundleKeyFetchResponse } from 'fxa-auth-client/lib/crypto';
import { getCredentials, hkdf, jweDecrypt, jweEncrypt, unbundleKeyFetchResponse } from 'fxa-auth-client/lib/crypto';
import { randomKey, getRecoveryKeyIdByUid } from 'fxa-auth-client/lib/recoveryKey';
import { hexToUint8 } from 'fxa-auth-client/lib/utils';
const uid = 'aaaaabbbbbcccccdddddeeeeefffff00';
describe('lib/crypto', () => {
describe('getCredentials', () => {
@ -36,4 +40,28 @@ describe('lib/crypto', () => {
);
});
});
describe('jwe', () => {
it('should encrypt and decrypt', async () => {
const recoveryKey = await randomKey();
const encoder = new TextEncoder();
const salt = hexToUint8(uid);
const data = {
kB: "aaaa"
};
const recoveryKeyId = await getRecoveryKeyIdByUid(recoveryKey, uid);
const encryptionKey = await hkdf(
recoveryKey,
salt,
encoder.encode('fxa recovery encrypt key'),
32
);
const encryptedData = await jweEncrypt(encryptionKey, recoveryKeyId, encoder.encode(JSON.stringify(data)));
const decryptedData = await jweDecrypt(encryptionKey, encryptedData);
assert.deepEqual(JSON.parse(decryptedData), data);
});
});
});

Просмотреть файл

@ -2,12 +2,13 @@
* 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 assert from 'assert';
import * as assert from 'assert';
import '../server'; // must import this to run with nodejs
import {
generateRecoveryKey,
getRecoveryKeyIdByUid,
} from 'fxa-auth-client/lib/recoveryKey';
import { decryptRecoveryKeyData } from '../lib/recoveryKey';
// as seen in https://github.com/mozilla/fxa/blob/main/packages/fxa-content-server/app/tests/spec/lib/crypto/recovery-keys.js
const uid = 'aaaaabbbbbcccccdddddeeeeefffff00';
@ -48,4 +49,17 @@ describe('lib/recoveryKey', () => {
assert.deepStrictEqual(recoveryData, expectedRecoveryData);
});
});
describe('decryptRecoveryKeyData', () => {
it('matches the test vector', async () => {
const { recoveryKey, recoveryKeyId, recoveryData } =
await generateRecoveryKey(uid, keys, {
testRecoveryKey: expectedRecoveryKey,
testIV: iv,
});
const result = await decryptRecoveryKeyData(recoveryKey, recoveryKeyId, recoveryData, uid);
assert.deepStrictEqual(result, keys);
});
});
});

Просмотреть файл

@ -28,7 +28,8 @@ import {
} from '../../../lib/hooks/useLinkStatus';
import AppLayout from '../../../components/AppLayout';
import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors';
import { randomBytes } from 'crypto';
import base32Decode from 'base32-decode';
import { decryptRecoveryKeyData } from 'fxa-auth-client/lib/recoveryKey';
type FormData = {
recoveryKey: string;
@ -93,17 +94,17 @@ const AccountRecoveryConfirmKey = (_: RouteComponentProps) => {
logViewEvent('flow', `${viewName}.success`, REACT_ENTRYPOINT);
// FOLLOW-UP: Get values by decoding recovery data.
const decode = (_data: string) => ({
kB: randomBytes(64).toString('hex'),
});
const { kB } = decode(recoveryData);
const decodedRecoveryKey = base32Decode(recoveryKey, 'Crockford');
const uint8RecoveryKey = new Uint8Array(decodedRecoveryKey);
const decryptedData = await decryptRecoveryKeyData(uint8RecoveryKey, recoveryKeyId, recoveryData, uid);
navigate(`/account_recovery_reset_password${window.location.search}`, {
state: {
accountResetToken,
recoveryKeyId,
kB,
kB: decryptedData.kB,
},
});
},

Просмотреть файл

@ -241,11 +241,14 @@ const AccountRecoveryResetPassword = ({
const password = data.newPassword;
try {
await account.resetPasswordWithRecoveryKey({
const options = {
password,
...verificationInfo,
...accountRecoveryKeyInfo,
});
accountResetToken: accountRecoveryKeyInfo.accountResetToken,
kB: accountRecoveryKeyInfo.kB,
recoveryKeyId: accountRecoveryKeyInfo.recoveryKeyId,
emailToHashWith: verificationInfo.emailToHashWith || verificationInfo.email
}
await account.resetPasswordWithRecoveryKey(options);
// FOLLOW-UP: Functionality not yet available.
await account.setLastLogin(Date.now());