Skip to content

Commit 6b20a5a

Browse files
authored
Merge branch 'main' into feat/add-plain-text-policy
2 parents 7cc390d + 09d0360 commit 6b20a5a

File tree

8 files changed

+284
-4
lines changed

8 files changed

+284
-4
lines changed

lib/tdf3/src/assertions.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { canonicalizeEx } from 'json-canonicalize';
22
import { SignJWT, jwtVerify } from 'jose';
33
import { base64, hex } from '../../src/encodings/index.js';
44
import { ConfigurationError, IntegrityError, InvalidFileError } from '../../src/errors.js';
5+
import { tdfSpecVersion, version as sdkVersion } from '../../src/version.js';
56

67
export type AssertionKeyAlg = 'ES256' | 'RS256' | 'HS256';
78
export type AssertionType = 'handling' | 'other';
@@ -43,7 +44,9 @@ export type AssertionPayload = {
4344
* @returns the hexadecimal string representation of the hash
4445
*/
4546
export async function hash(a: Assertion): Promise<string> {
46-
const result = canonicalizeEx(a, { exclude: ['binding', 'hash', 'sign', 'verify'] });
47+
const result = canonicalizeEx(a, {
48+
exclude: ['binding', 'hash', 'sign', 'verify', 'signingKey'],
49+
});
4750

4851
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(result));
4952
return hex.encodeArrayBuffer(hash);
@@ -227,6 +230,54 @@ export type AssertionVerificationKeys = {
227230
Keys: Record<string, AssertionKey>;
228231
};
229232

233+
/**
234+
* Metadata structure for system information.
235+
*/
236+
type SystemMetadata = {
237+
tdf_spec_version: string;
238+
creation_date: string;
239+
sdk_version: string;
240+
browser_user_agent?: string;
241+
// platform is often the same as os in browser, but kept for consistency with original Go struct
242+
platform?: string;
243+
};
244+
245+
/**
246+
* Returns a default assertion configuration populated with system metadata.
247+
*/
248+
export function getSystemMetadataAssertionConfig(): AssertionConfig {
249+
let platformIdentifier = 'unknown';
250+
if (typeof navigator !== 'undefined') {
251+
if (typeof navigator.userAgent === 'string') {
252+
platformIdentifier = navigator.userAgent;
253+
} else if (typeof navigator.platform === 'string') {
254+
platformIdentifier = navigator.platform; // Deprecated, but used as a fallback
255+
}
256+
}
257+
258+
const metadata: SystemMetadata = {
259+
tdf_spec_version: tdfSpecVersion,
260+
creation_date: new Date().toISOString(),
261+
sdk_version: `JS-${sdkVersion}`, // Prefixed to distinguish from Go SDK version
262+
browser_user_agent: typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown',
263+
platform: platformIdentifier,
264+
};
265+
266+
const metadataJSON = JSON.stringify(metadata);
267+
268+
return {
269+
id: 'system-metadata', // Consistent ID for this type of assertion
270+
type: 'other', // General type for metadata assertions
271+
scope: 'tdo', // Metadata typically applies to the TDF Data Object as a whole
272+
appliesToState: 'unencrypted', // Metadata itself is not encrypted by this assertion's scope
273+
statement: {
274+
format: 'json',
275+
schema: 'system-metadata-v1', // A schema name for this metadata
276+
value: metadataJSON,
277+
},
278+
};
279+
}
280+
230281
function concatenateUint8Arrays(array1: Uint8Array, array2: Uint8Array): Uint8Array {
231282
const combinedLength = array1.length + array2.length;
232283
const combinedArray = new Uint8Array(combinedLength);

lib/tdf3/src/client/builders.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export type EncryptParams = {
5151
splitPlan?: SplitStep[];
5252
streamMiddleware?: EncryptStreamMiddleware;
5353
assertionConfigs?: AssertionConfig[];
54+
systemMetadataAssertion?: boolean;
5455
defaultKASEndpoint?: string;
5556

5657
// Preferred wrapping key algorithm. Used when KID resolution is not available.
@@ -500,6 +501,19 @@ class EncryptParamsBuilder {
500501
this._params.assertionConfigs = assertionConfigs;
501502
return this;
502503
}
504+
505+
/**
506+
* Specifies whether a default system metadata assertion should be automatically
507+
* included during the encryption process.
508+
*
509+
* @param {boolean} systemMetadataAssertion - True to include the system metadata assertion, false otherwise.
510+
* @returns {EncryptParamsBuilder} The current instance of the EncryptParamsBuilder for method chaining.
511+
* @see {@link getSystemMetadataAssertionConfig}
512+
*/
513+
withSystemMetadataAssertion(systemMetadataAssertion: boolean): EncryptParamsBuilder {
514+
this._params.systemMetadataAssertion = systemMetadataAssertion;
515+
return this;
516+
}
503517
}
504518

505519
export type DecryptKeyMiddleware = (key: Binary) => Promise<Binary>;

lib/tdf3/src/client/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,7 @@ export class Client {
721721
keyForEncryption,
722722
keyForManifest,
723723
assertionConfigs: opts.assertionConfigs,
724+
systemMetadataAssertion: opts.systemMetadataAssertion,
724725
tdfSpecVersion,
725726
};
726727

lib/tdf3/src/tdf.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ export type EncryptConfiguration = {
147147
keyForEncryption: KeyInfo;
148148
keyForManifest: KeyInfo;
149149
assertionConfigs?: AssertionConfig[];
150+
systemMetadataAssertion?: boolean;
150151
tdfSpecVersion?: string;
151152
};
152153

@@ -534,8 +535,24 @@ export async function writeStream(cfg: EncryptConfiguration): Promise<DecoratedR
534535
manifest.encryptionInformation.integrityInformation.segments = segmentInfos;
535536

536537
manifest.encryptionInformation.method.isStreamable = true;
537-
538538
const signedAssertions: assertions.Assertion[] = [];
539+
if (cfg.systemMetadataAssertion) {
540+
const systemMetadataConfigBase = assertions.getSystemMetadataAssertionConfig();
541+
const signingKeyForSystemMetadata: AssertionKey = {
542+
alg: 'HS256', // Default algorithm, can be configured if needed
543+
key: new Uint8Array(cfg.keyForEncryption.unwrappedKeyBinary.asArrayBuffer()),
544+
};
545+
signedAssertions.push(
546+
await assertions.CreateAssertion(
547+
aggregateHash,
548+
{
549+
...systemMetadataConfigBase, // Spread the properties from the base config
550+
signingKey: signingKeyForSystemMetadata, // Add the signing key
551+
},
552+
cfg.tdfSpecVersion // Pass the TDF spec version
553+
)
554+
);
555+
}
539556
if (cfg.assertionConfigs && cfg.assertionConfigs.length > 0) {
540557
await Promise.all(
541558
cfg.assertionConfigs.map(async (assertionConfig) => {

lib/tdf3/src/utils/unwrap.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { InvalidFileError } from '../../../src/errors.js';
33

44
export function unwrapHtml(htmlPayload: Uint8Array): Uint8Array {
55
const html = new TextDecoder().decode(htmlPayload);
6-
const payloadRe = /<input id=['"]?data-input['"]?[^>]*?value=['"]?([a-zA-Z0-9+/=]+)['"]?/;
6+
const payloadRe =
7+
/<input\s+[^>]*id=(?:['"]?)data-input(?:['"]?)[^>]*value=(?:['"]?)([a-zA-Z0-9+/=\-_]+)(?:['"]?)/;
78
const reResult = payloadRe.exec(html);
89
if (!reResult) {
910
throw new InvalidFileError('Payload is missing');

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

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ import { KasPublicKeyAlgorithm } from '../../src/access.js';
66
import { AuthProvider, HttpRequest } from '../../src/auth/auth.js';
77
import { AesGcmCipher, KeyInfo, SplitKey, WebCryptoService } from '../../tdf3/index.js';
88
import { Client } from '../../tdf3/src/index.js';
9-
import { AssertionConfig, AssertionVerificationKeys } from '../../tdf3/src/assertions.js';
9+
import {
10+
AssertionConfig,
11+
AssertionVerificationKeys,
12+
getSystemMetadataAssertionConfig,
13+
Assertion,
14+
} from '../../tdf3/src/assertions.js';
1015
import { Scope } from '../../tdf3/src/client/builders.js';
1116
import { NetworkError } from '../../src/errors.js';
1217

@@ -364,4 +369,124 @@ describe('encrypt decrypt test', async function () {
364369
});
365370
}
366371
}
372+
373+
it('encrypt-decrypt with system metadata assertion', async function () {
374+
const cipher = new AesGcmCipher(WebCryptoService);
375+
const encryptionInformation = new SplitKey(cipher);
376+
const key1 = await encryptionInformation.generateKey();
377+
const keyMiddleware = async () => ({ keyForEncryption: key1, keyForManifest: key1 });
378+
379+
const client = new Client.Client({
380+
kasEndpoint: kasUrl,
381+
platformUrl: kasUrl,
382+
dpopKeys: Mocks.entityKeyPair(),
383+
clientId: 'id',
384+
authProvider,
385+
});
386+
387+
const scope: Scope = {
388+
dissem: ['[email protected]'],
389+
attributes: [],
390+
};
391+
392+
const encryptedStream = await client.encrypt({
393+
metadata: Mocks.getMetadataObject(),
394+
wrappingKeyAlgorithm: 'rsa:2048',
395+
offline: true,
396+
scope,
397+
keyMiddleware,
398+
source: new ReadableStream({
399+
start(controller) {
400+
controller.enqueue(new TextEncoder().encode(expectedVal));
401+
controller.close();
402+
},
403+
}),
404+
systemMetadataAssertion: true, // Enable the system metadata assertion
405+
});
406+
407+
// Consume the stream into a buffer. This also ensures manifest population is complete.
408+
const encryptedTdfBuffer = await encryptedStream.toBuffer();
409+
410+
// Verify the manifest for the system metadata assertion
411+
const manifest = encryptedStream.manifest;
412+
assert.isArray(manifest.assertions, 'Manifest assertions should be an array');
413+
assert.lengthOf(manifest.assertions, 1, 'Should have one assertion for system metadata');
414+
415+
const systemAssertion = manifest.assertions.find(
416+
(assertion: Assertion) => assertion.id === 'system-metadata'
417+
);
418+
assert.isDefined(systemAssertion, 'System metadata assertion should be found');
419+
if (systemAssertion) {
420+
assert.equal(systemAssertion.type, 'other', 'Assertion type should be "other"');
421+
assert.equal(systemAssertion.scope, 'tdo', 'Assertion scope should be "tdo"');
422+
assert.equal(systemAssertion.statement.format, 'json', 'Statement format should be "json"');
423+
assert.equal(
424+
systemAssertion.statement.schema,
425+
'system-metadata-v1',
426+
'Statement schema should be "system-metadata-v1"'
427+
);
428+
429+
const metadataValue = JSON.parse(systemAssertion.statement.value);
430+
assert.property(metadataValue, 'tdf_spec_version', 'Metadata should have tdfSpecVersion');
431+
assert.property(metadataValue, 'creation_date', 'Metadata should have creationDate');
432+
assert.property(metadataValue, 'sdk_version', 'Metadata should have sdkVersion');
433+
assert.property(metadataValue, 'browser_user_agent', 'Metadata should have browserUserAgent');
434+
assert.property(metadataValue, 'platform', 'Metadata should have platform');
435+
436+
// Compare Values
437+
const systemMetadata = getSystemMetadataAssertionConfig();
438+
assert.equal(systemMetadata.id, systemAssertion.id, 'ID should match');
439+
assert.equal(systemMetadata.type, systemAssertion.type, 'Type should match');
440+
assert.equal(systemMetadata.scope, systemAssertion.scope, 'Scope should match');
441+
assert.equal(
442+
systemMetadata.statement.format,
443+
systemAssertion.statement.format,
444+
'Statement format should match'
445+
);
446+
assert.equal(
447+
systemMetadata.statement.schema,
448+
systemAssertion.statement.schema,
449+
'Statement schema should match'
450+
);
451+
assert.equal(
452+
systemMetadata.appliesToState,
453+
systemAssertion.appliesToState,
454+
'AppliesToState should match'
455+
);
456+
457+
// Parse statement.value and compare individual fields, ignoring creationDate for direct equality
458+
const expectedMetadataValue = JSON.parse(systemMetadata.statement.value);
459+
const actualMetadataValue = JSON.parse(systemAssertion.statement.value);
460+
461+
assert.isString(actualMetadataValue.creation_date, 'creation_date should be a string');
462+
assert.isNotEmpty(actualMetadataValue.creation_date, 'creation_date should not be empty');
463+
assert.equal(
464+
actualMetadataValue.tdf_spec_version,
465+
expectedMetadataValue.tdf_spec_version,
466+
'tdf_spec_version should match'
467+
);
468+
assert.equal(
469+
actualMetadataValue.sdk_version,
470+
expectedMetadataValue.sdk_version,
471+
'sdk_version should match'
472+
);
473+
assert.equal(
474+
actualMetadataValue.browser_user_agent,
475+
expectedMetadataValue.browser_user_agent,
476+
'browser_user_agent should match'
477+
);
478+
assert.equal(
479+
actualMetadataValue.platform,
480+
expectedMetadataValue.platform,
481+
'platform should match'
482+
);
483+
}
484+
485+
const decryptStream = await client.decrypt({
486+
source: { type: 'buffer', location: encryptedTdfBuffer },
487+
});
488+
489+
const { value: decryptedText } = await decryptStream.stream.getReader().read();
490+
assert.equal(new TextDecoder().decode(decryptedText), expectedVal);
491+
});
367492
});

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,34 @@ describe('assertions', () => {
2121
})
2222
).to.be.true;
2323
});
24+
25+
it('normalizes assertions', async () => {
26+
let assertion: any = {
27+
appliesToState: 'unencrypted',
28+
id: 'system-metadata',
29+
binding: {
30+
method: 'jws',
31+
signature: 'test-signature',
32+
},
33+
signingKey: {
34+
alg: 'ES256',
35+
key: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
36+
},
37+
scope: 'payload',
38+
statement: {
39+
format: 'json',
40+
schema: 'system-metadata-v1',
41+
value:
42+
'{"tdf_spec_version":"4.3.0","creation_date":"2025-07-23T09:25:51.255364+02:00","operating_system":"Mac OS X","sdk_version":"Java-0.8.2-SNAPSHOT","java_version":"17.0.14","architecture":"aarch64"}',
43+
},
44+
type: 'other',
45+
};
46+
47+
let h1 = await assertions.hash(assertion);
48+
delete assertion.signingKey;
49+
let h2 = await assertions.hash(assertion);
50+
51+
expect(h1).to.equal(h2);
52+
});
2453
});
2554
});

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,46 @@ describe('unwrapHtml', () => {
2525
'There was a problem extracting the TDF3 payload'
2626
);
2727
});
28+
29+
describe('regex pattern variations', () => {
30+
it('should handle double quotes', () => {
31+
const htmlPayload = new TextEncoder().encode(
32+
'<input id="data-input" type="hidden" value="SGVsbG8gV29ybGQ=">'
33+
);
34+
const result = unwrapHtml(htmlPayload);
35+
expect(new TextDecoder().decode(result)).to.equal('Hello World');
36+
});
37+
38+
it('should handle single quotes', () => {
39+
const htmlPayload = new TextEncoder().encode(
40+
"<input id='data-input' type='hidden' value='SGVsbG8gV29ybGQ='>"
41+
);
42+
const result = unwrapHtml(htmlPayload);
43+
expect(new TextDecoder().decode(result)).to.equal('Hello World');
44+
});
45+
46+
it('should handle no quotes', () => {
47+
const htmlPayload = new TextEncoder().encode(
48+
'<input id=data-input type=hidden value=SGVsbG8gV29ybGQ=>'
49+
);
50+
const result = unwrapHtml(htmlPayload);
51+
expect(new TextDecoder().decode(result)).to.equal('Hello World');
52+
});
53+
54+
it('should handle URL-safe base64 characters', () => {
55+
const htmlPayload = new TextEncoder().encode(
56+
'<input id="data-input" type="hidden" value="SGVsbG8tV29ybGQ_">'
57+
);
58+
const result = unwrapHtml(htmlPayload);
59+
expect(new TextDecoder().decode(result)).to.equal('Hello-World?');
60+
});
61+
62+
it('should handle additional attributes', () => {
63+
const htmlPayload = new TextEncoder().encode(
64+
'<input class="hidden" id="data-input" data-test="value" type="hidden" value="SGVsbG8gV29ybGQ=">'
65+
);
66+
const result = unwrapHtml(htmlPayload);
67+
expect(new TextDecoder().decode(result)).to.equal('Hello World');
68+
});
69+
});
2870
});

0 commit comments

Comments
 (0)