Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
b1ea9e6
feat(core): initial obligations support in rewrap flow
jakedoublev Oct 6, 2025
3788ebf
🤖 🎨 Autoformat
jakedoublev Oct 6, 2025
2fe0892
wip
jakedoublev Oct 7, 2025
3351404
more wip
jakedoublev Oct 8, 2025
7d04691
rm unused import
jakedoublev Oct 8, 2025
2c4109d
🤖 🎨 Autoformat
jakedoublev Oct 8, 2025
a17b4ba
lint fix
jakedoublev Oct 8, 2025
d1480b9
tests
jakedoublev Oct 8, 2025
b19ec8e
🤖 🎨 Autoformat
jakedoublev Oct 8, 2025
9d6db84
move file
jakedoublev Oct 8, 2025
aeb3f7d
tdf3 client
jakedoublev Oct 8, 2025
3220c56
cleanup
jakedoublev Oct 15, 2025
f07fc3b
obligations method on opentdf reader classes
jakedoublev Oct 15, 2025
342e5a9
requiredObligations on DecoratedReadableStream in tdf3
jakedoublev Oct 15, 2025
00b2121
Merge remote-tracking branch 'origin' into feat/DSPX-1367-obligations
jakedoublev Oct 15, 2025
dcc3aac
wip: fetch decision if obligations haven't been set on reader
jakedoublev Oct 15, 2025
8b3d2c0
wip
jakedoublev Oct 15, 2025
f8ec06b
🤖 🎨 Autoformat
jakedoublev Oct 15, 2025
8396967
bugfix in case of no data attributes leading to no obligations
jakedoublev Oct 15, 2025
546dc7a
working state
jakedoublev Oct 16, 2025
02d24a5
fix
jakedoublev Oct 16, 2025
74f514c
🤖 🎨 Autoformat
jakedoublev Oct 16, 2025
7f39061
fix comments
jakedoublev Oct 16, 2025
53c1f6f
rm example web app hardcoded attributes and obligations
jakedoublev Oct 16, 2025
6a766ff
unit tests for getRequiredObligations
jakedoublev Oct 17, 2025
32c8f49
improve nullish operators
jakedoublev Oct 17, 2025
3e134f8
cleanup
jakedoublev Oct 17, 2025
058257a
🤖 🎨 Autoformat
jakedoublev Oct 17, 2025
b72797c
improvements
jakedoublev Oct 17, 2025
0e1bc27
fix
jakedoublev Oct 17, 2025
ed3b4a5
🤖 🎨 Autoformat
jakedoublev Oct 17, 2025
ed12407
improve log
jakedoublev Oct 17, 2025
4b57eb3
put back package.json changes
jakedoublev Oct 17, 2025
db1d725
pr feedback
jakedoublev Oct 17, 2025
bbebc87
🤖 🎨 Autoformat
jakedoublev Oct 17, 2025
f84b41b
rm rewrap header for obligations over legacy http for older platforms
jakedoublev Oct 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions lib/src/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
};
Expand All @@ -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<RewrapResponse> {
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,
Expand All @@ -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'
Expand Down
1 change: 1 addition & 0 deletions lib/src/access/access-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 13 additions & 4 deletions lib/src/access/access-rpc.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { CallOptions } from '@connectrpc/connect';
import {
isPublicKeyAlgorithm,
KasPublicKeyAlgorithm,
KasPublicKeyInfo,
noteInvalidPublicKey,
OriginAllowList,
} from '../access.js';

import { type AuthProvider } from '../auth/auth.js';
import { ConfigurationError, NetworkError } from '../errors.js';
import { PlatformClient } from '../platform.js';
Expand All @@ -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<RewrapResponse> {
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)}`);
}
Expand Down
2 changes: 2 additions & 0 deletions lib/src/access/constants.ts
Original file line number Diff line number Diff line change
@@ -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';
6 changes: 3 additions & 3 deletions lib/src/nanoclients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 28 additions & 6 deletions lib/src/nanotdf/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -168,6 +180,7 @@ export default class Client {
} else {
const {
allowedKases,
fulfillableObligationFQNs = [],
ignoreAllowList,
authProvider,
dpopEnabled,
Expand All @@ -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;
Expand Down Expand Up @@ -223,7 +237,7 @@ export default class Client {
kasRewrapUrl: string,
magicNumberVersion: ArrayBufferLike,
clientVersion: string
): Promise<CryptoKey> {
): Promise<RewrapKeyResult> {
let allowedKases = this.allowedKases;

if (!allowedKases) {
Expand Down Expand Up @@ -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);
Expand All @@ -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?`,
Expand Down Expand Up @@ -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,
};
}
}
Loading
Loading