feat: vendor fxa-crypto-relier as crypto-relier

Because:

* We want to incorporate the fxa-crypto-relier library into the Firefox
  Accounts codebase and deprecated its webextension functionality.

This commit:

* Adds the fxa-crypto-relier library to the Firefox Accounts codebase.

Closes FXA-9741
This commit is contained in:
Ben Bangert 2024-06-03 10:40:15 -07:00
Родитель a7df9a7458
Коммит 8771178ee3
Не найден ключ, соответствующий данной подписи
24 изменённых файлов: 1103 добавлений и 7 удалений

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

@ -0,0 +1,23 @@
{
"extends": ["../../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.json"],
"parser": "jsonc-eslint-parser",
"rules": {}
}
]
}

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

@ -0,0 +1,15 @@
# crypto-relier
This library was vendored from [fxa-crypto-relier](https://github.com/mozilla/fxa-crypto-relier/tree/master)
to directly incorporate and improve the code as needed for use in scoped key flows.
This version has had the OAuthUtils functionality used for webextensions removed and been updated
where possible to use the newer jose library.
## Building
Run `nx build crypto-relier` to build the library.
## Running unit tests
Run `nx test crypto-relier` to execute the unit tests via [Jest](https://jestjs.io).

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

@ -0,0 +1,11 @@
/* eslint-disable */
export default {
displayName: 'crypto-relier',
preset: '../../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../coverage/libs/vendored/crypto-relier',
};

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

@ -0,0 +1,4 @@
{
"name": "@fxa/vendored/crypto-relier",
"version": "0.0.1"
}

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

@ -0,0 +1,28 @@
{
"name": "crypto-relier",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/vendored/crypto-relier/src",
"projectType": "library",
"tags": [],
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/vendored/crypto-relier",
"tsConfig": "libs/vendored/crypto-relier/tsconfig.lib.json",
"packageJson": "libs/vendored/crypto-relier/package.json",
"main": "libs/vendored/crypto-relier/src/index.ts",
"assets": ["libs/vendored/crypto-relier/*.md"]
}
},
"test-unit": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/vendored/crypto-relier/jest.config.ts",
"testPathPattern": ["^(?!.*\\.in\\.spec\\.ts$).*$"]
}
}
}
}

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

@ -0,0 +1,6 @@
/* 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/. */
export * from './lib/deriver';
export * from './lib/relier';

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

@ -0,0 +1,44 @@
/* 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 * as jose from 'jose';
/**
* Scoped key deriver utilities
* @module deriver-DeriverUtils
* @private
*/
export class DeriverUtils {
/**
* @method encryptBundle
* @param {string} appPublicKeyJwk - base64url encoded string of the public key JWK
* @param {string} bundle - String bundle to encrypt using the provided key
* @returns {Promise}
*/
async encryptBundle(appPublicKeyJwk: string, bundle: string) {
const rawKey = jose.decodeJwt('.' + appPublicKeyJwk + '.');
const key = await jose.importJWK(rawKey);
// To help reliers do the right thing, we reject keys that aren't exactly as we expect.
// In the future we might open up to additional key types, but for now it's better to
// be strict in what we accept.
if (rawKey.kty !== 'EC') {
throw new Error('appJwk is not an EC key');
}
if (rawKey.crv !== 'P-256') {
throw new Error('appJwk is not on curve P-256');
}
if ('d' in rawKey) {
throw new Error('appJwk includes the private key');
}
return new jose.CompactEncrypt(new TextEncoder().encode(bundle))
.setProtectedHeader({
alg: 'ECDH-ES',
enc: 'A256GCM',
kid: rawKey.kid as any,
})
.encrypt(key);
}
}

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

