@vex-chat/libvex 7.4.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.
- package/dist/Client.d.ts +39 -24
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +194 -124
- package/dist/Client.js.map +1 -1
- package/dist/codecs.d.ts +8 -4
- package/dist/codecs.d.ts.map +1 -1
- package/dist/codecs.js +2 -5
- package/dist/codecs.js.map +1 -1
- package/dist/http.js +15 -1
- package/dist/http.js.map +1 -1
- package/dist/keystore/node.d.ts.map +1 -1
- package/dist/keystore/node.js +9 -11
- package/dist/keystore/node.js.map +1 -1
- package/dist/storage/sqlite.d.ts +6 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +89 -56
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/utils/verifyKeyBundle.d.ts.map +1 -1
- package/dist/utils/verifyKeyBundle.js +7 -10
- package/dist/utils/verifyKeyBundle.js.map +1 -1
- package/package.json +3 -3
- package/src/Client.ts +310 -173
- package/src/__tests__/harness/shared-suite.ts +82 -11
- package/src/__tests__/http.test.ts +22 -0
- package/src/__tests__/node-keystore.test.ts +69 -0
- package/src/__tests__/prekey-reuse.test.ts +13 -13
- package/src/__tests__/storage-sqlite.test.ts +90 -1
- package/src/__tests__/verifyKeyBundle.test.ts +11 -8
- package/src/codecs.ts +2 -5
- package/src/http.ts +16 -1
- package/src/keystore/node.ts +17 -19
- package/src/storage/sqlite.ts +109 -84
- package/src/utils/verifyKeyBundle.ts +13 -13
package/src/storage/sqlite.ts
CHANGED
|
@@ -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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
.
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
:
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1037
|
-
|
|
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
|
|
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.
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
56
|
+
kind: "one-time" | "signed",
|
|
60
57
|
): Promise<void> {
|
|
61
58
|
if (entry.deviceID !== device.deviceID) {
|
|
62
|
-
throw new Error(
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Key bundle ${kind} prekey belongs to a different device.`,
|
|
61
|
+
);
|
|
63
62
|
}
|
|
64
63
|
|
|
65
|
-
const payload =
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 ${
|
|
72
|
+
throw new Error(`Key bundle ${kind} prekey signature is invalid.`);
|
|
73
73
|
}
|
|
74
74
|
}
|