diff --git a/lib/tdf3/src/assertions.ts b/lib/tdf3/src/assertions.ts index c53c07c9..f0a27c9b 100644 --- a/lib/tdf3/src/assertions.ts +++ b/lib/tdf3/src/assertions.ts @@ -2,6 +2,7 @@ import { canonicalizeEx } from 'json-canonicalize'; import { SignJWT, jwtVerify } from 'jose'; import { base64, hex } from '../../src/encodings/index.js'; import { ConfigurationError, IntegrityError, InvalidFileError } from '../../src/errors.js'; +import { tdfSpecVersion, version as sdkVersion } from '../../src/version.js'; export type AssertionKeyAlg = 'ES256' | 'RS256' | 'HS256'; export type AssertionType = 'handling' | 'other'; @@ -229,6 +230,54 @@ export type AssertionVerificationKeys = { Keys: Record; }; +/** + * Metadata structure for system information. + */ +type SystemMetadata = { + tdf_spec_version: string; + creation_date: string; + sdk_version: string; + browser_user_agent?: string; + // platform is often the same as os in browser, but kept for consistency with original Go struct + platform?: string; +}; + +/** + * Returns a default assertion configuration populated with system metadata. + */ +export function getSystemMetadataAssertionConfig(): AssertionConfig { + let platformIdentifier = 'unknown'; + if (typeof navigator !== 'undefined') { + if (typeof navigator.userAgent === 'string') { + platformIdentifier = navigator.userAgent; + } else if (typeof navigator.platform === 'string') { + platformIdentifier = navigator.platform; // Deprecated, but used as a fallback + } + } + + const metadata: SystemMetadata = { + tdf_spec_version: tdfSpecVersion, + creation_date: new Date().toISOString(), + sdk_version: `JS-${sdkVersion}`, // Prefixed to distinguish from Go SDK version + browser_user_agent: typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown', + platform: platformIdentifier, + }; + + const metadataJSON = JSON.stringify(metadata); + + return { + id: 'system-metadata', // Consistent ID for this type of assertion + type: 'other', // General type for metadata assertions + scope: 'tdo', // Metadata typically applies to the TDF Data Object as a whole + appliesToState: 'unencrypted', // Metadata itself is not encrypted by this assertion's scope + statement: { + format: 'json', + schema: 'system-metadata-v1', // A schema name for this metadata + value: metadataJSON, + }, + }; +} + function concatenateUint8Arrays(array1: Uint8Array, array2: Uint8Array): Uint8Array { const combinedLength = array1.length + array2.length; const combinedArray = new Uint8Array(combinedLength); diff --git a/lib/tdf3/src/client/builders.ts b/lib/tdf3/src/client/builders.ts index 36fc388f..894860dc 100644 --- a/lib/tdf3/src/client/builders.ts +++ b/lib/tdf3/src/client/builders.ts @@ -51,6 +51,7 @@ export type EncryptParams = { splitPlan?: SplitStep[]; streamMiddleware?: EncryptStreamMiddleware; assertionConfigs?: AssertionConfig[]; + systemMetadataAssertion?: boolean; defaultKASEndpoint?: string; // Preferred wrapping key algorithm. Used when KID resolution is not available. @@ -500,6 +501,19 @@ class EncryptParamsBuilder { this._params.assertionConfigs = assertionConfigs; return this; } + + /** + * Specifies whether a default system metadata assertion should be automatically + * included during the encryption process. + * + * @param {boolean} systemMetadataAssertion - True to include the system metadata assertion, false otherwise. + * @returns {EncryptParamsBuilder} The current instance of the EncryptParamsBuilder for method chaining. + * @see {@link getSystemMetadataAssertionConfig} + */ + withSystemMetadataAssertion(systemMetadataAssertion: boolean): EncryptParamsBuilder { + this._params.systemMetadataAssertion = systemMetadataAssertion; + return this; + } } export type DecryptKeyMiddleware = (key: Binary) => Promise; diff --git a/lib/tdf3/src/client/index.ts b/lib/tdf3/src/client/index.ts index 0e138fbb..0a908fcd 100644 --- a/lib/tdf3/src/client/index.ts +++ b/lib/tdf3/src/client/index.ts @@ -721,6 +721,7 @@ export class Client { keyForEncryption, keyForManifest, assertionConfigs: opts.assertionConfigs, + systemMetadataAssertion: opts.systemMetadataAssertion, tdfSpecVersion, }; diff --git a/lib/tdf3/src/tdf.ts b/lib/tdf3/src/tdf.ts index 0ae86a54..73cd01a0 100644 --- a/lib/tdf3/src/tdf.ts +++ b/lib/tdf3/src/tdf.ts @@ -147,6 +147,7 @@ export type EncryptConfiguration = { keyForEncryption: KeyInfo; keyForManifest: KeyInfo; assertionConfigs?: AssertionConfig[]; + systemMetadataAssertion?: boolean; tdfSpecVersion?: string; }; @@ -534,8 +535,24 @@ export async function writeStream(cfg: EncryptConfiguration): Promise 0) { await Promise.all( cfg.assertionConfigs.map(async (assertionConfig) => { diff --git a/lib/tests/mocha/encrypt-decrypt.spec.ts b/lib/tests/mocha/encrypt-decrypt.spec.ts index 1ff56099..43b298c1 100644 --- a/lib/tests/mocha/encrypt-decrypt.spec.ts +++ b/lib/tests/mocha/encrypt-decrypt.spec.ts @@ -6,7 +6,12 @@ import { KasPublicKeyAlgorithm } from '../../src/access.js'; import { AuthProvider, HttpRequest } from '../../src/auth/auth.js'; import { AesGcmCipher, KeyInfo, SplitKey, WebCryptoService } from '../../tdf3/index.js'; import { Client } from '../../tdf3/src/index.js'; -import { AssertionConfig, AssertionVerificationKeys } from '../../tdf3/src/assertions.js'; +import { + AssertionConfig, + AssertionVerificationKeys, + getSystemMetadataAssertionConfig, + Assertion, +} from '../../tdf3/src/assertions.js'; import { Scope } from '../../tdf3/src/client/builders.js'; import { NetworkError } from '../../src/errors.js'; @@ -364,4 +369,124 @@ describe('encrypt decrypt test', async function () { }); } } + + it('encrypt-decrypt with system metadata assertion', 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, + dpopKeys: Mocks.entityKeyPair(), + clientId: 'id', + authProvider, + }); + + const scope: Scope = { + dissem: ['user@domain.com'], + attributes: [], + }; + + const encryptedStream = await client.encrypt({ + metadata: Mocks.getMetadataObject(), + wrappingKeyAlgorithm: 'rsa:2048', + offline: true, + scope, + keyMiddleware, + source: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(expectedVal)); + controller.close(); + }, + }), + systemMetadataAssertion: true, // Enable the system metadata assertion + }); + + // Consume the stream into a buffer. This also ensures manifest population is complete. + const encryptedTdfBuffer = await encryptedStream.toBuffer(); + + // Verify the manifest for the system metadata assertion + const manifest = encryptedStream.manifest; + assert.isArray(manifest.assertions, 'Manifest assertions should be an array'); + assert.lengthOf(manifest.assertions, 1, 'Should have one assertion for system metadata'); + + const systemAssertion = manifest.assertions.find( + (assertion: Assertion) => assertion.id === 'system-metadata' + ); + assert.isDefined(systemAssertion, 'System metadata assertion should be found'); + if (systemAssertion) { + assert.equal(systemAssertion.type, 'other', 'Assertion type should be "other"'); + assert.equal(systemAssertion.scope, 'tdo', 'Assertion scope should be "tdo"'); + assert.equal(systemAssertion.statement.format, 'json', 'Statement format should be "json"'); + assert.equal( + systemAssertion.statement.schema, + 'system-metadata-v1', + 'Statement schema should be "system-metadata-v1"' + ); + + const metadataValue = JSON.parse(systemAssertion.statement.value); + assert.property(metadataValue, 'tdf_spec_version', 'Metadata should have tdfSpecVersion'); + assert.property(metadataValue, 'creation_date', 'Metadata should have creationDate'); + assert.property(metadataValue, 'sdk_version', 'Metadata should have sdkVersion'); + assert.property(metadataValue, 'browser_user_agent', 'Metadata should have browserUserAgent'); + assert.property(metadataValue, 'platform', 'Metadata should have platform'); + + // Compare Values + const systemMetadata = getSystemMetadataAssertionConfig(); + assert.equal(systemMetadata.id, systemAssertion.id, 'ID should match'); + assert.equal(systemMetadata.type, systemAssertion.type, 'Type should match'); + assert.equal(systemMetadata.scope, systemAssertion.scope, 'Scope should match'); + assert.equal( + systemMetadata.statement.format, + systemAssertion.statement.format, + 'Statement format should match' + ); + assert.equal( + systemMetadata.statement.schema, + systemAssertion.statement.schema, + 'Statement schema should match' + ); + assert.equal( + systemMetadata.appliesToState, + systemAssertion.appliesToState, + 'AppliesToState should match' + ); + + // Parse statement.value and compare individual fields, ignoring creationDate for direct equality + const expectedMetadataValue = JSON.parse(systemMetadata.statement.value); + const actualMetadataValue = JSON.parse(systemAssertion.statement.value); + + assert.isString(actualMetadataValue.creation_date, 'creation_date should be a string'); + assert.isNotEmpty(actualMetadataValue.creation_date, 'creation_date should not be empty'); + assert.equal( + actualMetadataValue.tdf_spec_version, + expectedMetadataValue.tdf_spec_version, + 'tdf_spec_version should match' + ); + assert.equal( + actualMetadataValue.sdk_version, + expectedMetadataValue.sdk_version, + 'sdk_version should match' + ); + assert.equal( + actualMetadataValue.browser_user_agent, + expectedMetadataValue.browser_user_agent, + 'browser_user_agent should match' + ); + assert.equal( + actualMetadataValue.platform, + expectedMetadataValue.platform, + 'platform should match' + ); + } + + const decryptStream = await client.decrypt({ + source: { type: 'buffer', location: encryptedTdfBuffer }, + }); + + const { value: decryptedText } = await decryptStream.stream.getReader().read(); + assert.equal(new TextDecoder().decode(decryptedText), expectedVal); + }); });