@ -0,0 +1,103 @@
/* 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 * as jose from 'jose';
import { DeriverUtils } from './deriver-utils';
describe('DeriverUtils', () => {
const deriverUtils = new DeriverUtils();
const b64urlencode = jose.base64url.encode;
const exampleScope = 'https://identity.mozilla.com/apps/notes';
const keySample = {
[exampleScope]: {
kty: 'oct',
scope: exampleScope,
k: 'XQzv2cjJfSMsi3NPn0nVVWprUbhlVvuOBkyEqwvjMdk',
kid: '20171004201318-jcbS5axUtJCRK3Rc-5rj4fsLhh3LOENEIFGwrau2bjI',
},
};
describe('encryptBundle', () => {
it('can encrypt the bundle', async () => {
const appPublicKeyJwk =
'eyJrdHkiOiJFQyIsImtpZCI6ImduUGtGWjE2dHNyeTFsajdDUHdXaENxVkxPSGwtMXFETmJIbG5FNTJzOVEiLCJjcnYiOiJQLTI1NiIsIngiOiJFS3lFOWRta3U2aTNhclpOVVBqdkl0bmo2V2pPUzBldzdENkZQaDR2OFFZIiwieSI6IjRhX3VHenM2Rl9uN0ZrNTZIaDlUZGlMZHNjblg4UHdjTnlXZ3lqeG9td0kifQ';
const enc = await deriverUtils.encryptBundle(
appPublicKeyJwk,
JSON.stringify(keySample)
);
expect(enc.length).toBe(632);
});
it('rejects keys that include the private key component', async () => {
const appPublicKeyJwk = b64urlencode(
JSON.stringify({
kty: 'EC',
crv: 'P-256',
d: 'KXAjjEr4KT9UlYI4BE0BefVdoxP8vqO389U7lQlCigs',
x: 'SiBn6uebjigmQqw4TpNzs3AUyCae1_sG2b9Fzhq3Fyo',
y: 'q99Xq1RWNTFpk99pdQOSjUvwELss51PkmAGCXhLfMV4',
})
);
await expect(
deriverUtils.encryptBundle(appPublicKeyJwk, JSON.stringify(keySample))
).rejects.toThrow('appJwk includes the private key');
});
it('rejects symmetric keys', async () => {
const appPublicKeyJwk = b64urlencode(
JSON.stringify({
kty: 'oct',
k: 'U4ObmO4YmLfHLqYgFd9Q2Q',
})
);
await expect(
deriverUtils.encryptBundle(appPublicKeyJwk, JSON.stringify(keySample))
).rejects.toThrow('appJwk is not an EC key');
});
it('rejects non-ECDH public keys', async () => {
const appPublicKeyJwk = b64urlencode(
JSON.stringify({
kty: 'RSA',
e: 'AQAB',
n: 'nV-WzW3lHd03yEUG88M-r_F0WwCKhlv4O5Yxu5QNiOQnDdDvGwpWTZMeBz9iAtu2S_cia-woK2XcTBOnSorNcC_2YA44aJtBK2TnLR_Ks6Tru2QzO95uDKI7U8mQdUhU_66aCCHTtr5AK178Z29sKoqabivIj3tHnDSLiZSpQgZkJP-jCXat5JyRC2rU6eFX9mORLIkpIyXQxdz_WSSg5DYMhJ20EWoIfMODFIZS4H-w3aYkhv7Ao2dmwozq2iwYsNLmZA26uXdYbqUvpi6kjQZusmPk1OD6E-TnjKh1qI3fi5XesdIf4b-N8fTDwhub6-Vgdh1-8biWmXVFZjc2iQ',
})
);
await expect(
deriverUtils.encryptBundle(appPublicKeyJwk, JSON.stringify(keySample))
).rejects.toThrow('appJwk is not an EC key');
});
it('rejects keys on curves other than P-256', async () => {
const appPublicKeyJwk = b64urlencode(
JSON.stringify({
kty: 'EC',
crv: 'P-384',
x: 'Txvn927uYdiqgSRtHgX3aTVH1_3bMyDM08yN-SRF7Q-2wouLoI70vawCO8i2UaAv',
y: '38oIUqk9a6qtAyq25PAvxwApdPcHg6RaXN3Du70E3sIHKbGtXBX0KBbcFh4yYKUu',
})
);
await expect(
deriverUtils.encryptBundle(appPublicKeyJwk, JSON.stringify(keySample))
).rejects.toThrow('appJwk is not on curve P-256');
});
it('rejects public keys whose points are not on the curve', async () => {
const appPublicKeyJwk = b64urlencode(
JSON.stringify({
kty: 'EC',
crv: 'P-256',
x: 'SiBn6uebjigmQqw4TpNzs3AUyCae1_sG2b9Fzhq3Fyo',
y: 'q99Xq1RWNTFpk99pdQOSjUvwELss51PkmAGCXhLfMV3',
})
);
await expect(
deriverUtils.encryptBundle(appPublicKeyJwk, JSON.stringify(keySample))
).rejects.toThrow('Invalid JWK EC key');
});
});
});

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

@ -0,0 +1,8 @@
/* 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/. */
export * as jose from 'jose';
export * as base64url from 'base64url';
export * as DeriverUtils from './deriver-utils';
export * as ScopedKeys from './scoped-keys';

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

