@opentdf/sdk 0.5.0-rc.44 → 0.6.0-beta.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,14 @@ import {
8
8
  } from '../access.js';
9
9
 
10
10
  import { type AuthProvider } from '../auth/auth.js';
11
- import { ConfigurationError, NetworkError } from '../errors.js';
11
+ import {
12
+ ConfigurationError,
13
+ InvalidFileError,
14
+ NetworkError,
15
+ PermissionDeniedError,
16
+ ServiceError,
17
+ UnauthenticatedError,
18
+ } from '../errors.js';
12
19
  import { PlatformClient } from '../platform.js';
13
20
  import { RewrapResponse } from '../platform/kas/kas_pb.js';
14
21
  import { ListKeyAccessServersResponse } from '../platform/policy/kasregistry/key_access_server_registry_pb.js';
@@ -19,6 +26,7 @@ import {
19
26
  validateSecureUrl,
20
27
  } from '../utils.js';
21
28
  import { X_REWRAP_ADDITIONAL_CONTEXT } from './constants.js';
29
+ import { ConnectError, Code } from '@connectrpc/connect';
22
30
 
23
31
  /**
24
32
  * Get a rewrapped access key to the document, if possible
@@ -42,11 +50,75 @@ export async function fetchWrappedKey(
42
50
  [X_REWRAP_ADDITIONAL_CONTEXT]: rewrapAdditionalContextHeader,
43
51
  };
44
52
  }
53
+ let response: RewrapResponse;
45
54
  try {
46
- return await platform.v1.access.rewrap({ signedRequestToken }, options);
55
+ response = await platform.v1.access.rewrap({ signedRequestToken }, options);
47
56
  } catch (e) {
48
- throw new NetworkError(`[${platformUrl}] [Rewrap] ${extractRpcErrorMessage(e)}`);
57
+ handleRpcRewrapError(e, platformUrl);
58
+ }
59
+ return response;
60
+ }
61
+
62
+ export function handleRpcRewrapError(e: unknown, platformUrl: string): never {
63
+ if (e instanceof ConnectError) {
64
+ console.log('Error is a ConnectError with code:', e.code);
65
+ switch (e.code) {
66
+ case Code.InvalidArgument: // 400 Bad Request
67
+ throw new InvalidFileError(`400 for [${platformUrl}]: rewrap bad request [${e.message}]`);
68
+ case Code.PermissionDenied: // 403 Forbidden
69
+ throw new PermissionDeniedError(`403 for [${platformUrl}]; rewrap permission denied`);
70
+ case Code.Unauthenticated: // 401 Unauthorized
71
+ throw new UnauthenticatedError(`401 for [${platformUrl}]; rewrap auth failure`);
72
+ case Code.Internal:
73
+ case Code.Unimplemented:
74
+ case Code.DataLoss:
75
+ case Code.Unknown:
76
+ case Code.DeadlineExceeded:
77
+ case Code.Unavailable: // >=500 Server Error
78
+ throw new ServiceError(
79
+ `${e.code} for [${platformUrl}]: rewrap failure due to service error [${e.message}]`
80
+ );
81
+ default:
82
+ throw new NetworkError(`[${platformUrl}] [Rewrap] ${e.message}`);
83
+ }
84
+ }
85
+ throw new NetworkError(`[${platformUrl}] [Rewrap] ${extractRpcErrorMessage(e)}`);
86
+ }
87
+
88
+ export function handleRpcRewrapErrorString(
89
+ e: string,
90
+ platformUrl: string,
91
+ requiredObligations?: string[]
92
+ ): never {
93
+ if (e.includes(Code[Code.InvalidArgument])) {
94
+ // 400 Bad Request
95
+ throw new InvalidFileError(`400 for [${platformUrl}]: rewrap bad request [${e}]`);
96
+ }
97
+ if (e.includes(Code[Code.PermissionDenied])) {
98
+ if (requiredObligations && requiredObligations.length > 0) {
99
+ throw new PermissionDeniedError(
100
+ `403 for [${platformUrl}]; rewrap permission denied`,
101
+ requiredObligations
102
+ );
103
+ }
104
+ throw new PermissionDeniedError(`403 for [${platformUrl}]; rewrap permission denied`);
105
+ }
106
+ if (e.includes(Code[Code.Unauthenticated])) {
107
+ // 401 Unauthorized
108
+ throw new UnauthenticatedError(`401 for [${platformUrl}]; rewrap auth failure`);
109
+ }
110
+ if (
111
+ e.includes(Code[Code.Internal]) ||
112
+ e.includes(Code[Code.Unimplemented]) ||
113
+ e.includes(Code[Code.DataLoss]) ||
114
+ e.includes(Code[Code.Unknown]) ||
115
+ e.includes(Code[Code.DeadlineExceeded]) ||
116
+ e.includes(Code[Code.Unavailable])
117
+ ) {
118
+ // >=500
119
+ throw new ServiceError(`500+ [${platformUrl}]: rewrap failure due to service error [${e}]`);
49
120
  }
121
+ throw new NetworkError(`[${platformUrl}] [Rewrap] ${e}`);
50
122
  }
51
123
 
52
124
  export async function fetchKeyAccessServers(
package/src/errors.ts CHANGED
@@ -103,6 +103,14 @@ export class UnauthenticatedError extends TdfError {
103
103
  /** Authorization failure (403) */
