diff --git a/lib/tdf3/src/tdf.ts b/lib/tdf3/src/tdf.ts index 9d03f20ed..50e42e8c0 100644 --- a/lib/tdf3/src/tdf.ts +++ b/lib/tdf3/src/tdf.ts @@ -706,7 +706,7 @@ export async function loadTDFStream(chunker: Chunker): Promise> { +): Record { const allowed = (k: KeyAccessObject) => allowedKases.allows(k.url); const splitIds = new Set(keyAccess.map(({ sid }) => sid ?? '')); @@ -720,23 +720,17 @@ export function splitLookupTableFactory( ...disallowedKases ); } - const splitPotentials: Record> = Object.fromEntries( - [...splitIds].map((s) => [s, {}]) + // Each split id maps to the list of KAOs that can unwrap it (a disjunction: + // any one succeeding unwraps the split). The same KAS may legitimately appear + // more than once for a split - an authoring mistake (same key used twice) or + // intentional multiple keys on one KAS - so we keep every allowed KAO as an + // alternative rather than rejecting duplicates. See DSPX-3379. + const splitPotentials: Record = Object.fromEntries( + [...splitIds].map((s) => [s, []]) ); for (const kao of keyAccess) { - const disjunction = splitPotentials[kao.sid ?? '']; - if (kao.url in disjunction) { - // TODO(DSPX-3454): Handle duplicate KAS URLs with different KIDs. - // Each KAO contains a KID - the function should be updated to use this - // information to differentiate between keys from the same KAS. - // Cross-SDK validation needed via xtest. - throw new InvalidFileError( - `Unable to decrypt: Multiple keys detected for Key Access Server [${kao.url}]. ` + - `Please contact your administrator.` - ); - } if (allowed(kao)) { - disjunction[kao.url] = kao; + splitPotentials[kao.sid ?? ''].push(kao); } } return splitPotentials; @@ -931,22 +925,26 @@ async function unwrapKey({ const splitPromises: Record Promise> = {}; for (const splitId of Object.keys(splitPotentials)) { const potentials = splitPotentials[splitId]; - if (!potentials || !Object.keys(potentials).length) { + if (!potentials || !potentials.length) { throw new UnsafeUrlError( `Unreconstructable key - no valid KAS found for split ${JSON.stringify(splitId)}`, '' ); } const anyPromises: Record Promise> = {}; - for (const [kas, keySplitInfo] of Object.entries(potentials)) { - anyPromises[kas] = async () => { + potentials.forEach((keySplitInfo, i) => { + // Key by url+kid+index so multiple KAOs on the same KAS stay distinct + // alternatives within the split's disjunction (anyPool tries each until + // one succeeds). See DSPX-3379. + const alternativeKey = `${keySplitInfo.url}#${keySplitInfo.kid ?? ''}#${i}`; + anyPromises[alternativeKey] = async () => { try { return await tryKasRewrap(keySplitInfo); } catch (e) { throw handleRewrapError(e as Error); } }; - } + }); splitPromises[splitId] = () => anyPool(poolSize, anyPromises); } try { diff --git a/lib/tests/mocha/client.spec.ts b/lib/tests/mocha/client.spec.ts index 23d9b881a..147f8a6b7 100644 --- a/lib/tests/mocha/client.spec.ts +++ b/lib/tests/mocha/client.spec.ts @@ -112,6 +112,7 @@ describe('client wrapper tests', function () { }); it('encrypt error', async function () { + const fetchStub = sinon.stub(globalThis, 'fetch').rejects(new Error('Network error')); const encryptParams = new TDF.EncryptParamsBuilder().withStringSource('hello world').build(); const config = { kasEndpoint: 'https://kasUrl', @@ -123,10 +124,13 @@ describe('client wrapper tests', function () { assert.fail('did not throw'); } catch (expected) { assert.ok(expected); + } finally { + fetchStub.restore(); } }); it('decrypt error', async function () { + const fetchStub = sinon.stub(globalThis, 'fetch').rejects(new Error('Network error')); const decryptParams = new TDF.DecryptParamsBuilder().withStringSource('not a tdf').build(); const config = { kasEndpoint: 'https://kasUrl', @@ -138,6 +142,8 @@ describe('client wrapper tests', function () { assert.fail('did not throw'); } catch (expected) { assert.ok(expected); + } finally { + fetchStub.restore(); } }); diff --git a/lib/tests/mocha/encrypt-decrypt.spec.ts b/lib/tests/mocha/encrypt-decrypt.spec.ts index e1ba9fc72..9d4806277 100644 --- a/lib/tests/mocha/encrypt-decrypt.spec.ts +++ b/lib/tests/mocha/encrypt-decrypt.spec.ts @@ -363,6 +363,58 @@ describe('encrypt decrypt test', async function () { } } + it('decrypts when the same KAS wraps the same split twice (DSPX-3379)', async function () { + const cipher = new AesGcmCipher(WebCryptoService); + const encryptionInformation = new SplitKey(cipher); + const key1 = await encryptionInformation.generateKey(); + const keyMiddleware = async () => ({ keyForEncryption: key1, keyForManifest: key1 }); + + const client = new Client.Client({ + kasEndpoint: kasUrl, + platformUrl: kasUrl, + allowedKases: [kasUrl], + dpopKeys: Mocks.entityKeyPair(), + clientId: 'id', + authProvider, + }); + + const scope: Scope = { dissem: ['user@domain.com'], attributes: [] }; + + // Two KAOs pointing at the same KAS for the same split id: the same KAS + // wraps the same split twice. Previously this threw; now the copies are + // disjunction alternatives and the file must still decrypt. + const encryptedStream = await client.encrypt({ + metadata: Mocks.getMetadataObject(), + wrappingKeyAlgorithm: 'rsa:2048', + offline: true, + scope, + keyMiddleware, + splitPlan: [ + { kas: kasUrl, sid: '1' }, + { kas: kasUrl, sid: '1' }, + ], + source: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(expectedVal)); + controller.close(); + }, + }), + }); + + const kaos = encryptedStream.manifest.encryptionInformation.keyAccess; + assert.equal(kaos.length, 2, 'expected two KAOs for the duplicated split'); + assert.equal(kaos[0].url, kaos[1].url); + assert.equal(kaos[0].sid, kaos[1].sid); + + const decryptStream = await client.decrypt({ + source: { type: 'stream', location: encryptedStream.stream }, + wrappingKeyAlgorithm: 'rsa:2048', + }); + + const { value: decryptedText } = await decryptStream.stream.getReader().read(); + assert.equal(new TextDecoder().decode(decryptedText), expectedVal); + }); + it('encrypt-decrypt with system metadata assertion', async function () { const cipher = new AesGcmCipher(WebCryptoService); const encryptionInformation = new SplitKey(cipher); diff --git a/lib/tests/mocha/unit/tdf.spec.ts b/lib/tests/mocha/unit/tdf.spec.ts index e1642f3c5..898dc586e 100644 --- a/lib/tests/mocha/unit/tdf.spec.ts +++ b/lib/tests/mocha/unit/tdf.spec.ts @@ -260,8 +260,8 @@ describe('splitLookupTableFactory', () => { const result = TDF.splitLookupTableFactory(keyAccess, allowedKases); expect(result).to.deep.equal({ - split1: { 'https://kas1': keyAccess[0] }, - split2: { 'https://kas2': keyAccess[1] }, + split1: [keyAccess[0]], + split2: [keyAccess[1]], }); }); @@ -275,8 +275,8 @@ describe('splitLookupTableFactory', () => { const result = TDF.splitLookupTableFactory(keyAccess, allowedKases); expect(result).to.deep.equal({ - split1: { 'https://kas1': keyAccess[0] }, - split2: { 'https://kas2': keyAccess[1] }, + split1: [keyAccess[0]], + split2: [keyAccess[1]], }); }); @@ -293,17 +293,33 @@ describe('splitLookupTableFactory', () => { ); }); - it('should throw for duplicate URLs in the same splitId', () => { + it('should keep duplicate URLs in the same splitId as alternatives (DSPX-3379)', () => { const keyAccess: KeyAccessObject[] = [ { sid: 'split1', type: 'remote', url: 'https://kas1', protocol: 'kas' }, - { sid: 'split1', type: 'remote', url: 'https://kas1', protocol: 'kas' }, // duplicate URL in same splitId + { sid: 'split1', type: 'remote', url: 'https://kas1', protocol: 'kas' }, // same KAS + split ]; const allowedKases = new OriginAllowList(['https://kas1']); - expect(() => TDF.splitLookupTableFactory(keyAccess, allowedKases)).to.throw( - InvalidFileError, - 'Unable to decrypt: Multiple keys detected for Key Access Server [https://kas1]. Please contact your administrator.' - ); + const result = TDF.splitLookupTableFactory(keyAccess, allowedKases); + + // Both copies are retained as disjunction alternatives; unwrap tries each. + expect(result).to.deep.equal({ + split1: [keyAccess[0], keyAccess[1]], + }); + }); + + it('should keep same-KAS different-kid entries in the same splitId (DSPX-3379)', () => { + const keyAccess: KeyAccessObject[] = [ + { sid: 'split1', type: 'remote', url: 'https://kas1', protocol: 'kas', kid: 'k1' }, + { sid: 'split1', type: 'remote', url: 'https://kas1', protocol: 'kas', kid: 'k2' }, + ]; + const allowedKases = new OriginAllowList(['https://kas1']); + + const result = TDF.splitLookupTableFactory(keyAccess, allowedKases); + + expect(result).to.deep.equal({ + split1: [keyAccess[0], keyAccess[1]], + }); }); it('should handle empty keyAccess array', () => { @@ -323,7 +339,7 @@ describe('splitLookupTableFactory', () => { expect(() => TDF.splitLookupTableFactory(keyAccess, allowedKases)).to.throw( InvalidFileError, - 'Unreconstructable key - disallowed KASes include: ["https://kas1"]' + 'Unreconstructable key - disallowed KASes include: ["https://kas1"] from splitIds ["split1"]' ); }); @@ -336,7 +352,7 @@ describe('splitLookupTableFactory', () => { const result = TDF.splitLookupTableFactory(keyAccess, new OriginAllowList(allowedKases)); expect(result).to.deep.equal({ - '': { 'https://kas1': keyAccess[0] }, + '': [keyAccess[0]], }); }); });