wolfronix-sdk 2.4.3 → 2.4.4
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/README.md +189 -726
- package/dist/index.d.mts +161 -3
- package/dist/index.d.ts +161 -3
- package/dist/index.global.js +3 -3
- package/dist/index.js +1067 -18
- package/dist/index.mjs +1067 -18
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -271,6 +271,641 @@ var ValidationError = class extends WolfronixError {
|
|
|
271
271
|
this.name = "ValidationError";
|
|
272
272
|
}
|
|
273
273
|
};
|
|
274
|
+
var RECOVERY_WORDS = [
|
|
275
|
+
"able",
|
|
276
|
+
"about",
|
|
277
|
+
"absorb",
|
|
278
|
+
"access",
|
|
279
|
+
"acid",
|
|
280
|
+
"across",
|
|
281
|
+
"action",
|
|
282
|
+
"adapt",
|
|
283
|
+
"admit",
|
|
284
|
+
"adult",
|
|
285
|
+
"agent",
|
|
286
|
+
"agree",
|
|
287
|
+
"ahead",
|
|
288
|
+
"air",
|
|
289
|
+
"alert",
|
|
290
|
+
"alpha",
|
|
291
|
+
"anchor",
|
|
292
|
+
"angle",
|
|
293
|
+
"apple",
|
|
294
|
+
"arch",
|
|
295
|
+
"arena",
|
|
296
|
+
"argue",
|
|
297
|
+
"armed",
|
|
298
|
+
"arrow",
|
|
299
|
+
"asset",
|
|
300
|
+
"atlas",
|
|
301
|
+
"attack",
|
|
302
|
+
"audio",
|
|
303
|
+
"august",
|
|
304
|
+
"auto",
|
|
305
|
+
"avoid",
|
|
306
|
+
"awake",
|
|
307
|
+
"aware",
|
|
308
|
+
"badge",
|
|
309
|
+
"balance",
|
|
310
|
+
"banana",
|
|
311
|
+
"basic",
|
|
312
|
+
"beach",
|
|
313
|
+
"beauty",
|
|
314
|
+
"before",
|
|
315
|
+
"begin",
|
|
316
|
+
"below",
|
|
317
|
+
"benefit",
|
|
318
|
+
"best",
|
|
319
|
+
"beyond",
|
|
320
|
+
"bicycle",
|
|
321
|
+
"bird",
|
|
322
|
+
"black",
|
|
323
|
+
"bless",
|
|
324
|
+
"board",
|
|
325
|
+
"bold",
|
|
326
|
+
"bonus",
|
|
327
|
+
"border",
|
|
328
|
+
"borrow",
|
|
329
|
+
"bottle",
|
|
330
|
+
"bottom",
|
|
331
|
+
"brain",
|
|
332
|
+
"brand",
|
|
333
|
+
"brave",
|
|
334
|
+
"breeze",
|
|
335
|
+
"brick",
|
|
336
|
+
"brief",
|
|
337
|
+
"bring",
|
|
338
|
+
"brother",
|
|
339
|
+
"budget",
|
|
340
|
+
"build",
|
|
341
|
+
"camera",
|
|
342
|
+
"camp",
|
|
343
|
+
"canal",
|
|
344
|
+
"carbon",
|
|
345
|
+
"carry",
|
|
346
|
+
"casual",
|
|
347
|
+
"center",
|
|
348
|
+
"chain",
|
|
349
|
+
"change",
|
|
350
|
+
"charge",
|
|
351
|
+
"chase",
|
|
352
|
+
"cheap",
|
|
353
|
+
"check",
|
|
354
|
+
"chief",
|
|
355
|
+
"choice",
|
|
356
|
+
"circle",
|
|
357
|
+
"city",
|
|
358
|
+
"claim",
|
|
359
|
+
"class",
|
|
360
|
+
"clean",
|
|
361
|
+
"clear",
|
|
362
|
+
"client",
|
|
363
|
+
"clock",
|
|
364
|
+
"cloud",
|
|
365
|
+
"coach",
|
|
366
|
+
"coast",
|
|
367
|
+
"color",
|
|
368
|
+
"column",
|
|
369
|
+
"combo",
|
|
370
|
+
"common",
|
|
371
|
+
"concept",
|
|
372
|
+
"confirm",
|
|
373
|
+
"connect",
|
|
374
|
+
"copy",
|
|
375
|
+
"core",
|
|
376
|
+
"corner",
|
|
377
|
+
"correct",
|
|
378
|
+
"cost",
|
|
379
|
+
"cover",
|
|
380
|
+
"craft",
|
|
381
|
+
"create",
|
|
382
|
+
"credit",
|
|
383
|
+
"cross",
|
|
384
|
+
"crowd",
|
|
385
|
+
"crystal",
|
|
386
|
+
"current",
|
|
387
|
+
"custom",
|
|
388
|
+
"cycle",
|
|
389
|
+
"daily",
|
|
390
|
+
"danger",
|
|
391
|
+
"data",
|
|
392
|
+
"dealer",
|
|
393
|
+
"debate",
|
|
394
|
+
"decide",
|
|
395
|
+
"deep",
|
|
396
|
+
"define",
|
|
397
|
+
"degree",
|
|
398
|
+
"delay",
|
|
399
|
+
"demand",
|
|
400
|
+
"denial",
|
|
401
|
+
"design",
|
|
402
|
+
"detail",
|
|
403
|
+
"device",
|
|
404
|
+
"dialog",
|
|
405
|
+
"digital",
|
|
406
|
+
"direct",
|
|
407
|
+
"doctor",
|
|
408
|
+
"domain",
|
|
409
|
+
"double",
|
|
410
|
+
"draft",
|
|
411
|
+
"dragon",
|
|
412
|
+
"drama",
|
|
413
|
+
"dream",
|
|
414
|
+
"drive",
|
|
415
|
+
"early",
|
|
416
|
+
"earth",
|
|
417
|
+
"easy",
|
|
418
|
+
"echo",
|
|
419
|
+
"edge",
|
|
420
|
+
"edit",
|
|
421
|
+
"effect",
|
|
422
|
+
"either",
|
|
423
|
+
"elder",
|
|
424
|
+
"element",
|
|
425
|
+
"elite",
|
|
426
|
+
"email",
|
|
427
|
+
"energy",
|
|
428
|
+
"engine",
|
|
429
|
+
"enough",
|
|
430
|
+
"enter",
|
|
431
|
+
"equal",
|
|
432
|
+
"error",
|
|
433
|
+
"escape",
|
|
434
|
+
"estate",
|
|
435
|
+
"event",
|
|
436
|
+
"exact",
|
|
437
|
+
"example",
|
|
438
|
+
"exchange",
|
|
439
|
+
"exist",
|
|
440
|
+
"expand",
|
|
441
|
+
"expect",
|
|
442
|
+
"expert",
|
|
443
|
+
"extra",
|
|
444
|
+
"fabric",
|
|
445
|
+
"factor",
|
|
446
|
+
"family",
|
|
447
|
+
"famous",
|
|
448
|
+
"feature",
|
|
449
|
+
"fence",
|
|
450
|
+
"field",
|
|
451
|
+
"figure",
|
|
452
|
+
"filter",
|
|
453
|
+
"final",
|
|
454
|
+
"finger",
|
|
455
|
+
"finish",
|
|
456
|
+
"first",
|
|
457
|
+
"focus",
|
|
458
|
+
"follow",
|
|
459
|
+
"force",
|
|
460
|
+
"forest",
|
|
461
|
+
"format",
|
|
462
|
+
"forward",
|
|
463
|
+
"frame",
|
|
464
|
+
"fresh",
|
|
465
|
+
"front",
|
|
466
|
+
"future",
|
|
467
|
+
"gallery",
|
|
468
|
+
"general",
|
|
469
|
+
"giant",
|
|
470
|
+
"global",
|
|
471
|
+
"gold",
|
|
472
|
+
"good",
|
|
473
|
+
"grace",
|
|
474
|
+
"grant",
|
|
475
|
+
"green",
|
|
476
|
+
"group",
|
|
477
|
+
"guard",
|
|
478
|
+
"habit",
|
|
479
|
+
"half",
|
|
480
|
+
"hammer",
|
|
481
|
+
"handle",
|
|
482
|
+
"happy",
|
|
483
|
+
"harbor",
|
|
484
|
+
"health",
|
|
485
|
+
"height",
|
|
486
|
+
"hidden",
|
|
487
|
+
"history",
|
|
488
|
+
"honest",
|
|
489
|
+
"host",
|
|
490
|
+
"hotel",
|
|
491
|
+
"human",
|
|
492
|
+
"hybrid",
|
|
493
|
+
"idea",
|
|
494
|
+
"image",
|
|
495
|
+
"impact",
|
|
496
|
+
"income",
|
|
497
|
+
"index",
|
|
498
|
+
"input",
|
|
499
|
+
"inside",
|
|
500
|
+
"insight",
|
|
501
|
+
"island",
|
|
502
|
+
"item",
|
|
503
|
+
"jacket",
|
|
504
|
+
"jazz",
|
|
505
|
+
"join",
|
|
506
|
+
"jungle",
|
|
507
|
+
"keep",
|
|
508
|
+
"keyboard",
|
|
509
|
+
"kind",
|
|
510
|
+
"king",
|
|
511
|
+
"kitchen",
|
|
512
|
+
"label",
|
|
513
|
+
"ladder",
|
|
514
|
+
"language",
|
|
515
|
+
"large",
|
|
516
|
+
"laser",
|
|
517
|
+
"later",
|
|
518
|
+
"launch",
|
|
519
|
+
"layer",
|
|
520
|
+
"leader",
|
|
521
|
+
"learn",
|
|
522
|
+
"level",
|
|
523
|
+
"light",
|
|
524
|
+
"limit",
|
|
525
|
+
"linear",
|
|
526
|
+
"link",
|
|
527
|
+
"listen",
|
|
528
|
+
"local",
|
|
529
|
+
"logic",
|
|
530
|
+
"lucky",
|
|
531
|
+
"machine",
|
|
532
|
+
"magic",
|
|
533
|
+
"major",
|
|
534
|
+
"manage",
|
|
535
|
+
"manual",
|
|
536
|
+
"market",
|
|
537
|
+
"master",
|
|
538
|
+
"matrix",
|
|
539
|
+
"matter",
|
|
540
|
+
"member",
|
|
541
|
+
"memory",
|
|
542
|
+
"message",
|
|
543
|
+
"method",
|
|
544
|
+
"middle",
|
|
545
|
+
"million",
|
|
546
|
+
"mind",
|
|
547
|
+
"mirror",
|
|
548
|
+
"mobile",
|
|
549
|
+
"model",
|
|
550
|
+
"module",
|
|
551
|
+
"moment",
|
|
552
|
+
"monitor",
|
|
553
|
+
"moral",
|
|
554
|
+
"motion",
|
|
555
|
+
"mountain",
|
|
556
|
+
"music",
|
|
557
|
+
"native",
|
|
558
|
+
"nature",
|
|
559
|
+
"network",
|
|
560
|
+
"never",
|
|
561
|
+
"normal",
|
|
562
|
+
"notice",
|
|
563
|
+
"number",
|
|
564
|
+
"object",
|
|
565
|
+
"ocean",
|
|
566
|
+
"offer",
|
|
567
|
+
"office",
|
|
568
|
+
"online",
|
|
569
|
+
"option",
|
|
570
|
+
"orange",
|
|
571
|
+
"order",
|
|
572
|
+
"origin",
|
|
573
|
+
"output",
|
|
574
|
+
"owner",
|
|
575
|
+
"packet",
|
|
576
|
+
"panel",
|
|
577
|
+
"paper",
|
|
578
|
+
"parent",
|
|
579
|
+
"partner",
|
|
580
|
+
"pattern",
|
|
581
|
+
"pause",
|
|
582
|
+
"payment",
|
|
583
|
+
"people",
|
|
584
|
+
"perfect",
|
|
585
|
+
"phone",
|
|
586
|
+
"phrase",
|
|
587
|
+
"pilot",
|
|
588
|
+
"pixel",
|
|
589
|
+
"planet",
|
|
590
|
+
"platform",
|
|
591
|
+
"please",
|
|
592
|
+
"plus",
|
|
593
|
+
"policy",
|
|
594
|
+
"portal",
|
|
595
|
+
"position",
|
|
596
|
+
"power",
|
|
597
|
+
"predict",
|
|
598
|
+
"premium",
|
|
599
|
+
"prepare",
|
|
600
|
+
"present",
|
|
601
|
+
"pretty",
|
|
602
|
+
"price",
|
|
603
|
+
"prime",
|
|
604
|
+
"private",
|
|
605
|
+
"process",
|
|
606
|
+
"profile",
|
|
607
|
+
"project",
|
|
608
|
+
"protect",
|
|
609
|
+
"public",
|
|
610
|
+
"quality",
|
|
611
|
+
"quick",
|
|
612
|
+
"quiet",
|
|
613
|
+
"radio",
|
|
614
|
+
"random",
|
|
615
|
+
"rapid",
|
|
616
|
+
"rate",
|
|
617
|
+
"ready",
|
|
618
|
+
"reason",
|
|
619
|
+
"record",
|
|
620
|
+
"recover",
|
|
621
|
+
"region",
|
|
622
|
+
"release",
|
|
623
|
+
"remote",
|
|
624
|
+
"repair",
|
|
625
|
+
"repeat",
|
|
626
|
+
"report",
|
|
627
|
+
"request",
|
|
628
|
+
"result",
|
|
629
|
+
"return",
|
|
630
|
+
"review",
|
|
631
|
+
"right",
|
|
632
|
+
"rival",
|
|
633
|
+
"river",
|
|
634
|
+
"robot",
|
|
635
|
+
"route",
|
|
636
|
+
"royal",
|
|
637
|
+
"safe",
|
|
638
|
+
"sample",
|
|
639
|
+
"scale",
|
|
640
|
+
"scene",
|
|
641
|
+
"school",
|
|
642
|
+
"science",
|
|
643
|
+
"screen",
|
|
644
|
+
"search",
|
|
645
|
+
"secure",
|
|
646
|
+
"select",
|
|
647
|
+
"seller",
|
|
648
|
+
"senior",
|
|
649
|
+
"series",
|
|
650
|
+
"server",
|
|
651
|
+
"session",
|
|
652
|
+
"shadow",
|
|
653
|
+
"shape",
|
|
654
|
+
"share",
|
|
655
|
+
"shield",
|
|
656
|
+
"shift",
|
|
657
|
+
"ship",
|
|
658
|
+
"short",
|
|
659
|
+
"signal",
|
|
660
|
+
"silver",
|
|
661
|
+
"simple",
|
|
662
|
+
"single",
|
|
663
|
+
"skill",
|
|
664
|
+
"smart",
|
|
665
|
+
"smooth",
|
|
666
|
+
"social",
|
|
667
|
+
"solid",
|
|
668
|
+
"source",
|
|
669
|
+
"space",
|
|
670
|
+
"special",
|
|
671
|
+
"speed",
|
|
672
|
+
"spirit",
|
|
673
|
+
"split",
|
|
674
|
+
"square",
|
|
675
|
+
"stable",
|
|
676
|
+
"stack",
|
|
677
|
+
"stage",
|
|
678
|
+
"start",
|
|
679
|
+
"state",
|
|
680
|
+
"status",
|
|
681
|
+
"steel",
|
|
682
|
+
"step",
|
|
683
|
+
"stock",
|
|
684
|
+
"store",
|
|
685
|
+
"storm",
|
|
686
|
+
"story",
|
|
687
|
+
"stream",
|
|
688
|
+
"strike",
|
|
689
|
+
"strong",
|
|
690
|
+
"studio",
|
|
691
|
+
"style",
|
|
692
|
+
"subject",
|
|
693
|
+
"submit",
|
|
694
|
+
"success",
|
|
695
|
+
"sudden",
|
|
696
|
+
"sugar",
|
|
697
|
+
"supply",
|
|
698
|
+
"support",
|
|
699
|
+
"surface",
|
|
700
|
+
"switch",
|
|
701
|
+
"system",
|
|
702
|
+
"table",
|
|
703
|
+
"target",
|
|
704
|
+
"task",
|
|
705
|
+
"team",
|
|
706
|
+
"temple",
|
|
707
|
+
"tempo",
|
|
708
|
+
"tenant",
|
|
709
|
+
"term",
|
|
710
|
+
"test",
|
|
711
|
+
"theme",
|
|
712
|
+
"theory",
|
|
713
|
+
"thing",
|
|
714
|
+
"thread",
|
|
715
|
+
"time",
|
|
716
|
+
"title",
|
|
717
|
+
"token",
|
|
718
|
+
"tool",
|
|
719
|
+
"topic",
|
|
720
|
+
"total",
|
|
721
|
+
"tower",
|
|
722
|
+
"track",
|
|
723
|
+
"trade",
|
|
724
|
+
"traffic",
|
|
725
|
+
"train",
|
|
726
|
+
"travel",
|
|
727
|
+
"trust",
|
|
728
|
+
"tunnel",
|
|
729
|
+
"type",
|
|
730
|
+
"unable",
|
|
731
|
+
"update",
|
|
732
|
+
"upload",
|
|
733
|
+
"usage",
|
|
734
|
+
"useful",
|
|
735
|
+
"user",
|
|
736
|
+
"valid",
|
|
737
|
+
"value",
|
|
738
|
+
"vector",
|
|
739
|
+
"verify",
|
|
740
|
+
"version",
|
|
741
|
+
"video",
|
|
742
|
+
"view",
|
|
743
|
+
"virtual",
|
|
744
|
+
"vision",
|
|
745
|
+
"voice",
|
|
746
|
+
"volume",
|
|
747
|
+
"wait",
|
|
748
|
+
"wallet",
|
|
749
|
+
"watch",
|
|
750
|
+
"water",
|
|
751
|
+
"wealth",
|
|
752
|
+
"web",
|
|
753
|
+
"welcome",
|
|
754
|
+
"window",
|
|
755
|
+
"winner",
|
|
756
|
+
"wire",
|
|
757
|
+
"wise",
|
|
758
|
+
"wonder",
|
|
759
|
+
"work",
|
|
760
|
+
"world",
|
|
761
|
+
"write",
|
|
762
|
+
"xenon",
|
|
763
|
+
"year",
|
|
764
|
+
"yield",
|
|
765
|
+
"zone"
|
|
766
|
+
];
|
|
767
|
+
function randomInt(maxExclusive) {
|
|
768
|
+
const values = new Uint32Array(1);
|
|
769
|
+
globalThis.crypto.getRandomValues(values);
|
|
770
|
+
return values[0] % maxExclusive;
|
|
771
|
+
}
|
|
772
|
+
function generateRecoveryWords(count = 24) {
|
|
773
|
+
const words = [];
|
|
774
|
+
for (let i = 0; i < count; i++) {
|
|
775
|
+
words.push(RECOVERY_WORDS[randomInt(RECOVERY_WORDS.length)]);
|
|
776
|
+
}
|
|
777
|
+
return words;
|
|
778
|
+
}
|
|
779
|
+
var PFS_PROTOCOL = "wfx-dr-v1";
|
|
780
|
+
var ZERO_32 = new Uint8Array(32);
|
|
781
|
+
function toBase64(buf) {
|
|
782
|
+
if (typeof Buffer !== "undefined") {
|
|
783
|
+
return Buffer.from(buf).toString("base64");
|
|
784
|
+
}
|
|
785
|
+
const bytes = new Uint8Array(buf);
|
|
786
|
+
let binary = "";
|
|
787
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
788
|
+
return btoa(binary);
|
|
789
|
+
}
|
|
790
|
+
function fromBase64(b64) {
|
|
791
|
+
if (typeof Buffer !== "undefined") {
|
|
792
|
+
const buf = Buffer.from(b64, "base64");
|
|
793
|
+
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
|
794
|
+
}
|
|
795
|
+
const binary = atob(b64);
|
|
796
|
+
const bytes = new Uint8Array(binary.length);
|
|
797
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
798
|
+
return bytes.buffer;
|
|
799
|
+
}
|
|
800
|
+
function normalizeJwk(jwk) {
|
|
801
|
+
return JSON.stringify({
|
|
802
|
+
kty: jwk.kty || "",
|
|
803
|
+
crv: jwk.crv || "",
|
|
804
|
+
x: jwk.x || "",
|
|
805
|
+
y: jwk.y || ""
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
function ratchetKeyId(jwk, n) {
|
|
809
|
+
const j = normalizeJwk(jwk);
|
|
810
|
+
if (typeof Buffer !== "undefined") {
|
|
811
|
+
return `${Buffer.from(j).toString("base64")}:${n}`;
|
|
812
|
+
}
|
|
813
|
+
return `${btoa(j)}:${n}`;
|
|
814
|
+
}
|
|
815
|
+
async function generatePfsRatchetKeyPair() {
|
|
816
|
+
return globalThis.crypto.subtle.generateKey(
|
|
817
|
+
{ name: "ECDH", namedCurve: "P-256" },
|
|
818
|
+
true,
|
|
819
|
+
["deriveBits"]
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
async function exportPublicJwk(key) {
|
|
823
|
+
return globalThis.crypto.subtle.exportKey("jwk", key);
|
|
824
|
+
}
|
|
825
|
+
async function exportPrivateJwk(key) {
|
|
826
|
+
return globalThis.crypto.subtle.exportKey("jwk", key);
|
|
827
|
+
}
|
|
828
|
+
async function importPfsPublicJwk(jwk) {
|
|
829
|
+
return globalThis.crypto.subtle.importKey(
|
|
830
|
+
"jwk",
|
|
831
|
+
jwk,
|
|
832
|
+
{ name: "ECDH", namedCurve: "P-256" },
|
|
833
|
+
false,
|
|
834
|
+
[]
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
async function importPfsPrivateJwk(jwk) {
|
|
838
|
+
return globalThis.crypto.subtle.importKey(
|
|
839
|
+
"jwk",
|
|
840
|
+
jwk,
|
|
841
|
+
{ name: "ECDH", namedCurve: "P-256" },
|
|
842
|
+
false,
|
|
843
|
+
["deriveBits"]
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
async function deriveEcdhSecret(privateJwk, publicJwk) {
|
|
847
|
+
const priv = await importPfsPrivateJwk(privateJwk);
|
|
848
|
+
const pub = await importPfsPublicJwk(publicJwk);
|
|
849
|
+
return globalThis.crypto.subtle.deriveBits({ name: "ECDH", public: pub }, priv, 256);
|
|
850
|
+
}
|
|
851
|
+
async function hkdfExpand(ikm, salt, info, outBits) {
|
|
852
|
+
const ikmKey = await globalThis.crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"]);
|
|
853
|
+
return globalThis.crypto.subtle.deriveBits(
|
|
854
|
+
{
|
|
855
|
+
name: "HKDF",
|
|
856
|
+
hash: "SHA-256",
|
|
857
|
+
salt,
|
|
858
|
+
info: new TextEncoder().encode(info)
|
|
859
|
+
},
|
|
860
|
+
ikmKey,
|
|
861
|
+
outBits
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
async function hmacSha256(keyRaw, input) {
|
|
865
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
866
|
+
"raw",
|
|
867
|
+
keyRaw,
|
|
868
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
869
|
+
false,
|
|
870
|
+
["sign"]
|
|
871
|
+
);
|
|
872
|
+
return globalThis.crypto.subtle.sign("HMAC", key, new TextEncoder().encode(input));
|
|
873
|
+
}
|
|
874
|
+
async function deriveRootAndChains(rootKeyB64, dhSecret) {
|
|
875
|
+
const rootKeyRaw = rootKeyB64 ? fromBase64(rootKeyB64) : ZERO_32.buffer;
|
|
876
|
+
const mixed = await hkdfExpand(dhSecret, rootKeyRaw, `${PFS_PROTOCOL}:root`, 96 * 8);
|
|
877
|
+
const bytes = new Uint8Array(mixed);
|
|
878
|
+
return {
|
|
879
|
+
rootKey: toBase64(bytes.slice(0, 32).buffer),
|
|
880
|
+
chainA: toBase64(bytes.slice(32, 64).buffer),
|
|
881
|
+
chainB: toBase64(bytes.slice(64, 96).buffer)
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
async function deriveMessageKey(chainKeyB64, n) {
|
|
885
|
+
return hmacSha256(fromBase64(chainKeyB64), `msg:${n}`);
|
|
886
|
+
}
|
|
887
|
+
async function deriveNextChainKey(chainKeyB64) {
|
|
888
|
+
const next = await hmacSha256(fromBase64(chainKeyB64), "chain");
|
|
889
|
+
return toBase64(next);
|
|
890
|
+
}
|
|
891
|
+
async function encryptWithRawKey(rawKey, plaintext) {
|
|
892
|
+
const key = await globalThis.crypto.subtle.importKey("raw", rawKey, { name: "AES-GCM" }, false, ["encrypt"]);
|
|
893
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12));
|
|
894
|
+
const out = await globalThis.crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(plaintext));
|
|
895
|
+
return {
|
|
896
|
+
ciphertext: toBase64(out),
|
|
897
|
+
iv: toBase64(iv.buffer)
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
async function decryptWithRawKey(rawKey, ciphertextB64, ivB64) {
|
|
901
|
+
const key = await globalThis.crypto.subtle.importKey("raw", rawKey, { name: "AES-GCM" }, false, ["decrypt"]);
|
|
902
|
+
const out = await globalThis.crypto.subtle.decrypt(
|
|
903
|
+
{ name: "AES-GCM", iv: new Uint8Array(fromBase64(ivB64)) },
|
|
904
|
+
key,
|
|
905
|
+
fromBase64(ciphertextB64)
|
|
906
|
+
);
|
|
907
|
+
return new TextDecoder().decode(out);
|
|
908
|
+
}
|
|
274
909
|
var Wolfronix = class {
|
|
275
910
|
/**
|
|
276
911
|
* Create a new Wolfronix client
|
|
@@ -291,6 +926,9 @@ var Wolfronix = class {
|
|
|
291
926
|
this.publicKey = null;
|
|
292
927
|
this.privateKey = null;
|
|
293
928
|
this.publicKeyPEM = null;
|
|
929
|
+
this.pfsIdentityPrivateJwk = null;
|
|
930
|
+
this.pfsIdentityPublicJwk = null;
|
|
931
|
+
this.pfsSessions = /* @__PURE__ */ new Map();
|
|
294
932
|
if (typeof config === "string") {
|
|
295
933
|
this.config = {
|
|
296
934
|
baseUrl: config,
|
|
@@ -410,6 +1048,59 @@ var Wolfronix = class {
|
|
|
410
1048
|
throw new AuthenticationError("Not authenticated. Call login() or register() first.");
|
|
411
1049
|
}
|
|
412
1050
|
}
|
|
1051
|
+
toBlob(file) {
|
|
1052
|
+
if (file instanceof File || file instanceof Blob) {
|
|
1053
|
+
return file;
|
|
1054
|
+
}
|
|
1055
|
+
if (file instanceof ArrayBuffer) {
|
|
1056
|
+
return new Blob([new Uint8Array(file)]);
|
|
1057
|
+
}
|
|
1058
|
+
if (file instanceof Uint8Array) {
|
|
1059
|
+
const arrayBuffer = file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength);
|
|
1060
|
+
return new Blob([arrayBuffer]);
|
|
1061
|
+
}
|
|
1062
|
+
throw new ValidationError("Invalid file type. Expected File, Blob, Buffer, or ArrayBuffer");
|
|
1063
|
+
}
|
|
1064
|
+
async ensurePfsIdentity() {
|
|
1065
|
+
if (this.pfsIdentityPrivateJwk && this.pfsIdentityPublicJwk) {
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
const kp = await generatePfsRatchetKeyPair();
|
|
1069
|
+
this.pfsIdentityPrivateJwk = await exportPrivateJwk(kp.privateKey);
|
|
1070
|
+
this.pfsIdentityPublicJwk = await exportPublicJwk(kp.publicKey);
|
|
1071
|
+
}
|
|
1072
|
+
getPfsSession(sessionId) {
|
|
1073
|
+
const session = this.pfsSessions.get(sessionId);
|
|
1074
|
+
if (!session) {
|
|
1075
|
+
throw new ValidationError(`PFS session not found: ${sessionId}`);
|
|
1076
|
+
}
|
|
1077
|
+
return session;
|
|
1078
|
+
}
|
|
1079
|
+
async ratchetForSend(session) {
|
|
1080
|
+
const nextRatchet = await generatePfsRatchetKeyPair();
|
|
1081
|
+
const nextPriv = await exportPrivateJwk(nextRatchet.privateKey);
|
|
1082
|
+
const nextPub = await exportPublicJwk(nextRatchet.publicKey);
|
|
1083
|
+
const dh = await deriveEcdhSecret(nextPriv, session.their_ratchet_public_jwk);
|
|
1084
|
+
const mixed = await deriveRootAndChains(session.root_key, dh);
|
|
1085
|
+
session.root_key = mixed.rootKey;
|
|
1086
|
+
session.send_chain_key = mixed.chainA;
|
|
1087
|
+
session.recv_chain_key = mixed.chainB;
|
|
1088
|
+
session.prev_send_count = session.send_count;
|
|
1089
|
+
session.send_count = 0;
|
|
1090
|
+
session.my_ratchet_private_jwk = nextPriv;
|
|
1091
|
+
session.my_ratchet_public_jwk = nextPub;
|
|
1092
|
+
session.updated_at = Date.now();
|
|
1093
|
+
}
|
|
1094
|
+
async ratchetForReceive(session, theirRatchetPub) {
|
|
1095
|
+
const dh = await deriveEcdhSecret(session.my_ratchet_private_jwk, theirRatchetPub);
|
|
1096
|
+
const mixed = await deriveRootAndChains(session.root_key, dh);
|
|
1097
|
+
session.root_key = mixed.rootKey;
|
|
1098
|
+
session.recv_chain_key = mixed.chainA;
|
|
1099
|
+
session.send_chain_key = mixed.chainB;
|
|
1100
|
+
session.recv_count = 0;
|
|
1101
|
+
session.their_ratchet_public_jwk = theirRatchetPub;
|
|
1102
|
+
session.updated_at = Date.now();
|
|
1103
|
+
}
|
|
413
1104
|
// ==========================================================================
|
|
414
1105
|
// Authentication Methods
|
|
415
1106
|
// ==========================================================================
|
|
@@ -421,13 +1112,23 @@ var Wolfronix = class {
|
|
|
421
1112
|
* const { user_id, token } = await wfx.register('user@example.com', 'password123');
|
|
422
1113
|
* ```
|
|
423
1114
|
*/
|
|
424
|
-
async register(email, password) {
|
|
1115
|
+
async register(email, password, options = {}) {
|
|
425
1116
|
if (!email || !password) {
|
|
426
1117
|
throw new ValidationError("Email and password are required");
|
|
427
1118
|
}
|
|
428
1119
|
const keyPair = await generateKeyPair();
|
|
429
1120
|
const publicKeyPEM = await exportKeyToPEM(keyPair.publicKey, "public");
|
|
430
1121
|
const { encryptedKey, salt } = await wrapPrivateKey(keyPair.privateKey, password);
|
|
1122
|
+
const enableRecovery = options.enableRecovery !== false;
|
|
1123
|
+
const recoveryWords = enableRecovery ? options.recoveryPhrase ? options.recoveryPhrase.trim().split(/\s+/).filter(Boolean) : generateRecoveryWords(24) : [];
|
|
1124
|
+
const recoveryPhrase = recoveryWords.join(" ");
|
|
1125
|
+
let recoveryEncryptedPrivateKey = "";
|
|
1126
|
+
let recoverySalt = "";
|
|
1127
|
+
if (enableRecovery && recoveryPhrase) {
|
|
1128
|
+
const recoveryWrap = await wrapPrivateKey(keyPair.privateKey, recoveryPhrase);
|
|
1129
|
+
recoveryEncryptedPrivateKey = recoveryWrap.encryptedKey;
|
|
1130
|
+
recoverySalt = recoveryWrap.salt;
|
|
1131
|
+
}
|
|
431
1132
|
const response = await this.request("POST", "/api/v1/keys/register", {
|
|
432
1133
|
body: {
|
|
433
1134
|
client_id: this.config.clientId,
|
|
@@ -435,18 +1136,30 @@ var Wolfronix = class {
|
|
|
435
1136
|
// Using email as user_id for simplicity
|
|
436
1137
|
public_key_pem: publicKeyPEM,
|
|
437
1138
|
encrypted_private_key: encryptedKey,
|
|
438
|
-
salt
|
|
1139
|
+
salt,
|
|
1140
|
+
recovery_encrypted_private_key: recoveryEncryptedPrivateKey,
|
|
1141
|
+
recovery_salt: recoverySalt
|
|
439
1142
|
},
|
|
440
1143
|
includeAuth: false
|
|
441
1144
|
});
|
|
442
|
-
if (response.success) {
|
|
1145
|
+
if (response.status === "success" || response.success) {
|
|
443
1146
|
this.userId = email;
|
|
444
1147
|
this.publicKey = keyPair.publicKey;
|
|
445
1148
|
this.privateKey = keyPair.privateKey;
|
|
446
1149
|
this.publicKeyPEM = publicKeyPEM;
|
|
447
1150
|
this.token = "zk-session";
|
|
448
1151
|
}
|
|
449
|
-
|
|
1152
|
+
const out = {
|
|
1153
|
+
success: response.status === "success" || response.success === true,
|
|
1154
|
+
user_id: response.user_id || email,
|
|
1155
|
+
token: this.token || "zk-session",
|
|
1156
|
+
message: response.message || "Keys registered successfully"
|
|
1157
|
+
};
|
|
1158
|
+
if (enableRecovery && recoveryPhrase) {
|
|
1159
|
+
out.recoveryPhrase = recoveryPhrase;
|
|
1160
|
+
out.recoveryWords = recoveryWords;
|
|
1161
|
+
}
|
|
1162
|
+
return out;
|
|
450
1163
|
}
|
|
451
1164
|
/**
|
|
452
1165
|
* Login with existing credentials
|
|
@@ -490,6 +1203,69 @@ var Wolfronix = class {
|
|
|
490
1203
|
throw new AuthenticationError("Invalid password (decryption failed)");
|
|
491
1204
|
}
|
|
492
1205
|
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Recover account keys using a 24-word recovery phrase and set a new password.
|
|
1208
|
+
* Returns a fresh local auth session if recovery succeeds.
|
|
1209
|
+
*/
|
|
1210
|
+
async recoverAccount(email, recoveryPhrase, newPassword) {
|
|
1211
|
+
if (!email || !recoveryPhrase || !newPassword) {
|
|
1212
|
+
throw new ValidationError("email, recoveryPhrase, and newPassword are required");
|
|
1213
|
+
}
|
|
1214
|
+
const response = await this.request("POST", "/api/v1/keys/recover", {
|
|
1215
|
+
body: {
|
|
1216
|
+
client_id: this.config.clientId,
|
|
1217
|
+
user_id: email
|
|
1218
|
+
},
|
|
1219
|
+
includeAuth: false
|
|
1220
|
+
});
|
|
1221
|
+
if (!response.recovery_encrypted_private_key || !response.recovery_salt || !response.public_key_pem) {
|
|
1222
|
+
throw new AuthenticationError("Recovery material not found for this account");
|
|
1223
|
+
}
|
|
1224
|
+
const recoveredPrivateKey = await unwrapPrivateKey(
|
|
1225
|
+
response.recovery_encrypted_private_key,
|
|
1226
|
+
recoveryPhrase,
|
|
1227
|
+
response.recovery_salt
|
|
1228
|
+
);
|
|
1229
|
+
const newPasswordWrap = await wrapPrivateKey(recoveredPrivateKey, newPassword);
|
|
1230
|
+
await this.request("POST", "/api/v1/keys/update-password", {
|
|
1231
|
+
body: {
|
|
1232
|
+
client_id: this.config.clientId,
|
|
1233
|
+
user_id: email,
|
|
1234
|
+
encrypted_private_key: newPasswordWrap.encryptedKey,
|
|
1235
|
+
salt: newPasswordWrap.salt
|
|
1236
|
+
},
|
|
1237
|
+
includeAuth: false
|
|
1238
|
+
});
|
|
1239
|
+
this.privateKey = recoveredPrivateKey;
|
|
1240
|
+
this.publicKeyPEM = response.public_key_pem;
|
|
1241
|
+
this.publicKey = await importKeyFromPEM(response.public_key_pem, "public");
|
|
1242
|
+
this.userId = email;
|
|
1243
|
+
this.token = "zk-session";
|
|
1244
|
+
return {
|
|
1245
|
+
success: true,
|
|
1246
|
+
user_id: email,
|
|
1247
|
+
token: this.token,
|
|
1248
|
+
message: "Account recovered successfully"
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Rotates long-term RSA identity keys and re-wraps with password (+ optional recovery phrase).
|
|
1253
|
+
* Use this periodically to reduce long-term key exposure.
|
|
1254
|
+
*/
|
|
1255
|
+
async rotateIdentityKeys(password, recoveryPhrase) {
|
|
1256
|
+
this.ensureAuthenticated();
|
|
1257
|
+
if (!password) {
|
|
1258
|
+
throw new ValidationError("password is required");
|
|
1259
|
+
}
|
|
1260
|
+
if (recoveryPhrase !== void 0 && !recoveryPhrase.trim()) {
|
|
1261
|
+
throw new ValidationError("recoveryPhrase must be non-empty when provided");
|
|
1262
|
+
}
|
|
1263
|
+
throw new WolfronixError(
|
|
1264
|
+
"rotateIdentityKeys is not supported by the current server API. Use recoverAccount() to re-wrap the existing private key with a new password.",
|
|
1265
|
+
"NOT_SUPPORTED",
|
|
1266
|
+
501
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
493
1269
|
/**
|
|
494
1270
|
* Set authentication token directly (useful for server-side apps)
|
|
495
1271
|
*
|
|
@@ -546,20 +1322,8 @@ var Wolfronix = class {
|
|
|
546
1322
|
async encrypt(file, filename) {
|
|
547
1323
|
this.ensureAuthenticated();
|
|
548
1324
|
const formData = new FormData();
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
} else if (file instanceof Blob) {
|
|
552
|
-
formData.append("file", file, filename || "file");
|
|
553
|
-
} else if (file instanceof ArrayBuffer) {
|
|
554
|
-
const blob = new Blob([new Uint8Array(file)]);
|
|
555
|
-
formData.append("file", blob, filename || "file");
|
|
556
|
-
} else if (file instanceof Uint8Array) {
|
|
557
|
-
const arrayBuffer = file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength);
|
|
558
|
-
const blob = new Blob([arrayBuffer]);
|
|
559
|
-
formData.append("file", blob, filename || "file");
|
|
560
|
-
} else {
|
|
561
|
-
throw new ValidationError("Invalid file type. Expected File, Blob, Buffer, or ArrayBuffer");
|
|
562
|
-
}
|
|
1325
|
+
const blob = this.toBlob(file);
|
|
1326
|
+
formData.append("file", blob, filename || (file instanceof File ? file.name : "file"));
|
|
563
1327
|
formData.append("user_id", this.userId || "");
|
|
564
1328
|
if (!this.publicKeyPEM) {
|
|
565
1329
|
throw new Error("Public key not available. Is user logged in?");
|
|
@@ -573,6 +1337,65 @@ var Wolfronix = class {
|
|
|
573
1337
|
file_id: String(response.file_id)
|
|
574
1338
|
};
|
|
575
1339
|
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Resumable large-file encryption upload.
|
|
1342
|
+
* Splits a file into chunks (default 10MB) and uploads each chunk independently.
|
|
1343
|
+
* If upload fails mid-way, pass the returned state as `existingState` to resume.
|
|
1344
|
+
*/
|
|
1345
|
+
async encryptResumable(file, options = {}) {
|
|
1346
|
+
this.ensureAuthenticated();
|
|
1347
|
+
const chunkSize = options.chunkSizeBytes || 10 * 1024 * 1024;
|
|
1348
|
+
if (chunkSize < 1024 * 1024) {
|
|
1349
|
+
throw new ValidationError("chunkSizeBytes must be at least 1MB");
|
|
1350
|
+
}
|
|
1351
|
+
const blob = this.toBlob(file);
|
|
1352
|
+
const filename = options.filename || (file instanceof File ? file.name : "file.bin");
|
|
1353
|
+
const totalChunks = Math.ceil(blob.size / chunkSize);
|
|
1354
|
+
const baseUploadId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
1355
|
+
const state = options.existingState || {
|
|
1356
|
+
upload_id: baseUploadId,
|
|
1357
|
+
filename,
|
|
1358
|
+
file_size: blob.size,
|
|
1359
|
+
chunk_size_bytes: chunkSize,
|
|
1360
|
+
total_chunks: totalChunks,
|
|
1361
|
+
uploaded_chunks: [],
|
|
1362
|
+
chunk_file_ids: new Array(totalChunks).fill(""),
|
|
1363
|
+
created_at: Date.now(),
|
|
1364
|
+
updated_at: Date.now()
|
|
1365
|
+
};
|
|
1366
|
+
if (state.file_size !== blob.size || state.total_chunks !== totalChunks) {
|
|
1367
|
+
throw new ValidationError("existingState does not match current file/chunking settings");
|
|
1368
|
+
}
|
|
1369
|
+
const uploadedSet = new Set(state.uploaded_chunks);
|
|
1370
|
+
let uploaded = uploadedSet.size;
|
|
1371
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
1372
|
+
if (uploadedSet.has(i)) {
|
|
1373
|
+
continue;
|
|
1374
|
+
}
|
|
1375
|
+
const start = i * chunkSize;
|
|
1376
|
+
const end = Math.min(start + chunkSize, blob.size);
|
|
1377
|
+
const chunkBlob = blob.slice(start, end);
|
|
1378
|
+
const chunkName = `${filename}.part-${String(i + 1).padStart(6, "0")}-of-${String(totalChunks).padStart(6, "0")}`;
|
|
1379
|
+
const enc = await this.encrypt(chunkBlob, chunkName);
|
|
1380
|
+
state.chunk_file_ids[i] = enc.file_id;
|
|
1381
|
+
state.uploaded_chunks.push(i);
|
|
1382
|
+
state.updated_at = Date.now();
|
|
1383
|
+
uploaded++;
|
|
1384
|
+
if (options.onProgress) {
|
|
1385
|
+
options.onProgress(uploaded, totalChunks);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
const result = {
|
|
1389
|
+
upload_id: state.upload_id,
|
|
1390
|
+
filename: state.filename,
|
|
1391
|
+
total_chunks: state.total_chunks,
|
|
1392
|
+
chunk_size_bytes: state.chunk_size_bytes,
|
|
1393
|
+
uploaded_chunks: state.uploaded_chunks.length,
|
|
1394
|
+
chunk_file_ids: state.chunk_file_ids,
|
|
1395
|
+
complete: state.uploaded_chunks.length === state.total_chunks
|
|
1396
|
+
};
|
|
1397
|
+
return { result, state };
|
|
1398
|
+
}
|
|
576
1399
|
/**
|
|
577
1400
|
* Decrypt and retrieve a file using zero-knowledge flow.
|
|
578
1401
|
*
|
|
@@ -633,6 +1456,41 @@ var Wolfronix = class {
|
|
|
633
1456
|
}
|
|
634
1457
|
});
|
|
635
1458
|
}
|
|
1459
|
+
/**
|
|
1460
|
+
* Decrypts and reassembles a chunked upload produced by `encryptResumable`.
|
|
1461
|
+
*/
|
|
1462
|
+
async decryptChunkedToBuffer(manifest, role = "owner") {
|
|
1463
|
+
this.ensureAuthenticated();
|
|
1464
|
+
if (!manifest?.chunk_file_ids?.length) {
|
|
1465
|
+
throw new ValidationError("manifest.chunk_file_ids is required");
|
|
1466
|
+
}
|
|
1467
|
+
const chunks = [];
|
|
1468
|
+
let totalLength = 0;
|
|
1469
|
+
for (const fileId of manifest.chunk_file_ids) {
|
|
1470
|
+
if (!fileId) {
|
|
1471
|
+
throw new ValidationError("manifest contains empty chunk file ID");
|
|
1472
|
+
}
|
|
1473
|
+
const part = await this.decryptToBuffer(fileId, role);
|
|
1474
|
+
const bytes = new Uint8Array(part);
|
|
1475
|
+
chunks.push(bytes);
|
|
1476
|
+
totalLength += bytes.byteLength;
|
|
1477
|
+
}
|
|
1478
|
+
const merged = new Uint8Array(totalLength);
|
|
1479
|
+
let offset = 0;
|
|
1480
|
+
for (const part of chunks) {
|
|
1481
|
+
merged.set(part, offset);
|
|
1482
|
+
offset += part.byteLength;
|
|
1483
|
+
}
|
|
1484
|
+
return merged.buffer;
|
|
1485
|
+
}
|
|
1486
|
+
/**
|
|
1487
|
+
* Decrypts and reassembles a chunked upload into a Blob.
|
|
1488
|
+
* This is a browser-friendly alias over `decryptChunkedToBuffer`.
|
|
1489
|
+
*/
|
|
1490
|
+
async decryptChunkedManifest(manifest, role = "owner") {
|
|
1491
|
+
const merged = await this.decryptChunkedToBuffer(manifest, role);
|
|
1492
|
+
return new Blob([merged], { type: "application/octet-stream" });
|
|
1493
|
+
}
|
|
636
1494
|
/**
|
|
637
1495
|
* Fetch the encrypted key_part_a for a file (for client-side decryption)
|
|
638
1496
|
*
|
|
@@ -754,6 +1612,197 @@ var Wolfronix = class {
|
|
|
754
1612
|
throw new Error("Decryption failed. You may not be the intended recipient.");
|
|
755
1613
|
}
|
|
756
1614
|
}
|
|
1615
|
+
/**
|
|
1616
|
+
* Create/share a pre-key bundle for Double Ratchet PFS session setup.
|
|
1617
|
+
* Exchange this bundle out-of-band with the peer.
|
|
1618
|
+
*/
|
|
1619
|
+
async createPfsPreKeyBundle() {
|
|
1620
|
+
this.ensureAuthenticated();
|
|
1621
|
+
await this.ensurePfsIdentity();
|
|
1622
|
+
return {
|
|
1623
|
+
protocol: "wfx-dr-v1",
|
|
1624
|
+
user_id: this.userId || void 0,
|
|
1625
|
+
ratchet_pub_jwk: this.pfsIdentityPublicJwk,
|
|
1626
|
+
created_at: Date.now()
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
/**
|
|
1630
|
+
* Initialize a local PFS ratchet session from peer bundle.
|
|
1631
|
+
* Both sides must call this with opposite `asInitiator` values.
|
|
1632
|
+
*/
|
|
1633
|
+
async initPfsSession(sessionId, peerBundle, asInitiator) {
|
|
1634
|
+
this.ensureAuthenticated();
|
|
1635
|
+
if (!sessionId) {
|
|
1636
|
+
throw new ValidationError("sessionId is required");
|
|
1637
|
+
}
|
|
1638
|
+
if (!peerBundle || peerBundle.protocol !== PFS_PROTOCOL || !peerBundle.ratchet_pub_jwk) {
|
|
1639
|
+
throw new ValidationError("Invalid peerBundle");
|
|
1640
|
+
}
|
|
1641
|
+
await this.ensurePfsIdentity();
|
|
1642
|
+
const myPriv = this.pfsIdentityPrivateJwk;
|
|
1643
|
+
const myPub = this.pfsIdentityPublicJwk;
|
|
1644
|
+
const theirPub = peerBundle.ratchet_pub_jwk;
|
|
1645
|
+
const dh = await deriveEcdhSecret(myPriv, theirPub);
|
|
1646
|
+
const mixed = await deriveRootAndChains(toBase64(ZERO_32.buffer), dh);
|
|
1647
|
+
const session = {
|
|
1648
|
+
protocol: "wfx-dr-v1",
|
|
1649
|
+
session_id: sessionId,
|
|
1650
|
+
role: asInitiator ? "initiator" : "responder",
|
|
1651
|
+
root_key: mixed.rootKey,
|
|
1652
|
+
send_chain_key: asInitiator ? mixed.chainA : mixed.chainB,
|
|
1653
|
+
recv_chain_key: asInitiator ? mixed.chainB : mixed.chainA,
|
|
1654
|
+
send_count: 0,
|
|
1655
|
+
recv_count: 0,
|
|
1656
|
+
prev_send_count: 0,
|
|
1657
|
+
my_ratchet_private_jwk: myPriv,
|
|
1658
|
+
my_ratchet_public_jwk: myPub,
|
|
1659
|
+
their_ratchet_public_jwk: theirPub,
|
|
1660
|
+
skipped_keys: {},
|
|
1661
|
+
created_at: Date.now(),
|
|
1662
|
+
updated_at: Date.now()
|
|
1663
|
+
};
|
|
1664
|
+
this.pfsSessions.set(sessionId, session);
|
|
1665
|
+
return session;
|
|
1666
|
+
}
|
|
1667
|
+
/**
|
|
1668
|
+
* Export session state for persistence (e.g., localStorage/DB).
|
|
1669
|
+
*/
|
|
1670
|
+
exportPfsSession(sessionId) {
|
|
1671
|
+
const session = this.getPfsSession(sessionId);
|
|
1672
|
+
return JSON.parse(JSON.stringify(session));
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* Import session state from storage.
|
|
1676
|
+
*/
|
|
1677
|
+
importPfsSession(session) {
|
|
1678
|
+
if (!session || session.protocol !== PFS_PROTOCOL || !session.session_id) {
|
|
1679
|
+
throw new ValidationError("Invalid PFS session payload");
|
|
1680
|
+
}
|
|
1681
|
+
this.pfsSessions.set(session.session_id, JSON.parse(JSON.stringify(session)));
|
|
1682
|
+
}
|
|
1683
|
+
/**
|
|
1684
|
+
* Encrypt a message using Double Ratchet session state.
|
|
1685
|
+
*/
|
|
1686
|
+
async pfsEncryptMessage(sessionId, plaintext) {
|
|
1687
|
+
this.ensureAuthenticated();
|
|
1688
|
+
if (!plaintext) {
|
|
1689
|
+
throw new ValidationError("plaintext is required");
|
|
1690
|
+
}
|
|
1691
|
+
const session = this.getPfsSession(sessionId);
|
|
1692
|
+
await this.ratchetForSend(session);
|
|
1693
|
+
const n = session.send_count;
|
|
1694
|
+
const msgKey = await deriveMessageKey(session.send_chain_key, n);
|
|
1695
|
+
const enc = await encryptWithRawKey(msgKey, plaintext);
|
|
1696
|
+
session.send_chain_key = await deriveNextChainKey(session.send_chain_key);
|
|
1697
|
+
session.send_count += 1;
|
|
1698
|
+
session.updated_at = Date.now();
|
|
1699
|
+
return {
|
|
1700
|
+
v: 1,
|
|
1701
|
+
type: "pfs_ratchet",
|
|
1702
|
+
session_id: sessionId,
|
|
1703
|
+
n,
|
|
1704
|
+
pn: session.prev_send_count,
|
|
1705
|
+
ratchet_pub_jwk: session.my_ratchet_public_jwk,
|
|
1706
|
+
iv: enc.iv,
|
|
1707
|
+
ciphertext: enc.ciphertext,
|
|
1708
|
+
timestamp: Date.now()
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
/**
|
|
1712
|
+
* Decrypt a Double Ratchet packet for a session.
|
|
1713
|
+
* Handles basic out-of-order delivery through skipped message keys.
|
|
1714
|
+
*/
|
|
1715
|
+
async pfsDecryptMessage(sessionId, packet) {
|
|
1716
|
+
this.ensureAuthenticated();
|
|
1717
|
+
const session = this.getPfsSession(sessionId);
|
|
1718
|
+
const msg = typeof packet === "string" ? JSON.parse(packet) : packet;
|
|
1719
|
+
if (!msg || msg.type !== "pfs_ratchet" || msg.session_id !== sessionId) {
|
|
1720
|
+
throw new ValidationError("Invalid PFS message packet");
|
|
1721
|
+
}
|
|
1722
|
+
if (normalizeJwk(msg.ratchet_pub_jwk) !== normalizeJwk(session.their_ratchet_public_jwk)) {
|
|
1723
|
+
await this.ratchetForReceive(session, msg.ratchet_pub_jwk);
|
|
1724
|
+
}
|
|
1725
|
+
while (session.recv_count < msg.n) {
|
|
1726
|
+
const skippedKey = await deriveMessageKey(session.recv_chain_key, session.recv_count);
|
|
1727
|
+
session.skipped_keys[ratchetKeyId(session.their_ratchet_public_jwk, session.recv_count)] = toBase64(skippedKey);
|
|
1728
|
+
session.recv_chain_key = await deriveNextChainKey(session.recv_chain_key);
|
|
1729
|
+
session.recv_count += 1;
|
|
1730
|
+
}
|
|
1731
|
+
const skipId = ratchetKeyId(session.their_ratchet_public_jwk, msg.n);
|
|
1732
|
+
let msgKey;
|
|
1733
|
+
if (session.skipped_keys[skipId]) {
|
|
1734
|
+
msgKey = fromBase64(session.skipped_keys[skipId]);
|
|
1735
|
+
delete session.skipped_keys[skipId];
|
|
1736
|
+
} else {
|
|
1737
|
+
msgKey = await deriveMessageKey(session.recv_chain_key, msg.n);
|
|
1738
|
+
session.recv_chain_key = await deriveNextChainKey(session.recv_chain_key);
|
|
1739
|
+
session.recv_count = msg.n + 1;
|
|
1740
|
+
}
|
|
1741
|
+
session.updated_at = Date.now();
|
|
1742
|
+
return decryptWithRawKey(msgKey, msg.ciphertext, msg.iv);
|
|
1743
|
+
}
|
|
1744
|
+
/**
|
|
1745
|
+
* Group message encryption using sender-key fanout:
|
|
1746
|
+
* message encrypted once with AES key, AES key wrapped for each group member with their RSA public key.
|
|
1747
|
+
*/
|
|
1748
|
+
async encryptGroupMessage(text, groupId, recipientIds) {
|
|
1749
|
+
this.ensureAuthenticated();
|
|
1750
|
+
if (!text || !groupId) {
|
|
1751
|
+
throw new ValidationError("text and groupId are required");
|
|
1752
|
+
}
|
|
1753
|
+
if (!recipientIds?.length) {
|
|
1754
|
+
throw new ValidationError("recipientIds cannot be empty");
|
|
1755
|
+
}
|
|
1756
|
+
const uniqueRecipients = Array.from(new Set(recipientIds.filter(Boolean)));
|
|
1757
|
+
if (this.userId && !uniqueRecipients.includes(this.userId)) {
|
|
1758
|
+
uniqueRecipients.push(this.userId);
|
|
1759
|
+
}
|
|
1760
|
+
const sessionKey = await generateSessionKey();
|
|
1761
|
+
const { encrypted: ciphertext, iv } = await encryptData(text, sessionKey);
|
|
1762
|
+
const rawSessionKey = await exportSessionKey(sessionKey);
|
|
1763
|
+
const recipientKeys = {};
|
|
1764
|
+
for (const rid of uniqueRecipients) {
|
|
1765
|
+
const pem = await this.getPublicKey(rid);
|
|
1766
|
+
const pub = await importKeyFromPEM(pem, "public");
|
|
1767
|
+
recipientKeys[rid] = await rsaEncrypt(rawSessionKey, pub);
|
|
1768
|
+
}
|
|
1769
|
+
const packet = {
|
|
1770
|
+
v: 1,
|
|
1771
|
+
type: "group_sender_key",
|
|
1772
|
+
sender_id: this.userId || "",
|
|
1773
|
+
group_id: groupId,
|
|
1774
|
+
timestamp: Date.now(),
|
|
1775
|
+
ciphertext,
|
|
1776
|
+
iv,
|
|
1777
|
+
recipient_keys: recipientKeys
|
|
1778
|
+
};
|
|
1779
|
+
return JSON.stringify(packet);
|
|
1780
|
+
}
|
|
1781
|
+
/**
|
|
1782
|
+
* Decrypt a packet produced by `encryptGroupMessage`.
|
|
1783
|
+
*/
|
|
1784
|
+
async decryptGroupMessage(packetJson) {
|
|
1785
|
+
this.ensureAuthenticated();
|
|
1786
|
+
if (!this.privateKey || !this.userId) {
|
|
1787
|
+
throw new Error("Private key not available. Is user logged in?");
|
|
1788
|
+
}
|
|
1789
|
+
let packet;
|
|
1790
|
+
try {
|
|
1791
|
+
packet = JSON.parse(packetJson);
|
|
1792
|
+
} catch {
|
|
1793
|
+
throw new ValidationError("Invalid group packet format");
|
|
1794
|
+
}
|
|
1795
|
+
if (packet.type !== "group_sender_key" || !packet.recipient_keys || !packet.ciphertext || !packet.iv) {
|
|
1796
|
+
throw new ValidationError("Invalid group packet structure");
|
|
1797
|
+
}
|
|
1798
|
+
const wrappedKey = packet.recipient_keys[this.userId];
|
|
1799
|
+
if (!wrappedKey) {
|
|
1800
|
+
throw new PermissionDeniedError("You are not a recipient of this group message");
|
|
1801
|
+
}
|
|
1802
|
+
const rawSessionKey = await rsaDecrypt(wrappedKey, this.privateKey);
|
|
1803
|
+
const sessionKey = await importSessionKey(rawSessionKey);
|
|
1804
|
+
return decryptData(packet.ciphertext, packet.iv, sessionKey);
|
|
1805
|
+
}
|
|
757
1806
|
// ==========================================================================
|
|
758
1807
|
// Server-Side Message Encryption (Dual-Key Split)
|
|
759
1808
|
// ==========================================================================
|