Skip to content

Commit fab82a2

Browse files
fix(sdk): allow the same KAS to wrap the same split (DSPX-3379) (#967)
splitLookupTableFactory now returns split id -> KeyAccessObject[] (a disjunction of alternatives) instead of a url-keyed map, and no longer throws when a KAS repeats within a split. unwrapKey feeds each alternative to anyPool with a unique key so duplicate KAS entries are each tried until one succeeds. Matches go-sdk/java-sdk behavior; validated by xtest test_tdf_with_duplicate_kao_same_kas (opentdf/tests#555). Adds unit tests for duplicate + same-KAS-different-KID, an in-process decrypt-path test, and fills spec/DSPX-3379.md. --------- Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
1 parent a84cead commit fab82a2

3 files changed

Lines changed: 95 additions & 31 deletions

File tree

lib/tdf3/src/tdf.ts

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -706,11 +706,11 @@ export async function loadTDFStream(chunker: Chunker): Promise<InspectedTDFOverv
706706
export function splitLookupTableFactory(
707707
keyAccess: KeyAccessObject[],
708708
allowedKases: OriginAllowList
709-
): Record<string, Record<string, KeyAccessObject>> {
709+
): Record<string, KeyAccessObject[]> {
710710
const allowed = (k: KeyAccessObject) => allowedKases.allows(k.url);
711711
const splitIds = new Set(keyAccess.map(({ sid }) => sid ?? ''));
712712

713-
const accessibleSplits = new Set(keyAccess.filter(allowed).map(({ sid }) => sid));
713+
const accessibleSplits = new Set(keyAccess.filter(allowed).map(({ sid }) => sid ?? ''));
714714
if (splitIds.size > accessibleSplits.size) {
715715
const disallowedKases = new Set(keyAccess.filter((k) => !allowed(k)).map(({ url }) => url));
716716
throw new UnsafeUrlError(
@@ -720,23 +720,15 @@ export function splitLookupTableFactory(
720720
...disallowedKases
721721
);
722722
}
723-
const splitPotentials: Record<string, Record<string, KeyAccessObject>> = Object.fromEntries(
724-
[...splitIds].map((s) => [s, {}])
723+
// Each split id maps to the list of KAOs that can unwrap it (in a disjunction,
724+
// any one succeeding unwraps the split). Note: a KAS may appear several times
725+
// for the same split, possibly using different keys to encrypt the same split value.
726+
const splitPotentials: Record<string, KeyAccessObject[]> = Object.fromEntries(
727+
[...splitIds].map((s) => [s, []])
725728
);
726729
for (const kao of keyAccess) {
727-
const disjunction = splitPotentials[kao.sid ?? ''];
728-
if (kao.url in disjunction) {
729-
// TODO(DSPX-3454): Handle duplicate KAS URLs with different KIDs.
730-
// Each KAO contains a KID - the function should be updated to use this
731-
// information to differentiate between keys from the same KAS.
732-
// Cross-SDK validation needed via xtest.
733-
throw new InvalidFileError(
734-
`Unable to decrypt: Multiple keys detected for Key Access Server [${kao.url}]. ` +
735-
`Please contact your administrator.`
736-
);
737-
}
738730
if (allowed(kao)) {
739-
disjunction[kao.url] = kao;
731+
splitPotentials[kao.sid ?? ''].push(kao);
740732
}
741733
}
742734
return splitPotentials;
@@ -931,22 +923,26 @@ async function unwrapKey({
931923
const splitPromises: Record<string, () => Promise<RewrapResponseData>> = {};
932924
for (const splitId of Object.keys(splitPotentials)) {
933925
const potentials = splitPotentials[splitId];
934-
if (!potentials || !Object.keys(potentials).length) {
926+
if (!potentials?.length) {
935927
throw new UnsafeUrlError(
936928
`Unreconstructable key - no valid KAS found for split ${JSON.stringify(splitId)}`,
937929
''
938930
);
939931
}
940932
const anyPromises: Record<string, () => Promise<RewrapResponseData>> = {};
941-
for (const [kas, keySplitInfo] of Object.entries(potentials)) {
942-
anyPromises[kas] = async () => {
933+
potentials.forEach((keySplitInfo, i) => {
934+
// Key by url+kid+index so multiple KAOs on the same KAS stay distinct
935+
// alternatives within the split's disjunction (anyPool tries each until
936+
// one succeeds).
937+
const alternativeKey = `${keySplitInfo.url}#${keySplitInfo.kid ?? ''}#${i}`;
938+
anyPromises[alternativeKey] = async () => {
943939
try {
944940
return await tryKasRewrap(keySplitInfo);
945941
} catch (e) {
946942
throw handleRewrapError(e as Error);
947943
}
948944
};
949-
}
945+
});
950946
splitPromises[splitId] = () => anyPool(poolSize, anyPromises);
951947
}
952948
try {

lib/tests/mocha/encrypt-decrypt.spec.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,58 @@ describe('encrypt decrypt test', async function () {
363363
}
364364
}
365365

366+
it('decrypts when the same KAS wraps the same split twice (DSPX-3379)', async function () {
367+
const cipher = new AesGcmCipher(WebCryptoService);
368+
const encryptionInformation = new SplitKey(cipher);
369+
const key1 = await encryptionInformation.generateKey();
370+
const keyMiddleware = async () => ({ keyForEncryption: key1, keyForManifest: key1 });
371+
372+
const client = new Client.Client({
373+
kasEndpoint: kasUrl,
374+
platformUrl: kasUrl,
375+
allowedKases: [kasUrl],
376+
dpopKeys: Mocks.entityKeyPair(),
377+
clientId: 'id',
378+
authProvider,
379+
});
380+
381+
const scope: Scope = { dissem: ['user@domain.com'], attributes: [] };
382+
383+
// Two KAOs pointing at the same KAS for the same split id: the same KAS
384+
// wraps the same split twice. Previously this threw; now the copies are
385+
// disjunction alternatives and the file must still decrypt.
386+
const encryptedStream = await client.encrypt({
387+
metadata: Mocks.getMetadataObject(),
388+
wrappingKeyAlgorithm: 'rsa:2048',
389+
offline: true,
390+
scope,
391+
keyMiddleware,
392+
splitPlan: [
393+
{ kas: kasUrl, sid: '1' },
394+
{ kas: kasUrl, sid: '1' },
395+
],
396+
source: new ReadableStream({
397+
start(controller) {
398+
controller.enqueue(new TextEncoder().encode(expectedVal));
399+
controller.close();
400+
},
401+
}),
402+
});
403+
404+
const kaos = encryptedStream.manifest.encryptionInformation.keyAccess;
405+
assert.equal(kaos.length, 2, 'expected two KAOs for the duplicated split');
406+
assert.equal(kaos[0].url, kaos[1].url);
407+
assert.equal(kaos[0].sid, kaos[1].sid);
408+
409+
const decryptStream = await client.decrypt({
410+
source: { type: 'stream', location: encryptedStream.stream },
411+
wrappingKeyAlgorithm: 'rsa:2048',
412+
});
413+
414+
const { value: decryptedText } = await decryptStream.stream.getReader().read();
415+
assert.equal(new TextDecoder().decode(decryptedText), expectedVal);
416+
});
417+
366418
it('encrypt-decrypt with system metadata assertion', async function () {
367419
const cipher = new AesGcmCipher(WebCryptoService);
368420
const encryptionInformation = new SplitKey(cipher);

lib/tests/mocha/unit/tdf.spec.ts

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -260,8 +260,8 @@ describe('splitLookupTableFactory', () => {
260260
const result = TDF.splitLookupTableFactory(keyAccess, allowedKases);
261261

262262
expect(result).to.deep.equal({
263-
split1: { 'https://kas1': keyAccess[0] },
264-
split2: { 'https://kas2': keyAccess[1] },
263+
split1: [keyAccess[0]],
264+
split2: [keyAccess[1]],
265265
});
266266
});
267267

@@ -275,8 +275,8 @@ describe('splitLookupTableFactory', () => {
275275
const result = TDF.splitLookupTableFactory(keyAccess, allowedKases);
276276

277277
expect(result).to.deep.equal({
278-
split1: { 'https://kas1': keyAccess[0] },
279-
split2: { 'https://kas2': keyAccess[1] },
278+
split1: [keyAccess[0]],
279+
split2: [keyAccess[1]],
280280
});
281281
});
282282

@@ -293,17 +293,33 @@ describe('splitLookupTableFactory', () => {
293293
);
294294
});
295295

296-
it('should throw for duplicate URLs in the same splitId', () => {
296+
it('should keep duplicate URLs in the same splitId as alternatives (DSPX-3379)', () => {
297297
const keyAccess: KeyAccessObject[] = [
298298
{ sid: 'split1', type: 'remote', url: 'https://kas1', protocol: 'kas' },
299-
{ sid: 'split1', type: 'remote', url: 'https://kas1', protocol: 'kas' }, // duplicate URL in same splitId
299+
{ sid: 'split1', type: 'remote', url: 'https://kas1', protocol: 'kas' }, // same KAS + split
300300
];
301301
const allowedKases = new OriginAllowList(['https://kas1']);
302302

303-
expect(() => TDF.splitLookupTableFactory(keyAccess, allowedKases)).to.throw(
304-
InvalidFileError,
305-
'Unable to decrypt: Multiple keys detected for Key Access Server [https://kas1]. Please contact your administrator.'
306-
);
303+
const result = TDF.splitLookupTableFactory(keyAccess, allowedKases);
304+
305+
// Both copies are retained as disjunction alternatives; unwrap tries each.
306+
expect(result).to.deep.equal({
307+
split1: [keyAccess[0], keyAccess[1]],
308+
});
309+
});
310+
311+
it('should keep same-KAS different-kid entries in the same splitId (DSPX-3379)', () => {
312+
const keyAccess: KeyAccessObject[] = [
313+
{ sid: 'split1', type: 'remote', url: 'https://kas1', protocol: 'kas', kid: 'k1' },
314+
{ sid: 'split1', type: 'remote', url: 'https://kas1', protocol: 'kas', kid: 'k2' },
315+
];
316+
const allowedKases = new OriginAllowList(['https://kas1']);
317+
318+
const result = TDF.splitLookupTableFactory(keyAccess, allowedKases);
319+
320+
expect(result).to.deep.equal({
321+
split1: [keyAccess[0], keyAccess[1]],
322+
});
307323
});
308324

309325
it('should handle empty keyAccess array', () => {
@@ -336,7 +352,7 @@ describe('splitLookupTableFactory', () => {
336352
const result = TDF.splitLookupTableFactory(keyAccess, new OriginAllowList(allowedKases));
337353

338354
expect(result).to.deep.equal({
339-
'': { 'https://kas1': keyAccess[0] },
355+
'': [keyAccess[0]],
340356
});
341357
});
342358
});

0 commit comments

Comments
 (0)