@ -0,0 +1,448 @@
/* 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 { subtle } from 'crypto';
import { ScopedKeys } from './scoped-keys';
describe('ScopedKeys', function () {
const scopedKeys = new ScopedKeys();
const sampleKb =
'bc3851e9e610f631df94d7883d5defd5e5f55ab520bd5a9ae33dae26575c6b1a';
const identifier = 'https://identity.mozilla.com/apps/notes';
const keyRotationTimestamp = 1494446722583; // GMT Wednesday, May 10, 2017 8:05:22.583 PM
const keyRotationSecret =
'0000000000000000000000000000000000000000000000000000000000000000';
const uid = 'aeaa1725c7a24ff983c6295725d5fc9b';
it('should have HKDF work', async () => {
const key = await scopedKeys.deriveScopedKey({
inputKey: sampleKb,
keyRotationSecret: keyRotationSecret,
keyRotationTimestamp: keyRotationTimestamp,
identifier: identifier,
uid: uid,
});
const importSpec = {
name: 'AES-CTR',
};
expect(key.kty).toBe('oct');
expect(key.k).toBe('b9SaftekqPfXDOEeZx8714ktkcG9mBbNnKIyoUmhShE');
expect(key.kid).toBe('1494446723-sXWpWP2sQkP-KsjIPGm1gg');
expect(key.scope).toBe(identifier);
const rawKey = await subtle.importKey('jwk', key, importSpec, false, [
'encrypt',
]);
expect(rawKey.type).toBe('secret');
expect(rawKey.usages[0]).toBe('encrypt');
expect(rawKey.extractable).toBe(false);
});
it('should match the output of test vectors generated via python script', async () => {
const key = await scopedKeys.deriveScopedKey({
inputKey:
'8b2e1303e21eee06a945683b8d495b9bf079ca30baa37eb8392d9ffa4767be45',
keyRotationSecret:
'517d478cb4f994aa69930416648a416fdaa1762c5abf401a2acf11a0f185e98d',
keyRotationTimestamp: 1510726317000,
identifier: 'app_key:https%3A//example.com',
uid: uid,
});
expect(key.kty).toBe('oct');
expect(key.k).toBe('Kkbk1_Q0oCcTmggeDH6880bQrxin2RLu5D00NcJazdQ');
expect(key.kid).toBe('1510726317-Voc-Eb9IpoTINuo9ll7bjA');
});
it('should correctly derive legacy sync key to known test vectors', async () => {
const key = await scopedKeys.deriveScopedKey({
inputKey:
'eaf9570b7219a4187d3d6bf3cec2770c2e0719b7cc0dfbb38243d6f1881675e9',
keyRotationSecret:
'0000000000000000000000000000000000000000000000000000000000000000',
keyRotationTimestamp: 1510726317123,
identifier: 'https://identity.mozilla.com/apps/oldsync',
uid: uid,
});
expect(key.kty).toBe('oct');
expect(key.k).toBe(
'DW_ll5GwX6SJ5GPqJVAuMUP2t6kDqhUulc2cbt26xbTcaKGQl-9l29FHAQ7kUiJETma4s9fIpEHrt909zgFang'
);
expect(key.kid).toBe('1510726317123-IqQv4onc7VcVE1kTQkyyOw');
});
it('should correctly derive Thunderbird sync key to known test vectors', async () => {
const key = await scopedKeys.deriveScopedKey({
inputKey:
'eaf9570b7219a4187d3d6bf3cec2770c2e0719b7cc0dfbb38243d6f1881675e9',
keyRotationSecret:
'0000000000000000000000000000000000000000000000000000000000000000',
keyRotationTimestamp: 1715043913541,
identifier: 'https://identity.thunderbird.net/apps/sync',
uid: uid,
});
expect(key.kty).toBe('oct');
expect(key.k).toBe(
'DW_ll5GwX6SJ5GPqJVAuMUP2t6kDqhUulc2cbt26xbTcaKGQl-9l29FHAQ7kUiJETma4s9fIpEHrt909zgFang'
);
expect(key.kid).toBe('1715043913541-IqQv4onc7VcVE1kTQkyyOw');
});
it('validates that inputKey is provided', async () => {
await expect(
scopedKeys.deriveScopedKey({
keyRotationSecret: keyRotationSecret,
keyRotationTimestamp: keyRotationTimestamp,
identifier: identifier,
uid: uid,
} as any)
).rejects.toHaveProperty(
'message',
'inputKey must be a 64-character hex string'
);
});
it('validates that inputKey is a hex string', async () => {
await expect(
scopedKeys.deriveScopedKey({
inputKey: 'k' + sampleKb.slice(1),
keyRotationSecret: keyRotationSecret,
keyRotationTimestamp: keyRotationTimestamp,
identifier: identifier,
uid: uid,
})
).rejects.toHaveProperty(
'message',
'inputKey must be a 64-character hex string'
);
});
it('validates that inputKey has the required length', async () => {
try {
await scopedKeys.deriveScopedKey({
inputKey: sampleKb.slice(0, 16),
keyRotationSecret: keyRotationSecret,
keyRotationTimestamp: keyRotationTimestamp,
identifier: identifier,
uid: uid,
});
} catch (err) {
expect(err.message).toBe('inputKey must be a 64-character hex string');
}
});
it('validates that keyRotationSecret is provided', async () => {
try {
await scopedKeys.deriveScopedKey({
inputKey: sampleKb,
keyRotationTimestamp: keyRotationTimestamp,
identifier: identifier,
uid: uid,
} as any);
} catch (err) {
expect(err.message).toBe(
'keyRotationSecret must be a 64-character hex string'
);
}
});
it('validates that keyRotationSecret is a hex string', async () => {
try {
await scopedKeys.deriveScopedKey({
inputKey: sampleKb,
keyRotationSecret: 'Q' + keyRotationSecret.slice(1),
keyRotationTimestamp: keyRotationTimestamp,
identifier: identifier,
uid: uid,
});
} catch (err) {
expect(err.message).toBe(
'keyRotationSecret must be a 64-character hex string'
);
}
});
it('validates that keyRotationSecret has the required length', async () => {
try {
await scopedKeys.deriveScopedKey({
inputKey: sampleKb,
keyRotationSecret: keyRotationSecret.slice(0, 16),
keyRotationTimestamp: keyRotationTimestamp,
identifier: identifier,
uid: uid,
});
} catch (err) {
expect(err.message).toBe(
'keyRotationSecret must be a 64-character hex string'
);
}
});
it('validates that keyRotationTimestamp is provided', async () => {
await expect(
scopedKeys.deriveScopedKey({
inputKey: sampleKb,
keyRotationSecret: keyRotationSecret,
identifier: identifier,
uid: uid,
} as any)
).rejects.toHaveProperty(
'message',
'keyRotationTimestamp must be a 13-digit integer'
);
});
it('validates that keyRotationTimestamp is a number', async () => {
await expect(
scopedKeys.deriveScopedKey({
inputKey: sampleKb,
keyRotationSecret: keyRotationSecret,
keyRotationTimestamp: '1111111111111',
identifier: identifier,
uid: uid,
} as any)
).rejects.toHaveProperty(
'message',
'keyRotationTimestamp must be a 13-digit integer'
);
});
it('validates that keyRotationTimestamp is an integer', async () => {
await expect(
scopedKeys.deriveScopedKey({
inputKey: sampleKb,
keyRotationSecret: keyRotationSecret,
keyRotationTimestamp: 1234567890.23,
identifier: identifier,
uid: uid,
})
).rejects.toHaveProperty(
'message',
'keyRotationTimestamp must be a 13-digit integer'
);
});
it('validates that keyRotationTimestamp has correct number of digits', async () => {
await expect(
scopedKeys.deriveScopedKey({
inputKey: sampleKb,
keyRotationSecret: keyRotationSecret,
keyRotationTimestamp: 100,
identifier: identifier,
uid: uid,
})
).rejects.toHaveProperty(
'message',
'keyRotationTimestamp must be a 13-digit integer'
);
});
it('validates that identifier is provided', () => {
expect.assertions(1);
return scopedKeys
.deriveScopedKey({
inputKey: sampleKb,
keyRotationSecret: keyRotationSecret,
keyRotationTimestamp: keyRotationTimestamp,
uid: uid,
} as any)
.catch((err) => {
expect(err.message).toBe('identifier must be a string of length >= 10');
});
});
it('validates that identifier is a string', () => {
expect.assertions(1);
return scopedKeys
.deriveScopedKey({
inputKey: sampleKb,
keyRotationSecret: keyRotationSecret,
keyRotationTimestamp: keyRotationTimestamp,
identifier: true,
uid: uid,
} as any)
.catch((err) => {
expect(err.message).toBe('identifier must be a string of length >= 10');
});
});
it('validates that identifier is of non-trivial length', () => {
expect.assertions(1);
return scopedKeys
.deriveScopedKey({
inputKey: sampleKb,
keyRotationSecret: keyRotationSecret,
keyRotationTimestamp: keyRotationTimestamp,
identifier: 'https://x',
uid: uid,
})
.catch((err) => {
expect(err.message).toBe('identifier must be a string of length >= 10');
});
});
it('validates that uid is provided', () => {
expect.assertions(1);
return scopedKeys
.deriveScopedKey({
inputKey: sampleKb,
keyRotationSecret: keyRotationSecret,
keyRotationTimestamp: keyRotationTimestamp,
identifier: identifier,
} as any)
.catch((err) => {
expect(err.message).toBe('uid must be a 32-character hex string');
});
});
it('validates that uid is a hex string', () => {
expect.assertions(1);
return scopedKeys
.deriveScopedKey({
inputKey: sampleKb,
keyRotationSecret: keyRotationSecret,
keyRotationTimestamp: keyRotationTimestamp,
identifier: identifier,
uid: '!' + uid.slice(1),
})
.catch((err) => {
expect(err.message).toBe('uid must be a 32-character hex string');
});
});
it('validates that uid has the correct length', () => {
expect.assertions(1);
return scopedKeys
.deriveScopedKey({
inputKey: sampleKb,
keyRotationSecret: keyRotationSecret,
keyRotationTimestamp: keyRotationTimestamp,
identifier: identifier,
uid: uid.slice(0, 16),
})
.catch((err) => {
expect(err.message).toBe('uid must be a 32-character hex string');
});
});
describe('_deriveHKDF', () => {
it('vector 1', async () => {
const inputKey = Buffer.from(
'0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b',
'hex'
);
const keyRotationSecret = Buffer.from(
'000102030405060708090a0b0c',
'hex'
);
const context = Buffer.from('f0f1f2f3f4f5f6f7f8f9', 'hex');
const key = await scopedKeys._deriveHKDF(
keyRotationSecret,
inputKey,
context,
42
);
expect(key.toString('hex')).toBe(
'3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865'
);
});
it('vector 2', async () => {
const inputKey = Buffer.from(
'0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b',
'hex'
);
const keyRotationSecret = Buffer.from('');
const context = Buffer.from('');
const key = await scopedKeys._deriveHKDF(
keyRotationSecret,
inputKey,
context,
42
);
expect(key.toString('hex')).toBe(
'8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8'
);
});
it('vector 3', async () => {
const inputKey = Buffer.from(
'4a9cbe5ae7190a7bb7cc54d5d84f5e4ba743904f8a764933b72f10260067375a',
'hex'
);
const keyRotationSecret = Buffer.from('');
const context = Buffer.from('identity.mozilla.com/picl/v1/keyFetchToken');
const key = await scopedKeys._deriveHKDF(
keyRotationSecret,
inputKey,
context,
3 * 32
);
expect(key.toString('hex')).toBe(
'f4df04ffb79db35e94e4881719a6f145f9206e8efea17fc9f02a5ce09cbfac1e829a935f34111d75e0d16b7aa178e2766759eedb6f623c0babd2abcfea82bc12af75f6aa543a8ba7e0a029f87c785c4af0ad03889f7437f735b5256a88fc73fd'
);
});
it('vector 4', async () => {
const inputKey = Buffer.from(
'ba0a107dab60f3b065ff7a642d14fe824fbd71bc5c99087e9e172a1abd1634f1',
'hex'
);
const keyRotationSecret = Buffer.from('');
const context = Buffer.from('identity.mozilla.com/picl/v1/account/keys');
const key = await scopedKeys._deriveHKDF(
keyRotationSecret,
inputKey,
context,
3 * 32
);
expect(key.toString('hex')).toBe(
'17ab463653a94c9a6419b48781930edefe500395e3b4e7879a2be1599975702285de16c3218a126404668bf9b7acfb6ce2b7e03c8889047ba48b8b854c6d8beb3ae100e145ca6d69cb519a872a83af788771954455716143bc08225ea8644d85'
);
});
it('vector 5', async () => {
const inputKey = Buffer.from(
'bc3851e9e610f631df94d7883d5defd5e5f55ab520bd5a9ae33dae26575c6b1a',
'hex'
);
const keyRotationSecret = Buffer.from(
'0000000000000000000000000000000000000000000000000000000000000000',
'hex'
);
const context = Buffer.from('https://identity.mozilla.com/apps/notes');
const key = await scopedKeys._deriveHKDF(
keyRotationSecret,
inputKey,
context,
32
);
expect(key.toString('hex')).toBe(
'989131d32cd665c26a57cf9ece14d0e5cf015834e9d2916d683a3bb486ceb06f'
);
});
it('vector 6', async () => {
const inputKey = Buffer.from(
'bc3851e9e610f631df94d7883d5defd5e5f55ab520bd5a9ae33dae26575c6b1a',
'hex'
);
const keyRotationSecret = Buffer.from('');
const context = Buffer.from('https://identity.mozilla.com/apps/notes');
const key = await scopedKeys._deriveHKDF(
keyRotationSecret,
inputKey,
context,
32
);
expect(key.toString('hex')).toBe(
'989131d32cd665c26a57cf9ece14d0e5cf015834e9d2916d683a3bb486ceb06f'
);
});
});
});

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

@ -0,0 +1,188 @@
/* 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 { subtle } from 'crypto';
import * as jose from 'jose';
import HKDF from 'node-hkdf';
const KEY_LENGTH = 48;
const SYNC_SCOPES = [
'https://identity.mozilla.com/apps/oldsync',
'https://identity.thunderbird.net/apps/sync',
];
const REGEX_HEX32 = /^[0-9a-f]{32}$/i;
const REGEX_HEX64 = /^[0-9a-f]{64}$/i;
const REGEX_TIMESTAMP = /^[0-9]{13}$/;
/**
* Scoped key deriver
* @desc Used by the Firefox Accounts content server
* @module deriver-ScopedKeys
* @example
* ```js
* const scopedKeys = new fxaCryptoDeriver.ScopedKeys();
*
* return scopedKeys.deriveScopedKey({
* identifier: 'https://identity.mozilla.com/apps/notes',
* inputKey: 'bc3851e9e610f631df94d7883d5defd5e5f55ab520bd5a9ae33dae26575c6b1a',
* keyRotationSecret: '0000000000000000000000000000000000000000000000000000000000000000',
* keyRotationTimestamp: 1494446722583,
* uid: 'aeaa1725c7a24ff983c6295725d5fc9b'
* });
* ```
*/
export class ScopedKeys {
/**
* Derive a scoped key.
* This method derives the key material for a particular scope from the user's master key material.
* For most scopes it will produce a JWK containing a 32-byte symmetric key.
* There is also special-case support for a legacy key-derivation algorithm used by Firefox Sync,
* which generates a 64-byte key when `options.identifier` is 'https://identity.mozilla.com/apps/oldsync'.
* @method deriveScopedKey
* @param {object} options - required set of options to derive a scoped key
* @param {string} options.inputKey - input key hex string that the scoped key is derived from
* @param {string} options.keyRotationSecret - a 32-byte hex string of additional entropy specific to this scoped key
* @param {number} options.keyRotationTimestamp
* A 13-digit number, the timestamp in milliseconds at which this scoped key most recently changed
* @param {string} options.identifier - a unique URI string identifying the requested scoped key
* @param {string} options.uid - a 16-byte Firefox Account UID hex string
* @returns {Promise}
*/
async deriveScopedKey(options: {
identifier: string;
inputKey: string;
keyRotationSecret: string;
keyRotationTimestamp: number;
uid: string;
}) {
if (!REGEX_HEX64.test(options.inputKey)) {
throw new Error('inputKey must be a 64-character hex string');
}
if (!REGEX_HEX64.test(options.keyRotationSecret)) {
throw new Error('keyRotationSecret must be a 64-character hex string');
}
if (typeof options.keyRotationTimestamp !== 'number') {
throw new Error('keyRotationTimestamp must be a 13-digit integer');
}
if (!REGEX_TIMESTAMP.test(options.keyRotationTimestamp.toString())) {
throw new Error('keyRotationTimestamp must be a 13-digit integer');
}
if (
typeof options.identifier !== 'string' ||
options.identifier.length < 10
) {
throw new Error('identifier must be a string of length >= 10');
}
if (!REGEX_HEX32.test(options.uid)) {
throw new Error('uid must be a 32-character hex string');
}
if (SYNC_SCOPES.includes(options.identifier)) {
return this._deriveLegacySyncKey(options);
}
const context =
'identity.mozilla.com/picl/v1/scoped_key\n' + options.identifier;
const contextBuf = Buffer.from(context);
const inputKeyBuf = Buffer.from(options.inputKey, 'hex');
const keyRotationSecretBuf = Buffer.from(options.keyRotationSecret, 'hex');
const saltBuf = Buffer.from(options.uid, 'hex');
const scopedKey: Record<string, string> = {
kty: 'oct',
scope: options.identifier,
};
const key = await this._deriveHKDF(
saltBuf,
Buffer.concat([inputKeyBuf, keyRotationSecretBuf]),
contextBuf,
KEY_LENGTH
);
const kid = key.slice(0, 16);
const k = key.slice(16, 48);
const keyTimestamp = Math.round(options.keyRotationTimestamp / 1000);
scopedKey.k = jose.base64url.encode(k);
scopedKey.kid = keyTimestamp + '-' + jose.base64url.encode(kid);
return scopedKey;
}
/**
* Derive a scoped key using the special legacy algorithm from Firefox Sync.
* To access data in Firefox Sync, clients need to know:
* * 64 bytes of key material derived from kB using HKDF
* * The first 16 bytes of the SHA-256 hash of kB
* * The full millisecond precision timestamp of when kB was last changed.
* This method encodes that information as a JWK by using the first as the
* key material `k`, and combining the other two to form the `kid`.
* @method _deriveLegacySyncKey
* @private
* @param {object} options - required set of options to derive the scoped key
* @param {string} options.inputKey - input key hex string that the scoped key is derived from
* @param {number} options.keyRotationTimestamp
* A 13-digit number, the timestamp in milliseconds at which this scoped key most recently changed
* @returns {Promise}
*/
async _deriveLegacySyncKey(options: {
identifier: string;
inputKey: string;
keyRotationTimestamp: number;
}) {
const context = 'identity.mozilla.com/picl/v1/oldsync';
const contextBuf = Buffer.from(context);
const inputKeyBuf = Buffer.from(options.inputKey, 'hex');
const scopedKey: Record<string, string> = {
kty: 'oct',
scope: options.identifier,
};
const key = await this._deriveHKDF(
Buffer.from(''),
inputKeyBuf,
contextBuf,
64
);
scopedKey.k = jose.base64url.encode(Buffer.from(key));
const kHash = await subtle.digest('SHA-256', Buffer.from(inputKeyBuf));
scopedKey.kid =
options.keyRotationTimestamp +
'-' +
jose.base64url.encode(Buffer.from(kHash.slice(0, 16)));
return scopedKey;
}
/**
* Derive a key using HKDF.
* Ref: https://tools.ietf.org/html/rfc5869
* @method _deriveHKDF
* @private
* @param {buffer} salt
* @param {buffer} initialKeyingMaterial
* @param {buffer} info
* @param {number} keyLength - Key length
* @returns {Promise}
*/
async _deriveHKDF(
salt: Buffer,
initialKeyingMaterial: Buffer,
info: Buffer,
keyLength: number
): Promise<Buffer> {
return new Promise((resolve) => {
// Safari doesn't have HKDF yet in their Web Crypto API
const hkdf = new HKDF('sha256', salt, initialKeyingMaterial);
hkdf.derive(info, keyLength, (key: any) => {
return resolve(key);
});
});
}
}

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