104
104
  export class PermissionDeniedError extends TdfError {
105
105
  override name = 'PermissionDeniedError';
106
+ readonly requiredObligations?: string[];
107
+
108
+ constructor(message: string, obligations?: string[], cause?: Error) {
109
+ super(message, cause);
110
+ if (obligations && obligations.length > 0) {
111
+ this.requiredObligations = obligations;
112
+ }
113
+ }
106
114
  }
107
115
 
108
116
  /**
package/src/index.ts CHANGED
@@ -4,5 +4,15 @@ export { attributeFQNsAsValues } from './policy/api.js';
4
4
  export { version, clientType, tdfSpecVersion } from './version.js';
5
5
  export { PlatformClient, type PlatformClientOptions, type PlatformServices } from './platform.js';
6
6
  export * from './opentdf.js';
7
+ export {
8
+ TdfError,
9
+ PermissionDeniedError,
10
+ IntegrityError,
11
+ InvalidFileError,
12
+ DecryptError,
13
+ NetworkError,
14
+ AttributeValidationError,
15
+ ConfigurationError,
16
+ } from './errors.js';
7
17
  export * from './seekable.js';
8
18
  export * from '../tdf3/src/models/index.js';
@@ -1,4 +1,8 @@
1
- import * as base64 from '../encodings/base64.js';
1
+ import { create, toJsonString } from '@bufbuild/protobuf';
2
+ import {
3
+ UnsignedRewrapRequest_WithPolicyRequestSchema,
4
+ UnsignedRewrapRequestSchema,
5
+ } from '../platform/kas/kas_pb.js';
2
6
  import { generateKeyPair, keyAgreement } from '../nanotdf-crypto/index.js';
3
7
  import getHkdfSalt from './helpers/getHkdfSalt.js';
4
8
  import DefaultParams from './models/DefaultParams.js';
@@ -8,13 +12,16 @@ import {
8
12
  KasPublicKeyInfo,
9
13
  OriginAllowList,
10
14
  } from '../access.js';
15
+ import { handleRpcRewrapErrorString } from '../../src/access/access-rpc.js';
11
16
  import { AuthProvider, isAuthProvider, reqSignature } from '../auth/providers.js';
12
17
  import { ConfigurationError, DecryptError, TdfError, UnsafeUrlError } from '../errors.js';
13
18
  import {
14
19
  cryptoPublicToPem,
15
20
  getRequiredObligationFQNs,
16
21
  pemToCryptoPublicKey,
22
+ upgradeRewrapResponseV1,
17
23
  validateSecureUrl,
24
+ getPlatformUrlFromKasEndpoint,
18
25
  } from '../utils.js';
19
26
 
20
27
  export interface ClientConfig {
@@ -260,18 +267,35 @@ export default class Client {
260
267
  throw new ConfigurationError('Signer key has not been set or generated');
261
268
  }
262
269
 
263
- const requestBodyStr = JSON.stringify({
264
- algorithm: DefaultParams.defaultECAlgorithm,
265
- // nano keyAccess minimum, header is used for nano
270
+ const unsignedRequest = create(UnsignedRewrapRequestSchema, {
271
+ clientPublicKey: await cryptoPublicToPem(ephemeralKeyPair.publicKey),
272
+ requests: [
273
+ create(UnsignedRewrapRequest_WithPolicyRequestSchema, {
274
+ keyAccessObjects: [
275
+ {
276
+ keyAccessObjectId: 'kao-0', // only one kao, no bulk
277
+ keyAccessObject: {
278
+ header: new Uint8Array(nanoTdfHeader),
279
+ kasUrl: '',
280
+ protocol: Client.KAS_PROTOCOL,
281
+ keyType: Client.KEY_ACCESS_REMOTE,
282
+ },
283
+ },
284
+ ],
285
+ algorithm: DefaultParams.defaultECAlgorithm,
286
+ }),
287
+ ],
266
288
  keyAccess: {
267
- type: Client.KEY_ACCESS_REMOTE,
268
- url: '',
289
+ header: new Uint8Array(nanoTdfHeader),
290
+ kasUrl: '',
269
291
  protocol: Client.KAS_PROTOCOL,
270
- header: base64.encodeArrayBuffer(nanoTdfHeader),
292
+ keyType: Client.KEY_ACCESS_REMOTE,
271
293
  },
272
- clientPublicKey: await cryptoPublicToPem(ephemeralKeyPair.publicKey),
294
+ algorithm: DefaultParams.defaultECAlgorithm,
273
295
  });
274
296
 
297
+ const requestBodyStr = toJsonString(UnsignedRewrapRequestSchema, unsignedRequest);
298
+
275
299
  const jwtPayload = { requestBody: requestBodyStr };
276
300
 
277
301
  const signedRequestToken = await reqSignature(jwtPayload, requestSignerKeyPair.privateKey, {
@@ -285,9 +309,37 @@ export default class Client {
285
309
  this.authProvider,
286
310
  this.fulfillableObligationFQNs
287
311
  );
312
+ // Upgrade any V1 responses to V2
313
+ upgradeRewrapResponseV1(rewrapResp);
314
+
315
+ const result = rewrapResp.responses?.[0]?.results?.[0];
316
+ if (!result) {
317
+ // This should not happen - KAS should always return at least one response and one result
318
+ // or the upgradeRewrapResponseV1 should have created them
319
+ throw new DecryptError('KAS rewrap response missing expected response or result');
320
+ }
321
+
322
+ const requiredObligations = getRequiredObligationFQNs(rewrapResp);
323
+
324
+ let entityWrappedKey: Uint8Array<ArrayBufferLike>;
325
+ switch (result.result.case) {
326
+ case 'kasWrappedKey': {
327
+ entityWrappedKey = result.result.value;
328
+ break;
329
+ }
330
+ case 'error': {
331
+ handleRpcRewrapErrorString(
332
+ result.result.value,
333
+ getPlatformUrlFromKasEndpoint(kasRewrapUrl),
334
+ requiredObligations
335
+ );
336
+ }
337
+ default: {
338
+ throw new DecryptError('KAS rewrap response missing wrapped key');
339
+ }
340
+ }
288
341
 
289
342
  // Extract the iv and ciphertext
290
- const entityWrappedKey = rewrapResp.entityWrappedKey;
291
343
  const ivLength =
292
344
  clientVersion == Client.SDK_INITIAL_RELEASE ? Client.INITIAL_RELEASE_IV_SIZE : Client.IV_SIZE;
293
345
  const iv = entityWrappedKey.subarray(0, ivLength);
@@ -366,7 +418,7 @@ export default class Client {
366
418
  }
367
419
 
368
420
  return {
369
- requiredObligations: getRequiredObligationFQNs(rewrapResp),
421
+ requiredObligations,
370
422
  unwrappedKey: unwrappedKey,
371
423
  };
372
424
  }
package/src/utils.ts CHANGED
@@ -3,7 +3,12 @@ import { exportSPKI, importX509 } from 'jose';
3
3
  import { base64 } from './encodings/index.js';
4
4
  import { pemCertToCrypto, pemPublicToCrypto } from './nanotdf-crypto/pemPublicToCrypto.js';
5
5
  import { ConfigurationError } from './errors.js';
6
- import { RewrapResponse } from './platform/kas/kas_pb.js';
6
+ import {
7
+ RewrapResponse,
8
+ PolicyRewrapResultSchema,
9
+ KeyAccessRewrapResultSchema,
10
+ } from './platform/kas/kas_pb.js';
11
+ import { create } from '@bufbuild/protobuf';
7
12
  import { ConnectError } from '@connectrpc/connect';
8
13
 
9
14
  const REQUIRED_OBLIGATIONS_METADATA_KEY = 'X-Required-Obligations';
@@ -255,3 +260,32 @@ export function getRequiredObligationFQNs(response: RewrapResponse) {
255
260
 
256
261
  return [...requiredObligations.values()];
257
262
  }
263
+
264
+ /**
265
+ * Upgrades a RewrapResponse from v1 format to v2.
266
+ * Note: This mutates the response in place.
267
+ */
268
+ export function upgradeRewrapResponseV1(response: RewrapResponse) {
269
+ if (response.responses.length > 0) {
270
+ return;
271
+ }
272
+ if (response.entityWrappedKey.length === 0) {
273
+ return;
274
+ }
275
+
276
+ response.responses = [
277
+ create(PolicyRewrapResultSchema, {
278
+ policyId: 'policy',
279
+ results: [
280
+ create(KeyAccessRewrapResultSchema, {
281
+ keyAccessObjectId: 'kao-0',
282
+ status: 'permit',
283
+ result: {
284
+ case: 'kasWrappedKey',
285
+ value: response.entityWrappedKey,
286
+ },
287
+ }),
288
+ ],
289
+ }),
290
+ ];
291
+ }
package/src/version.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Exposes the released version number of the `@opentdf/sdk` package
3
3
  */
