@vex-chat/libvex 8.0.0 → 9.1.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 +54 -21
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +292 -164
- 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 +139 -113
- 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 +476 -219
- package/src/__tests__/harness/shared-suite.ts +110 -11
- package/src/__tests__/http.test.ts +22 -0
- package/src/__tests__/multi-device-delivery.test.ts +147 -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 +117 -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 +162 -139
- package/src/utils/verifyKeyBundle.ts +13 -13
|
@@ -10,9 +10,8 @@ import type { Device, KeyBundle, KeyBundleEntry } from "@vex-chat/types";
|
|
|
10
10
|
import {
|
|
11
11
|
setCryptoProfile,
|
|
12
12
|
xBoxKeyPairAsync,
|
|
13
|
-
xConstants,
|
|
14
13
|
xEcdhKeyPairFromEcdsaKeyPairAsync,
|
|
15
|
-
|
|
14
|
+
xPreKeySignaturePayload,
|
|
16
15
|
xSignAsync,
|
|
17
16
|
xSignKeyPairAsync,
|
|
18
17
|
XUtils,
|
|
@@ -20,7 +19,6 @@ import {
|
|
|
20
19
|
|
|
21
20
|
import { afterEach, describe, expect, it } from "vitest";
|
|
22
21
|
|
|
23
|
-
import { fipsP256PreKeySignPayload } from "../utils/fipsMailExtra.js";
|
|
24
22
|
import { verifyKeyBundleSignatures } from "../utils/verifyKeyBundle.js";
|
|
25
23
|
|
|
26
24
|
describe.sequential("verifyKeyBundleSignatures", () => {
|
|
@@ -126,7 +124,13 @@ async function makeBundle(
|
|
|
126
124
|
};
|
|
127
125
|
|
|
128
126
|
const keyBundle: KeyBundle = {
|
|
129
|
-
preKey: await makeBundleEntry(
|
|
127
|
+
preKey: await makeBundleEntry(
|
|
128
|
+
signKeys,
|
|
129
|
+
profile,
|
|
130
|
+
device.deviceID,
|
|
131
|
+
1,
|
|
132
|
+
"signed",
|
|
133
|
+
),
|
|
130
134
|
signKey: identityPublic,
|
|
131
135
|
};
|
|
132
136
|
if (includeOtk) {
|
|
@@ -135,6 +139,7 @@ async function makeBundle(
|
|
|
135
139
|
profile,
|
|
136
140
|
device.deviceID,
|
|
137
141
|
2,
|
|
142
|
+
"one-time",
|
|
138
143
|
);
|
|
139
144
|
}
|
|
140
145
|
|
|
@@ -146,12 +151,10 @@ async function makeBundleEntry(
|
|
|
146
151
|
profile: CryptoProfile,
|
|
147
152
|
deviceID: string,
|
|
148
153
|
index: number,
|
|
154
|
+
kind: "one-time" | "signed",
|
|
149
155
|
): Promise<KeyBundleEntry> {
|
|
150
156
|
const preKey = await xBoxKeyPairAsync();
|
|
151
|
-
const payload =
|
|
152
|
-
profile === "fips"
|
|
153
|
-
? fipsP256PreKeySignPayload(preKey.publicKey)
|
|
154
|
-
: xEncode(xConstants.CURVE, preKey.publicKey);
|
|
157
|
+
const payload = xPreKeySignaturePayload(preKey.publicKey, kind, profile);
|
|
155
158
|
return {
|
|
156
159
|
deviceID,
|
|
157
160
|
index,
|
package/src/codecs.ts
CHANGED
|
@@ -93,11 +93,7 @@ export const RegisterPendingApprovalCodec = createCodec(
|
|
|
93
93
|
expiresAt: z.string(),
|
|
94
94
|
requestID: z.string(),
|
|
95
95
|
status: z.literal("pending_approval"),
|
|
96
|
-
|
|
97
|
-
// surface the existing user's ID in this response. When present,
|
|
98
|
-
// clients can use it to fetch the unauthenticated avatar and
|
|
99
|
-
// show an "is this you?" confirmation before proceeding.
|
|
100
|
-
userID: z.string().optional(),
|
|
96
|
+
userID: z.string(),
|
|
101
97
|
}),
|
|
102
98
|
);
|
|
103
99
|
|
|
@@ -116,6 +112,7 @@ export const DeviceRegistrationResultCodec = createCodec(
|
|
|
116
112
|
expiresAt: z.string(),
|
|
117
113
|
requestID: z.string(),
|
|
118
114
|
status: z.literal("pending_approval"),
|
|
115
|
+
userID: z.string(),
|
|
119
116
|
}),
|
|
120
117
|
]),
|
|
121
118
|
);
|
package/src/http.ts
CHANGED
|
@@ -148,7 +148,7 @@ export class FetchHttpClient {
|
|
|
148
148
|
maybeReportUploadProgress(data, config.onUploadProgress);
|
|
149
149
|
|
|
150
150
|
const requestRecord: HttpRequestRecord = {
|
|
151
|
-
headers:
|
|
151
|
+
headers: headersToSafeRecord(headers),
|
|
152
152
|
method,
|
|
153
153
|
url,
|
|
154
154
|
};
|
|
@@ -393,6 +393,21 @@ function headersToRecord(headers: Headers): Record<string, string> {
|
|
|
393
393
|
return out;
|
|
394
394
|
}
|
|
395
395
|
|
|
396
|
+
function headersToSafeRecord(headers: Headers): Record<string, string> {
|
|
397
|
+
const sensitive = new Set([
|
|
398
|
+
"authorization",
|
|
399
|
+
"cookie",
|
|
400
|
+
"proxy-authorization",
|
|
401
|
+
"x-api-key",
|
|
402
|
+
"x-device-token",
|
|
403
|
+
]);
|
|
404
|
+
const out: Record<string, string> = {};
|
|
405
|
+
headers.forEach((value, key) => {
|
|
406
|
+
out[key] = sensitive.has(key.toLowerCase()) ? "[REDACTED]" : value;
|
|
407
|
+
});
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
|
|
396
411
|
function isFormDataValue(value: unknown): value is FormData {
|
|
397
412
|
return typeof FormData !== "undefined" && value instanceof FormData;
|
|
398
413
|
}
|
package/src/keystore/node.ts
CHANGED
|
@@ -15,7 +15,7 @@ import * as path from "node:path";
|
|
|
15
15
|
* Stores credentials as encrypted files on disk using XUtils.encryptKeyData.
|
|
16
16
|
* Node-only — imports node:fs.
|
|
17
17
|
*/
|
|
18
|
-
import {
|
|
18
|
+
import { XUtils } from "@vex-chat/crypto";
|
|
19
19
|
|
|
20
20
|
export class NodeKeyStore implements KeyStore {
|
|
21
21
|
private readonly dir: string;
|
|
@@ -65,16 +65,18 @@ export class NodeKeyStore implements KeyStore {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
async save(creds: StoredCredentials): Promise<void> {
|
|
68
|
-
const data = JSON.stringify(creds);
|
|
69
|
-
const encrypted =
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
68
|
+
const data = XUtils.encodeHex(XUtils.decodeUTF8(JSON.stringify(creds)));
|
|
69
|
+
const encrypted = await XUtils.encryptKeyDataAsync(
|
|
70
|
+
this.passphrase,
|
|
71
|
+
data,
|
|
72
|
+
);
|
|
73
|
+
const filePath = this.filePath(creds.username);
|
|
74
|
+
fs.writeFileSync(filePath, encrypted, { mode: 0o600 });
|
|
75
|
+
fs.chmodSync(filePath, 0o600);
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
private filePath(username: string): string {
|
|
77
|
-
return path.join(this.dir, `${username}.vex`);
|
|
79
|
+
return path.join(this.dir, `${encodeURIComponent(username)}.vex`);
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
private async readFile(
|
|
@@ -82,17 +84,13 @@ export class NodeKeyStore implements KeyStore {
|
|
|
82
84
|
): Promise<null | StoredCredentials> {
|
|
83
85
|
try {
|
|
84
86
|
const data = fs.readFileSync(filePath);
|
|
85
|
-
const decrypted =
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
new Uint8Array(data),
|
|
93
|
-
this.passphrase,
|
|
94
|
-
);
|
|
95
|
-
const parsed: unknown = JSON.parse(decrypted);
|
|
87
|
+
const decrypted = await XUtils.decryptKeyDataAsync(
|
|
88
|
+
new Uint8Array(data),
|
|
89
|
+
this.passphrase,
|
|
90
|
+
);
|
|
91
|
+
const parsed: unknown = JSON.parse(
|
|
92
|
+
XUtils.encodeUTF8(XUtils.decodeHex(decrypted)),
|
|
93
|
+
);
|
|
96
94
|
if (isStoredCredentials(parsed)) {
|
|
97
95
|
return parsed;
|
|
98
96
|
}
|
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,61 +658,56 @@ 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);
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
.
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
.execute();
|
|
710
|
-
} else {
|
|
711
|
-
throw err;
|
|
712
|
-
}
|
|
713
|
-
}
|
|
661
|
+
const sealedSkippedKeys = await this.sealSecretText(
|
|
662
|
+
session.skippedKeys,
|
|
663
|
+
);
|
|
664
|
+
const values = {
|
|
665
|
+
CKr: sealedCKr,
|
|
666
|
+
CKs: sealedCKs,
|
|
667
|
+
deviceID: session.deviceID,
|
|
668
|
+
DHr: session.DHr,
|
|
669
|
+
DHsPrivate: sealedDHsPrivate,
|
|
670
|
+
DHsPublic: session.DHsPublic,
|
|
671
|
+
fingerprint: session.fingerprint,
|
|
672
|
+
lastUsed: session.lastUsed,
|
|
673
|
+
mode: session.mode,
|
|
674
|
+
Nr: session.Nr,
|
|
675
|
+
Ns: session.Ns,
|
|
676
|
+
PN: session.PN,
|
|
677
|
+
publicKey: session.publicKey,
|
|
678
|
+
RK: sealedRK,
|
|
679
|
+
sessionID: session.sessionID,
|
|
680
|
+
SK: sealedSK,
|
|
681
|
+
skippedKeys: sealedSkippedKeys,
|
|
682
|
+
userID: session.userID,
|
|
683
|
+
verified: session.verified ? 1 : 0,
|
|
684
|
+
};
|
|
685
|
+
await this.db
|
|
686
|
+
.insertInto("sessions")
|
|
687
|
+
.values(values)
|
|
688
|
+
.onConflict((conflict) =>
|
|
689
|
+
conflict.column("sessionID").doUpdateSet({
|
|
690
|
+
CKr: values.CKr,
|
|
691
|
+
CKs: values.CKs,
|
|
692
|
+
deviceID: values.deviceID,
|
|
693
|
+
DHr: values.DHr,
|
|
694
|
+
DHsPrivate: values.DHsPrivate,
|
|
695
|
+
DHsPublic: values.DHsPublic,
|
|
696
|
+
fingerprint: values.fingerprint,
|
|
697
|
+
lastUsed: values.lastUsed,
|
|
698
|
+
mode: values.mode,
|
|
699
|
+
Nr: values.Nr,
|
|
700
|
+
Ns: values.Ns,
|
|
701
|
+
PN: values.PN,
|
|
702
|
+
publicKey: values.publicKey,
|
|
703
|
+
RK: values.RK,
|
|
704
|
+
SK: values.SK,
|
|
705
|
+
skippedKeys: values.skippedKeys,
|
|
706
|
+
userID: values.userID,
|
|
707
|
+
verified: values.verified,
|
|
708
|
+
}),
|
|
709
|
+
)
|
|
710
|
+
.execute();
|
|
714
711
|
}
|
|
715
712
|
|
|
716
713
|
async updateMessage(
|
|
@@ -749,18 +746,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
749
746
|
: {}),
|
|
750
747
|
};
|
|
751
748
|
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
|
-
);
|
|
749
|
+
const sealedMessage = await this.sealMessagePlaintext(storedPlaintext);
|
|
764
750
|
if (this.isClosingNow()) {
|
|
765
751
|
return false;
|
|
766
752
|
}
|
|
@@ -768,7 +754,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
768
754
|
.updateTable("messages")
|
|
769
755
|
.set({
|
|
770
756
|
extra: null,
|
|
771
|
-
message:
|
|
757
|
+
message: sealedMessage,
|
|
772
758
|
})
|
|
773
759
|
.where("mailID", "=", mailID)
|
|
774
760
|
.executeTakeFirst();
|
|
@@ -780,7 +766,6 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
780
766
|
private async decryptMessagesAsync(
|
|
781
767
|
messages: MessageRow[],
|
|
782
768
|
): Promise<Message[]> {
|
|
783
|
-
const fips = getCryptoProfile() === "fips";
|
|
784
769
|
const out: Message[] = [];
|
|
785
770
|
let processed = 0;
|
|
786
771
|
/** Yield so RN / web UIs can paint between at-rest decrypt blocks. */
|
|
@@ -796,20 +781,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
796
781
|
const isPlaintextFailurePlaceholder =
|
|
797
782
|
!decryptedFlag && msg.message === "" && msg.extra === null;
|
|
798
783
|
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
|
-
}
|
|
784
|
+
plaintext = await this.openMessagePlaintext(msg.message);
|
|
813
785
|
}
|
|
814
786
|
const direction =
|
|
815
787
|
msg.direction === "incoming" ? "incoming" : "outgoing";
|
|
@@ -930,8 +902,14 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
930
902
|
}
|
|
931
903
|
|
|
932
904
|
private isDuplicateError(err: unknown): boolean {
|
|
933
|
-
|
|
934
|
-
|
|
905
|
+
const message =
|
|
906
|
+
err instanceof Error
|
|
907
|
+
? err.message
|
|
908
|
+
: typeof err === "string"
|
|
909
|
+
? err
|
|
910
|
+
: "";
|
|
911
|
+
if (message.toUpperCase().includes("UNIQUE")) {
|
|
912
|
+
return true;
|
|
935
913
|
}
|
|
936
914
|
if (typeof err === "object" && err !== null && "errno" in err) {
|
|
937
915
|
return err.errno === 19;
|
|
@@ -955,26 +933,52 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
955
933
|
return false;
|
|
956
934
|
}
|
|
957
935
|
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
936
|
+
private async openMessagePlaintext(stored: string): Promise<string> {
|
|
937
|
+
if (!stored.startsWith(STORAGE_CIPHERTEXT_PREFIX)) {
|
|
938
|
+
throw new Error(
|
|
939
|
+
"Stored message is missing its encryption envelope.",
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
const plain = await this.unsealBytes(
|
|
943
|
+
stored.slice(STORAGE_CIPHERTEXT_PREFIX.length),
|
|
944
|
+
);
|
|
945
|
+
return XUtils.encodeUTF8(plain);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
private async sealBytes(plaintext: Uint8Array): Promise<string> {
|
|
963
949
|
const nonce = xMakeNonce();
|
|
964
950
|
const fips = getCryptoProfile() === "fips";
|
|
965
951
|
const ct = fips
|
|
966
|
-
? await xSecretboxAsync(
|
|
967
|
-
|
|
968
|
-
nonce,
|
|
969
|
-
this.atRestAesKey,
|
|
970
|
-
)
|
|
971
|
-
: xSecretbox(XUtils.decodeHex(plainHex), nonce, this.atRestAesKey);
|
|
952
|
+
? await xSecretboxAsync(plaintext, nonce, this.atRestAesKey)
|
|
953
|
+
: xSecretbox(plaintext, nonce, this.atRestAesKey);
|
|
972
954
|
const sealed = new Uint8Array(nonce.length + ct.length);
|
|
973
955
|
sealed.set(nonce);
|
|
974
956
|
sealed.set(ct, nonce.length);
|
|
975
957
|
return XUtils.encodeHex(sealed);
|
|
976
958
|
}
|
|
977
959
|
|
|
960
|
+
/**
|
|
961
|
+
* Encrypt a hex-encoded secret for at-rest storage.
|
|
962
|
+
* Returns hex(nonce || ciphertext) where nonce is 24 random bytes.
|
|
963
|
+
*/
|
|
964
|
+
private async sealHex(plainHex: string): Promise<string> {
|
|
965
|
+
return this.sealBytes(XUtils.decodeHex(plainHex));
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
private async sealMessagePlaintext(plaintext: string): Promise<string> {
|
|
969
|
+
return (
|
|
970
|
+
STORAGE_CIPHERTEXT_PREFIX +
|
|
971
|
+
(await this.sealBytes(XUtils.decodeUTF8(plaintext)))
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
private async sealSecretText(plaintext: string): Promise<string> {
|
|
976
|
+
return (
|
|
977
|
+
STORAGE_SECRET_TEXT_PREFIX +
|
|
978
|
+
(await this.sealBytes(XUtils.decodeUTF8(plaintext)))
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
|
|
978
982
|
private async sessionRowToSQLAsync(row: SessionRow): Promise<SessionSQL> {
|
|
979
983
|
const rawSK = await this.unsealHex(row.SK);
|
|
980
984
|
const rawRK = row.RK ? await this.unsealHex(row.RK) : rawSK;
|
|
@@ -997,7 +1001,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
997
1001
|
RK: rawRK,
|
|
998
1002
|
sessionID: row.sessionID,
|
|
999
1003
|
SK: rawSK,
|
|
1000
|
-
skippedKeys: row.skippedKeys,
|
|
1004
|
+
skippedKeys: await this.unsealSecretText(row.skippedKeys),
|
|
1001
1005
|
userID: row.userID,
|
|
1002
1006
|
verified: row.verified !== 0,
|
|
1003
1007
|
};
|
|
@@ -1027,14 +1031,13 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
1027
1031
|
};
|
|
1028
1032
|
}
|
|
1029
1033
|
|
|
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> {
|
|
1034
|
+
private async unsealBytes(sealed: string): Promise<Uint8Array> {
|
|
1035
1035
|
const bytes = XUtils.decodeHex(sealed);
|
|
1036
|
-
|
|
1037
|
-
|
|
1036
|
+
if (bytes.length < STORAGE_NONCE_BYTES + STORAGE_MAC_BYTES) {
|
|
1037
|
+
throw new Error("Sealed column value is truncated.");
|
|
1038
|
+
}
|
|
1039
|
+
const nonce = bytes.slice(0, STORAGE_NONCE_BYTES);
|
|
1040
|
+
const ct = bytes.slice(STORAGE_NONCE_BYTES);
|
|
1038
1041
|
const fips = getCryptoProfile() === "fips";
|
|
1039
1042
|
const plain = fips
|
|
1040
1043
|
? await xSecretboxOpenAsync(ct, nonce, this.atRestAesKey)
|
|
@@ -1042,21 +1045,41 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
1042
1045
|
if (!plain) {
|
|
1043
1046
|
throw new Error("Failed to decrypt sealed column value.");
|
|
1044
1047
|
}
|
|
1045
|
-
return
|
|
1048
|
+
return plain;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* Decrypt a value produced by sealHex().
|
|
1053
|
+
* Expects hex(nonce || ciphertext), returns the original hex string.
|
|
1054
|
+
*/
|
|
1055
|
+
private async unsealHex(sealed: string): Promise<string> {
|
|
1056
|
+
return XUtils.encodeHex(await this.unsealBytes(sealed));
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
private async unsealSecretText(stored: string): Promise<string> {
|
|
1060
|
+
if (!stored.startsWith(STORAGE_SECRET_TEXT_PREFIX)) {
|
|
1061
|
+
throw new Error(
|
|
1062
|
+
"Stored secret is missing its encryption envelope.",
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
const plain = await this.unsealBytes(
|
|
1066
|
+
stored.slice(STORAGE_SECRET_TEXT_PREFIX.length),
|
|
1067
|
+
);
|
|
1068
|
+
return XUtils.encodeUTF8(plain);
|
|
1046
1069
|
}
|
|
1047
1070
|
|
|
1048
1071
|
private async untilReady(): Promise<void> {
|
|
1049
|
-
if (this.
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
}
|
|
1072
|
+
if (this.closing) {
|
|
1073
|
+
throw new Error("SqliteStorage is closed.");
|
|
1074
|
+
}
|
|
1075
|
+
if (this.ready) {
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
if (this.initInFlight) {
|
|
1079
|
+
await this.initInFlight;
|
|
1080
|
+
} else {
|
|
1081
|
+
await this.init();
|
|
1082
|
+
}
|
|
1060
1083
|
}
|
|
1061
1084
|
}
|
|
1062
1085
|
|
|
@@ -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
|
}
|