зеркало из https://github.com/mozilla/fxa.git
Merge pull request #15078 from mozilla/fxa-7044
feat(password): Add ability to decrypt a user's `recoveryKeyData`
This commit is contained in:
Коммит
2358852361
|
@ -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());
|
||||
|
|
Загрузка…
Ссылка в новой задаче