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