@ -0,0 +1,5 @@
/* 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/. */
export * from './key-utils';

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

@ -0,0 +1,83 @@
/* 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 * as jose from 'node-jose';
import { DeriverUtils } from '../deriver/deriver-utils';
import { KeyUtils } from './key-utils';
describe('KeyUtils', function () {
describe('createApplicationKeyPair', () => {
it('should output a JWK public key', () => {
const keyUtils = new KeyUtils();
return keyUtils.createApplicationKeyPair().then((result) => {
const jwk = result.jwkPublicKey;
expect(jwk.kty).toBe('EC');
expect(jwk.kid.length).toBe(43);
expect(jwk.crv).toBe('P-256');
expect(jwk.x.length).toBe(43);
expect(jwk.y.length).toBe(43);
});
});
});
describe('decryptBundle', () => {
it('fails with no key store', async () => {
const keyUtils = new KeyUtils();
return expect(keyUtils.decryptBundle('bundle')).rejects.toThrowError(
'No Key Store. Use .createApplicationKeyPair() to create it first.'
);
});
it('can decrypt a bundle', async () => {
const derivedKeys = {
'https://identity.mozilla.com/apps/notes': {
kid: '<opaque key identifier>',
k: '<notes encryption key, b64url-encoded>',
kty: 'oct',
},
};
const keyUtils = new KeyUtils();
const deriverUtils = new DeriverUtils();
const keys = await keyUtils.createApplicationKeyPair();
const base64JwkPublicKey = jose.util.base64url.encode(
JSON.stringify(keys.jwkPublicKey),
'utf8'
);
const encryptedBundle = await deriverUtils.encryptBundle(
base64JwkPublicKey,
JSON.stringify(derivedKeys)
);
const decryptedBundle = await keyUtils.decryptBundle(encryptedBundle);
expect(decryptedBundle).toEqual(derivedKeys);
});
it('can decrypt a test vector key bundle generated via python code', async () => {
const keyUtils = new KeyUtils();
keyUtils.keystore = jose.JWK.createKeyStore();
await keyUtils.keystore.add({
kty: 'EC',
crv: 'P-256',
d: 'KXAjjEr4KT9UlYI4BE0BefVdoxP8vqO389U7lQlCigs',
x: 'SiBn6uebjigmQqw4TpNzs3AUyCae1_sG2b9Fzhq3Fyo',
y: 'q99Xq1RWNTFpk99pdQOSjUvwELss51PkmAGCXhLfMV4',
});
const result = await keyUtils.decryptBundle(
'eyJhbGciOiJFQ0RILUVTIiwiZW5jIjoiQTI1NkdDTSIsImVwayI6eyJjcnYiOiJQLTI1NiIsImt0eSI6IkVDIiwieCI6Ik40elBSYXpCODd2cGVCZ0h6RnZrdmRfNDhvd0ZZWXhFVlhSTXJPVTZMRG8iLCJ5IjoiNG5jVXhONnhfeFQxVDFrenlfU19WMmZZWjd1VUpUX0hWUk5aQkxKUnN4VSJ9fQ.._0sYf7HdWuRv2cM0.U5ZK5BYZWhLluS7q4y4ZFW1UwcW-s7mjuY_khe4OVjtvLE5jOQw-qGyT_06wY2zpqN6FhGMa16Qhn4UABz0LuDwAfrHOtfRlpqeV3nrKhas2gXt1yLvDFLide4hEPfBJk60t2CXjxprsA1BulinIER2EIJbA.rpf5rzO78Hj-9CWRTLx7TQ'
);
expect(result).toEqual({
app_key: {
k: 'rTcZ5olrrJWqVD6bVtLjHJT0P6d_9IdpEgWT4zVzMb0',
kid: '1510726317-UvyHCg_RD3zKl_hQdlRsfw',
kty: 'oct',
},
});
});
});
});

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

@ -0,0 +1,49 @@
/* 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 * as jose from 'node-jose';
/**
* Scoped key utilities
* @module relier-KeyUtils
* @private
*/
export class KeyUtils {
/**
* @constructor
*/
constructor(public keystore: any = null) {}
/**
* @method createApplicationKeyPair
* @desc Returns a JWK public key
* @returns {Promise}
*/
async createApplicationKeyPair() {
const keystore = jose.JWK.createKeyStore();
const keyPair = await keystore.generate('EC', 'P-256');
this.keystore = keystore;
return {
jwkPublicKey: keyPair.toJSON(),
};
}
/**
* @method decryptBundle
* @desc Decrypts a given bundle using the JWK key store
* @param {string} bundle
* @returns {Promise}
*/
async decryptBundle(bundle: string) {
if (!this.keystore) {
throw new Error(
'No Key Store. Use .createApplicationKeyPair() to create it first.'
);
}
const decryptedBundle = await jose.JWE.createDecrypt(this.keystore).decrypt(
bundle
);
return JSON.parse(jose.util.utf8.encode(decryptedBundle.plaintext));
}
}

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

