@opentdf/sdk 0.5.0-beta.48 → 0.5.0-beta.50

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,66 @@ 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(e: string, platformUrl: string): never {
89
+ if (e.includes(Code[Code.InvalidArgument])) {
90
+ // 400 Bad Request
91
+ throw new InvalidFileError(`400 for [${platformUrl}]: rewrap bad request [${e}]`);
92
+ }
93
+ if (e.includes(Code[Code.PermissionDenied])) {
94
+ // 403 Forbidden
95
+ throw new PermissionDeniedError(`403 for [${platformUrl}]; rewrap permission denied`);
96
+ }
97
+ if (e.includes(Code[Code.Unauthenticated])) {
98
+ // 401 Unauthorized
99
+ throw new UnauthenticatedError(`401 for [${platformUrl}]; rewrap auth failure`);
100
+ }
101
+ if (
102
+ e.includes(Code[Code.Internal]) ||
103
+ e.includes(Code[Code.Unimplemented]) ||
104
+ e.includes(Code[Code.DataLoss]) ||
105
+ e.includes(Code[Code.Unknown]) ||
106
+ e.includes(Code[Code.DeadlineExceeded]) ||
107
+ e.includes(Code[Code.Unavailable])
108
+ ) {
109
+ // >=500
110
+ throw new ServiceError(`500+ [${platformUrl}]: rewrap failure due to service error [${e}]`);
49
111
  }
112
+ throw new NetworkError(`[${platformUrl}] [Rewrap] ${e}`);
50
113
  }
51
114
 
52
115
  export async function fetchKeyAccessServers(
@@ -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,34 @@ 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
+ let entityWrappedKey: Uint8Array<ArrayBufferLike>;
323
+ switch (result.result.case) {
324
+ case 'kasWrappedKey': {
325
+ entityWrappedKey = result.result.value;
326
+ break;
327
+ }
328
+ case 'error': {
329
+ handleRpcRewrapErrorString(
330
+ result.result.value,
331
+ getPlatformUrlFromKasEndpoint(kasRewrapUrl)
332
+ );
333
+ }
334
+ default: {
335
+ throw new DecryptError('KAS rewrap response missing wrapped key');
336
+ }
337
+ }
288
338
 
289
339
  // Extract the iv and ciphertext
290
- const entityWrappedKey = rewrapResp.entityWrappedKey;
291
340
  const ivLength =
292
341
  clientVersion == Client.SDK_INITIAL_RELEASE ? Client.INITIAL_RELEASE_IV_SIZE : Client.IV_SIZE;
293
342
  const iv = entityWrappedKey.subarray(0, ivLength);
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/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,63 @@ 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(result.result.value, getPlatformUrlFromKasEndpoint(url));
898
+ }
899
+
900
+ default: {
901
+ throw new DecryptError('KAS rewrap response missing wrapped key');
902
+ }
903
+ }
835
904
  }
836
905
 
837
906
  let poolSize = 1;