@vex-chat/libvex 5.5.2 → 6.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.map +1 -1
- package/dist/Client.js +76 -21
- 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 +23 -1
- package/dist/__tests__/harness/memory-storage.js.map +1 -1
- package/dist/storage/schema.d.ts +10 -0
- package/dist/storage/schema.d.ts.map +1 -1
- package/dist/storage/sqlite.d.ts +3 -1
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +121 -4
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/types/crypto.d.ts +11 -0
- package/dist/types/crypto.d.ts.map +1 -1
- package/dist/utils/ratchet.d.ts +72 -0
- package/dist/utils/ratchet.d.ts.map +1 -0
- package/dist/utils/ratchet.js +172 -0
- package/dist/utils/ratchet.js.map +1 -0
- package/dist/utils/sqlSessionToCrypto.d.ts.map +1 -1
- package/dist/utils/sqlSessionToCrypto.js +30 -0
- package/dist/utils/sqlSessionToCrypto.js.map +1 -1
- package/package.json +3 -3
- package/src/Client.ts +111 -31
- package/src/__tests__/harness/memory-storage.ts +23 -1
- package/src/__tests__/ratchet.test.ts +141 -0
- package/src/storage/schema.ts +10 -0
- package/src/storage/sqlite.ts +131 -5
- package/src/types/crypto.ts +11 -0
- package/src/utils/ratchet.ts +289 -0
- package/src/utils/sqlSessionToCrypto.ts +30 -0
package/src/storage/sqlite.ts
CHANGED
|
@@ -18,7 +18,6 @@ import type {
|
|
|
18
18
|
SessionRow,
|
|
19
19
|
} from "./schema.js";
|
|
20
20
|
import type { Device, PreKeysSQL, SessionSQL } from "@vex-chat/types";
|
|
21
|
-
import type { Kysely } from "kysely";
|
|
22
21
|
|
|
23
22
|
/**
|
|
24
23
|
* Unified Kysely-based SQLite storage implementation.
|
|
@@ -43,6 +42,7 @@ import {
|
|
|
43
42
|
} from "@vex-chat/crypto";
|
|
44
43
|
|
|
45
44
|
import { EventEmitter } from "eventemitter3";
|
|
45
|
+
import { type Kysely, sql } from "kysely";
|
|
46
46
|
|
|
47
47
|
export class SqliteStorage extends EventEmitter implements Storage {
|
|
48
48
|
public ready = false;
|
|
@@ -273,7 +273,9 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
273
273
|
const rows = await this.db
|
|
274
274
|
.selectFrom("sessions")
|
|
275
275
|
.selectAll()
|
|
276
|
-
.where(
|
|
276
|
+
.where((eb) =>
|
|
277
|
+
eb.or([eb("publicKey", "=", hex), eb("DHr", "=", hex)]),
|
|
278
|
+
)
|
|
277
279
|
.limit(1)
|
|
278
280
|
.execute();
|
|
279
281
|
|
|
@@ -331,7 +333,20 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
331
333
|
.addColumn("mode", "text")
|
|
332
334
|
.addColumn("lastUsed", "text")
|
|
333
335
|
.addColumn("verified", "integer")
|
|
336
|
+
.addColumn("RK", "text")
|
|
337
|
+
.addColumn("DHsPublic", "text")
|
|
338
|
+
.addColumn("DHsPrivate", "text")
|
|
339
|
+
.addColumn("DHr", "text")
|
|
340
|
+
.addColumn("CKs", "text")
|
|
341
|
+
.addColumn("CKr", "text")
|
|
342
|
+
.addColumn("Ns", "integer", (col) => col.defaultTo(0))
|
|
343
|
+
.addColumn("Nr", "integer", (col) => col.defaultTo(0))
|
|
344
|
+
.addColumn("PN", "integer", (col) => col.defaultTo(0))
|
|
345
|
+
.addColumn("skippedKeys", "text", (col) =>
|
|
346
|
+
col.defaultTo("{}"),
|
|
347
|
+
)
|
|
334
348
|
.execute();
|
|
349
|
+
await this.ensureSessionRatchetColumns();
|
|
335
350
|
|
|
336
351
|
await this.db.schema
|
|
337
352
|
.createTable("preKeys")
|
|
@@ -531,24 +546,62 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
531
546
|
if (this.closing) {
|
|
532
547
|
return;
|
|
533
548
|
}
|
|
549
|
+
const sealedCKr = session.CKr ? await this.sealHex(session.CKr) : null;
|
|
550
|
+
const sealedCKs = session.CKs ? await this.sealHex(session.CKs) : null;
|
|
551
|
+
const sealedDHsPrivate = await this.sealHex(session.DHsPrivate);
|
|
552
|
+
const sealedRK = await this.sealHex(session.RK);
|
|
553
|
+
const sealedSK = await this.sealHex(session.SK);
|
|
534
554
|
try {
|
|
535
555
|
await this.db
|
|
536
556
|
.insertInto("sessions")
|
|
537
557
|
.values({
|
|
558
|
+
CKr: sealedCKr,
|
|
559
|
+
CKs: sealedCKs,
|
|
538
560
|
deviceID: session.deviceID,
|
|
561
|
+
DHr: session.DHr,
|
|
562
|
+
DHsPrivate: sealedDHsPrivate,
|
|
563
|
+
DHsPublic: session.DHsPublic,
|
|
539
564
|
fingerprint: session.fingerprint,
|
|
540
565
|
lastUsed: session.lastUsed,
|
|
541
566
|
mode: session.mode,
|
|
567
|
+
Nr: session.Nr,
|
|
568
|
+
Ns: session.Ns,
|
|
569
|
+
PN: session.PN,
|
|
542
570
|
publicKey: session.publicKey,
|
|
571
|
+
RK: sealedRK,
|
|
543
572
|
sessionID: session.sessionID,
|
|
544
|
-
SK:
|
|
573
|
+
SK: sealedSK,
|
|
574
|
+
skippedKeys: session.skippedKeys,
|
|
545
575
|
userID: session.userID,
|
|
546
576
|
verified: session.verified ? 1 : 0,
|
|
547
577
|
})
|
|
548
578
|
.execute();
|
|
549
579
|
} catch (err: unknown) {
|
|
550
580
|
if (this.isDuplicateError(err)) {
|
|
551
|
-
|
|
581
|
+
await this.db
|
|
582
|
+
.updateTable("sessions")
|
|
583
|
+
.set({
|
|
584
|
+
CKr: sealedCKr,
|
|
585
|
+
CKs: sealedCKs,
|
|
586
|
+
deviceID: session.deviceID,
|
|
587
|
+
DHr: session.DHr,
|
|
588
|
+
DHsPrivate: sealedDHsPrivate,
|
|
589
|
+
DHsPublic: session.DHsPublic,
|
|
590
|
+
fingerprint: session.fingerprint,
|
|
591
|
+
lastUsed: session.lastUsed,
|
|
592
|
+
mode: session.mode,
|
|
593
|
+
Nr: session.Nr,
|
|
594
|
+
Ns: session.Ns,
|
|
595
|
+
PN: session.PN,
|
|
596
|
+
publicKey: session.publicKey,
|
|
597
|
+
RK: sealedRK,
|
|
598
|
+
SK: sealedSK,
|
|
599
|
+
skippedKeys: session.skippedKeys,
|
|
600
|
+
userID: session.userID,
|
|
601
|
+
verified: session.verified ? 1 : 0,
|
|
602
|
+
})
|
|
603
|
+
.where("sessionID", "=", session.sessionID)
|
|
604
|
+
.execute();
|
|
552
605
|
} else {
|
|
553
606
|
throw err;
|
|
554
607
|
}
|
|
@@ -614,6 +667,35 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
614
667
|
};
|
|
615
668
|
}
|
|
616
669
|
|
|
670
|
+
private async ensureSessionRatchetColumns(): Promise<void> {
|
|
671
|
+
const add = async (
|
|
672
|
+
column: string,
|
|
673
|
+
type: "integer" | "text",
|
|
674
|
+
defaultSql: null | string = null,
|
|
675
|
+
) => {
|
|
676
|
+
const defaultClause = defaultSql ? ` DEFAULT ${defaultSql}` : "";
|
|
677
|
+
try {
|
|
678
|
+
await sql
|
|
679
|
+
.raw(
|
|
680
|
+
`ALTER TABLE sessions ADD COLUMN ${column} ${type}${defaultClause}`,
|
|
681
|
+
)
|
|
682
|
+
.execute(this.db);
|
|
683
|
+
} catch {
|
|
684
|
+
// Existing databases may already have this column.
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
await add("RK", "text");
|
|
688
|
+
await add("DHsPublic", "text");
|
|
689
|
+
await add("DHsPrivate", "text");
|
|
690
|
+
await add("DHr", "text");
|
|
691
|
+
await add("CKs", "text");
|
|
692
|
+
await add("CKr", "text");
|
|
693
|
+
await add("Ns", "integer", "0");
|
|
694
|
+
await add("Nr", "integer", "0");
|
|
695
|
+
await add("PN", "integer", "0");
|
|
696
|
+
await add("skippedKeys", "text", "'{}'");
|
|
697
|
+
}
|
|
698
|
+
|
|
617
699
|
/**
|
|
618
700
|
* Read `closing` where TypeScript would incorrectly assume it cannot
|
|
619
701
|
* become true after an earlier guard (e.g. across `await`).
|
|
@@ -648,6 +730,24 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
648
730
|
return false;
|
|
649
731
|
}
|
|
650
732
|
|
|
733
|
+
private parseSkippedKeys(raw: string): Record<string, string> {
|
|
734
|
+
try {
|
|
735
|
+
const parsed: unknown = JSON.parse(raw);
|
|
736
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
737
|
+
return {};
|
|
738
|
+
}
|
|
739
|
+
const out: Record<string, string> = {};
|
|
740
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
741
|
+
if (typeof v === "string") {
|
|
742
|
+
out[k] = v;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return out;
|
|
746
|
+
} catch {
|
|
747
|
+
return {};
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
651
751
|
/**
|
|
652
752
|
* Encrypt a hex-encoded secret for at-rest storage.
|
|
653
753
|
* Returns hex(nonce || ciphertext) where nonce is 24 random bytes.
|
|
@@ -669,28 +769,54 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
669
769
|
}
|
|
670
770
|
|
|
671
771
|
private async sessionRowToSQLAsync(row: SessionRow): Promise<SessionSQL> {
|
|
772
|
+
const rawSK = await this.unsealHex(row.SK);
|
|
773
|
+
const rawRK = row.RK ? await this.unsealHex(row.RK) : rawSK;
|
|
672
774
|
return {
|
|
775
|
+
CKr: row.CKr ? await this.unsealHex(row.CKr) : null,
|
|
776
|
+
CKs: row.CKs ? await this.unsealHex(row.CKs) : null,
|
|
673
777
|
deviceID: row.deviceID,
|
|
778
|
+
DHr: row.DHr,
|
|
779
|
+
DHsPrivate: row.DHsPrivate
|
|
780
|
+
? await this.unsealHex(row.DHsPrivate)
|
|
781
|
+
: rawSK,
|
|
782
|
+
DHsPublic: row.DHsPublic,
|
|
674
783
|
fingerprint: row.fingerprint,
|
|
675
784
|
lastUsed: row.lastUsed,
|
|
676
785
|
mode: row.mode === "initiator" ? "initiator" : "receiver",
|
|
786
|
+
Nr: row.Nr,
|
|
787
|
+
Ns: row.Ns,
|
|
788
|
+
PN: row.PN,
|
|
677
789
|
publicKey: row.publicKey,
|
|
790
|
+
RK: rawRK,
|
|
678
791
|
sessionID: row.sessionID,
|
|
679
|
-
SK:
|
|
792
|
+
SK: rawSK,
|
|
793
|
+
skippedKeys: row.skippedKeys,
|
|
680
794
|
userID: row.userID,
|
|
681
795
|
verified: row.verified !== 0,
|
|
682
796
|
};
|
|
683
797
|
}
|
|
684
798
|
|
|
685
799
|
private sqlToCrypto(session: SessionSQL): SessionCrypto {
|
|
800
|
+
const skippedKeys = this.parseSkippedKeys(session.skippedKeys);
|
|
686
801
|
return {
|
|
802
|
+
CKr: session.CKr ? XUtils.decodeHex(session.CKr) : null,
|
|
803
|
+
CKs: session.CKs ? XUtils.decodeHex(session.CKs) : null,
|
|
804
|
+
DHr: session.DHr ? XUtils.decodeHex(session.DHr) : null,
|
|
805
|
+
DHsPrivate: XUtils.decodeHex(session.DHsPrivate),
|
|
806
|
+
DHsPublic: XUtils.decodeHex(session.DHsPublic),
|
|
687
807
|
fingerprint: XUtils.decodeHex(session.fingerprint),
|
|
688
808
|
lastUsed: session.lastUsed,
|
|
689
809
|
mode: session.mode,
|
|
810
|
+
Nr: session.Nr,
|
|
811
|
+
Ns: session.Ns,
|
|
812
|
+
PN: session.PN,
|
|
690
813
|
publicKey: XUtils.decodeHex(session.publicKey),
|
|
814
|
+
RK: XUtils.decodeHex(session.RK),
|
|
691
815
|
sessionID: session.sessionID,
|
|
692
816
|
SK: XUtils.decodeHex(session.SK),
|
|
817
|
+
skippedKeys,
|
|
693
818
|
userID: session.userID,
|
|
819
|
+
verified: session.verified,
|
|
694
820
|
};
|
|
695
821
|
}
|
|
696
822
|
|
package/src/types/crypto.ts
CHANGED
|
@@ -25,14 +25,25 @@ export interface PreKeysCrypto extends UnsavedPreKey {
|
|
|
25
25
|
|
|
26
26
|
/** In-memory representation of an encryption session (not yet persisted to SQL). */
|
|
27
27
|
export interface SessionCrypto {
|
|
28
|
+
CKr: null | Uint8Array;
|
|
29
|
+
CKs: null | Uint8Array;
|
|
30
|
+
DHr: null | Uint8Array;
|
|
31
|
+
DHsPrivate: Uint8Array;
|
|
32
|
+
DHsPublic: Uint8Array;
|
|
28
33
|
fingerprint: Uint8Array;
|
|
29
34
|
lastUsed: string;
|
|
30
35
|
mode: "initiator" | "receiver";
|
|
36
|
+
Nr: number;
|
|
37
|
+
Ns: number;
|
|
38
|
+
PN: number;
|
|
31
39
|
publicKey: Uint8Array;
|
|
40
|
+
RK: Uint8Array;
|
|
32
41
|
sessionID: string;
|
|
33
42
|
/** Shared secret key derived during X3DH. */
|
|
34
43
|
SK: Uint8Array;
|
|
44
|
+
skippedKeys: Record<string, string>;
|
|
35
45
|
userID: string;
|
|
46
|
+
verified: boolean;
|
|
36
47
|
}
|
|
37
48
|
|
|
38
49
|
/** Prekey before DB storage — no index yet. */
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2020-2026 Vex Heavy Industries LLC
|
|
3
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
4
|
+
* Commercial licenses available at vex.wtf
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { RatchetHeader, SessionSQL } from "@vex-chat/types";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
xBoxKeyPairAsync,
|
|
11
|
+
xConcat,
|
|
12
|
+
xDHAsync,
|
|
13
|
+
xHMAC,
|
|
14
|
+
xKDF,
|
|
15
|
+
XUtils,
|
|
16
|
+
} from "@vex-chat/crypto";
|
|
17
|
+
|
|
18
|
+
const VERSION = 1;
|
|
19
|
+
|
|
20
|
+
const encoder = new TextEncoder();
|
|
21
|
+
|
|
22
|
+
export function decodeRatchetHeader(extra: Uint8Array): RatchetHeader {
|
|
23
|
+
if (extra.length < 11) {
|
|
24
|
+
throw new Error("Malformed ratchet header: too short.");
|
|
25
|
+
}
|
|
26
|
+
const view = new DataView(extra.buffer, extra.byteOffset, extra.byteLength);
|
|
27
|
+
const version = view.getUint8(0);
|
|
28
|
+
if (version !== VERSION) {
|
|
29
|
+
throw new Error("Unsupported ratchet header version.");
|
|
30
|
+
}
|
|
31
|
+
const dhLen = view.getUint16(1, false);
|
|
32
|
+
const expected = 3 + dhLen + 8;
|
|
33
|
+
if (extra.length !== expected) {
|
|
34
|
+
throw new Error("Malformed ratchet header length.");
|
|
35
|
+
}
|
|
36
|
+
const dhPub = extra.slice(3, 3 + dhLen);
|
|
37
|
+
const pn = view.getUint32(3 + dhLen, false);
|
|
38
|
+
const n = view.getUint32(7 + dhLen, false);
|
|
39
|
+
return { dhPub, n, pn, version: 1 };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function deriveInitialRootKey(sk: Uint8Array): Uint8Array {
|
|
43
|
+
return xKDF(xConcat(sk, encoder.encode("dr-root-v1")));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function encodeRatchetHeader(header: RatchetHeader): Uint8Array {
|
|
47
|
+
if (header.dhPub.length > 65535) {
|
|
48
|
+
throw new Error("Ratchet header dhPub too large.");
|
|
49
|
+
}
|
|
50
|
+
const out = new Uint8Array(3 + header.dhPub.length + 8);
|
|
51
|
+
const view = new DataView(out.buffer);
|
|
52
|
+
view.setUint8(0, VERSION);
|
|
53
|
+
view.setUint16(1, header.dhPub.length, false);
|
|
54
|
+
out.set(header.dhPub, 3);
|
|
55
|
+
view.setUint32(3 + header.dhPub.length, header.pn, false);
|
|
56
|
+
view.setUint32(7 + header.dhPub.length, header.n, false);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function hasRemoteDhChanged(
|
|
61
|
+
current: null | Uint8Array,
|
|
62
|
+
incoming: Uint8Array,
|
|
63
|
+
): boolean {
|
|
64
|
+
if (!current) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
return !XUtils.bytesEqual(current, incoming);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function initRatchetSession(
|
|
71
|
+
sk: Uint8Array,
|
|
72
|
+
mode: "initiator" | "receiver",
|
|
73
|
+
): Promise<{
|
|
74
|
+
CKr: null | string;
|
|
75
|
+
CKs: null | string;
|
|
76
|
+
DHr: null | string;
|
|
77
|
+
DHsPrivate: string;
|
|
78
|
+
DHsPublic: string;
|
|
79
|
+
Nr: number;
|
|
80
|
+
Ns: number;
|
|
81
|
+
PN: number;
|
|
82
|
+
RK: string;
|
|
83
|
+
skippedKeys: string;
|
|
84
|
+
}> {
|
|
85
|
+
const RK = deriveInitialRootKey(sk);
|
|
86
|
+
const DHs = await xBoxKeyPairAsync();
|
|
87
|
+
const CKs =
|
|
88
|
+
mode === "initiator"
|
|
89
|
+
? xHMAC({ label: "init-send-chain", version: VERSION }, RK)
|
|
90
|
+
: null;
|
|
91
|
+
return {
|
|
92
|
+
CKr: null,
|
|
93
|
+
CKs: CKs ? XUtils.encodeHex(CKs) : null,
|
|
94
|
+
DHr: null,
|
|
95
|
+
DHsPrivate: XUtils.encodeHex(DHs.secretKey),
|
|
96
|
+
DHsPublic: XUtils.encodeHex(DHs.publicKey),
|
|
97
|
+
Nr: 0,
|
|
98
|
+
Ns: 0,
|
|
99
|
+
PN: 0,
|
|
100
|
+
RK: XUtils.encodeHex(RK),
|
|
101
|
+
skippedKeys: "{}",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function ratchetStepReceive(
|
|
106
|
+
state: {
|
|
107
|
+
CKr: null | Uint8Array;
|
|
108
|
+
CKs: null | Uint8Array;
|
|
109
|
+
DHr: null | Uint8Array;
|
|
110
|
+
DHsPrivate: Uint8Array;
|
|
111
|
+
DHsPublic: Uint8Array;
|
|
112
|
+
Nr: number;
|
|
113
|
+
Ns: number;
|
|
114
|
+
PN: number;
|
|
115
|
+
RK: Uint8Array;
|
|
116
|
+
skippedKeys: Record<string, string>;
|
|
117
|
+
},
|
|
118
|
+
remoteDhPub: Uint8Array,
|
|
119
|
+
pn: number,
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
if (state.CKr && state.DHr) {
|
|
122
|
+
while (state.Nr < pn) {
|
|
123
|
+
const { chainKey, messageKey } = kdfChain(state.CKr);
|
|
124
|
+
state.CKr = chainKey;
|
|
125
|
+
state.skippedKeys[skippedKeyId(state.DHr, state.Nr)] =
|
|
126
|
+
XUtils.encodeHex(messageKey);
|
|
127
|
+
state.Nr += 1;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
state.PN = state.Ns;
|
|
132
|
+
state.Ns = 0;
|
|
133
|
+
state.Nr = 0;
|
|
134
|
+
state.CKs = null;
|
|
135
|
+
state.DHr = remoteDhPub;
|
|
136
|
+
|
|
137
|
+
const dhOut = await xDHAsync(state.DHsPrivate, remoteDhPub);
|
|
138
|
+
const recv = kdfRoot(state.RK, dhOut);
|
|
139
|
+
state.RK = recv.rootKey;
|
|
140
|
+
state.CKr = recv.chainKey;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function ratchetStepSend(state: {
|
|
144
|
+
CKr: null | Uint8Array;
|
|
145
|
+
CKs: null | Uint8Array;
|
|
146
|
+
DHr: null | Uint8Array;
|
|
147
|
+
DHsPrivate: Uint8Array;
|
|
148
|
+
DHsPublic: Uint8Array;
|
|
149
|
+
Nr: number;
|
|
150
|
+
Ns: number;
|
|
151
|
+
PN: number;
|
|
152
|
+
RK: Uint8Array;
|
|
153
|
+
skippedKeys: Record<string, string>;
|
|
154
|
+
}): Promise<void> {
|
|
155
|
+
if (!state.DHr) {
|
|
156
|
+
if (!state.CKs) {
|
|
157
|
+
state.CKs = xHMAC(
|
|
158
|
+
{ label: "bootstrap-send-chain", version: VERSION },
|
|
159
|
+
state.RK,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const nextDh = await xBoxKeyPairAsync();
|
|
165
|
+
state.PN = state.Ns;
|
|
166
|
+
state.Ns = 0;
|
|
167
|
+
state.DHsPrivate = nextDh.secretKey;
|
|
168
|
+
state.DHsPublic = nextDh.publicKey;
|
|
169
|
+
const dhOut = await xDHAsync(state.DHsPrivate, state.DHr);
|
|
170
|
+
const send = kdfRoot(state.RK, dhOut);
|
|
171
|
+
state.RK = send.rootKey;
|
|
172
|
+
state.CKs = send.chainKey;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function sessionToSqlPatch(session: {
|
|
176
|
+
CKr: null | Uint8Array;
|
|
177
|
+
CKs: null | Uint8Array;
|
|
178
|
+
DHr: null | Uint8Array;
|
|
179
|
+
DHsPrivate: Uint8Array;
|
|
180
|
+
DHsPublic: Uint8Array;
|
|
181
|
+
Nr: number;
|
|
182
|
+
Ns: number;
|
|
183
|
+
PN: number;
|
|
184
|
+
RK: Uint8Array;
|
|
185
|
+
skippedKeys: Record<string, string>;
|
|
186
|
+
}): Pick<
|
|
187
|
+
SessionSQL,
|
|
188
|
+
| "CKr"
|
|
189
|
+
| "CKs"
|
|
190
|
+
| "DHr"
|
|
191
|
+
| "DHsPrivate"
|
|
192
|
+
| "DHsPublic"
|
|
193
|
+
| "Nr"
|
|
194
|
+
| "Ns"
|
|
195
|
+
| "PN"
|
|
196
|
+
| "RK"
|
|
197
|
+
| "skippedKeys"
|
|
198
|
+
> {
|
|
199
|
+
return {
|
|
200
|
+
CKr: session.CKr ? XUtils.encodeHex(session.CKr) : null,
|
|
201
|
+
CKs: session.CKs ? XUtils.encodeHex(session.CKs) : null,
|
|
202
|
+
DHr: session.DHr ? XUtils.encodeHex(session.DHr) : null,
|
|
203
|
+
DHsPrivate: XUtils.encodeHex(session.DHsPrivate),
|
|
204
|
+
DHsPublic: XUtils.encodeHex(session.DHsPublic),
|
|
205
|
+
Nr: session.Nr,
|
|
206
|
+
Ns: session.Ns,
|
|
207
|
+
PN: session.PN,
|
|
208
|
+
RK: XUtils.encodeHex(session.RK),
|
|
209
|
+
skippedKeys: JSON.stringify(session.skippedKeys),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function takeReceiveMessageKey(
|
|
214
|
+
state: {
|
|
215
|
+
CKr: null | Uint8Array;
|
|
216
|
+
DHr: null | Uint8Array;
|
|
217
|
+
Nr: number;
|
|
218
|
+
skippedKeys: Record<string, string>;
|
|
219
|
+
},
|
|
220
|
+
remoteDhPub: Uint8Array,
|
|
221
|
+
n: number,
|
|
222
|
+
): Uint8Array {
|
|
223
|
+
const skippedId = skippedKeyId(remoteDhPub, n);
|
|
224
|
+
const skipped = state.skippedKeys[skippedId];
|
|
225
|
+
if (skipped) {
|
|
226
|
+
const { [skippedId]: _discarded, ...rest } = state.skippedKeys;
|
|
227
|
+
state.skippedKeys = rest;
|
|
228
|
+
return XUtils.decodeHex(skipped);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!state.CKr) {
|
|
232
|
+
throw new Error("Missing receiving chain key.");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
while (state.Nr < n) {
|
|
236
|
+
const { chainKey, messageKey } = kdfChain(state.CKr);
|
|
237
|
+
state.CKr = chainKey;
|
|
238
|
+
if (!state.DHr) {
|
|
239
|
+
throw new Error("Missing DHr when storing skipped key.");
|
|
240
|
+
}
|
|
241
|
+
state.skippedKeys[skippedKeyId(state.DHr, state.Nr)] =
|
|
242
|
+
XUtils.encodeHex(messageKey);
|
|
243
|
+
state.Nr += 1;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const { chainKey, messageKey } = kdfChain(state.CKr);
|
|
247
|
+
state.CKr = chainKey;
|
|
248
|
+
state.Nr += 1;
|
|
249
|
+
return messageKey;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function takeSendMessageKey(state: {
|
|
253
|
+
CKs: null | Uint8Array;
|
|
254
|
+
Ns: number;
|
|
255
|
+
}): { messageKey: Uint8Array; n: number } {
|
|
256
|
+
if (!state.CKs) {
|
|
257
|
+
throw new Error("Missing sending chain key.");
|
|
258
|
+
}
|
|
259
|
+
const n = state.Ns;
|
|
260
|
+
const { chainKey, messageKey } = kdfChain(state.CKs);
|
|
261
|
+
state.CKs = chainKey;
|
|
262
|
+
state.Ns += 1;
|
|
263
|
+
return { messageKey, n };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function kdfChain(ck: Uint8Array): {
|
|
267
|
+
chainKey: Uint8Array;
|
|
268
|
+
messageKey: Uint8Array;
|
|
269
|
+
} {
|
|
270
|
+
return {
|
|
271
|
+
chainKey: xHMAC({ label: "ck-next", version: VERSION }, ck),
|
|
272
|
+
messageKey: xHMAC({ label: "msg-key", version: VERSION }, ck),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function kdfRoot(
|
|
277
|
+
rootKey: Uint8Array,
|
|
278
|
+
dhOut: Uint8Array,
|
|
279
|
+
): { chainKey: Uint8Array; rootKey: Uint8Array } {
|
|
280
|
+
const material = xKDF(xConcat(rootKey, dhOut, encoder.encode("dr-v1")));
|
|
281
|
+
return {
|
|
282
|
+
chainKey: xHMAC({ label: "chain", version: VERSION }, material),
|
|
283
|
+
rootKey: xHMAC({ label: "root", version: VERSION }, material),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function skippedKeyId(dhPub: Uint8Array, n: number): string {
|
|
288
|
+
return `${XUtils.encodeHex(dhPub)}:${String(n)}`;
|
|
289
|
+
}
|
|
@@ -10,13 +10,43 @@ import type { SessionSQL } from "@vex-chat/types";
|
|
|
10
10
|
import { XUtils } from "@vex-chat/crypto";
|
|
11
11
|
|
|
12
12
|
export function sqlSessionToCrypto(session: SessionSQL): SessionCrypto {
|
|
13
|
+
const skippedKeys = parseSkippedKeys(session.skippedKeys);
|
|
13
14
|
return {
|
|
15
|
+
CKr: session.CKr ? XUtils.decodeHex(session.CKr) : null,
|
|
16
|
+
CKs: session.CKs ? XUtils.decodeHex(session.CKs) : null,
|
|
17
|
+
DHr: session.DHr ? XUtils.decodeHex(session.DHr) : null,
|
|
18
|
+
DHsPrivate: XUtils.decodeHex(session.DHsPrivate),
|
|
19
|
+
DHsPublic: XUtils.decodeHex(session.DHsPublic),
|
|
14
20
|
fingerprint: XUtils.decodeHex(session.fingerprint),
|
|
15
21
|
lastUsed: session.lastUsed,
|
|
16
22
|
mode: session.mode,
|
|
23
|
+
Nr: session.Nr,
|
|
24
|
+
Ns: session.Ns,
|
|
25
|
+
PN: session.PN,
|
|
17
26
|
publicKey: XUtils.decodeHex(session.publicKey),
|
|
27
|
+
RK: XUtils.decodeHex(session.RK),
|
|
18
28
|
sessionID: session.sessionID,
|
|
19
29
|
SK: XUtils.decodeHex(session.SK),
|
|
30
|
+
skippedKeys,
|
|
20
31
|
userID: session.userID,
|
|
32
|
+
verified: session.verified,
|
|
21
33
|
};
|
|
22
34
|
}
|
|
35
|
+
|
|
36
|
+
function parseSkippedKeys(raw: string): Record<string, string> {
|
|
37
|
+
try {
|
|
38
|
+
const parsed: unknown = JSON.parse(raw);
|
|
39
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
const out: Record<string, string> = {};
|
|
43
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
44
|
+
if (typeof v === "string") {
|
|
45
|
+
out[k] = v;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
} catch {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|