@vex-chat/libvex 9.1.1 → 10.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/README.md +1 -1
- package/dist/Client.d.ts +3 -21
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +35 -117
- package/dist/Client.js.map +1 -1
- package/dist/__tests__/harness/memory-storage.d.ts.map +1 -1
- package/dist/__tests__/harness/memory-storage.js +6 -19
- package/dist/__tests__/harness/memory-storage.js.map +1 -1
- package/dist/preset/node.d.ts.map +1 -1
- package/dist/preset/node.js +1 -2
- package/dist/preset/node.js.map +1 -1
- package/dist/preset/test.d.ts.map +1 -1
- package/dist/preset/test.js +1 -2
- package/dist/preset/test.js.map +1 -1
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +5 -15
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/utils/resolveAtRestAesKey.d.ts +1 -3
- package/dist/utils/resolveAtRestAesKey.d.ts.map +1 -1
- package/dist/utils/resolveAtRestAesKey.js +7 -13
- package/dist/utils/resolveAtRestAesKey.js.map +1 -1
- package/dist/utils/verifyKeyBundle.d.ts +1 -2
- 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 +47 -181
- package/src/__tests__/harness/memory-storage.ts +17 -48
- package/src/__tests__/harness/shared-suite.ts +1 -137
- package/src/__tests__/platform-browser.test.ts +1 -6
- package/src/__tests__/platform-node.test.ts +1 -7
- package/src/__tests__/prekey-reuse.test.ts +8 -77
- package/src/__tests__/verifyKeyBundle.test.ts +15 -43
- package/src/preset/node.ts +1 -5
- package/src/preset/test.ts +1 -5
- package/src/storage/sqlite.ts +4 -20
- package/src/utils/resolveAtRestAesKey.ts +7 -23
- package/src/utils/verifyKeyBundle.ts +2 -16
- package/dist/utils/fipsMailExtra.d.ts +0 -30
- package/dist/utils/fipsMailExtra.d.ts.map +0 -1
- package/dist/utils/fipsMailExtra.js +0 -114
- package/dist/utils/fipsMailExtra.js.map +0 -1
- package/src/utils/fipsMailExtra.ts +0 -164
|
@@ -11,27 +11,18 @@
|
|
|
11
11
|
* Runs register → login → connect → send/receive DM against a real spire.
|
|
12
12
|
*
|
|
13
13
|
* **Vitest e2e only** — the published `Client` does not read the environment;
|
|
14
|
-
* app code passes `ClientOptions` (`host`, `devApiKey`,
|
|
14
|
+
* app code passes `ClientOptions` (`host`, `devApiKey`, …).
|
|
15
15
|
* These `process.env` values are for running **this** repo’s platform tests
|
|
16
16
|
* (shell, CI, or your own env injection — not a “libvex .env” contract).
|
|
17
17
|
* - `API_URL` — Spire base, e.g. `http://127.0.0.1:16777` or `host:port` (http assumed).
|
|
18
|
-
* When set, `beforeAll` reads `GET …/status` `cryptoProfile` and **sets the
|
|
19
|
-
* test process to match** (so a FIPS Spire does not require a separate client flag).
|
|
20
|
-
* A post-check then fails if the client and server still disagree.
|
|
21
18
|
* - `DEV_API_KEY` — must match the Spire `DEV_API_KEY` so the client can send
|
|
22
19
|
* `x-dev-api-key` and avoid dev rate limits.
|
|
23
|
-
* - `LIBVEX_E2E_SKIP_STATUS_CHECK=1` — opt out of the /status profile read + check
|
|
24
|
-
* (e.g. older Spire). Then `LIBVEX_E2E_CRYPTO` or default tweetnacl is used.
|
|
25
|
-
* - `LIBVEX_E2E_CRYPTO` — optional override: `fips` | `tweetnacl` (wins over auto
|
|
26
|
-
* detect from /status; use to force a profile when you know what you need).
|
|
27
20
|
* - `LIBVEX_DEBUG_DM=1` — logs DM/X3dh paths in `Client` to stderr (remove / gate off when done).
|
|
28
21
|
*/
|
|
29
22
|
|
|
30
23
|
import type { ClientOptions, Message } from "../../index.js";
|
|
31
24
|
import type { Storage } from "../../Storage.js";
|
|
32
25
|
|
|
33
|
-
import { getCryptoProfile, setCryptoProfile } from "@vex-chat/crypto";
|
|
34
|
-
|
|
35
26
|
import { isHttpError } from "../../http.js";
|
|
36
27
|
import { Client } from "../../index.js";
|
|
37
28
|
|
|
@@ -47,14 +38,11 @@ export function platformSuite(
|
|
|
47
38
|
const password = "platform-test-pw";
|
|
48
39
|
|
|
49
40
|
beforeAll(async () => {
|
|
50
|
-
const profile = await resolveE2eCryptoProfile();
|
|
51
|
-
setCryptoProfile(profile);
|
|
52
41
|
const SK = await e2eGenerateSecretKey();
|
|
53
42
|
|
|
54
43
|
const opts: ClientOptions = e2eClientOptionsBase();
|
|
55
44
|
const storage = await makeStorage(SK, opts);
|
|
56
45
|
client = await Client.create(SK, opts, storage);
|
|
57
|
-
await assertSpireCryptoProfileMatchesTest();
|
|
58
46
|
});
|
|
59
47
|
|
|
60
48
|
afterAll(async () => {
|
|
@@ -640,53 +628,6 @@ function apiUrlOverrideFromEnv():
|
|
|
640
628
|
};
|
|
641
629
|
}
|
|
642
630
|
|
|
643
|
-
async function assertSpireCryptoProfileMatchesTest(): Promise<void> {
|
|
644
|
-
if (process.env["LIBVEX_E2E_SKIP_STATUS_CHECK"] === "1") {
|
|
645
|
-
return;
|
|
646
|
-
}
|
|
647
|
-
const url = spireStatusUrlFromEnv();
|
|
648
|
-
if (url === null) {
|
|
649
|
-
return;
|
|
650
|
-
}
|
|
651
|
-
const want = getCryptoProfile();
|
|
652
|
-
let res: Response;
|
|
653
|
-
try {
|
|
654
|
-
res = await fetch(url, { method: "GET" });
|
|
655
|
-
} catch (e: unknown) {
|
|
656
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
657
|
-
throw new Error(
|
|
658
|
-
`libvex e2e: could not GET ${url} (check API_URL; is Spire running?): ${msg}`,
|
|
659
|
-
);
|
|
660
|
-
}
|
|
661
|
-
if (!res.ok) {
|
|
662
|
-
throw new Error(
|
|
663
|
-
`libvex e2e: ${url} returned HTTP ${String(res.status)} — Spire not reachable on this base URL?`,
|
|
664
|
-
);
|
|
665
|
-
}
|
|
666
|
-
const data: unknown = await res.json();
|
|
667
|
-
if (
|
|
668
|
-
typeof data !== "object" ||
|
|
669
|
-
data === null ||
|
|
670
|
-
!("cryptoProfile" in data) ||
|
|
671
|
-
typeof (data as { cryptoProfile: unknown }).cryptoProfile !== "string"
|
|
672
|
-
) {
|
|
673
|
-
throw new Error(
|
|
674
|
-
`libvex e2e: Spire /status is missing a string "cryptoProfile" (upgrade Spire) or set LIBVEX_E2E_SKIP_STATUS_CHECK=1 to skip this check`,
|
|
675
|
-
);
|
|
676
|
-
}
|
|
677
|
-
const gotStr = (data as { cryptoProfile: string }).cryptoProfile;
|
|
678
|
-
if (gotStr !== "fips" && gotStr !== "tweetnacl") {
|
|
679
|
-
throw new Error(
|
|
680
|
-
`libvex e2e: Spire /status cryptoProfile is not fips|tweetnacl: ${gotStr}`,
|
|
681
|
-
);
|
|
682
|
-
}
|
|
683
|
-
if (gotStr !== want) {
|
|
684
|
-
throw new Error(
|
|
685
|
-
`libvex e2e: Spire is cryptoProfile=${gotStr} (see SPIRE_FIPS + SPK) but this test has getCryptoProfile()=${want}. Use matching keys/scripts (gen-spk.js vs gen-spk-fips.js) and the same mode on client and server.`,
|
|
686
|
-
);
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
|
|
690
631
|
function connectAndWait(
|
|
691
632
|
c: Client,
|
|
692
633
|
label: string,
|
|
@@ -713,90 +654,13 @@ function e2eClientOptionsBase(): ClientOptions {
|
|
|
713
654
|
return {
|
|
714
655
|
inMemoryDb: true,
|
|
715
656
|
...apiUrlOverrideFromEnv(),
|
|
716
|
-
cryptoProfile: getCryptoProfile(),
|
|
717
657
|
};
|
|
718
658
|
}
|
|
719
659
|
|
|
720
|
-
/** `LIBVEX_E2E_CRYPTO` only — used when status auto-detect is skipped. */
|
|
721
|
-
function e2eCryptoProfileFromEnvOnly(): "fips" | "tweetnacl" {
|
|
722
|
-
const v = process.env["LIBVEX_E2E_CRYPTO"]?.trim().toLowerCase();
|
|
723
|
-
if (v === "fips" || v === "p-256" || v === "p256") {
|
|
724
|
-
return "fips";
|
|
725
|
-
}
|
|
726
|
-
if (v === "tweetnacl" || v === "nacl" || v === "ed25519") {
|
|
727
|
-
return "tweetnacl";
|
|
728
|
-
}
|
|
729
|
-
return "tweetnacl";
|
|
730
|
-
}
|
|
731
|
-
|
|
732
660
|
async function e2eGenerateSecretKey(): Promise<string> {
|
|
733
661
|
return await Client.generateSecretKeyAsync();
|
|
734
662
|
}
|
|
735
663
|
|
|
736
|
-
/**
|
|
737
|
-
* Picks the signing profile for the suite: optional env override, else `GET` Spire
|
|
738
|
-
* `/status` when `API_URL` is set, else tweetnacl.
|
|
739
|
-
*/
|
|
740
|
-
async function resolveE2eCryptoProfile(): Promise<"fips" | "tweetnacl"> {
|
|
741
|
-
if (process.env["LIBVEX_E2E_SKIP_STATUS_CHECK"] === "1") {
|
|
742
|
-
return e2eCryptoProfileFromEnvOnly();
|
|
743
|
-
}
|
|
744
|
-
const v = process.env["LIBVEX_E2E_CRYPTO"]?.trim().toLowerCase();
|
|
745
|
-
if (v === "fips" || v === "p-256" || v === "p256") {
|
|
746
|
-
return "fips";
|
|
747
|
-
}
|
|
748
|
-
if (v === "tweetnacl" || v === "nacl" || v === "ed25519") {
|
|
749
|
-
return "tweetnacl";
|
|
750
|
-
}
|
|
751
|
-
if (v !== undefined && v.length > 0) {
|
|
752
|
-
throw new Error(
|
|
753
|
-
`libvex e2e: invalid LIBVEX_E2E_CRYPTO=${JSON.stringify(v)}. Use fips, tweetnacl, or leave unset to auto-detect from Spire /status`,
|
|
754
|
-
);
|
|
755
|
-
}
|
|
756
|
-
const url = spireStatusUrlFromEnv();
|
|
757
|
-
if (url === null) {
|
|
758
|
-
return "tweetnacl";
|
|
759
|
-
}
|
|
760
|
-
let res: Response;
|
|
761
|
-
try {
|
|
762
|
-
res = await fetch(url, { method: "GET" });
|
|
763
|
-
} catch {
|
|
764
|
-
return "tweetnacl";
|
|
765
|
-
}
|
|
766
|
-
if (!res.ok) {
|
|
767
|
-
return "tweetnacl";
|
|
768
|
-
}
|
|
769
|
-
const data: unknown = await res.json();
|
|
770
|
-
if (
|
|
771
|
-
typeof data !== "object" ||
|
|
772
|
-
data === null ||
|
|
773
|
-
!("cryptoProfile" in data)
|
|
774
|
-
) {
|
|
775
|
-
return "tweetnacl";
|
|
776
|
-
}
|
|
777
|
-
const cp = (data as { cryptoProfile: unknown }).cryptoProfile;
|
|
778
|
-
if (cp === "fips" || cp === "tweetnacl") {
|
|
779
|
-
return cp;
|
|
780
|
-
}
|
|
781
|
-
return "tweetnacl";
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
/**
|
|
785
|
-
* `GET` `{API_URL or http://}{host}/status` — used for crypto profile preflight
|
|
786
|
-
* (must match `getCryptoProfile()` when running e2e against a custom Spire).
|
|
787
|
-
*/
|
|
788
|
-
function spireStatusUrlFromEnv(): null | string {
|
|
789
|
-
const raw = process.env["API_URL"]?.trim();
|
|
790
|
-
if (raw === undefined || raw.length === 0) {
|
|
791
|
-
return null;
|
|
792
|
-
}
|
|
793
|
-
if (/^https?:\/\//i.test(raw)) {
|
|
794
|
-
const u = new URL(raw);
|
|
795
|
-
return `${u.protocol}//${u.host}/status`;
|
|
796
|
-
}
|
|
797
|
-
return `http://${raw}/status`;
|
|
798
|
-
}
|
|
799
|
-
|
|
800
664
|
/*
|
|
801
665
|
type ClientE2EInternals = { getMail(): Promise<void> };
|
|
802
666
|
type ClientE2EDeviceList = {
|
|
@@ -6,8 +6,6 @@
|
|
|
6
6
|
|
|
7
7
|
import type { ClientOptions } from "../index.js";
|
|
8
8
|
|
|
9
|
-
import { getCryptoProfile } from "@vex-chat/crypto";
|
|
10
|
-
|
|
11
9
|
import { resolveAtRestAesKeyFromSignKeyHex } from "../utils/resolveAtRestAesKey.js";
|
|
12
10
|
|
|
13
11
|
import { MemoryStorage } from "./harness/memory-storage.js";
|
|
@@ -18,10 +16,7 @@ import { MemoryStorage } from "./harness/memory-storage.js";
|
|
|
18
16
|
import { platformSuite } from "./harness/shared-suite.js";
|
|
19
17
|
|
|
20
18
|
platformSuite("browser", async (SK: string, _opts: ClientOptions) => {
|
|
21
|
-
const atRest = await resolveAtRestAesKeyFromSignKeyHex(
|
|
22
|
-
SK,
|
|
23
|
-
getCryptoProfile(),
|
|
24
|
-
);
|
|
19
|
+
const atRest = await resolveAtRestAesKeyFromSignKeyHex(SK);
|
|
25
20
|
const storage = new MemoryStorage(atRest);
|
|
26
21
|
await storage.init();
|
|
27
22
|
return storage;
|
|
@@ -6,18 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
import type { ClientOptions } from "../index.js";
|
|
8
8
|
|
|
9
|
-
import { getCryptoProfile } from "@vex-chat/crypto";
|
|
10
|
-
|
|
11
9
|
import { createNodeStorage } from "../storage/node.js";
|
|
12
10
|
import { resolveAtRestAesKeyFromSignKeyHex } from "../utils/resolveAtRestAesKey.js";
|
|
13
11
|
|
|
14
12
|
import { platformSuite } from "./harness/shared-suite.js";
|
|
15
13
|
|
|
16
|
-
// Profile is set in shared-suite `beforeAll` (see `LIBVEX_E2E_CRYPTO`).
|
|
17
14
|
platformSuite("node", async (SK: string, _opts: ClientOptions) => {
|
|
18
|
-
const atRest = await resolveAtRestAesKeyFromSignKeyHex(
|
|
19
|
-
SK,
|
|
20
|
-
getCryptoProfile(),
|
|
21
|
-
);
|
|
15
|
+
const atRest = await resolveAtRestAesKeyFromSignKeyHex(SK);
|
|
22
16
|
return createNodeStorage(":memory:", atRest);
|
|
23
17
|
});
|
|
@@ -5,22 +5,19 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { PreKeysCrypto, UnsavedPreKey, XKeyRing } from "../types/index.js";
|
|
8
|
-
import type {
|
|
8
|
+
import type { KeyPair } from "@vex-chat/crypto";
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
|
-
setCryptoProfile,
|
|
12
11
|
xBoxKeyPairAsync,
|
|
13
|
-
xEcdhKeyPairFromEcdsaKeyPairAsync,
|
|
14
12
|
XKeyConvert,
|
|
15
13
|
xPreKeySignaturePayload,
|
|
16
14
|
xSignAsync,
|
|
17
15
|
xSignKeyPair,
|
|
18
|
-
xSignKeyPairAsync,
|
|
19
16
|
xSignOpenAsync,
|
|
20
17
|
XUtils,
|
|
21
18
|
} from "@vex-chat/crypto";
|
|
22
19
|
|
|
23
|
-
import {
|
|
20
|
+
import { describe, expect, it } from "vitest";
|
|
24
21
|
|
|
25
22
|
import { Client } from "../Client.js";
|
|
26
23
|
|
|
@@ -28,11 +25,10 @@ import { MemoryStorage } from "./harness/memory-storage.js";
|
|
|
28
25
|
|
|
29
26
|
interface KeyRingHarness {
|
|
30
27
|
createPreKey: (kind: "one-time" | "signed") => Promise<UnsavedPreKey>;
|
|
31
|
-
cryptoProfile: CryptoProfile;
|
|
32
28
|
database: MemoryStorage;
|
|
33
29
|
idKeys: KeyPair;
|
|
34
30
|
isPreKeySignedByCurrentDevice: (preKey: PreKeysCrypto) => Promise<boolean>;
|
|
35
|
-
|
|
31
|
+
runCrypto: <T>(fn: () => Promise<T>) => Promise<T>;
|
|
36
32
|
sessionRecords: Record<string, unknown>;
|
|
37
33
|
signKeys: KeyPair;
|
|
38
34
|
xKeyRing?: XKeyRing;
|
|
@@ -42,35 +38,21 @@ const clientMethods = Client.prototype as unknown as {
|
|
|
42
38
|
createPreKey: (kind: "one-time" | "signed") => Promise<UnsavedPreKey>;
|
|
43
39
|
isPreKeySignedByCurrentDevice: (preKey: PreKeysCrypto) => Promise<boolean>;
|
|
44
40
|
populateKeyRing: () => Promise<void>;
|
|
45
|
-
|
|
41
|
+
runCrypto: <T>(fn: () => Promise<T>) => Promise<T>;
|
|
46
42
|
};
|
|
47
43
|
|
|
48
44
|
async function isValidFor(
|
|
49
45
|
preKey: PreKeysCrypto,
|
|
50
46
|
signKeys: KeyPair,
|
|
51
|
-
profile: CryptoProfile = "tweetnacl",
|
|
52
47
|
): Promise<boolean> {
|
|
53
|
-
setCryptoProfile(profile);
|
|
54
48
|
const opened = await xSignOpenAsync(preKey.signature, signKeys.publicKey);
|
|
55
|
-
const payload = xPreKeySignaturePayload(
|
|
56
|
-
preKey.keyPair.publicKey,
|
|
57
|
-
"signed",
|
|
58
|
-
profile,
|
|
59
|
-
);
|
|
49
|
+
const payload = xPreKeySignaturePayload(preKey.keyPair.publicKey, "signed");
|
|
60
50
|
return Boolean(opened && XUtils.bytesEqual(opened, payload));
|
|
61
51
|
}
|
|
62
52
|
|
|
63
|
-
async function makeSignedPreKey(
|
|
64
|
-
signKeys: KeyPair,
|
|
65
|
-
profile: CryptoProfile = "tweetnacl",
|
|
66
|
-
): Promise<UnsavedPreKey> {
|
|
67
|
-
setCryptoProfile(profile);
|
|
53
|
+
async function makeSignedPreKey(signKeys: KeyPair): Promise<UnsavedPreKey> {
|
|
68
54
|
const keyPair = await xBoxKeyPairAsync();
|
|
69
|
-
const payload = xPreKeySignaturePayload(
|
|
70
|
-
keyPair.publicKey,
|
|
71
|
-
"signed",
|
|
72
|
-
profile,
|
|
73
|
-
);
|
|
55
|
+
const payload = xPreKeySignaturePayload(keyPair.publicKey, "signed");
|
|
74
56
|
return {
|
|
75
57
|
keyPair,
|
|
76
58
|
signature: await xSignAsync(payload, signKeys.secretKey),
|
|
@@ -78,14 +60,6 @@ async function makeSignedPreKey(
|
|
|
78
60
|
}
|
|
79
61
|
|
|
80
62
|
describe("local signed prekey reuse", () => {
|
|
81
|
-
beforeEach(() => {
|
|
82
|
-
setCryptoProfile("tweetnacl");
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
afterEach(() => {
|
|
86
|
-
setCryptoProfile("tweetnacl");
|
|
87
|
-
});
|
|
88
|
-
|
|
89
63
|
it("regenerates a stored prekey signed by a different device key", async () => {
|
|
90
64
|
const staleSignKeys = xSignKeyPair();
|
|
91
65
|
const currentSignKeys = xSignKeyPair();
|
|
@@ -103,12 +77,11 @@ describe("local signed prekey reuse", () => {
|
|
|
103
77
|
|
|
104
78
|
const harness: KeyRingHarness = {
|
|
105
79
|
createPreKey: clientMethods.createPreKey,
|
|
106
|
-
cryptoProfile: "tweetnacl",
|
|
107
80
|
database: storage,
|
|
108
81
|
idKeys,
|
|
109
82
|
isPreKeySignedByCurrentDevice:
|
|
110
83
|
clientMethods.isPreKeySignedByCurrentDevice,
|
|
111
|
-
|
|
84
|
+
runCrypto: clientMethods.runCrypto,
|
|
112
85
|
sessionRecords: {},
|
|
113
86
|
signKeys: currentSignKeys,
|
|
114
87
|
};
|
|
@@ -127,46 +100,4 @@ describe("local signed prekey reuse", () => {
|
|
|
127
100
|
expect(persisted).not.toBeNull();
|
|
128
101
|
expect(await isValidFor(persisted!, currentSignKeys)).toBe(true);
|
|
129
102
|
});
|
|
130
|
-
|
|
131
|
-
it("reuses a valid FIPS prekey when the global profile is tweetnacl", async () => {
|
|
132
|
-
setCryptoProfile("fips");
|
|
133
|
-
const currentSignKeys = await xSignKeyPairAsync();
|
|
134
|
-
const idKeys = await xEcdhKeyPairFromEcdsaKeyPairAsync(currentSignKeys);
|
|
135
|
-
const storedPreKey = await makeSignedPreKey(currentSignKeys, "fips");
|
|
136
|
-
|
|
137
|
-
const storage = new MemoryStorage(new Uint8Array(32).fill(8));
|
|
138
|
-
await storage.init();
|
|
139
|
-
await storage.savePreKeys([storedPreKey], false);
|
|
140
|
-
|
|
141
|
-
setCryptoProfile("tweetnacl");
|
|
142
|
-
const harness: KeyRingHarness = {
|
|
143
|
-
createPreKey: clientMethods.createPreKey,
|
|
144
|
-
cryptoProfile: "fips",
|
|
145
|
-
database: storage,
|
|
146
|
-
idKeys,
|
|
147
|
-
isPreKeySignedByCurrentDevice:
|
|
148
|
-
clientMethods.isPreKeySignedByCurrentDevice,
|
|
149
|
-
runWithThisCryptoProfile: clientMethods.runWithThisCryptoProfile,
|
|
150
|
-
sessionRecords: {},
|
|
151
|
-
signKeys: currentSignKeys,
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
await clientMethods.populateKeyRing.call(harness);
|
|
155
|
-
|
|
156
|
-
expect(harness.xKeyRing).toBeDefined();
|
|
157
|
-
expect(harness.xKeyRing!.preKeys.index).toBe(1);
|
|
158
|
-
expect(
|
|
159
|
-
XUtils.bytesEqual(
|
|
160
|
-
harness.xKeyRing!.preKeys.keyPair.publicKey,
|
|
161
|
-
storedPreKey.keyPair.publicKey,
|
|
162
|
-
),
|
|
163
|
-
).toBe(true);
|
|
164
|
-
expect(
|
|
165
|
-
await isValidFor(
|
|
166
|
-
harness.xKeyRing!.preKeys,
|
|
167
|
-
currentSignKeys,
|
|
168
|
-
"fips",
|
|
169
|
-
),
|
|
170
|
-
).toBe(true);
|
|
171
|
-
});
|
|
172
103
|
});
|
|
@@ -4,48 +4,42 @@
|
|
|
4
4
|
* Commercial licenses available at vex.wtf
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type {
|
|
7
|
+
import type { KeyPair } from "@vex-chat/crypto";
|
|
8
8
|
import type { Device, KeyBundle, KeyBundleEntry } from "@vex-chat/types";
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
|
-
setCryptoProfile,
|
|
12
11
|
xBoxKeyPairAsync,
|
|
13
|
-
xEcdhKeyPairFromEcdsaKeyPairAsync,
|
|
14
12
|
xPreKeySignaturePayload,
|
|
15
13
|
xSignAsync,
|
|
16
14
|
xSignKeyPairAsync,
|
|
17
15
|
XUtils,
|
|
18
16
|
} from "@vex-chat/crypto";
|
|
19
17
|
|
|
20
|
-
import {
|
|
18
|
+
import { describe, expect, it } from "vitest";
|
|
21
19
|
|
|
22
20
|
import { verifyKeyBundleSignatures } from "../utils/verifyKeyBundle.js";
|
|
23
21
|
|
|
24
|
-
describe
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("accepts a valid tweetnacl key bundle", async () => {
|
|
30
|
-
const { device, keyBundle } = await makeBundle("tweetnacl");
|
|
22
|
+
describe("verifyKeyBundleSignatures", () => {
|
|
23
|
+
it("accepts a valid key bundle", async () => {
|
|
24
|
+
const { device, keyBundle } = await makeBundle();
|
|
31
25
|
|
|
32
26
|
await expect(
|
|
33
|
-
verifyKeyBundleSignatures(keyBundle, device
|
|
27
|
+
verifyKeyBundleSignatures(keyBundle, device),
|
|
34
28
|
).resolves.toBeUndefined();
|
|
35
29
|
});
|
|
36
30
|
|
|
37
31
|
it("rejects a tampered signed prekey public key", async () => {
|
|
38
|
-
const { device, keyBundle } = await makeBundle(
|
|
32
|
+
const { device, keyBundle } = await makeBundle();
|
|
39
33
|
const tampered = cloneBundle(keyBundle);
|
|
40
34
|
tampered.preKey.publicKey = flipFirstByte(tampered.preKey.publicKey);
|
|
41
35
|
|
|
42
36
|
await expect(
|
|
43
|
-
verifyKeyBundleSignatures(tampered, device
|
|
37
|
+
verifyKeyBundleSignatures(tampered, device),
|
|
44
38
|
).rejects.toThrow("signed prekey signature is invalid");
|
|
45
39
|
});
|
|
46
40
|
|
|
47
41
|
it("rejects a tampered one-time prekey public key", async () => {
|
|
48
|
-
const { device, keyBundle } = await makeBundle(
|
|
42
|
+
const { device, keyBundle } = await makeBundle(true);
|
|
49
43
|
const tampered = cloneBundle(keyBundle);
|
|
50
44
|
if (!tampered.otk) {
|
|
51
45
|
throw new Error("Expected OTK fixture.");
|
|
@@ -53,28 +47,20 @@ describe.sequential("verifyKeyBundleSignatures", () => {
|
|
|
53
47
|
tampered.otk.publicKey = flipFirstByte(tampered.otk.publicKey);
|
|
54
48
|
|
|
55
49
|
await expect(
|
|
56
|
-
verifyKeyBundleSignatures(tampered, device
|
|
50
|
+
verifyKeyBundleSignatures(tampered, device),
|
|
57
51
|
).rejects.toThrow("one-time prekey signature is invalid");
|
|
58
52
|
});
|
|
59
53
|
|
|
60
54
|
it("rejects a bundle identity key that does not match the device", async () => {
|
|
61
|
-
const { device, keyBundle } = await makeBundle(
|
|
55
|
+
const { device, keyBundle } = await makeBundle();
|
|
62
56
|
const other = await xSignKeyPairAsync();
|
|
63
57
|
const tampered = cloneBundle(keyBundle);
|
|
64
58
|
tampered.signKey = other.publicKey;
|
|
65
59
|
|
|
66
60
|
await expect(
|
|
67
|
-
verifyKeyBundleSignatures(tampered, device
|
|
61
|
+
verifyKeyBundleSignatures(tampered, device),
|
|
68
62
|
).rejects.toThrow("identity key does not match");
|
|
69
63
|
});
|
|
70
|
-
|
|
71
|
-
it("accepts a valid FIPS key bundle", async () => {
|
|
72
|
-
const { device, keyBundle } = await makeBundle("fips", true);
|
|
73
|
-
|
|
74
|
-
await expect(
|
|
75
|
-
verifyKeyBundleSignatures(keyBundle, device, "fips"),
|
|
76
|
-
).resolves.toBeUndefined();
|
|
77
|
-
});
|
|
78
64
|
});
|
|
79
65
|
|
|
80
66
|
function cloneBundle(bundle: KeyBundle): KeyBundle {
|
|
@@ -105,15 +91,9 @@ function flipFirstByte(value: Uint8Array): Uint8Array {
|
|
|
105
91
|
}
|
|
106
92
|
|
|
107
93
|
async function makeBundle(
|
|
108
|
-
profile: CryptoProfile,
|
|
109
94
|
includeOtk = false,
|
|
110
95
|
): Promise<{ device: Device; keyBundle: KeyBundle }> {
|
|
111
|
-
setCryptoProfile(profile);
|
|
112
96
|
const signKeys = await xSignKeyPairAsync();
|
|
113
|
-
const identityPublic =
|
|
114
|
-
profile === "fips"
|
|
115
|
-
? (await xEcdhKeyPairFromEcdsaKeyPairAsync(signKeys)).publicKey
|
|
116
|
-
: signKeys.publicKey;
|
|
117
97
|
const device: Device = {
|
|
118
98
|
deleted: false,
|
|
119
99
|
deviceID: "device-a",
|
|
@@ -124,19 +104,12 @@ async function makeBundle(
|
|
|
124
104
|
};
|
|
125
105
|
|
|
126
106
|
const keyBundle: KeyBundle = {
|
|
127
|
-
preKey: await makeBundleEntry(
|
|
128
|
-
|
|
129
|
-
profile,
|
|
130
|
-
device.deviceID,
|
|
131
|
-
1,
|
|
132
|
-
"signed",
|
|
133
|
-
),
|
|
134
|
-
signKey: identityPublic,
|
|
107
|
+
preKey: await makeBundleEntry(signKeys, device.deviceID, 1, "signed"),
|
|
108
|
+
signKey: signKeys.publicKey,
|
|
135
109
|
};
|
|
136
110
|
if (includeOtk) {
|
|
137
111
|
keyBundle.otk = await makeBundleEntry(
|
|
138
112
|
signKeys,
|
|
139
|
-
profile,
|
|
140
113
|
device.deviceID,
|
|
141
114
|
2,
|
|
142
115
|
"one-time",
|
|
@@ -148,13 +121,12 @@ async function makeBundle(
|
|
|
148
121
|
|
|
149
122
|
async function makeBundleEntry(
|
|
150
123
|
signKeys: KeyPair,
|
|
151
|
-
profile: CryptoProfile,
|
|
152
124
|
deviceID: string,
|
|
153
125
|
index: number,
|
|
154
126
|
kind: "one-time" | "signed",
|
|
155
127
|
): Promise<KeyBundleEntry> {
|
|
156
128
|
const preKey = await xBoxKeyPairAsync();
|
|
157
|
-
const payload = xPreKeySignaturePayload(preKey.publicKey, kind
|
|
129
|
+
const payload = xPreKeySignaturePayload(preKey.publicKey, kind);
|
|
158
130
|
return {
|
|
159
131
|
deviceID,
|
|
160
132
|
index,
|
package/src/preset/node.ts
CHANGED
|
@@ -16,14 +16,10 @@ import type { PlatformPreset } from "./common.js";
|
|
|
16
16
|
export function nodePreset(): PlatformPreset {
|
|
17
17
|
return {
|
|
18
18
|
async createStorage(dbName, privateKey): Promise<Storage> {
|
|
19
|
-
const { getCryptoProfile } = await import("@vex-chat/crypto");
|
|
20
19
|
const { createNodeStorage } = await import("../storage/node.js");
|
|
21
20
|
const { resolveAtRestAesKeyFromSignKeyHex } =
|
|
22
21
|
await import("../utils/resolveAtRestAesKey.js");
|
|
23
|
-
const atRest = await resolveAtRestAesKeyFromSignKeyHex(
|
|
24
|
-
privateKey,
|
|
25
|
-
getCryptoProfile(),
|
|
26
|
-
);
|
|
22
|
+
const atRest = await resolveAtRestAesKeyFromSignKeyHex(privateKey);
|
|
27
23
|
return createNodeStorage(dbName, atRest);
|
|
28
24
|
},
|
|
29
25
|
deviceName: process.platform,
|
package/src/preset/test.ts
CHANGED
|
@@ -15,15 +15,11 @@ import type { PlatformPreset } from "./common.js";
|
|
|
15
15
|
export function testPreset(): PlatformPreset {
|
|
16
16
|
return {
|
|
17
17
|
async createStorage(_dbName, privateKey) {
|
|
18
|
-
const { getCryptoProfile } = await import("@vex-chat/crypto");
|
|
19
18
|
const { resolveAtRestAesKeyFromSignKeyHex } =
|
|
20
19
|
await import("../utils/resolveAtRestAesKey.js");
|
|
21
20
|
const { MemoryStorage } =
|
|
22
21
|
await import("../__tests__/harness/memory-storage.js");
|
|
23
|
-
const atRest = await resolveAtRestAesKeyFromSignKeyHex(
|
|
24
|
-
privateKey,
|
|
25
|
-
getCryptoProfile(),
|
|
26
|
-
);
|
|
22
|
+
const atRest = await resolveAtRestAesKeyFromSignKeyHex(privateKey);
|
|
27
23
|
const storage = new MemoryStorage(atRest);
|
|
28
24
|
await storage.init();
|
|
29
25
|
return storage;
|
package/src/storage/sqlite.ts
CHANGED
|
@@ -37,13 +37,9 @@ import type { Device, PreKeysSQL, SessionSQL } from "@vex-chat/types";
|
|
|
37
37
|
* smaller “crypto state” store separate from bulk message history.
|
|
38
38
|
*/
|
|
39
39
|
import {
|
|
40
|
-
getCryptoProfile,
|
|
41
40
|
xBoxKeyPairFromSecret,
|
|
42
|
-
xBoxKeyPairFromSecretAsync,
|
|
43
41
|
xMakeNonce,
|
|
44
|
-
xSecretbox,
|
|
45
42
|
xSecretboxAsync,
|
|
46
|
-
xSecretboxOpen,
|
|
47
43
|
xSecretboxOpenAsync,
|
|
48
44
|
XUtils,
|
|
49
45
|
} from "@vex-chat/crypto";
|
|
@@ -223,10 +219,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
223
219
|
const rawSk = await this.unsealHex(otkInfo.privateKey);
|
|
224
220
|
return {
|
|
225
221
|
index: otkInfo.index,
|
|
226
|
-
keyPair:
|
|
227
|
-
getCryptoProfile() === "fips"
|
|
228
|
-
? await xBoxKeyPairFromSecretAsync(XUtils.decodeHex(rawSk))
|
|
229
|
-
: xBoxKeyPairFromSecret(XUtils.decodeHex(rawSk)),
|
|
222
|
+
keyPair: xBoxKeyPairFromSecret(XUtils.decodeHex(rawSk)),
|
|
230
223
|
signature: XUtils.decodeHex(otkInfo.signature),
|
|
231
224
|
};
|
|
232
225
|
}
|
|
@@ -246,10 +239,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
246
239
|
const rawPk = await this.unsealHex(preKeyInfo.privateKey);
|
|
247
240
|
return {
|
|
248
241
|
index: preKeyInfo.index,
|
|
249
|
-
keyPair:
|
|
250
|
-
getCryptoProfile() === "fips"
|
|
251
|
-
? await xBoxKeyPairFromSecretAsync(XUtils.decodeHex(rawPk))
|
|
252
|
-
: xBoxKeyPairFromSecret(XUtils.decodeHex(rawPk)),
|
|
242
|
+
keyPair: xBoxKeyPairFromSecret(XUtils.decodeHex(rawPk)),
|
|
253
243
|
signature: XUtils.decodeHex(preKeyInfo.signature),
|
|
254
244
|
};
|
|
255
245
|
}
|
|
@@ -947,10 +937,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
947
937
|
|
|
948
938
|
private async sealBytes(plaintext: Uint8Array): Promise<string> {
|
|
949
939
|
const nonce = xMakeNonce();
|
|
950
|
-
const
|
|
951
|
-
const ct = fips
|
|
952
|
-
? await xSecretboxAsync(plaintext, nonce, this.atRestAesKey)
|
|
953
|
-
: xSecretbox(plaintext, nonce, this.atRestAesKey);
|
|
940
|
+
const ct = await xSecretboxAsync(plaintext, nonce, this.atRestAesKey);
|
|
954
941
|
const sealed = new Uint8Array(nonce.length + ct.length);
|
|
955
942
|
sealed.set(nonce);
|
|
956
943
|
sealed.set(ct, nonce.length);
|
|
@@ -1038,10 +1025,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
1038
1025
|
}
|
|
1039
1026
|
const nonce = bytes.slice(0, STORAGE_NONCE_BYTES);
|
|
1040
1027
|
const ct = bytes.slice(STORAGE_NONCE_BYTES);
|
|
1041
|
-
const
|
|
1042
|
-
const plain = fips
|
|
1043
|
-
? await xSecretboxOpenAsync(ct, nonce, this.atRestAesKey)
|
|
1044
|
-
: xSecretboxOpen(ct, nonce, this.atRestAesKey);
|
|
1028
|
+
const plain = await xSecretboxOpenAsync(ct, nonce, this.atRestAesKey);
|
|
1045
1029
|
if (!plain) {
|
|
1046
1030
|
throw new Error("Failed to decrypt sealed column value.");
|
|
1047
1031
|
}
|
|
@@ -4,36 +4,20 @@
|
|
|
4
4
|
* Commercial licenses available at vex.wtf
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
type CryptoProfile,
|
|
9
|
-
xEcdhKeyPairFromEcdsaKeyPairAsync,
|
|
10
|
-
XKeyConvert,
|
|
11
|
-
xSignKeyPairFromSecret,
|
|
12
|
-
xSignKeyPairFromSecretAsync,
|
|
13
|
-
XUtils,
|
|
14
|
-
} from "@vex-chat/crypto";
|
|
7
|
+
import { XKeyConvert, xSignKeyPairFromSecret, XUtils } from "@vex-chat/crypto";
|
|
15
8
|
|
|
16
9
|
/**
|
|
17
10
|
* Produces the 32-byte at-rest key used by {@link Storage} (sqlite, memory) from
|
|
18
11
|
* a hex signing secret, matching the derivation used by {@link Client.create}.
|
|
19
|
-
* `setCryptoProfile(profile)` should already be in effect (or `tweetnacl` implied).
|
|
20
12
|
*/
|
|
21
|
-
export
|
|
13
|
+
export function resolveAtRestAesKeyFromSignKeyHex(
|
|
22
14
|
privateKeyHex: string,
|
|
23
|
-
profile: CryptoProfile,
|
|
24
15
|
): Promise<Uint8Array> {
|
|
25
16
|
const dec = XUtils.decodeHex(privateKeyHex);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
throw new Error(
|
|
31
|
-
"Could not convert signing key to X25519 identity.",
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
return XUtils.deriveLocalAtRestAesKey(id.secretKey, "tweetnacl");
|
|
17
|
+
const sign = xSignKeyPairFromSecret(dec);
|
|
18
|
+
const id = XKeyConvert.convertKeyPair(sign);
|
|
19
|
+
if (!id) {
|
|
20
|
+
throw new Error("Could not convert signing key to X25519 identity.");
|
|
35
21
|
}
|
|
36
|
-
|
|
37
|
-
const id = await xEcdhKeyPairFromEcdsaKeyPairAsync(sign);
|
|
38
|
-
return XUtils.deriveLocalAtRestAesKey(id.secretKey, "fips");
|
|
22
|
+
return Promise.resolve(XUtils.deriveLocalAtRestAesKey(id.secretKey));
|
|
39
23
|
}
|