diff --git a/lib/src/access.ts b/lib/src/access.ts index 2523efaa8..972fd9eaf 100644 --- a/lib/src/access.ts +++ b/lib/src/access.ts @@ -2,6 +2,7 @@ import { type AuthProvider } from './auth/auth.js'; import { ServiceError } from './errors.js'; import { RewrapResponse } from './platform/kas/kas_pb.js'; import { getPlatformUrlFromKasEndpoint, validateSecureUrl } from './utils.js'; +import { base64 } from './encodings/index.js'; import { fetchKasBasePubKey, @@ -13,6 +14,15 @@ import { fetchWrappedKey as fetchWrappedKeysLegacy } from './access/access-fetch import { fetchKasPubKey as fetchKasPubKeyRpc } from './access/access-rpc.js'; import { fetchKasPubKey as fetchKasPubKeyLegacy } from './access/access-fetch.js'; +/** + * Header value structure for 'X-Rewrap-Additional-Context` + */ +export type RewrapAdditionalContext = { + obligations: { + fulfillableFQNs: string[]; + }; +}; + export type RewrapRequest = { signedRequestToken: string; }; @@ -22,17 +32,27 @@ export type RewrapRequest = { * @param url Key access server rewrap endpoint * @param requestBody a signed request with an encrypted document key * @param authProvider Authorization middleware + * @param fulfillableObligationFQNs client-configured list of obligation value FQNs that can be fulfilled in this PEP * @param clientVersion */ export async function fetchWrappedKey( url: string, signedRequestToken: string, - authProvider: AuthProvider + authProvider: AuthProvider, + fulfillableObligationFQNs: string[] ): Promise { const platformUrl = getPlatformUrlFromKasEndpoint(url); return await tryPromisesUntilFirstSuccess( - () => fetchWrappedKeysRpc(platformUrl, signedRequestToken, authProvider), + () => + fetchWrappedKeysRpc( + platformUrl, + signedRequestToken, + authProvider, + rewrapAdditionalContextHeader(fulfillableObligationFQNs) + ), + // We intentionally do not provide the rewrap additional context to legacy requests destined for older platforms. + // Platforms new enough to have knowledge of obligations will be handling RPC requests successfully. () => fetchWrappedKeysLegacy( url, @@ -42,6 +62,23 @@ export async function fetchWrappedKey( ); } +/** + * Transform fulfillable, fully-qualified obligations into the expected KAS Rewrap 'X-Rewrap-Additional-Context' header value. + * @param fulfillableObligationValueFQNs + */ +export const rewrapAdditionalContextHeader = ( + fulfillableObligationValueFQNs: string[] +): string | undefined => { + if (!fulfillableObligationValueFQNs.length) return; + + const context: RewrapAdditionalContext = { + obligations: { + fulfillableFQNs: fulfillableObligationValueFQNs.map((fqn) => fqn.toLowerCase()), + }, + }; + return base64.encode(JSON.stringify(context)); +}; + export type KasPublicKeyAlgorithm = | 'ec:secp256r1' | 'ec:secp384r1' diff --git a/lib/src/access/access-fetch.ts b/lib/src/access/access-fetch.ts index 0ae02d90c..8105a996a 100644 --- a/lib/src/access/access-fetch.ts +++ b/lib/src/access/access-fetch.ts @@ -31,6 +31,7 @@ export type RewrapResponseLegacy = { * @param url Key access server rewrap endpoint * @param requestBody a signed request with an encrypted document key * @param authProvider Authorization middleware + * @param rewrapAdditionalContextHeader optional value for 'X-Rewrap-Additional-Context' */ export async function fetchWrappedKey( url: string, diff --git a/lib/src/access/access-rpc.ts b/lib/src/access/access-rpc.ts index 6e598fc1d..dc1df0c49 100644 --- a/lib/src/access/access-rpc.ts +++ b/lib/src/access/access-rpc.ts @@ -1,3 +1,4 @@ +import { CallOptions } from '@connectrpc/connect'; import { isPublicKeyAlgorithm, KasPublicKeyAlgorithm, @@ -5,6 +6,7 @@ import { noteInvalidPublicKey, OriginAllowList, } from '../access.js'; + import { type AuthProvider } from '../auth/auth.js'; import { ConfigurationError, NetworkError } from '../errors.js'; import { PlatformClient } from '../platform.js'; @@ -16,25 +18,32 @@ import { pemToCryptoPublicKey, validateSecureUrl, } from '../utils.js'; +import { X_REWRAP_ADDITIONAL_CONTEXT } from './constants.js'; /** * Get a rewrapped access key to the document, if possible * @param url Key access server rewrap endpoint * @param requestBody a signed request with an encrypted document key * @param authProvider Authorization middleware + * @param rewrapAdditionalContextHeader optional value for 'X-Rewrap-Additional-Context' * @param clientVersion */ export async function fetchWrappedKey( url: string, signedRequestToken: string, - authProvider: AuthProvider + authProvider: AuthProvider, + rewrapAdditionalContextHeader?: string ): Promise { const platformUrl = getPlatformUrlFromKasEndpoint(url); const platform = new PlatformClient({ authProvider, platformUrl }); + const options: CallOptions = {}; + if (rewrapAdditionalContextHeader) { + options.headers = { + [X_REWRAP_ADDITIONAL_CONTEXT]: rewrapAdditionalContextHeader, + }; + } try { - return await platform.v1.access.rewrap({ - signedRequestToken, - }); + return await platform.v1.access.rewrap({ signedRequestToken }, options); } catch (e) { throw new NetworkError(`[${platformUrl}] [Rewrap] ${extractRpcErrorMessage(e)}`); } diff --git a/lib/src/access/constants.ts b/lib/src/access/constants.ts new file mode 100644 index 000000000..1863bf4e1 --- /dev/null +++ b/lib/src/access/constants.ts @@ -0,0 +1,2 @@ +/** Header expected by KAS rewrap containing additional context in base64 encoded JSON */ +export const X_REWRAP_ADDITIONAL_CONTEXT = 'X-Rewrap-Additional-Context'; diff --git a/lib/src/nanoclients.ts b/lib/src/nanoclients.ts index 845ff8966..22edf48f1 100644 --- a/lib/src/nanoclients.ts +++ b/lib/src/nanoclients.ts @@ -46,7 +46,7 @@ export class NanoTDFClient extends Client { const kasUrl = nanotdf.header.getKasRewrapUrl(); // Rewrap key on every request - const ukey = await this.rewrapKey( + const { unwrappedKey: ukey } = await this.rewrapKey( nanotdf.header.toBuffer(), kasUrl, nanotdf.header.magicNumberVersion, @@ -73,7 +73,7 @@ export class NanoTDFClient extends Client { const legacyVersion = '0.0.0'; // Rewrap key on every request - const key = await this.rewrapKey( + const { unwrappedKey: key } = await this.rewrapKey( nanotdf.header.toBuffer(), nanotdf.header.getKasRewrapUrl(), nanotdf.header.magicNumberVersion, @@ -351,7 +351,7 @@ export class NanoTDFDatasetClient extends Client { // TODO: The version number should be fetched from the API const version = '0.0.1'; // Rewrap key on every request - const ukey = await this.rewrapKey( + const { unwrappedKey: ukey } = await this.rewrapKey( nanotdf.header.toBuffer(), nanotdf.header.getKasRewrapUrl(), nanotdf.header.magicNumberVersion, diff --git a/lib/src/nanotdf/Client.ts b/lib/src/nanotdf/Client.ts index 9617f0282..771666d58 100644 --- a/lib/src/nanotdf/Client.ts +++ b/lib/src/nanotdf/Client.ts @@ -10,10 +10,16 @@ import { } from '../access.js'; import { AuthProvider, isAuthProvider, reqSignature } from '../auth/providers.js'; import { ConfigurationError, DecryptError, TdfError, UnsafeUrlError } from '../errors.js'; -import { cryptoPublicToPem, pemToCryptoPublicKey, validateSecureUrl } from '../utils.js'; +import { + cryptoPublicToPem, + getRequiredObligationFQNs, + pemToCryptoPublicKey, + validateSecureUrl, +} from '../utils.js'; export interface ClientConfig { allowedKases?: string[]; + fulfillableObligationFQNs?: string[]; ignoreAllowList?: boolean; authProvider: AuthProvider; dpopEnabled?: boolean; @@ -23,6 +29,11 @@ export interface ClientConfig { platformUrl: string; } +type RewrapKeyResult = { + unwrappedKey: CryptoKey; + requiredObligations: string[]; +}; + function toJWSAlg(c: CryptoKey): string { const { algorithm } = c; switch (algorithm.name) { @@ -106,6 +117,7 @@ export default class Client { static readonly IV_SIZE = 12; allowedKases?: OriginAllowList; + readonly fulfillableObligationFQNs: string[]; /* These variables are expected to be either assigned during initialization or within the methods. This is needed as the flow is very specific. Errors should be thrown if the necessary step is not completed. @@ -168,6 +180,7 @@ export default class Client { } else { const { allowedKases, + fulfillableObligationFQNs = [], ignoreAllowList, authProvider, dpopEnabled, @@ -184,6 +197,7 @@ export default class Client { if (allowedKases?.length || ignoreAllowList) { this.allowedKases = new OriginAllowList(allowedKases || [], ignoreAllowList); } + this.fulfillableObligationFQNs = fulfillableObligationFQNs; this.dpopEnabled = !!dpopEnabled; if (dpopKeys) { this.requestSignerKeyPair = dpopKeys; @@ -223,7 +237,7 @@ export default class Client { kasRewrapUrl: string, magicNumberVersion: ArrayBufferLike, clientVersion: string - ): Promise { + ): Promise { let allowedKases = this.allowedKases; if (!allowedKases) { @@ -265,10 +279,15 @@ export default class Client { }); // Wrapped - const wrappedKey = await fetchWrappedKey(kasRewrapUrl, signedRequestToken, this.authProvider); + const rewrapResp = await fetchWrappedKey( + kasRewrapUrl, + signedRequestToken, + this.authProvider, + this.fulfillableObligationFQNs + ); // Extract the iv and ciphertext - const entityWrappedKey = wrappedKey.entityWrappedKey; + const entityWrappedKey = rewrapResp.entityWrappedKey; const ivLength = clientVersion == Client.SDK_INITIAL_RELEASE ? Client.INITIAL_RELEASE_IV_SIZE : Client.IV_SIZE; const iv = entityWrappedKey.subarray(0, ivLength); @@ -277,7 +296,7 @@ export default class Client { let kasPublicKey; try { // Let us import public key as a cert or public key - kasPublicKey = await pemToCryptoPublicKey(wrappedKey.sessionPublicKey); + kasPublicKey = await pemToCryptoPublicKey(rewrapResp.sessionPublicKey); } catch (cause) { throw new ConfigurationError( `internal: [${kasRewrapUrl}] PEM Public Key to crypto public key failed. Is PEM formatted correctly?`, @@ -346,6 +365,9 @@ export default class Client { throw new DecryptError('Unable to import raw key.', cause); } - return unwrappedKey; + return { + requiredObligations: getRequiredObligationFQNs(rewrapResp), + unwrappedKey: unwrappedKey, + }; } } diff --git a/lib/src/opentdf.ts b/lib/src/opentdf.ts index 69b51be26..7e9008f85 100644 --- a/lib/src/opentdf.ts +++ b/lib/src/opentdf.ts @@ -55,6 +55,12 @@ export type Keys = { [keyID: string]: CryptoKey | CryptoKeyPair; }; +/** The fully qualified obligations that the caller is required to fulfill. */ +export type RequiredObligations = { + /** List of obligations values' fully qualified names. */ + fqns: string[]; +}; + /** Options for creating a new TDF object, shared between all container types. */ export type CreateOptions = { /** If the policy service should be used to control creation options. */ @@ -156,6 +162,8 @@ export type ReadOptions = { allowedKASEndpoints?: string[]; /** Optionally disable checking the allowlist. */ ignoreAllowlist?: boolean; + /** Optionally override client fulfillableObligationFQNs. */ + fulfillableObligationFQNs?: string[]; /** Public (or shared) keys for verifying assertions. */ assertionVerificationKeys?: AssertionVerificationKeys; /** Optionally disable assertion verification. */ @@ -307,6 +315,11 @@ export type TDFReader = { * @returns Any data attributes found in the policy. Currently only works for plain text, embedded policies (not remote or encrypted policies) */ attributes: () => Promise; + + /** + * @returns Any obligation value FQNs that are required to be fulfilled on the TDF, populated during the decrypt flow. + */ + obligations: () => Promise; }; /** @@ -543,11 +556,18 @@ class UnknownTypeReader { this.state = 'done'; }); } + + async obligations() { + const actual = await this.delegate; + return actual.obligations(); + } } /** A TDF reader for NanoTDF files. */ class NanoTDFReader { container: Promise; + // Required obligation FQNs that must be fulfilled, provided via the decrypt flow. + private requiredObligations?: RequiredObligations; constructor( readonly outer: OpenTDF, readonly opts: ReadOptions, @@ -573,7 +593,10 @@ class NanoTDFReader { }); } - /** Decrypts the NanoTDF file and returns a decorated stream. */ + /** + * Decrypts the NanoTDF file and returns a decorated stream. + * Sets required obligations on the reader when retrieved from KAS rewrap response. + */ async decrypt(): Promise { const nanotdf = await this.container; const cachedDEK = this.rewrapCache.get(nanotdf.header.ephemeralPublicKey); @@ -587,6 +610,7 @@ class NanoTDFReader { this.opts.allowedKASEndpoints?.[0] || platformUrl || 'https://disallow.all.invalid'; const nc = new Client({ allowedKases: this.opts.allowedKASEndpoints, + fulfillableObligationFQNs: this.opts.fulfillableObligationFQNs, authProvider: this.outer.authProvider, ignoreAllowList: this.opts.ignoreAllowlist, dpopEnabled: this.outer.dpopEnabled, @@ -597,7 +621,7 @@ class NanoTDFReader { // TODO: The version number should be fetched from the API const version = '0.0.1'; // Rewrap key on every request - const dek = await nc.rewrapKey( + const { unwrappedKey: dek, requiredObligations } = await nc.rewrapKey( nanotdf.header.toBuffer(), nanotdf.header.getKasRewrapUrl(), nanotdf.header.magicNumberVersion, @@ -607,6 +631,7 @@ class NanoTDFReader { // These should have thrown already. throw new Error('internal: key rewrap failure'); } + this.requiredObligations = { fqns: requiredObligations }; this.rewrapCache.set(nanotdf.header.ephemeralPublicKey, dek); const r: DecoratedStream = await streamify(decryptNanoTDF(dek, nanotdf)); // TODO figure out how to attach policy and metadata to the stream @@ -634,11 +659,25 @@ class NanoTDFReader { const policy = JSON.parse(policyString) as Policy; return policy?.body?.dataAttributes.map((a) => a.attribute) || []; } + + /** + * Returns obligations populated from the decrypt flow. + * If a decrypt has not occurred, attempts one to retrieve obligations. + */ + async obligations(): Promise { + if (this.requiredObligations) { + return this.requiredObligations; + } + await this.decrypt(); + return this.requiredObligations ?? { fqns: [] }; + } } /** A reader for TDF files. */ class ZTDFReader { overview: Promise; + // Required obligation FQNs that must be fulfilled, provided via the decrypt flow. + private requiredObligations?: RequiredObligations; constructor( readonly client: TDF3Client, readonly opts: ReadOptions, @@ -650,6 +689,7 @@ class ZTDFReader { /** * Decrypts the TDF file and returns a decorated stream. * The stream will have a manifest and metadata attached if available. + * Sets required obligations on the reader when retrieved from KAS rewrap response. */ async decrypt(): Promise { const { @@ -695,9 +735,13 @@ class ZTDFReader { assertionVerificationKeys, noVerifyAssertions, wrappingKeyAlgorithm, + fulfillableObligations: this.opts.fulfillableObligationFQNs || [], }, overview ); + this.requiredObligations = { + fqns: oldStream.obligations(), + }; const stream: DecoratedStream = oldStream.stream; stream.manifest = Promise.resolve(overview.manifest); stream.metadata = Promise.resolve(oldStream.metadata); @@ -721,6 +765,18 @@ class ZTDFReader { const policy = JSON.parse(policyJSON) as Policy; return policy?.body?.dataAttributes.map((a) => a.attribute) || []; } + + /** + * Returns obligations populated from the decrypt flow. + * If a decrypt has not occurred, attempts one to retrieve obligations. + */ + async obligations(): Promise { + if (this.requiredObligations) { + return this.requiredObligations; + } + await this.decrypt(); + return this.requiredObligations ?? { fqns: [] }; + } } async function streamify(ab: Promise): Promise> { diff --git a/lib/src/platform.ts b/lib/src/platform.ts index ca8786b35..881fc5e16 100644 --- a/lib/src/platform.ts +++ b/lib/src/platform.ts @@ -8,6 +8,7 @@ import { AuthProvider } from '../tdf3/index.js'; import { Client, createClient, Interceptor } from '@connectrpc/connect'; import { WellKnownService } from './platform/wellknownconfiguration/wellknown_configuration_pb.js'; import { AuthorizationService } from './platform/authorization/authorization_pb.js'; +import { AuthorizationService as AuthorizationServiceV2 } from './platform/authorization/v2/authorization_pb.js'; import { EntityResolutionService } from './platform/entityresolution/entity_resolution_pb.js'; import { AccessService } from './platform/kas/kas_pb.js'; import { ActionService } from './platform/policy/actions/actions_pb.js'; @@ -32,6 +33,10 @@ export interface PlatformServices { wellknown: Client; } +export interface PlatformServicesV2 { + authorization: Client; +} + export interface PlatformClientOptions { /** Optional authentication provider for generating auth interceptor. */ authProvider?: AuthProvider; @@ -68,6 +73,7 @@ export interface PlatformClientOptions { export class PlatformClient { readonly v1: PlatformServices; + readonly v2: PlatformServicesV2; constructor(options: PlatformClientOptions) { const interceptors: Interceptor[] = []; @@ -99,6 +105,10 @@ export class PlatformClient { unsafe: createClient(UnsafeService, transport), wellknown: createClient(WellKnownService, transport), }; + + this.v2 = { + authorization: createClient(AuthorizationServiceV2, transport), + }; } } diff --git a/lib/src/utils.ts b/lib/src/utils.ts index 30ecb9a45..16434806c 100644 --- a/lib/src/utils.ts +++ b/lib/src/utils.ts @@ -3,8 +3,11 @@ import { exportSPKI, importX509 } from 'jose'; import { base64 } from './encodings/index.js'; import { pemCertToCrypto, pemPublicToCrypto } from './nanotdf-crypto/pemPublicToCrypto.js'; import { ConfigurationError } from './errors.js'; +import { RewrapResponse } from './platform/kas/kas_pb.js'; import { ConnectError } from '@connectrpc/connect'; +const REQUIRED_OBLIGATIONS_METADATA_KEY = 'X-Required-Obligations'; + /** * Check to see if the given URL is 'secure'. This assumes: * @@ -223,3 +226,32 @@ export function getPlatformUrlFromKasEndpoint(endpoint: string): string { } return result; } + +/** + * Retrieves the fully qualified Obligations (values) that must be fulfilled from a rewrap response. + */ +export function getRequiredObligationFQNs(response: RewrapResponse) { + const requiredObligations = new Set(); + + // Loop through response key access object results, checking proto values/types for a metadata key + // that matches the expected KAS-provided fulfillable obligations list. + for (const resp of response.responses) { + for (const result of resp.results) { + if (!result.metadata.hasOwnProperty(REQUIRED_OBLIGATIONS_METADATA_KEY)) { + continue; + } + const value = result.metadata[REQUIRED_OBLIGATIONS_METADATA_KEY]; + if (value?.kind.case !== 'listValue') { + continue; + } + const obligations = value.kind.value.values; + for (const obligation of obligations) { + if (obligation.kind.case === 'stringValue') { + requiredObligations.add(obligation.kind.value.toLowerCase()); + } + } + } + } + + return [...requiredObligations.values()]; +} diff --git a/lib/tdf3/src/client/DecoratedReadableStream.ts b/lib/tdf3/src/client/DecoratedReadableStream.ts index 2054e7d06..e8e3852fb 100644 --- a/lib/tdf3/src/client/DecoratedReadableStream.ts +++ b/lib/tdf3/src/client/DecoratedReadableStream.ts @@ -21,6 +21,7 @@ export class DecoratedReadableStream { metadata?: Metadata; manifest: Manifest; fileStreamServiceWorker?: string; + requiredObligations?: string[]; constructor( underlyingSource: UnderlyingSource & { @@ -60,6 +61,14 @@ export class DecoratedReadableStream { async toString(): Promise { return new Response(this.stream).text(); } + + /** + * The fully qualified obligations required to be fulfilled on stream contents + * are set as decoration during the decrypt flow. + */ + obligations(): string[] { + return this.requiredObligations ?? []; + } } export function isDecoratedReadableStream(s: unknown): s is DecoratedReadableStream { diff --git a/lib/tdf3/src/client/builders.ts b/lib/tdf3/src/client/builders.ts index 894860dc1..65e434080 100644 --- a/lib/tdf3/src/client/builders.ts +++ b/lib/tdf3/src/client/builders.ts @@ -538,6 +538,7 @@ export type DecryptParams = { concurrencyLimit?: number; noVerifyAssertions?: boolean; wrappingKeyAlgorithm?: KasPublicKeyAlgorithm; + fulfillableObligationFQNs?: string[]; }; /** diff --git a/lib/tdf3/src/client/index.ts b/lib/tdf3/src/client/index.ts index 0a908fcd4..4a42f2dac 100644 --- a/lib/tdf3/src/client/index.ts +++ b/lib/tdf3/src/client/index.ts @@ -143,6 +143,12 @@ export interface ClientConfig { * Defaults to `[]`. */ allowedKases?: string[]; + /** + * List of obligation value FQNs in platform policy that can be fulfilled + * by the PEP handling this client (i.e. 'https://example.com/obl/drm/value/mask'). + * Defaults to '[]'. + */ + fulfillableObligationFQNs?: string[]; // Platform URL to use to lookup allowed KASes when allowedKases is empty platformUrl?: string; ignoreAllowList?: boolean; @@ -337,6 +343,13 @@ export class Client { */ readonly allowedKases?: OriginAllowList; + /** + * List of obligation value FQNs in platform policy that can be fulfilled + * by the PEP utilizing this client (i.e. 'https://example.com/obl/drm/value/mask'). + * Defaults to '[]'. Currently set per Client and not per TDF. + */ + readonly fulfillableObligationFQNs: string[]; + /** * URL of the platform, required to fetch list of allowed KASes when allowedKases is empty */ @@ -417,6 +430,14 @@ export class Client { } } + this.fulfillableObligationFQNs = config.fulfillableObligationFQNs?.length + ? config.fulfillableObligationFQNs + : []; + + if (clientConfig.easEndpoint) { + this.easEndpoint = clientConfig.easEndpoint; + } + this.authProvider = config.authProvider; this.clientConfig = clientConfig; @@ -736,6 +757,7 @@ export class Client { * @param params.source A data stream object, one of remote, stream, buffer, etc. types. * @param params.eo Optional entity object (legacy AuthZ) * @param params.assertionVerificationKeys Optional verification keys for assertions. + * @param params.fulfillableObligationFQNs Optional fulfillable obligation value FQNs (overrides those on the Client) * @return a {@link https://nodejs.org/api/stream.html#stream_class_stream_readable|Readable} stream containing the decrypted plaintext. * @see DecryptParamsBuilder */ @@ -748,6 +770,7 @@ export class Client { noVerifyAssertions, concurrencyLimit = 1, wrappingKeyAlgorithm, + fulfillableObligationFQNs = [], }: DecryptParams): Promise { const dpopKeys = await this.dpopKeys; if (!this.authProvider) { @@ -762,6 +785,12 @@ export class Client { throw new ConfigurationError('platformUrl is required when allowedKases is empty'); } + const hasEmptyDecryptParamObligationsButGlobal = + !fulfillableObligationFQNs.length && this.fulfillableObligationFQNs.length; + if (hasEmptyDecryptParamObligationsButGlobal) { + fulfillableObligationFQNs = this.fulfillableObligationFQNs; + } + // Await in order to catch any errors from this call. // TODO: Write error event to stream and don't await. return await (streamMiddleware as DecryptStreamMiddleware)( @@ -778,6 +807,7 @@ export class Client { assertionVerificationKeys, noVerifyAssertions, wrappingKeyAlgorithm, + fulfillableObligations: fulfillableObligationFQNs, }) ); } diff --git a/lib/tdf3/src/tdf.ts b/lib/tdf3/src/tdf.ts index 73cd01a02..937f0f44c 100644 --- a/lib/tdf3/src/tdf.ts +++ b/lib/tdf3/src/tdf.ts @@ -55,6 +55,7 @@ import { ZipReader, ZipWriter, keyMerge, concatUint8, buffToString } from './uti import { CentralDirectory } from './utils/zip-reader.js'; import { ztdfSalt } from './crypto/salt.js'; import { Payload } from './models/payload.js'; +import { getRequiredObligationFQNs } from '../../src/utils.js'; // TODO: input validation on manifest JSON const DEFAULT_SEGMENT_SIZE = 1024 * 1024; @@ -152,6 +153,7 @@ export type EncryptConfiguration = { }; export type DecryptConfiguration = { + fulfillableObligations: string[]; allowedKases?: string[]; allowList?: OriginAllowList; authProvider: AuthProvider; @@ -735,6 +737,7 @@ export function splitLookupTableFactory( type RewrapResponseData = { key: Uint8Array; metadata: Record; + requiredObligations: string[]; }; async function unwrapKey({ @@ -745,6 +748,7 @@ async function unwrapKey({ concurrencyLimit, cryptoService, wrappingKeyAlgorithm, + fulfillableObligations, }: { manifest: Manifest; allowedKases: OriginAllowList; @@ -753,6 +757,7 @@ async function unwrapKey({ dpopKeys: CryptoKeyPair; cryptoService: CryptoService; wrappingKeyAlgorithm?: KasPublicKeyAlgorithm; + fulfillableObligations: string[]; }) { if (authProvider === undefined) { throw new ConfigurationError( @@ -788,11 +793,14 @@ async function unwrapKey({ const jwtPayload = { requestBody: requestBodyStr }; const signedRequestToken = await reqSignature(jwtPayload, dpopKeys.privateKey); - const { entityWrappedKey, metadata, sessionPublicKey } = await fetchWrappedKey( + const rewrapResp = await fetchWrappedKey( url, signedRequestToken, - authProvider + authProvider, + fulfillableObligations ); + const { entityWrappedKey, metadata, sessionPublicKey } = rewrapResp; + const requiredObligations = getRequiredObligationFQNs(rewrapResp); if (wrappingKeyAlgorithm === 'ec:secp256r1') { const serverEphemeralKey: CryptoKey = await pemPublicToCrypto(sessionPublicKey); @@ -810,6 +818,7 @@ async function unwrapKey({ return { key: new Uint8Array(dek), metadata, + requiredObligations, }; } const key = Binary.fromArrayBuffer(entityWrappedKey); @@ -821,6 +830,7 @@ async function unwrapKey({ return { key: new Uint8Array(decryptedKeyBinary.asByteArray()), metadata, + requiredObligations, }; } @@ -850,12 +860,20 @@ async function unwrapKey({ splitPromises[splitId] = () => anyPool(poolSize, anyPromises); } try { - const splitResults = await allPool(poolSize, splitPromises); - // Merge all the split keys - const reconstructedKey = keyMerge(splitResults.map((r) => r.key)); + const rewrapResponseData = await allPool(poolSize, splitPromises); + const splitKeys = []; + const requiredObligations = new Set(); + for (const resp of rewrapResponseData) { + splitKeys.push(resp.key); + for (const requiredObligation of resp.requiredObligations) { + requiredObligations.add(requiredObligation.toLowerCase()); + } + } + const reconstructedKey = keyMerge(splitKeys); return { reconstructedKeyBinary: Binary.fromArrayBuffer(reconstructedKey), - metadata: splitResults[0].metadata, // Use metadata from first split + metadata: rewrapResponseData[0].metadata, // Use metadata from first split + requiredObligations: [...requiredObligations], }; } catch (e) { if (e instanceof AggregateError) { @@ -1039,7 +1057,8 @@ export async function decryptStreamFrom( segmentSizeDefault, segments, } = manifest.encryptionInformation.integrityInformation; - const { metadata, reconstructedKeyBinary } = await unwrapKey({ + const { metadata, reconstructedKeyBinary, requiredObligations } = await unwrapKey({ + fulfillableObligations: cfg.fulfillableObligations, manifest, authProvider: cfg.authProvider, allowedKases: allowList, @@ -1162,6 +1181,7 @@ export async function decryptStreamFrom( const outputStream = new DecoratedReadableStream(underlyingSource); + outputStream.requiredObligations = requiredObligations; outputStream.manifest = manifest; outputStream.metadata = metadata; return outputStream; diff --git a/lib/tests/mocha/client.spec.ts b/lib/tests/mocha/client.spec.ts index 6f9ccc5e2..d96693f22 100644 --- a/lib/tests/mocha/client.spec.ts +++ b/lib/tests/mocha/client.spec.ts @@ -155,4 +155,18 @@ describe('tdf stream tests', function () { }); assert.equal('hello world', new TextDecoder().decode(await stream.toBuffer())); }); + it('always returns a list of obligations', async function () { + const pt = new TextEncoder().encode('hello world'); + const stream = new DecoratedReadableStream({ + start(controller) { + controller.enqueue(pt); + controller.close(); + }, + }); + assert.isEmpty(stream.obligations()); + const obligations = ['https://example.com/obl/example/value/obligated_behavior']; + // replicate an assignment during the decrypt flow + stream.requiredObligations = obligations; + assert.deepEqual(stream.obligations(), obligations); + }); }); diff --git a/lib/tests/web/access/access.test.ts b/lib/tests/web/access/access.test.ts new file mode 100644 index 000000000..c76b5499b --- /dev/null +++ b/lib/tests/web/access/access.test.ts @@ -0,0 +1,65 @@ +import { expect } from 'chai'; +import { + rewrapAdditionalContextHeader, + type RewrapAdditionalContext, +} from '../../../src/access.js'; +import { base64 } from '../../../src/encodings/index.js'; + +describe('rewrapAdditionalContextHeader', () => { + it('should return undefined for empty array', () => { + const result = rewrapAdditionalContextHeader([]); + expect(result).to.be.undefined; + }); + + it('should return base64 encoded header for single FQN', () => { + const fqns = ['https://example.com/obl/drm/value/mask']; + const result = rewrapAdditionalContextHeader(fqns); + + expect(result).to.be.a('string'); + + // Decode and verify structure + const decoded = JSON.parse(base64.decode(result!)) as RewrapAdditionalContext; + expect(decoded).to.have.property('obligations'); + expect(decoded.obligations).to.have.property('fulfillableFQNs'); + expect(decoded.obligations.fulfillableFQNs).to.deep.equal([ + 'https://example.com/obl/drm/value/mask', + ]); + }); + + it('should lowercase all FQNs', () => { + const fqns = [ + 'https://EXAMPLE.com/obl/DRM-TEST/value/MASK-123', + 'https://example.COM/obl/water_mark/VALUE/apply_now', + ]; + const result = rewrapAdditionalContextHeader(fqns); + + expect(result).to.be.a('string'); + + // Decode and verify FQNs are lowercased + const decoded = JSON.parse(base64.decode(result!)) as RewrapAdditionalContext; + expect(decoded.obligations.fulfillableFQNs).to.deep.equal([ + 'https://example.com/obl/drm-test/value/mask-123', + 'https://example.com/obl/water_mark/value/apply_now', + ]); + }); + + it('should produce valid JSON structure', () => { + const fqns = ['https://example.com/obl/test/value/v1']; + const result = rewrapAdditionalContextHeader(fqns); + + expect(result).to.be.a('string'); + + // Verify it's valid base64 + expect(() => base64.decode(result!)).to.not.throw(); + + // Verify decoded value is valid JSON + const decoded = base64.decode(result!); + expect(() => JSON.parse(decoded)).to.not.throw(); + + // Verify structure + const parsed = JSON.parse(decoded) as RewrapAdditionalContext; + expect(parsed).to.have.property('obligations'); + expect(parsed.obligations).to.have.property('fulfillableFQNs'); + expect(parsed.obligations.fulfillableFQNs).to.be.an('array'); + }); +}); diff --git a/lib/tests/web/nanotdf/Client.test.ts b/lib/tests/web/nanotdf/Client.test.ts index 7443d98ad..c396666b5 100644 --- a/lib/tests/web/nanotdf/Client.test.ts +++ b/lib/tests/web/nanotdf/Client.test.ts @@ -1,6 +1,6 @@ import { expect } from '@esm-bundle/chai'; import { clientAuthProvider } from '../../../src/auth/providers.js'; -import Client from '../../../src/nanotdf/Client.js'; +import Client, { ClientConfig } from '../../../src/nanotdf/Client.js'; describe('nanotdf client', () => { it('Can create a client with a mock EAS', async () => { @@ -15,4 +15,51 @@ describe('nanotdf client', () => { const client = new Client({ authProvider, kasEndpoint, platformUrl }); expect(client.authProvider).to.be.ok; }); + + describe('fulfillableObligationFQNs', async () => { + const authProvider = await clientAuthProvider({ + clientId: 'string', + oidcOrigin: 'string', + exchange: 'client', + clientSecret: 'password', + }); + const defaultConfig: ClientConfig = { + authProvider, + kasEndpoint: 'https://opentdf.io/kas', + platformUrl: 'https://opentdf.io', + }; + + it('should default to empty array when not provided', async () => { + const client = new Client(defaultConfig); + + expect(client.fulfillableObligationFQNs).to.be.an('array'); + expect(client.fulfillableObligationFQNs).to.have.lengthOf(0); + }); + + it('should store fulfillableObligationFQNs when provided', async () => { + const fqns = [ + 'https://example.com/obl/drm/value/mask', + 'https://example.com/obl/watermark/value/apply', + ]; + const config = { + ...defaultConfig, + fulfillableObligationFQNs: fqns, + }; + + const client = new Client(config); + expect(client.fulfillableObligationFQNs).to.deep.equal(fqns); + }); + + it('should store empty array when explicitly provided as empty', async () => { + const fqns: string[] = []; + const config = { + ...defaultConfig, + fulfillableObligationFQNs: fqns, + }; + + const client = new Client(config); + expect(client.fulfillableObligationFQNs).to.be.an('array'); + expect(client.fulfillableObligationFQNs).to.have.lengthOf(0); + }); + }); }); diff --git a/lib/tests/web/tdf3/Client.test.ts b/lib/tests/web/tdf3/Client.test.ts new file mode 100644 index 000000000..e2ea58046 --- /dev/null +++ b/lib/tests/web/tdf3/Client.test.ts @@ -0,0 +1,52 @@ +import { expect } from '@esm-bundle/chai'; +import { clientAuthProvider } from '../../../src/auth/providers.js'; +import { Client as TDF3Client, type ClientConfig } from '../../../tdf3/src/client/index.js'; + +describe('tdf3 client', () => { + describe('fulfillableObligationFQNs', async () => { + const authProvider = await clientAuthProvider({ + clientId: 'string', + oidcOrigin: 'string', + exchange: 'client', + clientSecret: 'password', + }); + const defaultConfig: ClientConfig = { + authProvider, + kasEndpoint: 'https://opentdf.io/kas', + platformUrl: 'https://opentdf.io', + }; + + it('should default to empty array when not provided', async () => { + const client = new TDF3Client(defaultConfig); + + expect(client.fulfillableObligationFQNs).to.be.an('array'); + expect(client.fulfillableObligationFQNs).to.have.lengthOf(0); + }); + + it('should store fulfillableObligationFQNs when provided', async () => { + const fqns = [ + 'https://example.com/obl/drm/value/mask', + 'https://example.com/obl/watermark/value/apply', + ]; + const config = { + ...defaultConfig, + fulfillableObligationFQNs: fqns, + }; + + const client = new TDF3Client(config); + expect(client.fulfillableObligationFQNs).to.deep.equal(fqns); + }); + + it('should store empty array when explicitly provided as empty', async () => { + const fqns: string[] = []; + const config = { + ...defaultConfig, + fulfillableObligationFQNs: fqns, + }; + + const client = new TDF3Client(config); + expect(client.fulfillableObligationFQNs).to.be.an('array'); + expect(client.fulfillableObligationFQNs).to.have.lengthOf(0); + }); + }); +}); diff --git a/lib/tests/web/utils.test.ts b/lib/tests/web/utils.test.ts index 565f16294..72607c280 100644 --- a/lib/tests/web/utils.test.ts +++ b/lib/tests/web/utils.test.ts @@ -4,11 +4,18 @@ import { addNewLines, estimateSkew, estimateSkewFromHeaders, + getRequiredObligationFQNs, padSlashToUrl, rstrip, validateSecureUrl, } from '../../src/utils.js'; import { TdfError } from '../../src/errors.js'; +import { + KeyAccessRewrapResultSchema, + PolicyRewrapResultSchema, + RewrapResponseSchema, +} from '../../src/platform/kas/kas_pb.js'; +import { create } from '@bufbuild/protobuf'; describe('errors', () => { it('Avoids errors due to loops', () => { @@ -29,7 +36,6 @@ describe('errors', () => { expect(e.cause.cause.stack).to.equal(cause.stack); expect(e.cause.cause.cause.stack).to.equal(cause.stack); expect(e.cause.cause.cause.cause.stack).to.equal(cause.stack); - expect(e.cause.cause.cause.cause.cause.stack).to.equal(cause.stack); expect(e.cause.cause.cause.cause.cause.cause).to.be.undefined; } }); @@ -194,3 +200,245 @@ describe('addNewLines', () => { ); }); }); + +describe('getRequiredObligationFQNs', () => { + it('should return an empty array when no obligations are present', () => { + const rewrapResponse = create(RewrapResponseSchema, { + responses: [ + create(PolicyRewrapResultSchema, { + results: [ + create(KeyAccessRewrapResultSchema, { + metadata: {}, + }), + ], + }), + ], + }); + const result = getRequiredObligationFQNs(rewrapResponse); + expect(result).to.be.an('array').that.is.empty; + }); + + it('should return a single obligation', () => { + const rewrapResponse = create(RewrapResponseSchema, { + responses: [ + create(PolicyRewrapResultSchema, { + results: [ + create(KeyAccessRewrapResultSchema, { + metadata: { + 'X-Required-Obligations': { + kind: { + case: 'listValue', + value: { + values: [ + { + kind: { + case: 'stringValue', + value: 'https://example.com/attr/Test/value/someval', + }, + }, + ], + }, + }, + }, + }, + }), + ], + }), + ], + }); + const result = getRequiredObligationFQNs(rewrapResponse); + expect(result).to.deep.equal(['https://example.com/attr/test/value/someval']); + }); + + it('should return multiple obligations', () => { + const rewrapResponse = create(RewrapResponseSchema, { + responses: [ + create(PolicyRewrapResultSchema, { + results: [ + create(KeyAccessRewrapResultSchema, { + metadata: { + 'X-Required-Obligations': { + kind: { + case: 'listValue', + value: { + values: [ + { + kind: { + case: 'stringValue', + value: 'https://example.com/attr/Test/value/someval', + }, + }, + { + kind: { + case: 'stringValue', + value: 'https://example.com/attr/Test2/value/someval2', + }, + }, + ], + }, + }, + }, + }, + }), + ], + }), + ], + }); + const result = getRequiredObligationFQNs(rewrapResponse); + expect(result).to.deep.equal([ + 'https://example.com/attr/test/value/someval', + 'https://example.com/attr/test2/value/someval2', + ]); + }); + + it('should return unique obligations', () => { + const rewrapResponse = create(RewrapResponseSchema, { + responses: [ + create(PolicyRewrapResultSchema, { + results: [ + create(KeyAccessRewrapResultSchema, { + metadata: { + 'X-Required-Obligations': { + kind: { + case: 'listValue', + value: { + values: [ + { + kind: { + case: 'stringValue', + value: 'https://example.com/attr/Test/value/someval', + }, + }, + { + kind: { + case: 'stringValue', + value: 'https://example.com/attr/Test/value/someval', + }, + }, + ], + }, + }, + }, + }, + }), + ], + }), + ], + }); + const result = getRequiredObligationFQNs(rewrapResponse); + expect(result).to.deep.equal(['https://example.com/attr/test/value/someval']); + }); + + it('should return an empty array if metadata value is not a list', () => { + const rewrapResponse = create(RewrapResponseSchema, { + responses: [ + create(PolicyRewrapResultSchema, { + results: [ + create(KeyAccessRewrapResultSchema, { + metadata: { + 'X-Required-Obligations': { + kind: { + case: 'stringValue', + value: 'not a list', + }, + }, + }, + }), + ], + }), + ], + }); + const result = getRequiredObligationFQNs(rewrapResponse); + expect(result).to.be.an('array').that.is.empty; + }); + + it('should return an empty array if list value is not a string', () => { + const rewrapResponse = create(RewrapResponseSchema, { + responses: [ + create(PolicyRewrapResultSchema, { + results: [ + create(KeyAccessRewrapResultSchema, { + metadata: { + 'X-Required-Obligations': { + kind: { + case: 'listValue', + value: { + values: [ + { + kind: { + case: 'numberValue', + value: 123, + }, + }, + ], + }, + }, + }, + }, + }), + ], + }), + ], + }); + const result = getRequiredObligationFQNs(rewrapResponse); + expect(result).to.be.an('array').that.is.empty; + }); + + it('should handle multiple responses and results', () => { + const rewrapResponse = create(RewrapResponseSchema, { + responses: [ + create(PolicyRewrapResultSchema, { + results: [ + create(KeyAccessRewrapResultSchema, { + metadata: { + 'X-Required-Obligations': { + kind: { + case: 'listValue', + value: { + values: [ + { + kind: { + case: 'stringValue', + value: 'https://example.com/attr/Test/value/someval', + }, + }, + ], + }, + }, + }, + }, + }), + ], + }), + create(PolicyRewrapResultSchema, { + results: [ + create(KeyAccessRewrapResultSchema, { + metadata: { + 'X-Required-Obligations': { + kind: { + case: 'listValue', + value: { + values: [ + { + kind: { + case: 'stringValue', + value: 'https://example.com/attr/Test2/value/someval2', + }, + }, + ], + }, + }, + }, + }, + }), + ], + }), + ], + }); + const result = getRequiredObligationFQNs(rewrapResponse); + expect(result).to.deep.equal([ + 'https://example.com/attr/test/value/someval', + 'https://example.com/attr/test2/value/someval2', + ]); + }); +}); diff --git a/web-app/src/App.tsx b/web-app/src/App.tsx index 9d1c9fa6b..df110c30e 100644 --- a/web-app/src/App.tsx +++ b/web-app/src/App.tsx @@ -5,7 +5,7 @@ import { showSaveFilePicker } from 'native-file-system-adapter'; import './App.css'; import { type Chunker, type Source, OpenTDF } from '@opentdf/sdk'; import { type SessionInformation, OidcClient } from './session.js'; -import { c } from './config.js'; +import { config } from './config.js'; async function toFile( stream: ReadableStream, @@ -44,7 +44,7 @@ function decryptedFileExtension(encryptedFileName: string): string { return m[2]; } -const oidcClient = new OidcClient(c.oidc.host, c.oidc.clientId, 'otdf-sample-web-app'); +const oidcClient = new OidcClient(config.oidc.host, config.oidc.clientId, 'otdf-sample-web-app'); async function getNewFileHandle( extension: string, @@ -353,7 +353,7 @@ function App() { const client = new OpenTDF({ authProvider: oidcClient, defaultCreateOptions: { - defaultKASEndpoint: c.kas, + defaultKASEndpoint: config.kas, }, dpopKeys: oidcClient.getSigningKey(), }); @@ -432,7 +432,7 @@ function App() { const client = new OpenTDF({ authProvider: oidcClient, defaultReadOptions: { - allowedKASEndpoints: [c.kas], + allowedKASEndpoints: [config.kas], }, dpopKeys: oidcClient.getSigningKey(), }); @@ -462,7 +462,8 @@ function App() { // so we kinda fake it with percentages by tracking output, which should // strictly be smaller than the input file. try { - const plainText = await client.read({ source }); + const reader = client.open({ source }); + const plainText = await reader.decrypt(); const plainTextStream = plainText .pipeThrough(progressTransformers.reader) .pipeThrough(progressTransformers.writer); @@ -481,6 +482,10 @@ function App() { await plainTextStream.pipeTo(drain(), { signal: sc.signal }); break; } + const { fqns: requiredObligations } = await reader.obligations(); + console.log( + `Found required obligations count: ${requiredObligations.length}. ${requiredObligations.length ?? JSON.stringify(requiredObligations)}` + ); } catch (e) { console.error('Decrypt Failed', e); setDownloadState(`Decrypt Failed: ${e}`); diff --git a/web-app/src/config.ts b/web-app/src/config.ts index b9d587db3..f31accf7a 100644 --- a/web-app/src/config.ts +++ b/web-app/src/config.ts @@ -25,4 +25,4 @@ function cfg(): TDFConfig { return JSON.parse(VITE_TDF_CFG); } -export const c = cfg(); +export const config = cfg();