@ -0,0 +1,16 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs"
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

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

@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"outDir": "../../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"],
"include": ["src/**/*.ts"]
}

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

@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

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

@ -80,6 +80,7 @@
"@swc/helpers": "~0.5.2",
"@type-cacheable/core": "^14.0.1",
"@type-cacheable/ioredis-adapter": "^10.0.4",
"base64url": "^3.0.1",
"bn.js": "^5.2.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
@ -89,6 +90,7 @@
"graphql-request": "^6.1.0",
"hot-shots": "^10.0.0",
"husky": "^4.2.5",
"jose": "^5.3.0",
"knex": "^3.1.0",
"kysely": "^0.27.2",
"lint-staged": "^15.2.0",
@ -100,6 +102,8 @@
"next": "^14.2.3",
"next-auth": "beta",
"node-fetch": "^2.6.7",
"node-hkdf": "^0.0.2",
"node-jose": "^2.2.0",
"nps": "^5.10.0",
"objection": "^3.1.3",
"passport": "^0.7.0",

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

@ -78,7 +78,6 @@
"ajv": "^8.13.0",
"app-store-server-api": "^0.7.0",
"aws-sdk": "^2.1616.0",
"base64url": "3.0.1",
"buf": "0.1.1",
"buffer-equal-constant-time": "1.0.1",
"cldr-core": "^44.1.0",

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

