@vex-chat/libvex 8.0.0 → 9.0.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.
@@ -55,6 +55,10 @@ import { effectiveMessageRetentionHintDays } from "../retention.js";
55
55
  import { parseSkippedKeysStrict } from "../utils/ratchet.js";
56
56
 
57
57
  const STORAGE_MESSAGE_BLOB_PREFIX = "vex-storage-message:1\n";
58
+ const STORAGE_CIPHERTEXT_PREFIX = "vex-storage-cipher:2:";
59
+ const STORAGE_SECRET_TEXT_PREFIX = "vex-storage-secret:1:";
60
+ const STORAGE_NONCE_BYTES = 24;
61
+ const STORAGE_MAC_BYTES = 16;
58
62
 
59
63
  export class SqliteStorage extends EventEmitter implements Storage {
60
64
  public ready = false;
@@ -408,10 +412,10 @@ export class SqliteStorage extends EventEmitter implements Storage {
408
412
  this.ready = true;
409
413
  this.emit("ready");
410
414
  } catch (err: unknown) {
411
- this.emit(
412
- "error",
413
- err instanceof Error ? err : new Error(String(err)),
414
- );
415
+ const error =
416
+ err instanceof Error ? err : new Error(String(err));
417
+ this.emit("error", error);
418
+ throw error;
415
419
  } finally {
416
420
  this.initInFlight = null;
417
421
  }
@@ -526,14 +530,25 @@ export class SqliteStorage extends EventEmitter implements Storage {
526
530
  return;
527
531
  }
528
532
  await this.untilReady();
533
+ if (this.isClosingNow()) {
534
+ return;
535
+ }
529
536
 
530
537
  // Fan-out to multiple devices reuses one `mailID` but each encrypt path
531
538
  // uses a fresh nonce (table PK). Keep a single local row per logical mail.
532
- const dupe = await this.db
533
- .selectFrom("messages")
534
- .select("nonce")
535
- .where("mailID", "=", message.mailID)
536
- .executeTakeFirst();
539
+ let dupe: undefined | { nonce: string };
540
+ try {
541
+ dupe = await this.db
542
+ .selectFrom("messages")
543
+ .select("nonce")
544
+ .where("mailID", "=", message.mailID)
545
+ .executeTakeFirst();
546
+ } catch (err: unknown) {
547
+ if (this.isClosingNow() || this.isTornDownError(err)) {
548
+ return;
549
+ }
550
+ throw err;
551
+ }
537
552
  if (dupe !== undefined) {
538
553
  return;
539
554
  }
@@ -543,22 +558,9 @@ export class SqliteStorage extends EventEmitter implements Storage {
543
558
  !message.decrypted &&
544
559
  message.message === "" &&
545
560
  message.extra === undefined;
546
- const fips = getCryptoProfile() === "fips";
547
561
  const encryptedMessage = isPlaintextFailurePlaceholder
548
562
  ? storedPlaintext
549
- : XUtils.encodeHex(
550
- fips
551
- ? await xSecretboxAsync(
552
- XUtils.decodeUTF8(storedPlaintext),
553
- XUtils.decodeHex(message.nonce),
554
- this.atRestAesKey,
555
- )
556
- : xSecretbox(
557
- XUtils.decodeUTF8(storedPlaintext),
558
- XUtils.decodeHex(message.nonce),
559
- this.atRestAesKey,
560
- ),
561
- );
563
+ : await this.sealMessagePlaintext(storedPlaintext);
562
564
  if (this.isClosingNow()) {
563
565
  return;
564
566
  }
@@ -656,6 +658,9 @@ export class SqliteStorage extends EventEmitter implements Storage {
656
658
  const sealedDHsPrivate = await this.sealHex(session.DHsPrivate);
657
659
  const sealedRK = await this.sealHex(session.RK);
658
660
  const sealedSK = await this.sealHex(session.SK);
661
+ const sealedSkippedKeys = await this.sealSecretText(
662
+ session.skippedKeys,
663
+ );
659
664
  try {
660
665
  await this.db
661
666
  .insertInto("sessions")
@@ -676,7 +681,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
676
681
  RK: sealedRK,
677
682
  sessionID: session.sessionID,
678
683
  SK: sealedSK,
679
- skippedKeys: session.skippedKeys,
684
+ skippedKeys: sealedSkippedKeys,
680
685
  userID: session.userID,
681
686
  verified: session.verified ? 1 : 0,
682
687
  })
@@ -701,7 +706,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
701
706
  publicKey: session.publicKey,
702
707
  RK: sealedRK,
703
708
  SK: sealedSK,
704
- skippedKeys: session.skippedKeys,
709
+ skippedKeys: sealedSkippedKeys,
705
710
  userID: session.userID,
706
711
  verified: session.verified ? 1 : 0,
707
712
  })