4
- export const version = '0.5.0'; // x-release-please-version
4
+ export const version = '0.6.0'; // x-release-please-version
5
5
 
6
6
  /**
7
7
  * A string name used to label requests as coming from this library client.
package/tdf3/src/tdf.ts CHANGED
@@ -8,7 +8,16 @@ import {
8
8
  fetchWrappedKey,
9
9
  publicKeyAlgorithmToJwa,
10
10
  } from '../../src/access.js';
11
+ import { create, toJsonString } from '@bufbuild/protobuf';
12
+ import {
13
+ KeyAccessSchema,
14
+ UnsignedRewrapRequestSchema,
15
+ UnsignedRewrapRequest_WithPolicyRequestSchema,
16
+ UnsignedRewrapRequest_WithPolicySchema,
17
+ UnsignedRewrapRequest_WithKeyAccessObjectSchema,
18
+ } from '../../src/platform/kas/kas_pb.js';
11
19
  import { type AuthProvider, reqSignature } from '../../src/auth/auth.js';
20
+ import { handleRpcRewrapErrorString } from '../../src/access/access-rpc.js';
12
21
  import { allPool, anyPool } from '../../src/concurrency.js';
13
22
  import { base64, hex } from '../../src/encodings/index.js';
14
23
  import {
@@ -55,7 +64,11 @@ import { ZipReader, ZipWriter, keyMerge, concatUint8, buffToString } from './uti
55
64
  import { CentralDirectory } from './utils/zip-reader.js';
56
65
  import { ztdfSalt } from './crypto/salt.js';
57
66
  import { Payload } from './models/payload.js';
58
- import { getRequiredObligationFQNs } from '../../src/utils.js';
67
+ import {
68
+ getRequiredObligationFQNs,
69
+ upgradeRewrapResponseV1,
70
+ getPlatformUrlFromKasEndpoint,
71
+ } from '../../src/utils.js';
59
72
 
60
73
  // TODO: input validation on manifest JSON
61
74
  const DEFAULT_SEGMENT_SIZE = 1024 * 1024;
@@ -189,11 +202,6 @@ export type RewrapRequest = {
189
202
 
190
203
  export type KasPublicKeyFormat = 'pkcs8' | 'jwks';
191
204
 
192
- export type RewrapResponse = {
193
- entityWrappedKey: string;
194
- sessionPublicKey: string;
195
- };
196
-
197
205
  /**
198
206
  * If we have KAS url but not public key we can fetch it from KAS, fetching
199
207
  * the value from `${kas}/kas_public_key`.
@@ -783,13 +791,50 @@ async function unwrapKey({
783
791
 
784
792
  const clientPublicKey = ephemeralEncryptionKeys.publicKey;
785
793
 
786
- const requestBodyStr = JSON.stringify({
794
+ // Convert keySplitInfo to protobuf KeyAccess
795
+ const keyAccessProto = create(KeyAccessSchema, {
796
+ ...(keySplitInfo.type && { keyType: keySplitInfo.type }),
797
+ ...(keySplitInfo.url && { kasUrl: keySplitInfo.url }),
798
+ ...(keySplitInfo.protocol && { protocol: keySplitInfo.protocol }),
799
+ ...(keySplitInfo.wrappedKey && {
800
+ wrappedKey: new Uint8Array(base64.decodeArrayBuffer(keySplitInfo.wrappedKey)),
801
+ }),
802
+ ...(keySplitInfo.policyBinding && { policyBinding: keySplitInfo.policyBinding }),
803
+ ...(keySplitInfo.kid && { kid: keySplitInfo.kid }),
804
+ ...(keySplitInfo.sid && { splitId: keySplitInfo.sid }),
805
+ ...(keySplitInfo.encryptedMetadata && { encryptedMetadata: keySplitInfo.encryptedMetadata }),
806
+ ...(keySplitInfo.ephemeralPublicKey && {
807
+ ephemeralPublicKey: keySplitInfo.ephemeralPublicKey,
808
+ }),
809
+ });
810
+
811
+ // Create the protobuf request
812
+ const unsignedRequest = create(UnsignedRewrapRequestSchema, {
813
+ clientPublicKey,
814
+ requests: [
815
+ create(UnsignedRewrapRequest_WithPolicyRequestSchema, {
816
+ keyAccessObjects: [
817
+ create(UnsignedRewrapRequest_WithKeyAccessObjectSchema, {
818
+ keyAccessObjectId: 'kao-0',
819
+ keyAccessObject: keyAccessProto,
820
+ }),
821
+ ],
822
+ ...(manifest.encryptionInformation.policy && {
823
+ policy: create(UnsignedRewrapRequest_WithPolicySchema, {
824
+ id: 'policy',
825
+ body: manifest.encryptionInformation.policy,
826
+ }),
827
+ }),
828
+ }),
829
+ ],
830
+ // include deprecated fields for backward compatibility
787
831
  algorithm: 'RS256',
788
- keyAccess: keySplitInfo,
832
+ keyAccess: keyAccessProto,
789
833
  policy: manifest.encryptionInformation.policy,
790
- clientPublicKey,
791
834
  });
792
835
 
836
+ const requestBodyStr = toJsonString(UnsignedRewrapRequestSchema, unsignedRequest);
837
+
793
838
  const jwtPayload = { requestBody: requestBodyStr };
794
839
  const signedRequestToken = await reqSignature(jwtPayload, dpopKeys.privateKey);
795
840
 
@@ -799,39 +844,67 @@ async function unwrapKey({
799
844
  authProvider,
800
845
  fulfillableObligations
801
846
  );
802
- const { entityWrappedKey, metadata, sessionPublicKey } = rewrapResp;
847
+ // Upgrade V1 response to V2 format if needed
848
+ upgradeRewrapResponseV1(rewrapResp);
849
+ const { sessionPublicKey } = rewrapResp;
803
850
  const requiredObligations = getRequiredObligationFQNs(rewrapResp);
804
-
805
- if (wrappingKeyAlgorithm === 'ec:secp256r1') {
806
- const serverEphemeralKey: CryptoKey = await pemPublicToCrypto(sessionPublicKey);
807
- const ekr = ephemeralEncryptionKeysRaw as CryptoKeyPair;
808
- const kek = await keyAgreement(ekr.privateKey, serverEphemeralKey, {
809
- hkdfSalt: await ztdfSalt,
810
- hkdfHash: 'SHA-256',
811
- });
812
- const wrappedKeyAndNonce = entityWrappedKey;
813
- const iv = wrappedKeyAndNonce.slice(0, 12);
814
- const wrappedKey = wrappedKeyAndNonce.slice(12);
815
-
816
- const dek = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, kek, wrappedKey);
817
-
818
- return {
819
- key: new Uint8Array(dek),
820
- metadata,
821
- requiredObligations,
822
- };
851
+ // Assume only one response and one result for now (V1 style)
852
+ const result = rewrapResp.responses?.[0]?.results?.[0];
853
+ if (!result) {
854
+ // This should not happen - KAS should always return at least one response and one result
855
+ // or the upgradeRewrapResponseV1 should have created them
856
+ throw new DecryptError('KAS rewrap response missing expected response or result');
823
857
  }
824
- const key = Binary.fromArrayBuffer(entityWrappedKey);
825
- const decryptedKeyBinary = await cryptoService.decryptWithPrivateKey(
826
- key,
827
- ephemeralEncryptionKeys.privateKey
828
- );
858
+ const metadata = result.metadata;
859
+ // Handle the different cases of result.result
860
+ switch (result.result.case) {
861
+ case 'kasWrappedKey': {
862
+ const entityWrappedKey = result.result.value;
863
+
864
+ if (wrappingKeyAlgorithm === 'ec:secp256r1') {
865
+ const serverEphemeralKey: CryptoKey = await pemPublicToCrypto(sessionPublicKey);
866
+ const ekr = ephemeralEncryptionKeysRaw as CryptoKeyPair;
867
+ const kek = await keyAgreement(ekr.privateKey, serverEphemeralKey, {
868
+ hkdfSalt: await ztdfSalt,
869
+ hkdfHash: 'SHA-256',
870
+ });
871
+ const wrappedKeyAndNonce = entityWrappedKey;
872
+ const iv = wrappedKeyAndNonce.slice(0, 12);
873
+ const wrappedKey = wrappedKeyAndNonce.slice(12);
829
874
 
830
- return {
831
- key: new Uint8Array(decryptedKeyBinary.asByteArray()),
832
- metadata,
833
- requiredObligations,
834
- };
875
+ const dek = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, kek, wrappedKey);
876
+
877
+ return {
878
+ key: new Uint8Array(dek),
879
+ metadata,
880
+ requiredObligations,
881
+ };
882
+ }
883
+ const key = Binary.fromArrayBuffer(entityWrappedKey);
884
+ const decryptedKeyBinary = await cryptoService.decryptWithPrivateKey(
885
+ key,
886
+ ephemeralEncryptionKeys.privateKey
887
+ );
888
+
889
+ return {
890
+ key: new Uint8Array(decryptedKeyBinary.asByteArray()),
891
+ metadata,
892
+ requiredObligations,
893
+ };
894
+ }
895
+
896
+ case 'error': {
897
+ handleRpcRewrapErrorString(
898
+ result.result.value,
899
+ getPlatformUrlFromKasEndpoint(url),
900
+ requiredObligations
901
+ );
902
+ }
903
+
904
+ default: {
905
+ throw new DecryptError('KAS rewrap response missing wrapped key');
906
+ }
907
+ }
835
908
  }
836
909
 
837
910
  let poolSize = 1;