wolfronix-sdk 1.3.1 → 2.3.0

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.
package/dist/index.mjs CHANGED
@@ -4,7 +4,7 @@ var getCrypto = () => {
4
4
  return globalThis.crypto;
5
5
  }
6
6
  throw new Error(
7
- "Web Crypto API not available. Requires a modern browser or Node.js 16+."
7
+ "Web Crypto API not available. Requires a modern browser or Node.js 18+."
8
8
  );
9
9
  };
10
10
  var RSA_ALG = {
@@ -179,6 +179,10 @@ async function rsaDecrypt(encryptedBase64, privateKey) {
179
179
  );
180
180
  return decrypted;
181
181
  }
182
+ async function rsaDecryptBase64(encryptedBase64, privateKey) {
183
+ const decrypted = await rsaDecrypt(encryptedBase64, privateKey);
184
+ return arrayBufferToBase64(decrypted);
185
+ }
182
186
  async function exportSessionKey(key) {
183
187
  return await getCrypto().subtle.exportKey("raw", key);
184
188
  }
@@ -291,6 +295,7 @@ var Wolfronix = class {
291
295
  this.config = {
292
296
  baseUrl: config,
293
297
  clientId: "",
298
+ wolfronixKey: "",
294
299
  timeout: 3e4,
295
300
  retries: 3,
296
301
  insecure: false
@@ -299,6 +304,7 @@ var Wolfronix = class {
299
304
  this.config = {
300
305
  baseUrl: config.baseUrl,
301
306
  clientId: config.clientId || "",
307
+ wolfronixKey: config.wolfronixKey || "",
302
308
  timeout: config.timeout || 3e4,
303
309
  retries: config.retries || 3,
304
310
  insecure: config.insecure || false
@@ -306,6 +312,10 @@ var Wolfronix = class {
306
312
  }
307
313
  this.config.baseUrl = this.config.baseUrl.replace(/\/$/, "");
308
314
  }
315
+ /** Expose private key status for testing */
316
+ hasPrivateKey() {
317
+ return this.privateKey !== null;
318
+ }
309
319
  // ==========================================================================
310
320
  // Private Helpers
311
321
  // ==========================================================================
@@ -316,6 +326,9 @@ var Wolfronix = class {
316
326
  if (this.config.clientId) {
317
327
  headers["X-Client-ID"] = this.config.clientId;
318
328
  }
329
+ if (this.config.wolfronixKey) {
330
+ headers["X-Wolfronix-Key"] = this.config.wolfronixKey;
331
+ }
319
332
  if (includeAuth && this.token) {
320
333
  headers["Authorization"] = `Bearer ${this.token}`;
321
334
  if (this.userId) {
@@ -341,6 +354,15 @@ var Wolfronix = class {
341
354
  headers,
342
355
  signal: controller.signal
343
356
  };
357
+ if (this.config.insecure && typeof process !== "undefined") {
358
+ try {
359
+ const { Agent } = await import("undici");
360
+ fetchOptions.dispatcher = new Agent({
361
+ connect: { rejectUnauthorized: false }
362
+ });
363
+ } catch {
364
+ }
365
+ }
344
366
  if (formData) {
345
367
  fetchOptions.body = formData;
346
368
  } else if (body) {
@@ -428,7 +450,7 @@ var Wolfronix = class {
428
450
  this.publicKey = keyPair.publicKey;
429
451
  this.privateKey = keyPair.privateKey;
430
452
  this.publicKeyPEM = publicKeyPEM;
431
- this.token = "session_" + Date.now();
453
+ this.token = "zk-session";
432
454
  }
433
455
  return response;
434
456
  }
@@ -463,7 +485,7 @@ var Wolfronix = class {
463
485
  this.publicKeyPEM = response.public_key_pem;
464
486
  this.publicKey = await importKeyFromPEM(response.public_key_pem, "public");
465
487
  this.userId = email;
466
- this.token = "session_" + Date.now();
488
+ this.token = "zk-session";
467
489
  return {
468
490
  success: true,
469
491
  user_id: email,
@@ -560,7 +582,14 @@ var Wolfronix = class {
560
582
  };
561
583
  }
562
584
  /**
563
- * Decrypt and retrieve a file
585
+ * Decrypt and retrieve a file using zero-knowledge flow.
586
+ *
587
+ * Flow:
588
+ * 1. GET /api/v1/files/{id}/key → encrypted key_part_a
589
+ * 2. Decrypt key_part_a client-side with private key (RSA-OAEP)
590
+ * 3. POST /api/v1/files/{id}/decrypt with { decrypted_key_a } in body
591
+ *
592
+ * The private key NEVER leaves the client.
564
593
  *
565
594
  * @example
566
595
  * ```typescript
@@ -573,7 +602,7 @@ var Wolfronix = class {
573
602
  * fs.writeFileSync('decrypted.pdf', buffer);
574
603
  * ```
575
604
  */
576
- async decrypt(fileId) {
605
+ async decrypt(fileId, role = "owner") {
577
606
  this.ensureAuthenticated();
578
607
  if (!fileId) {
579
608
  throw new ValidationError("File ID is required");
@@ -581,19 +610,20 @@ var Wolfronix = class {
581
610
  if (!this.privateKey) {
582
611
  throw new Error("Private key not available. Is user logged in?");
583
612
  }
584
- const privateKeyPEM = await exportKeyToPEM(this.privateKey, "private");
613
+ const keyResponse = await this.getFileKey(fileId);
614
+ const decryptedKeyA = await rsaDecryptBase64(keyResponse.key_part_a, this.privateKey);
585
615
  return this.request("POST", `/api/v1/files/${fileId}/decrypt`, {
586
616
  responseType: "blob",
587
- headers: {
588
- "X-Private-Key": privateKeyPEM,
589
- "X-User-Role": "owner"
617
+ body: {
618
+ decrypted_key_a: decryptedKeyA,
619
+ user_role: role
590
620
  }
591
621
  });
592
622
  }
593
623
  /**
594
- * Decrypt and return as ArrayBuffer
624
+ * Decrypt and return as ArrayBuffer (zero-knowledge flow)
595
625
  */
596
- async decryptToBuffer(fileId) {
626
+ async decryptToBuffer(fileId, role = "owner") {
597
627
  this.ensureAuthenticated();
598
628
  if (!fileId) {
599
629
  throw new ValidationError("File ID is required");
@@ -601,15 +631,29 @@ var Wolfronix = class {
601
631
  if (!this.privateKey) {
602
632
  throw new Error("Private key not available. Is user logged in?");
603
633
  }
604
- const privateKeyPEM = await exportKeyToPEM(this.privateKey, "private");
634
+ const keyResponse = await this.getFileKey(fileId);
635
+ const decryptedKeyA = await rsaDecryptBase64(keyResponse.key_part_a, this.privateKey);
605
636
  return this.request("POST", `/api/v1/files/${fileId}/decrypt`, {
606
637
  responseType: "arraybuffer",
607
- headers: {
608
- "X-Private-Key": privateKeyPEM,
609
- "X-User-Role": "owner"
638
+ body: {
639
+ decrypted_key_a: decryptedKeyA,
640
+ user_role: role
610
641
  }
611
642
  });
612
643
  }
644
+ /**
645
+ * Fetch the encrypted key_part_a for a file (for client-side decryption)
646
+ *
647
+ * @param fileId The file ID to get the key for
648
+ * @returns KeyPartResponse containing the RSA-OAEP encrypted key_part_a
649
+ */
650
+ async getFileKey(fileId) {
651
+ this.ensureAuthenticated();
652
+ if (!fileId) {
653
+ throw new ValidationError("File ID is required");
654
+ }
655
+ return this.request("GET", `/api/v1/files/${fileId}/key`);
656
+ }
613
657
  /**
614
658
  * List all encrypted files for current user
615
659
  *
@@ -654,11 +698,19 @@ var Wolfronix = class {
654
698
  /**
655
699
  * Get another user's public key (for E2E encryption)
656
700
  * @param userId The ID of the recipient
701
+ * @param clientId Optional: override the configured clientId
657
702
  */
658
- async getPublicKey(userId) {
703
+ async getPublicKey(userId, clientId) {
659
704
  this.ensureAuthenticated();
660
- const result = await this.request("GET", `/api/v1/keys/${userId}`);
661
- return result.public_key;
705
+ const cid = clientId || this.config.clientId;
706
+ if (!cid) {
707
+ throw new ValidationError("clientId is required for getPublicKey(). Set it in config or pass as second argument.");
708
+ }
709
+ const result = await this.request(
710
+ "GET",
711
+ `/api/v1/keys/public/${encodeURIComponent(cid)}/${encodeURIComponent(userId)}`
712
+ );
713
+ return result.public_key_pem;
662
714
  }
663
715
  /**
664
716
  * Encrypt a short text message for a recipient (Hybrid Encryption: RSA + AES)
@@ -711,6 +763,166 @@ var Wolfronix = class {
711
763
  }
712
764
  }
713
765
  // ==========================================================================
766
+ // Server-Side Message Encryption (Dual-Key Split)
767
+ // ==========================================================================
768
+ /**
769
+ * Encrypt a text message via the Wolfronix server (dual-key split).
770
+ * The server generates an AES key, encrypts the message, and splits the key —
771
+ * you get key_part_a, the server holds key_part_b.
772
+ *
773
+ * Use this for server-managed message encryption (e.g., stored encrypted messages).
774
+ * For true E2E (where the server never sees plaintext), use encryptMessage() instead.
775
+ *
776
+ * @param message The plaintext message to encrypt
777
+ * @param options.layer 3 = AES only (full key returned), 4 = dual-key split (default)
778
+ *
779
+ * @example
780
+ * ```typescript
781
+ * const result = await wfx.serverEncrypt('Hello, World!');
782
+ * // Store result.encrypted_message, result.nonce, result.key_part_a, result.message_tag
783
+ * ```
784
+ */
785
+ async serverEncrypt(message, options) {
786
+ this.ensureAuthenticated();
787
+ if (!message) {
788
+ throw new ValidationError("Message is required");
789
+ }
790
+ return this.request("POST", "/api/v1/messages/encrypt", {
791
+ body: {
792
+ message,
793
+ user_id: this.userId,
794
+ layer: options?.layer || 4
795
+ }
796
+ });
797
+ }
798
+ /**
799
+ * Decrypt a message previously encrypted via serverEncrypt().
800
+ *
801
+ * @param params The encrypted message data (from serverEncrypt result)
802
+ * @returns The decrypted plaintext message
803
+ *
804
+ * @example
805
+ * ```typescript
806
+ * const text = await wfx.serverDecrypt({
807
+ * encryptedMessage: result.encrypted_message,
808
+ * nonce: result.nonce,
809
+ * keyPartA: result.key_part_a,
810
+ * messageTag: result.message_tag,
811
+ * });
812
+ * ```
813
+ */
814
+ async serverDecrypt(params) {
815
+ this.ensureAuthenticated();
816
+ if (!params.encryptedMessage || !params.nonce || !params.keyPartA) {
817
+ throw new ValidationError("encryptedMessage, nonce, and keyPartA are required");
818
+ }
819
+ const response = await this.request(
820
+ "POST",
821
+ "/api/v1/messages/decrypt",
822
+ {
823
+ body: {
824
+ encrypted_message: params.encryptedMessage,
825
+ nonce: params.nonce,
826
+ key_part_a: params.keyPartA,
827
+ message_tag: params.messageTag || "",
828
+ user_id: this.userId
829
+ }
830
+ }
831
+ );
832
+ return response.message;
833
+ }
834
+ /**
835
+ * Encrypt multiple messages in a single round-trip (batch).
836
+ * All messages share one AES key (different nonce per message).
837
+ * Efficient for chat history encryption or bulk operations.
838
+ *
839
+ * @param messages Array of { id, message } objects (max 100)
840
+ * @param options.layer 3 or 4 (default: 4)
841
+ *
842
+ * @example
843
+ * ```typescript
844
+ * const result = await wfx.serverEncryptBatch([
845
+ * { id: 'msg1', message: 'Hello' },
846
+ * { id: 'msg2', message: 'World' },
847
+ * ]);
848
+ * // result.results[0].encrypted_message, result.key_part_a, result.batch_tag
849
+ * ```
850
+ */
851
+ async serverEncryptBatch(messages, options) {
852
+ this.ensureAuthenticated();
853
+ if (!messages || messages.length === 0) {
854
+ throw new ValidationError("At least one message is required");
855
+ }
856
+ if (messages.length > 100) {
857
+ throw new ValidationError("Maximum 100 messages per batch");
858
+ }
859
+ return this.request("POST", "/api/v1/messages/batch/encrypt", {
860
+ body: {
861
+ messages,
862
+ user_id: this.userId,
863
+ layer: options?.layer || 4
864
+ }
865
+ });
866
+ }
867
+ /**
868
+ * Decrypt a single message from a batch result.
869
+ * Uses the shared key_part_a and batch_tag from the batch result.
870
+ *
871
+ * @param batchResult The batch encrypt result
872
+ * @param index The index of the message to decrypt
873
+ */
874
+ async serverDecryptBatchItem(batchResult, index) {
875
+ if (index < 0 || index >= batchResult.results.length) {
876
+ throw new ValidationError("Invalid batch index");
877
+ }
878
+ const item = batchResult.results[index];
879
+ return this.serverDecrypt({
880
+ encryptedMessage: item.encrypted_message,
881
+ nonce: item.nonce,
882
+ keyPartA: batchResult.key_part_a,
883
+ messageTag: batchResult.batch_tag
884
+ });
885
+ }
886
+ // ==========================================================================
887
+ // Real-Time Streaming Encryption (WebSocket)
888
+ // ==========================================================================
889
+ /**
890
+ * Create a streaming encryption/decryption session over WebSocket.
891
+ * Data flows in real-time: send chunks, receive encrypted/decrypted chunks back.
892
+ *
893
+ * @param direction 'encrypt' for plaintext→ciphertext, 'decrypt' for reverse
894
+ * @param streamKey Required for decrypt — the key_part_a and stream_tag from the encrypt session
895
+ *
896
+ * @example
897
+ * ```typescript
898
+ * // Encrypt stream
899
+ * const stream = await wfx.createStream('encrypt');
900
+ * stream.onData((chunk, seq) => console.log('Encrypted chunk', seq));
901
+ * stream.send('Hello chunk 1');
902
+ * stream.send('Hello chunk 2');
903
+ * const summary = await stream.end();
904
+ * // Save stream.keyPartA and stream.streamTag for decryption
905
+ *
906
+ * // Decrypt stream
907
+ * const dStream = await wfx.createStream('decrypt', {
908
+ * keyPartA: stream.keyPartA!,
909
+ * streamTag: stream.streamTag!,
910
+ * });
911
+ * dStream.onData((chunk, seq) => console.log('Decrypted:', chunk));
912
+ * dStream.send(encryptedChunk1);
913
+ * await dStream.end();
914
+ * ```
915
+ */
916
+ async createStream(direction, streamKey) {
917
+ this.ensureAuthenticated();
918
+ if (direction === "decrypt" && !streamKey) {
919
+ throw new ValidationError("streamKey (keyPartA + streamTag) is required for decrypt streams");
920
+ }
921
+ const stream = new WolfronixStream(this.config, this.userId);
922
+ await stream.connect(direction, streamKey);
923
+ return stream;
924
+ }
925
+ // ==========================================================================
714
926
  // Metrics & Status
715
927
  // ==========================================================================
716
928
  /**
@@ -740,6 +952,186 @@ var Wolfronix = class {
740
952
  }
741
953
  }
742
954
  };
955
+ var WolfronixStream = class {
956
+ /** @internal */
957
+ constructor(config, userId) {
958
+ this.config = config;
959
+ this.userId = userId;
960
+ this.ws = null;
961
+ this.dataCallbacks = [];
962
+ this.errorCallbacks = [];
963
+ this.pendingChunks = /* @__PURE__ */ new Map();
964
+ this.seqCounter = 0;
965
+ /** Client's key half (available after encrypt stream init) */
966
+ this.keyPartA = null;
967
+ /** Stream tag (available after encrypt stream init) */
968
+ this.streamTag = null;
969
+ }
970
+ /** @internal Connect and initialize the stream session */
971
+ async connect(direction, streamKey) {
972
+ return new Promise((resolve, reject) => {
973
+ const wsBase = this.config.baseUrl.replace(/^http/, "ws");
974
+ const params = new URLSearchParams();
975
+ if (this.config.wolfronixKey) {
976
+ params.set("wolfronix_key", this.config.wolfronixKey);
977
+ }
978
+ if (this.config.clientId) {
979
+ params.set("client_id", this.config.clientId);
980
+ }
981
+ const wsUrl = `${wsBase}/api/v1/stream?${params.toString()}`;
982
+ this.ws = new WebSocket(wsUrl);
983
+ this.ws.onopen = () => {
984
+ const initMsg = { type: "init", direction };
985
+ if (direction === "decrypt" && streamKey) {
986
+ initMsg.key_part_a = streamKey.keyPartA;
987
+ initMsg.stream_tag = streamKey.streamTag;
988
+ }
989
+ this.ws.send(JSON.stringify(initMsg));
990
+ };
991
+ let initResolved = false;
992
+ this.ws.onmessage = (event) => {
993
+ try {
994
+ const msg = JSON.parse(event.data);
995
+ if (msg.type === "error") {
996
+ const err = new Error(msg.error);
997
+ if (!initResolved) {
998
+ initResolved = true;
999
+ reject(err);
1000
+ }
1001
+ this.errorCallbacks.forEach((cb) => cb(err));
1002
+ return;
1003
+ }
1004
+ if (msg.type === "init_ack" && !initResolved) {
1005
+ initResolved = true;
1006
+ if (msg.key_part_a) this.keyPartA = msg.key_part_a;
1007
+ if (msg.stream_tag) this.streamTag = msg.stream_tag;
1008
+ resolve();
1009
+ return;
1010
+ }
1011
+ if (msg.type === "data") {
1012
+ this.dataCallbacks.forEach((cb) => cb(msg.data, msg.seq));
1013
+ const pending = this.pendingChunks.get(msg.seq);
1014
+ if (pending) {
1015
+ pending.resolve(msg.data);
1016
+ this.pendingChunks.delete(msg.seq);
1017
+ }
1018
+ return;
1019
+ }
1020
+ if (msg.type === "end_ack") {
1021
+ return;
1022
+ }
1023
+ } catch (e) {
1024
+ const err = new Error("Failed to parse stream message");
1025
+ this.errorCallbacks.forEach((cb) => cb(err));
1026
+ }
1027
+ };
1028
+ this.ws.onerror = (event) => {
1029
+ const err = new Error("WebSocket error");
1030
+ if (!initResolved) {
1031
+ initResolved = true;
1032
+ reject(err);
1033
+ }
1034
+ this.errorCallbacks.forEach((cb) => cb(err));
1035
+ };
1036
+ this.ws.onclose = () => {
1037
+ this.pendingChunks.forEach((p) => p.reject(new Error("Stream closed")));
1038
+ this.pendingChunks.clear();
1039
+ };
1040
+ });
1041
+ }
1042
+ /**
1043
+ * Send a data chunk for encryption/decryption.
1044
+ * Returns a promise that resolves with the processed (encrypted/decrypted) chunk.
1045
+ *
1046
+ * @param data String or base64-encoded binary data
1047
+ * @returns The processed chunk (base64-encoded)
1048
+ */
1049
+ async send(data) {
1050
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1051
+ throw new Error("Stream not connected");
1052
+ }
1053
+ const b64Data = this.isBase64(data) ? data : btoa(data);
1054
+ const seq = this.seqCounter++;
1055
+ return new Promise((resolve, reject) => {
1056
+ this.pendingChunks.set(seq, { resolve, reject });
1057
+ this.ws.send(JSON.stringify({ type: "data", data: b64Data }));
1058
+ });
1059
+ }
1060
+ /**
1061
+ * Send raw binary data for encryption/decryption.
1062
+ *
1063
+ * @param buffer ArrayBuffer or Uint8Array
1064
+ * @returns The processed chunk (base64-encoded)
1065
+ */
1066
+ async sendBinary(buffer) {
1067
+ const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
1068
+ let binary = "";
1069
+ for (let i = 0; i < bytes.byteLength; i++) {
1070
+ binary += String.fromCharCode(bytes[i]);
1071
+ }
1072
+ const b64 = btoa(binary);
1073
+ return this.send(b64);
1074
+ }
1075
+ /**
1076
+ * Register a callback for incoming data chunks.
1077
+ *
1078
+ * @param callback Called with (base64Data, sequenceNumber) for each chunk
1079
+ */
1080
+ onData(callback) {
1081
+ this.dataCallbacks.push(callback);
1082
+ }
1083
+ /**
1084
+ * Register a callback for stream errors.
1085
+ */
1086
+ onError(callback) {
1087
+ this.errorCallbacks.push(callback);
1088
+ }
1089
+ /**
1090
+ * End the stream session. Returns the total number of chunks processed.
1091
+ */
1092
+ async end() {
1093
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1094
+ return { chunksProcessed: this.seqCounter };
1095
+ }
1096
+ return new Promise((resolve) => {
1097
+ const originalHandler = this.ws.onmessage;
1098
+ this.ws.onmessage = (event) => {
1099
+ try {
1100
+ const msg = JSON.parse(event.data);
1101
+ if (msg.type === "end_ack") {
1102
+ resolve({ chunksProcessed: msg.chunks_processed || this.seqCounter });
1103
+ this.ws.close();
1104
+ return;
1105
+ }
1106
+ } catch {
1107
+ }
1108
+ if (originalHandler && this.ws) originalHandler.call(this.ws, event);
1109
+ };
1110
+ this.ws.send(JSON.stringify({ type: "end" }));
1111
+ setTimeout(() => {
1112
+ resolve({ chunksProcessed: this.seqCounter });
1113
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
1114
+ this.ws.close();
1115
+ }
1116
+ }, 5e3);
1117
+ });
1118
+ }
1119
+ /**
1120
+ * Close the stream immediately without sending an end message.
1121
+ */
1122
+ close() {
1123
+ if (this.ws) {
1124
+ this.ws.close();
1125
+ this.ws = null;
1126
+ }
1127
+ this.pendingChunks.forEach((p) => p.reject(new Error("Stream closed")));
1128
+ this.pendingChunks.clear();
1129
+ }
1130
+ isBase64(str) {
1131
+ if (str.length % 4 !== 0) return false;
1132
+ return /^[A-Za-z0-9+/]*={0,2}$/.test(str);
1133
+ }
1134
+ };
743
1135
  function createClient(config) {
744
1136
  return new Wolfronix(config);
745
1137
  }
@@ -752,6 +1144,7 @@ export {
752
1144
  ValidationError,
753
1145
  Wolfronix,
754
1146
  WolfronixError,
1147
+ WolfronixStream,
755
1148
  createClient,
756
1149
  index_default as default
757
1150
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wolfronix-sdk",
3
- "version": "1.3.1",
3
+ "version": "2.3.0",
4
4
  "description": "Official Wolfronix SDK for JavaScript/TypeScript - Zero-knowledge encryption made simple",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -46,20 +46,12 @@
46
46
  },
47
47
  "homepage": "https://wolfronix.com/docs/sdk/javascript",
48
48
  "engines": {
49
- "node": ">=16.0.0"
49
+ "node": ">=18.0.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/node": "^20.10.0",
53
53
  "tsup": "^8.0.0",
54
54
  "typescript": "^5.3.0",
55
55
  "vitest": "^1.0.0"
56
- },
57
- "peerDependencies": {
58
- "node-fetch": ">=3.0.0"
59
- },
60
- "peerDependenciesMeta": {
61
- "node-fetch": {
62
- "optional": true
63
- }
64
56
  }
65
- }
57
+ }