@ -49,7 +49,6 @@
"backbone": "^1.6.0",
"backbone.cocktail": "0.5.15",
"base32-decode": "1.0.0",
"base64url": "3.0.1",
"body-parser": "^1.20.1",
"buffer": "^6.0.3",
"cache-loader": "^4.1.0",

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

@ -62,6 +62,9 @@
"@fxa/vendored/common-password-list": [
"libs/vendored/common-password-list/src/index.ts"
],
"@fxa/vendored/crypto-relier": [
"libs/vendored/crypto-relier/src/index.ts"
],
"@fxa/vendored/incremental-encoder": [
"libs/vendored/incremental-encoder/src/index.ts"
],

1
types/node-hkdf/index.d.ts поставляемый Normal file
Просмотреть файл

@ -0,0 +1 @@
declare module 'node-hkdf';

1
types/node-jose/index.d.ts поставляемый Normal file
Просмотреть файл

@ -0,0 +1 @@
declare module 'node-jose';

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

@ -27621,7 +27621,7 @@ __metadata:
languageName: node
linkType: hard
"base64url@npm:3.0.1, base64url@npm:^3.0.0":
"base64url@npm:^3.0.0, base64url@npm:^3.0.1":
version: 3.0.1
resolution: "base64url@npm:3.0.1"
checksum: a77b2a3a526b3343e25be424de3ae0aa937d78f6af7c813ef9020ef98001c0f4e2323afcd7d8b2d2978996bf8c42445c3e9f60c218c622593e5fdfd54a3d6e18
@ -34009,6 +34009,13 @@ __metadata:
languageName: node
linkType: hard
"es6-promise@npm:^4.2.8":
version: 4.2.8
resolution: "es6-promise@npm:4.2.8"
checksum: 95614a88873611cb9165a85d36afa7268af5c03a378b35ca7bda9508e1d4f1f6f19a788d4bc755b3fd37c8ebba40782018e02034564ff24c9d6fa37e959ad57d
languageName: node
linkType: hard
"esbuild-plugin-alias@npm:^0.2.1":
version: 0.2.1
resolution: "esbuild-plugin-alias@npm:0.2.1"
@ -37843,7 +37850,6 @@ fsevents@~2.1.1:
audit-filter: ^0.5.0
aws-sdk: ^2.1616.0
babel-loader: ^9.1.3
base64url: 3.0.1
binary-split: 1.0.5
buf: 0.1.1
buffer-equal-constant-time: 1.0.1
@ -37979,7 +37985,6 @@ fsevents@~2.1.1:
backbone: ^1.6.0
backbone.cocktail: 0.5.15
base32-decode: 1.0.0
base64url: 3.0.1
body-parser: ^1.20.1
buffer: ^6.0.3
cache-loader: ^4.1.0
@ -38850,6 +38855,7 @@ fsevents@~2.1.1:
"@typescript-eslint/parser": ^7.1.1
autoprefixer: ^10.4.14
babel-jest: ^29.7.0
base64url: ^3.0.1
bn.js: ^5.2.1
class-transformer: ^0.5.1
class-validator: ^0.14.1
@ -38877,6 +38883,7 @@ fsevents@~2.1.1:
jest: ^29.5.0
jest-environment-jsdom: ^29.5.0
jest-environment-node: ^29.7.0
jose: ^5.3.0
json: ^11.0.0
knex: ^3.1.0
kysely: ^0.27.2
@ -38891,6 +38898,8 @@ fsevents@~2.1.1:
next: ^14.2.3
next-auth: beta
node-fetch: ^2.6.7
node-hkdf: ^0.0.2
node-jose: ^2.2.0
nps: ^5.10.0
nx: 18.3.1
nx-cloud: 18.0.0
@ -47949,7 +47958,7 @@ fsevents@~2.1.1:
languageName: node
linkType: hard
"long@npm:^5.2.1":
"long@npm:^5.2.0, long@npm:^5.2.1":
version: 5.2.3
resolution: "long@npm:5.2.3"
checksum: 885ede7c3de4facccbd2cacc6168bae3a02c3e836159ea4252c87b6e34d40af819824b2d4edce330bfb5c4d6e8ce3ec5864bdcf9473fa1f53a4f8225860e5897
@ -50890,7 +50899,7 @@ fsevents@~2.1.1:
languageName: node
linkType: hard
"node-hkdf@npm:0.0.2":
"node-hkdf@npm:0.0.2, node-hkdf@npm:^0.0.2":
version: 0.0.2
resolution: "node-hkdf@npm:0.0.2"
checksum: 20ce3a8877f96e8cd058cd8049caa24a559d69fb0c95d68449e25b36c954ee3018522e74ead9a22728061b0d3273dcd2b3b45bc7a8303943a64dc09a1b9ae807
@ -50927,6 +50936,23 @@ fsevents@~2.1.1:
languageName: node
linkType: hard
"node-jose@npm:^2.2.0":
version: 2.2.0
resolution: "node-jose@npm:2.2.0"
dependencies:
base64url: ^3.0.1
buffer: ^6.0.3
es6-promise: ^4.2.8
lodash: ^4.17.21
long: ^5.2.0
node-forge: ^1.2.1
pako: ^2.0.4
process: ^0.11.10
uuid: ^9.0.0
checksum: ec021aaf12e256a88e0f8504b529d94685b9af774d3009048d4b005294add7735a99ef771f135a0cf6648c2c2c9c82ea4883f1c79b99fe6003b700e67819e24f
languageName: node
linkType: hard
"node-libs-browser@npm:^2.2.1":
version: 2.2.1
resolution: "node-libs-browser@npm:2.2.1"
@ -52488,6 +52514,13 @@ fsevents@~2.1.1:
languageName: node
linkType: hard
"pako@npm:^2.0.4":
version: 2.1.0
resolution: "pako@npm:2.1.0"
checksum: 71666548644c9a4d056bcaba849ca6fd7242c6cf1af0646d3346f3079a1c7f4a66ffec6f7369ee0dc88f61926c10d6ab05da3e1fca44b83551839e89edd75a3e
languageName: node
linkType: hard
"parallel-transform@npm:^1.1.0":
version: 1.2.0
resolution: "parallel-transform@npm:1.2.0"