Skip to content

Commit 0361361

Browse files
authored
feat(sdk): initial obligations support in rewrap flow (#748)
* feat(core): initial obligations support in rewrap flow * 🤖 🎨 Autoformat Signed-off-by: jakedoublev <[email protected]> * wip Signed-off-by: jakedoublev <[email protected]> * more wip * rm unused import * 🤖 🎨 Autoformat Signed-off-by: jakedoublev <[email protected]> * lint fix Signed-off-by: jakedoublev <[email protected]> * tests Signed-off-by: jakedoublev <[email protected]> * 🤖 🎨 Autoformat Signed-off-by: jakedoublev <[email protected]> * move file * tdf3 client * cleanup * obligations method on opentdf reader classes * requiredObligations on DecoratedReadableStream in tdf3 * wip: fetch decision if obligations haven't been set on reader * wip * 🤖 🎨 Autoformat Signed-off-by: jakedoublev <[email protected]> * bugfix in case of no data attributes leading to no obligations Signed-off-by: jakedoublev <[email protected]> * working state Signed-off-by: jakedoublev <[email protected]> * fix Signed-off-by: jakedoublev <[email protected]> * 🤖 🎨 Autoformat Signed-off-by: jakedoublev <[email protected]> * fix comments * rm example web app hardcoded attributes and obligations * unit tests for getRequiredObligations * improve nullish operators * cleanup * 🤖 🎨 Autoformat Signed-off-by: jakedoublev <[email protected]> * improvements * fix * 🤖 🎨 Autoformat Signed-off-by: jakedoublev <[email protected]> * improve log * put back package.json changes * pr feedback Signed-off-by: jakedoublev <[email protected]> * 🤖 🎨 Autoformat Signed-off-by: jakedoublev <[email protected]> * rm rewrap header for obligations over legacy http for older platforms --------- Signed-off-by: jakedoublev <[email protected]>
1 parent 5b8ef25 commit 0361361

File tree

20 files changed

+692
-32
lines changed

20 files changed

+692
-32
lines changed

lib/src/access.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { type AuthProvider } from './auth/auth.js';
22
import { ServiceError } from './errors.js';
33
import { RewrapResponse } from './platform/kas/kas_pb.js';
44
import { getPlatformUrlFromKasEndpoint, validateSecureUrl } from './utils.js';
5+
import { base64 } from './encodings/index.js';
56

67
import {
78
fetchKasBasePubKey,
@@ -13,6 +14,15 @@ import { fetchWrappedKey as fetchWrappedKeysLegacy } from './access/access-fetch
1314
import { fetchKasPubKey as fetchKasPubKeyRpc } from './access/access-rpc.js';
1415
import { fetchKasPubKey as fetchKasPubKeyLegacy } from './access/access-fetch.js';
1516

17+
/**
18+
* Header value structure for 'X-Rewrap-Additional-Context`
19+
*/
20+
export type RewrapAdditionalContext = {
21+
obligations: {
22+
fulfillableFQNs: string[];
23+
};
24+
};
25+
1626
export type RewrapRequest = {
1727
signedRequestToken: string;
1828
};
@@ -22,17 +32,27 @@ export type RewrapRequest = {
2232
* @param url Key access server rewrap endpoint
2333
* @param requestBody a signed request with an encrypted document key
2434
* @param authProvider Authorization middleware
35+
* @param fulfillableObligationFQNs client-configured list of obligation value FQNs that can be fulfilled in this PEP
2536
* @param clientVersion
2637
*/
2738
export async function fetchWrappedKey(
2839
url: string,
2940
signedRequestToken: string,
30-
authProvider: AuthProvider
41+
authProvider: AuthProvider,
42+
fulfillableObligationFQNs: string[]
3143
): Promise<RewrapResponse> {
3244
const platformUrl = getPlatformUrlFromKasEndpoint(url);
3345

3446
return await tryPromisesUntilFirstSuccess(
35-
() => fetchWrappedKeysRpc(platformUrl, signedRequestToken, authProvider),
47+
() =>
48+
fetchWrappedKeysRpc(
49+
platformUrl,
50+
signedRequestToken,
51+
authProvider,
52+
rewrapAdditionalContextHeader(fulfillableObligationFQNs)
53+
),
54+
// We intentionally do not provide the rewrap additional context to legacy requests destined for older platforms.
55+
// Platforms new enough to have knowledge of obligations will be handling RPC requests successfully.
3656
() =>
3757
fetchWrappedKeysLegacy(
3858
url,
@@ -42,6 +62,23 @@ export async function fetchWrappedKey(
4262
);
4363
}
4464

65+
/**
66+
* Transform fulfillable, fully-qualified obligations into the expected KAS Rewrap 'X-Rewrap-Additional-Context' header value.
67+
* @param fulfillableObligationValueFQNs
68+
*/
69+
export const rewrapAdditionalContextHeader = (
70+
fulfillableObligationValueFQNs: string[]
71+
): string | undefined => {
72+
if (!fulfillableObligationValueFQNs.length) return;
73+
74+
const context: RewrapAdditionalContext = {
75+
obligations: {
76+
fulfillableFQNs: fulfillableObligationValueFQNs.map((fqn) => fqn.toLowerCase()),
77+
},
78+
};
79+
return base64.encode(JSON.stringify(context));
80+
};
81+
4582
export type KasPublicKeyAlgorithm =
4683
| 'ec:secp256r1'
4784
| 'ec:secp384r1'

lib/src/access/access-fetch.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export type RewrapResponseLegacy = {
3131
* @param url Key access server rewrap endpoint
3232
* @param requestBody a signed request with an encrypted document key
3333
* @param authProvider Authorization middleware
34+
* @param rewrapAdditionalContextHeader optional value for 'X-Rewrap-Additional-Context'
3435
*/
3536
export async function fetchWrappedKey(
3637
url: string,

lib/src/access/access-rpc.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import { CallOptions } from '@connectrpc/connect';
12
import {
23
isPublicKeyAlgorithm,
34
KasPublicKeyAlgorithm,
45
KasPublicKeyInfo,
56
noteInvalidPublicKey,
67
OriginAllowList,
78
} from '../access.js';
9+
810
import { type AuthProvider } from '../auth/auth.js';
911
import { ConfigurationError, NetworkError } from '../errors.js';
1012
import { PlatformClient } from '../platform.js';
@@ -16,25 +18,32 @@ import {
1618
pemToCryptoPublicKey,
1719
validateSecureUrl,
1820
} from '../utils.js';
21+
import { X_REWRAP_ADDITIONAL_CONTEXT } from './constants.js';
1922

2023
/**
2124
* Get a rewrapped access key to the document, if possible
2225
* @param url Key access server rewrap endpoint
2326
* @param requestBody a signed request with an encrypted document key
2427
* @param authProvider Authorization middleware
28+
* @param rewrapAdditionalContextHeader optional value for 'X-Rewrap-Additional-Context'
2529
* @param clientVersion
2630
*/
2731
export async function fetchWrappedKey(
2832
url: string,
2933
signedRequestToken: string,
30-
authProvider: AuthProvider
34+
authProvider: AuthProvider,
35+
rewrapAdditionalContextHeader?: string
3136
): Promise<RewrapResponse> {
3237
const platformUrl = getPlatformUrlFromKasEndpoint(url);
3338
const platform = new PlatformClient({ authProvider, platformUrl });
39+
const options: CallOptions = {};
40+
if (rewrapAdditionalContextHeader) {
41+
options.headers = {
42+
[X_REWRAP_ADDITIONAL_CONTEXT]: rewrapAdditionalContextHeader,
43+
};
44+
}
3445
try {
35-
return await platform.v1.access.rewrap({
36-
signedRequestToken,
37-
});
46+
return await platform.v1.access.rewrap({ signedRequestToken }, options);
3847
} catch (e) {
3948
throw new NetworkError(`[${platformUrl}] [Rewrap] ${extractRpcErrorMessage(e)}`);
4049
}

lib/src/access/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/** Header expected by KAS rewrap containing additional context in base64 encoded JSON */
2+
export const X_REWRAP_ADDITIONAL_CONTEXT = 'X-Rewrap-Additional-Context';

lib/src/nanoclients.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export class NanoTDFClient extends Client {
4646
const kasUrl = nanotdf.header.getKasRewrapUrl();
4747

4848
// Rewrap key on every request
49-
const ukey = await this.rewrapKey(
49+
const { unwrappedKey: ukey } = await this.rewrapKey(
5050
nanotdf.header.toBuffer(),
5151
kasUrl,
5252
nanotdf.header.magicNumberVersion,
@@ -73,7 +73,7 @@ export class NanoTDFClient extends Client {
7373

7474
const legacyVersion = '0.0.0';
7575
// Rewrap key on every request
76-
const key = await this.rewrapKey(
76+
const { unwrappedKey: key } = await this.rewrapKey(
7777
nanotdf.header.toBuffer(),
7878
nanotdf.header.getKasRewrapUrl(),
7979
nanotdf.header.magicNumberVersion,
@@ -351,7 +351,7 @@ export class NanoTDFDatasetClient extends Client {
351351
// TODO: The version number should be fetched from the API
352352
const version = '0.0.1';
353353
// Rewrap key on every request
354-
const ukey = await this.rewrapKey(
354+
const { unwrappedKey: ukey } = await this.rewrapKey(
355355
nanotdf.header.toBuffer(),
356356
nanotdf.header.getKasRewrapUrl(),
357357
nanotdf.header.magicNumberVersion,

lib/src/nanotdf/Client.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,16 @@ import {
1010
} from '../access.js';
1111
import { AuthProvider, isAuthProvider, reqSignature } from '../auth/providers.js';
1212
import { ConfigurationError, DecryptError, TdfError, UnsafeUrlError } from '../errors.js';
13-
import { cryptoPublicToPem, pemToCryptoPublicKey, validateSecureUrl } from '../utils.js';
13+
import {
14+
cryptoPublicToPem,
15+
getRequiredObligationFQNs,
16+
pemToCryptoPublicKey,
17+
validateSecureUrl,
18+
} from '../utils.js';
1419

1520
export interface ClientConfig {
1621
allowedKases?: string[];
22+
fulfillableObligationFQNs?: string[];
1723
ignoreAllowList?: boolean;
1824
authProvider: AuthProvider;
1925
dpopEnabled?: boolean;
@@ -23,6 +29,11 @@ export interface ClientConfig {
2329
platformUrl: string;
2430
}
2531

32+
type RewrapKeyResult = {
33+
unwrappedKey: CryptoKey;
34+
requiredObligations: string[];
35+
};
36+
2637
function toJWSAlg(c: CryptoKey): string {
2738
const { algorithm } = c;
2839
switch (algorithm.name) {
@@ -106,6 +117,7 @@ export default class Client {
106117
static readonly IV_SIZE = 12;
107118

108119
allowedKases?: OriginAllowList;
120+
readonly fulfillableObligationFQNs: string[];
109121
/*
110122
These variables are expected to be either assigned during initialization or within the methods.
111123
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 {
168180
} else {
169181
const {
170182
allowedKases,
183+
fulfillableObligationFQNs = [],
171184
ignoreAllowList,
172185
authProvider,
173186
dpopEnabled,
@@ -184,6 +197,7 @@ export default class Client {
184197
if (allowedKases?.length || ignoreAllowList) {
185198
this.allowedKases = new OriginAllowList(allowedKases || [], ignoreAllowList);
186199
}
200+
this.fulfillableObligationFQNs = fulfillableObligationFQNs;
187201
this.dpopEnabled = !!dpopEnabled;
188202
if (dpopKeys) {
189203
this.requestSignerKeyPair = dpopKeys;
@@ -223,7 +237,7 @@ export default class Client {
223237
kasRewrapUrl: string,
224238
magicNumberVersion: ArrayBufferLike,
225239
clientVersion: string
226-
): Promise<CryptoKey> {
240+
): Promise<RewrapKeyResult> {
227241
let allowedKases = this.allowedKases;
228242

229243
if (!allowedKases) {
@@ -265,10 +279,15 @@ export default class Client {
265279
});
266280

267281
// Wrapped
268-
const wrappedKey = await fetchWrappedKey(kasRewrapUrl, signedRequestToken, this.authProvider);
282+
const rewrapResp = await fetchWrappedKey(
283+
kasRewrapUrl,
284+
signedRequestToken,
285+
this.authProvider,
286+
this.fulfillableObligationFQNs
287+
);
269288

270289
// Extract the iv and ciphertext
271-
const entityWrappedKey = wrappedKey.entityWrappedKey;
290+
const entityWrappedKey = rewrapResp.entityWrappedKey;
272291
const ivLength =
273292
clientVersion == Client.SDK_INITIAL_RELEASE ? Client.INITIAL_RELEASE_IV_SIZE : Client.IV_SIZE;
274293
const iv = entityWrappedKey.subarray(0, ivLength);
@@ -277,7 +296,7 @@ export default class Client {
277296
let kasPublicKey;
278297
try {
279298
// Let us import public key as a cert or public key
280-
kasPublicKey = await pemToCryptoPublicKey(wrappedKey.sessionPublicKey);
299+
kasPublicKey = await pemToCryptoPublicKey(rewrapResp.sessionPublicKey);
281300
} catch (cause) {
282301
throw new ConfigurationError(
283302
`internal: [${kasRewrapUrl}] PEM Public Key to crypto public key failed. Is PEM formatted correctly?`,
@@ -346,6 +365,9 @@ export default class Client {
346365
throw new DecryptError('Unable to import raw key.', cause);
347366
}
348367

349-
return unwrappedKey;
368+
return {
369+
requiredObligations: getRequiredObligationFQNs(rewrapResp),
370+
unwrappedKey: unwrappedKey,
371+
};
350372
}
351373
}

0 commit comments

Comments
 (0)