@@ -749,18 +754,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
749
754
  : {}),
750
755
  };
751
756
  const storedPlaintext = encodeStoredMessagePlaintext(next);
752
- const fips = getCryptoProfile() === "fips";
753
- const ct = fips
754
- ? await xSecretboxAsync(
755
- XUtils.decodeUTF8(storedPlaintext),
756
- XUtils.decodeHex(row.nonce),
757
- this.atRestAesKey,
758
- )
759
- : xSecretbox(
760
- XUtils.decodeUTF8(storedPlaintext),
761
- XUtils.decodeHex(row.nonce),
762
- this.atRestAesKey,
763
- );
757
+ const sealedMessage = await this.sealMessagePlaintext(storedPlaintext);
764
758
  if (this.isClosingNow()) {
765
759
  return false;
766
760
  }
@@ -768,7 +762,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
768
762
  .updateTable("messages")
769
763
  .set({
770
764
  extra: null,
771
- message: XUtils.encodeHex(ct),
765
+ message: sealedMessage,
772
766
  })
773
767
  .where("mailID", "=", mailID)
774
768
  .executeTakeFirst();
@@ -780,7 +774,6 @@ export class SqliteStorage extends EventEmitter implements Storage {
780
774
  private async decryptMessagesAsync(
781
775
  messages: MessageRow[],
782
776
  ): Promise<Message[]> {
783
- const fips = getCryptoProfile() === "fips";
784
777
  const out: Message[] = [];
785
778
  let processed = 0;
786
779
  /** Yield so RN / web UIs can paint between at-rest decrypt blocks. */
@@ -796,20 +789,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
796
789
  const isPlaintextFailurePlaceholder =
797
790
  !decryptedFlag && msg.message === "" && msg.extra === null;
798
791
  if (!isPlaintextFailurePlaceholder) {
799
- const cipher = XUtils.decodeHex(msg.message);
800
- const nonce = XUtils.decodeHex(msg.nonce);
801
- const decrypted = fips
802
- ? await xSecretboxOpenAsync(
803
- cipher,
804
- nonce,
805
- this.atRestAesKey,
806
- )
807
- : xSecretboxOpen(cipher, nonce, this.atRestAesKey);
808
- if (decrypted) {
809
- plaintext = XUtils.encodeUTF8(decrypted);
810
- } else {
811
- throw new Error("Couldn't decrypt messages on disk!");
812
- }
792
+ plaintext = await this.openMessagePlaintext(msg.message);
813
793
  }
814
794
  const direction =
815
795
  msg.direction === "incoming" ? "incoming" : "outgoing";
@@ -955,26 +935,52 @@ export class SqliteStorage extends EventEmitter implements Storage {
955
935
  return false;
956
936
  }
957
937
 
958
- /**
959
- * Encrypt a hex-encoded secret for at-rest storage.
960
- * Returns hex(nonce || ciphertext) where nonce is 24 random bytes.
961
- */
962
- private async sealHex(plainHex: string): Promise<string> {
938
+ private async openMessagePlaintext(stored: string): Promise<string> {
939
+ if (!stored.startsWith(STORAGE_CIPHERTEXT_PREFIX)) {
940
+ throw new Error(
941
+ "Stored message is missing its encryption envelope.",
942
+ );
943
+ }
944
+ const plain = await this.unsealBytes(
945
+ stored.slice(STORAGE_CIPHERTEXT_PREFIX.length),
946
+ );
947
+ return XUtils.encodeUTF8(plain);
948
+ }
949
+
950
+ private async sealBytes(plaintext: Uint8Array): Promise<string> {
963
951
  const nonce = xMakeNonce();
964
952
  const fips = getCryptoProfile() === "fips";
965
953
  const ct = fips
966
- ? await xSecretboxAsync(
967
- XUtils.decodeHex(plainHex),
968
- nonce,
969
- this.atRestAesKey,
970
- )
971
- : xSecretbox(XUtils.decodeHex(plainHex), nonce, this.atRestAesKey);
954
+ ? await xSecretboxAsync(plaintext, nonce, this.atRestAesKey)
955
+ : xSecretbox(plaintext, nonce, this.atRestAesKey);
972
956
  const sealed = new Uint8Array(nonce.length + ct.length);
973
957
  sealed.set(nonce);
974
958
  sealed.set(ct, nonce.length);
975
959
  return XUtils.encodeHex(sealed);
976
960
  }
977
961
 
962
+ /**
963
+ * Encrypt a hex-encoded secret for at-rest storage.
964
+ * Returns hex(nonce || ciphertext) where nonce is 24 random bytes.
965
+ */
966
+ private async sealHex(plainHex: string): Promise<string> {
967
+ return this.sealBytes(XUtils.decodeHex(plainHex));
968
+ }
969
+
970
+ private async sealMessagePlaintext(plaintext: string): Promise<string> {
971
+ return (
972
+ STORAGE_CIPHERTEXT_PREFIX +
973
+ (await this.sealBytes(XUtils.decodeUTF8(plaintext)))
974
+ );
975
+ }
976
+
977
+ private async sealSecretText(plaintext: string): Promise<string> {
978
+ return (
979
+ STORAGE_SECRET_TEXT_PREFIX +
980
+ (await this.sealBytes(XUtils.decodeUTF8(plaintext)))
981
+ );
982
+ }
983
+
978
984
  private async sessionRowToSQLAsync(row: SessionRow): Promise<SessionSQL> {
979
985
  const rawSK = await this.unsealHex(row.SK);
980
986
  const rawRK = row.RK ? await this.unsealHex(row.RK) : rawSK;
@@ -997,7 +1003,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
997
1003
  RK: rawRK,
998
1004
  sessionID: row.sessionID,
999
1005
  SK: rawSK,
1000
- skippedKeys: row.skippedKeys,
1006
+ skippedKeys: await this.unsealSecretText(row.skippedKeys),
1001
1007
  userID: row.userID,
1002
1008
  verified: row.verified !== 0,
1003
1009
  };
@@ -1027,14 +1033,13 @@ export class SqliteStorage extends EventEmitter implements Storage {
1027
1033
  };
1028
1034
  }
1029
1035
 
1030
- /**
1031
- * Decrypt a value produced by sealHex().
1032
- * Expects hex(nonce || ciphertext), returns the original hex string.
1033
- */
1034
- private async unsealHex(sealed: string): Promise<string> {
1036
+ private async unsealBytes(sealed: string): Promise<Uint8Array> {
1035
1037
  const bytes = XUtils.decodeHex(sealed);
1036
- const nonce = bytes.slice(0, 24);
1037
- const ct = bytes.slice(24);
1038
+ if (bytes.length < STORAGE_NONCE_BYTES + STORAGE_MAC_BYTES) {
1039
+ throw new Error("Sealed column value is truncated.");
1040
+ }
1041
+ const nonce = bytes.slice(0, STORAGE_NONCE_BYTES);
1042
+ const ct = bytes.slice(STORAGE_NONCE_BYTES);
1038
1043
  const fips = getCryptoProfile() === "fips";
1039
1044
  const plain = fips
1040
1045
  ? await xSecretboxOpenAsync(ct, nonce, this.atRestAesKey)
@@ -1042,21 +1047,41 @@ export class SqliteStorage extends EventEmitter implements Storage {
1042
1047
  if (!plain) {
1043
1048
  throw new Error("Failed to decrypt sealed column value.");
1044
1049
  }
1045
- return XUtils.encodeHex(plain);
1050
+ return plain;
1051
+ }
1052
+
1053
+ /**
1054
+ * Decrypt a value produced by sealHex().
1055
+ * Expects hex(nonce || ciphertext), returns the original hex string.
1056
+ */
1057
+ private async unsealHex(sealed: string): Promise<string> {
1058
+ return XUtils.encodeHex(await this.unsealBytes(sealed));
1059
+ }
1060
+
1061
+ private async unsealSecretText(stored: string): Promise<string> {
1062
+ if (!stored.startsWith(STORAGE_SECRET_TEXT_PREFIX)) {
1063
+ throw new Error(
1064
+ "Stored secret is missing its encryption envelope.",
1065
+ );
1066
+ }
1067
+ const plain = await this.unsealBytes(
1068
+ stored.slice(STORAGE_SECRET_TEXT_PREFIX.length),
1069
+ );
1070
+ return XUtils.encodeUTF8(plain);
1046
1071
  }
1047
1072
 
1048
1073
  private async untilReady(): Promise<void> {
1049
- if (this.ready) return;
1050
- return new Promise((resolve) => {
1051
- const check = () => {
1052
- if (this.ready) {
1053
- resolve();
1054
- return;
1055
- }
1056
- setTimeout(check, 10);
1057
- };
1058
- check();
1059
- });
1074
+ if (this.closing) {
1075
+ throw new Error("SqliteStorage is closed.");
1076
+ }
1077
+ if (this.ready) {
1078
+ return;
1079
+ }
1080
+ if (this.initInFlight) {
1081
+ await this.initInFlight;
1082
+ } else {
1083
+ await this.init();
1084
+ }
1060
1085
  }
1061
1086
  }
1062
1087
 
@@ -9,14 +9,11 @@ import type { Device, KeyBundle, KeyBundleEntry } from "@vex-chat/types";
9
9
 
10
10
  import {
11
11
  fipsEcdhRawPublicKeyFromEcdsaSpkiAsync,
12
- xConstants,
13
- xEncode,
12
+ xPreKeySignaturePayload,
14
13
  xSignOpenAsync,
15
14
  XUtils,
16
15
  } from "@vex-chat/crypto";
17
16
 
18
- import { fipsP256PreKeySignPayload } from "./fipsMailExtra.js";
19
-
20
17
  export async function verifyKeyBundleSignatures(
21
18
  keyBundle: KeyBundle,
22
19
  device: Device,
@@ -37,7 +34,7 @@ export async function verifyKeyBundleSignatures(
37
34
  device,
38
35
  deviceSignKey,
39
36
  cryptoProfile,
40
- "signed prekey",
37
+ "signed",
41
38
  );
42
39
 
43
40
  if (keyBundle.otk) {
@@ -46,7 +43,7 @@ export async function verifyKeyBundleSignatures(
46
43
  device,
47
44
  deviceSignKey,
48
45
  cryptoProfile,
49
- "one-time prekey",
46
+ "one-time",
50
47
  );
51
48
  }
52
49
  }
@@ -56,19 +53,22 @@ async function verifyKeyBundleEntrySignature(
56
53
  device: Device,
57
54
  deviceSignKey: Uint8Array,
58
55
  cryptoProfile: CryptoProfile,
59
- label: string,
56
+ kind: "one-time" | "signed",
60
57
  ): Promise<void> {
61
58
  if (entry.deviceID !== device.deviceID) {
62
- throw new Error(`Key bundle ${label} belongs to a different device.`);
59
+ throw new Error(
60
+ `Key bundle ${kind} prekey belongs to a different device.`,
61
+ );
63
62
  }
64
63
 
65
- const payload =
66
- cryptoProfile === "fips"
67
- ? fipsP256PreKeySignPayload(entry.publicKey)
68
- : xEncode(xConstants.CURVE, entry.publicKey);
64
+ const payload = xPreKeySignaturePayload(
65
+ entry.publicKey,
66
+ kind,
67
+ cryptoProfile,
68
+ );
69
69
  const opened = await xSignOpenAsync(entry.signature, deviceSignKey);
70
70
 
71
71
  if (!opened || !XUtils.bytesEqual(opened, payload)) {
72
- throw new Error(`Key bundle ${label} signature is invalid.`);
72
+ throw new Error(`Key bundle ${kind} prekey signature is invalid.`);
73
73
  }
74
74
  }