diff --git a/package.json b/package.json index 62fcf167da..96623a50ff 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,15 @@ "import": "./dist/node/webcrypto/esm/jwe/flattened/encrypt.js", "require": "./dist/node/webcrypto/cjs/jwe/flattened/encrypt.js" }, + "./jwe/general/decrypt": { + "browser": "./dist/browser/jwe/general/decrypt.js", + "import": "./dist/node/esm/jwe/general/decrypt.js", + "require": "./dist/node/cjs/jwe/general/decrypt.js" + }, + "./webcrypto/jwe/general/decrypt": { + "import": "./dist/node/webcrypto/esm/jwe/general/decrypt.js", + "require": "./dist/node/webcrypto/cjs/jwe/general/decrypt.js" + }, "./jwk/embedded": { "browser": "./dist/browser/jwk/embedded.js", "import": "./dist/node/esm/jwk/embedded.js", diff --git a/src/jwe/general/decrypt.ts b/src/jwe/general/decrypt.ts new file mode 100644 index 0000000000..4f7ecb0410 --- /dev/null +++ b/src/jwe/general/decrypt.ts @@ -0,0 +1,118 @@ +import decrypt from '../flattened/decrypt.js' +import { JWEDecryptionFailed, JWEInvalid } from '../../util/errors.js' +import type { + KeyLike, + DecryptOptions, + JWEHeaderParameters, + GetKeyFunction, + FlattenedJWE, + GeneralJWE, + GeneralDecryptResult, +} from '../../types.d' +import isObject from '../../lib/is_object.js' + +/** + * Interface for General JWE Decryption dynamic key resolution. + * No token components have been verified at the time of this function call. + */ +export interface GeneralDecryptGetKey extends GetKeyFunction {} + +/** + * Decrypts a General JWE. + * + * @param jwe General JWE. + * @param key Private Key or Secret, or a function resolving one, to decrypt the JWE with. + * @param options JWE Decryption options. + * + * @example + * ``` + * // ESM import + * import generalDecrypt from 'jose/jwe/general/decrypt' + * ``` + * + * @example + * ``` + * // CJS import + * const { default: generalDecrypt } = require('jose/jwe/general/decrypt') + * ``` + * + * @example + * ``` + * // usage + * import parseJwk from 'jose/jwk/parse' + * + * const decoder = new TextDecoder() + * const jwe = { + * ciphertext: '9EzjFISUyoG-ifC2mSihfP0DPC80yeyrxhTzKt1C_VJBkxeBG0MI4Te61Pk45RAGubUvBpU9jm4', + * iv: '8Fy7A_IuoX5VXG9s', + * tag: 'W76IYV6arGRuDSaSyWrQNg', + * aad: 'VGhlIEZlbGxvd3NoaXAgb2YgdGhlIFJpbmc', + * protected: 'eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMjU2R0NNIn0', + * recipients: [ + * { + * encrypted_key: 'Z6eD4UK_yFb5ZoKvKkGAdqywEG_m0e4IYo0x8Vf30LAMJcsc-_zSgIeiF82teZyYi2YYduHKoqImk7MRnoPZOlEs0Q5BNK1OgBmSOhCE8DFyqh9Zh48TCTP6lmBQ52naqoUJFMtHzu-0LwZH26hxos0GP3Dt19O379MJB837TdKKa87skq0zHaVLAquRHOBF77GI54Bc7O49d8aOrSu1VEFGMThlW2caspPRiTSePDMDPq7_WGk50izRhB3Asl9wmP9wEeaTrkJKRnQj5ips1SAZ1hDBsqEQKKukxP1HtdcopHV5_qgwU8Hjm5EwSLMluMQuiE6hwlkXGOujZLVizA' + * } + * ] + * } + * const privateKey = await parseJwk({ + * e: 'AQAB', + * n: 'qpzYkTGRKSUcd12hZaJnYEKVLfdEsqu6HBAxZgRSvzLFj_zTSAEXjbf3fX47MPEHRw8NDcEXPjVOz84t4FTXYF2w2_LGWfp_myjV8pR6oUUncJjS7DhnUmTG5bpuK2HFXRMRJYz_iNR48xRJPMoY84jrnhdIFx8Tqv6w4ZHVyEvcvloPgwG3UjLidP6jmqbTiJtidVLnpQJRuFNFQJiluQXBZ1nOLC7raQshu7L9y0IatVU7vf0BPnmuSkcNNvmQkSta6ODQBPaL5-o5SW8H37vQjPDkrlJpreViNa3jqP5DB5HYUO-DMh4FegRv9gZWLDEvXpSd9A13YXCa9Q8K_w', + * d: 'YAfYfiEAK8CPvUAeUC6RMUVI4o6DRG4UWydiJqHYUXYqbVlJMwYqU8Jws1oRxwJjrkNyfYNpqcInkh_jApm-gKc7nRGRQ6QTnynlAp1ASPW7tUzPq9YzkdTXfwboa9KkXDcXN6OdUU8GpQuODYFTegBfXqSMFzeOwniI5u5G_m2I6YU1zU4x7dxaKhPSK2mJ1v-tJu88j855DYIY0AiX5uf_oa0CgaqyOOY3LaxGjV0FxrkAzYluHfQef7ux-1ocXD1aUrdj3owk48ZVEb2o-V1bMLtk415ngS-u89bABHuJ50-gIwpO-y7ofe6ik4fAd9NfD8PVKHHsrNYbC5FdAQ', + * p: '4WlvPw4Vf-mHzoqem_2VUf7hMiLEM5sl_th-CZyA0dowhEnNBJPtaqCz2k_6_ECKZ5C-KoT-EmQOBILQFJtR9SOs6fI9yZGL1OpbjGNKpWzym8iQrFcKAhFvQ_hG7Fkwz6_yRV5fKnOWSD78Rk6wuOTaXqwJS7uljvrn7SmRFpE', + * q: 'wcO_PHrkHazbqDgBVvTDaMXJ7W5l0RTxhrOsU6qGCLp367Zc2F9BwPAlMy9KKMhf9RLxgv32lGqWxVh3WQ1GSJqswSIKhfAOzmuTDjlYxqrte_TMcaVDxtRuO8Bxp5A8Y7i3VxQ_Rjfa04QLxJfiRdap4UamYWco25WKH4rkcI8', + * dp: 'rWynEIZPeEg-GmSAP1fMqHdG34HsHiBCDV6XKeHlIo-SQFVfjSQax6y4c0CRw74MPj4YcTI9H_0m48WZPiF53vcBtESR0SFPyhI9OTezWK8HwV-AH3gf1ROA3XSJbJH6ge_GoCRJZ6nid9ct1RH52WcJs0j9Je1LJURZaBhQ7mE', + * dq: 'tYrMc0ME1dTuHQcUIj_Dkje2gLGtzZ6cyMMw01byq9zhnMRI6yUcu0OE5xcImXtbhIfSJhQCYn4XcyD2-UWZs07QS0e0qlcH2Fkr9-i9B66AQWJT5qqb_P9tpKgjFIbsPdaEWJ8MxaJxcTnHuNNBWoPMuNfz7VC1FD9goTsF23s', + * qi: 'qAZmEWhWcDgW_pQZA5e7r185-sOnNPAW53y16QKh5wNThGjpUl7OvePZWY59ekd6PYwvkloNIRki6mLskP9NZ73CsAdZknSAPaAmBuNGYDabtObcigQDPFQ5DeqyAdRUrim66eN7whE5mf_XgOwVAx3-9PtfHvvmTTNezHfoZdo', + * kty: 'RSA' + * }, 'RSA-OAEP-256') + * + * const { + * plaintext, + * protectedHeader, + * additionalAuthenticatedData + * } = await generalDecrypt(jwe, privateKey) + * + * console.log(protectedHeader) + * console.log(decoder.decode(plaintext)) + * console.log(decoder.decode(additionalAuthenticatedData)) + * ``` + */ +export default async function generalDecrypt( + jwe: GeneralJWE, + key: KeyLike | GeneralDecryptGetKey, + options?: DecryptOptions, +): Promise { + if (!isObject(jwe)) { + throw new JWEInvalid('General JWE must be an object') + } + + if (!Array.isArray(jwe.recipients) || !jwe.recipients.every(isObject)) { + throw new JWEInvalid('JWE Recipients missing or incorrect type') + } + + // eslint-disable-next-line no-restricted-syntax + for (const recipient of jwe.recipients) { + try { + // eslint-disable-next-line no-await-in-loop + return await decrypt( + { + aad: jwe.aad, + ciphertext: jwe.ciphertext, + encrypted_key: recipient.encrypted_key, + header: recipient.header, + iv: jwe.iv, + protected: jwe.protected, + tag: jwe.tag, + unprotected: jwe.unprotected, + }, + [1]>key, + options, + ) + } catch { + // + } + } + throw new JWEDecryptionFailed() +} + +export type { KeyLike, GeneralJWE, DecryptOptions } diff --git a/src/types.d.ts b/src/types.d.ts index 524d77c601..9e6ec8bf42 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -316,6 +316,10 @@ export interface FlattenedJWE { unprotected?: JWEHeaderParameters } +export interface GeneralJWE extends Omit { + recipients: Pick[] +} + /** * Recognized JWE Header Parameters, any other Header members * may also be present. @@ -552,6 +556,8 @@ export interface FlattenedDecryptResult { unprotectedHeader?: JWEHeaderParameters } +export interface GeneralDecryptResult extends FlattenedDecryptResult {} + export interface CompactDecryptResult { /** * Plaintext. diff --git a/test/jwe/general.decrypt.test.mjs b/test/jwe/general.decrypt.test.mjs new file mode 100644 index 0000000000..547a001602 --- /dev/null +++ b/test/jwe/general.decrypt.test.mjs @@ -0,0 +1,97 @@ +/* eslint-disable no-param-reassign */ +import test from 'ava'; + +const root = !('WEBCRYPTO' in process.env) ? '#dist' : '#dist/webcrypto'; +Promise.all([ + import(`${root}/jwe/flattened/encrypt`), + import(`${root}/jwe/general/decrypt`), + import(`${root}/util/random`), +]).then( + ([{ default: FlattenedEncrypt }, { default: generalDecrypt }, { default: random }]) => { + test.before(async (t) => { + const encode = TextEncoder.prototype.encode.bind(new TextEncoder()); + t.context.plaintext = encode('It’s a dangerous business, Frodo, going out your door.'); + t.context.additionalAuthenticatedData = encode('The Fellowship of the Ring'); + t.context.initializationVector = random(new Uint8Array(12)); + t.context.secret = random(new Uint8Array(16)); + }); + + test('JWS format validation', async (t) => { + const flattenedJwe = await new FlattenedEncrypt(t.context.plaintext) + .setProtectedHeader({ bar: 'baz' }) + .setUnprotectedHeader({ foo: 'bar' }) + .setSharedUnprotectedHeader({ alg: 'A128GCMKW', enc: 'A128GCM' }) + .setAdditionalAuthenticatedData(t.context.additionalAuthenticatedData) + .encrypt(t.context.secret); + + const generalJwe = { + aad: flattenedJwe.aad, + ciphertext: flattenedJwe.ciphertext, + iv: flattenedJwe.iv, + protected: flattenedJwe.protected, + tag: flattenedJwe.tag, + unprotected: flattenedJwe.unprotected, + recipients: [ + { + encrypted_key: flattenedJwe.encrypted_key, + header: flattenedJwe.header, + }, + ], + }; + + { + await t.throwsAsync(generalDecrypt(null, t.context.secret), { + message: 'General JWE must be an object', + code: 'ERR_JWE_INVALID', + }); + } + + { + await t.throwsAsync(generalDecrypt({ recipients: null }, t.context.secret), { + message: 'JWE Recipients missing or incorrect type', + code: 'ERR_JWE_INVALID', + }); + } + + { + await t.throwsAsync(generalDecrypt({ recipients: [null] }, t.context.secret), { + message: 'JWE Recipients missing or incorrect type', + code: 'ERR_JWE_INVALID', + }); + } + + { + const jwe = { ...generalJwe, recipients: [] }; + + await t.throwsAsync(generalDecrypt(jwe, t.context.secret), { + message: 'decryption operation failed', + code: 'ERR_JWE_DECRYPTION_FAILED', + }); + } + + { + const jwe = { ...generalJwe, recipients: [generalJwe.recipients[0]] }; + + await t.notThrowsAsync(generalDecrypt(jwe, t.context.secret)); + } + + { + const jwe = { ...generalJwe, recipients: [generalJwe.recipients[0], {}] }; + + await t.notThrowsAsync(generalDecrypt(jwe, t.context.secret)); + } + + { + const jwe = { ...generalJwe, recipients: [{}, generalJwe.recipients[0]] }; + + await t.notThrowsAsync(generalDecrypt(jwe, t.context.secret)); + } + }); + }, + (err) => { + test('failed to import', (t) => { + console.error(err); + t.fail(); + }); + }, +); diff --git a/tsconfig/base.json b/tsconfig/base.json index ea1a17f2de..5009b77629 100644 --- a/tsconfig/base.json +++ b/tsconfig/base.json @@ -4,6 +4,7 @@ "../src/jwe/compact/decrypt.ts", "../src/jwe/flattened/encrypt.ts", "../src/jwe/flattened/decrypt.ts", + "../src/jwe/general/decrypt.ts", "../src/jws/compact/sign.ts", "../src/jws/compact/verify.ts",