@chisquare-tech/yan-ngx-apps-messages 0.1.1 → 0.2.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/fesm2022/{chisquare-tech-yan-ngx-apps-messages-chisquare-tech-yan-ngx-apps-messages-BYCxC81Q.mjs → chisquare-tech-yan-ngx-apps-messages-chisquare-tech-yan-ngx-apps-messages-BBsDA0CG.mjs} +350 -41
- package/fesm2022/chisquare-tech-yan-ngx-apps-messages-chisquare-tech-yan-ngx-apps-messages-BBsDA0CG.mjs.map +1 -0
- package/fesm2022/{chisquare-tech-yan-ngx-apps-messages-messages.page-Dy0RhQwj.mjs → chisquare-tech-yan-ngx-apps-messages-messages.page-Dc4cpq4L.mjs} +84 -7
- package/fesm2022/chisquare-tech-yan-ngx-apps-messages-messages.page-Dc4cpq4L.mjs.map +1 -0
- package/fesm2022/{chisquare-tech-yan-ngx-apps-messages-start.page-D97JIFrb.mjs → chisquare-tech-yan-ngx-apps-messages-start.page-Dnv52Der.mjs} +2 -2
- package/fesm2022/{chisquare-tech-yan-ngx-apps-messages-start.page-D97JIFrb.mjs.map → chisquare-tech-yan-ngx-apps-messages-start.page-Dnv52Der.mjs.map} +1 -1
- package/fesm2022/chisquare-tech-yan-ngx-apps-messages.mjs +1 -1
- package/package.json +2 -2
- package/types/chisquare-tech-yan-ngx-apps-messages.d.ts +62 -2
- package/fesm2022/chisquare-tech-yan-ngx-apps-messages-chisquare-tech-yan-ngx-apps-messages-BYCxC81Q.mjs.map +0 -1
- package/fesm2022/chisquare-tech-yan-ngx-apps-messages-messages.page-Dy0RhQwj.mjs.map +0 -1
|
@@ -5,10 +5,10 @@ import { firstValueFrom } from 'rxjs';
|
|
|
5
5
|
|
|
6
6
|
/** Mounted at `messages`. `to/:userId` opens (or starts) the direct conversation with someone, then replaces itself. */
|
|
7
7
|
const MESSAGES_ROUTES = [
|
|
8
|
-
{ path: '', pathMatch: 'full', loadComponent: () => import('./chisquare-tech-yan-ngx-apps-messages-messages.page-
|
|
9
|
-
{ path: 'requests', loadComponent: () => import('./chisquare-tech-yan-ngx-apps-messages-messages.page-
|
|
10
|
-
{ path: 'to/:userId', loadComponent: () => import('./chisquare-tech-yan-ngx-apps-messages-start.page-
|
|
11
|
-
{ path: ':id', loadComponent: () => import('./chisquare-tech-yan-ngx-apps-messages-messages.page-
|
|
8
|
+
{ path: '', pathMatch: 'full', loadComponent: () => import('./chisquare-tech-yan-ngx-apps-messages-messages.page-Dc4cpq4L.mjs').then((m) => m.MessagesPage) },
|
|
9
|
+
{ path: 'requests', loadComponent: () => import('./chisquare-tech-yan-ngx-apps-messages-messages.page-Dc4cpq4L.mjs').then((m) => m.MessagesPage) },
|
|
10
|
+
{ path: 'to/:userId', loadComponent: () => import('./chisquare-tech-yan-ngx-apps-messages-start.page-Dnv52Der.mjs').then((m) => m.StartPage) },
|
|
11
|
+
{ path: ':id', loadComponent: () => import('./chisquare-tech-yan-ngx-apps-messages-messages.page-Dc4cpq4L.mjs').then((m) => m.MessagesPage) },
|
|
12
12
|
];
|
|
13
13
|
|
|
14
14
|
// The `dm` channel of Yan's one WebSocket (/ws, cloud/server/realtime): auth with an access token in the first frame
|
|
@@ -124,6 +124,15 @@ class DmSocket {
|
|
|
124
124
|
// - its plaintext: {"t":"yan.room_key","conversationId","epoch","sessionId","sessionKey",
|
|
125
125
|
// "sender":{"userId","deviceId","ed25519"},"recipient":{"userId","deviceId","ed25519"}}
|
|
126
126
|
// - a message's Megolm plaintext: {"v":1,"conversationId","kind","text"}
|
|
127
|
+
// - history for a new device of yours (owner-approved, "Verifying a new device" in the yan-e2ee README):
|
|
128
|
+
// new → old {"t":"yan.history_request","requestId","commitment":sha256(n2),"requester":Party}
|
|
129
|
+
// old → new {"t":"yan.history_challenge","requestId","nonce":n1,"approver":Party,"recipient":Party}
|
|
130
|
+
// new → old {"t":"yan.history_reveal","requestId","nonce":n2,"requester":Party,"recipient":Party}
|
|
131
|
+
// both show verification_code("yan.verify.v1|user|newDevice|newEd|oldDevice|oldEd|n1|n2"); the person allows
|
|
132
|
+
// it on the old device, which then sends
|
|
133
|
+
// old → new {"t":"yan.forwarded_room_keys","requestId","keys":[{conversationId,sessionId,senderUserId,
|
|
134
|
+
// senderDeviceId,exported}],"sender":Party,"recipient":Party}
|
|
135
|
+
// The commitment means neither side (nor a server posing as one) can steer the code.
|
|
127
136
|
// A room key is accepted only when it names this device as the recipient and its sender matches the device that
|
|
128
137
|
// sent it (its keys as the server published them, signed by itself). A message is shown only when its session's
|
|
129
138
|
// conversation and sender match the message's own.
|
|
@@ -164,6 +173,9 @@ function keysToGenerate(onServer, target = KEY_TARGET) {
|
|
|
164
173
|
return onServer >= target / 2 ? 0 : target - onServer;
|
|
165
174
|
}
|
|
166
175
|
const MAX_OLM_PER_PEER = 5;
|
|
176
|
+
/** forwarded sessions per to-device message (each well under the server's 64 KB) */
|
|
177
|
+
const KEYS_PER_MESSAGE = 40;
|
|
178
|
+
const verifyText = (userId, requester, approver, n1, n2) => `yan.verify.v1|${userId}|${requester.deviceId}|${requester.ed25519}|${approver.deviceId}|${approver.ed25519}|${n1}|${n2}`;
|
|
167
179
|
/** One person's encryption on one device. */
|
|
168
180
|
class E2eeMachine {
|
|
169
181
|
constructor(o) {
|
|
@@ -479,6 +491,27 @@ class E2eeMachine {
|
|
|
479
491
|
async receive(it) {
|
|
480
492
|
if (it.type !== OLM)
|
|
481
493
|
return { added: 0, newSession: false };
|
|
494
|
+
const { from, plaintext, newSession } = await this.openOlm(it);
|
|
495
|
+
const msg = JSON.parse(plaintext);
|
|
496
|
+
const me = { userId: this.userId, deviceId: this.dev.deviceId, ed25519: this.ed };
|
|
497
|
+
const sender = { userId: from.userId, deviceId: from.deviceId, ed25519: from.ed25519 };
|
|
498
|
+
switch (msg.t) {
|
|
499
|
+
case 'yan.room_key':
|
|
500
|
+
return { added: await this.roomKey(msg, me, sender), newSession };
|
|
501
|
+
case 'yan.history_request':
|
|
502
|
+
case 'yan.history_challenge':
|
|
503
|
+
case 'yan.history_reveal':
|
|
504
|
+
case 'yan.forwarded_room_keys':
|
|
505
|
+
// only between your own devices
|
|
506
|
+
if (from.userId !== this.userId || from.deviceId === this.dev.deviceId)
|
|
507
|
+
throw new Error('history from someone else');
|
|
508
|
+
return { added: await this.history(msg, me, sender, from), newSession };
|
|
509
|
+
default:
|
|
510
|
+
throw new Error('unknown payload');
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
/** Decrypts an Olm to-device payload from a device whose keys check out. */
|
|
514
|
+
async openOlm(it) {
|
|
482
515
|
const env = JSON.parse(it.body);
|
|
483
516
|
if (env.v !== 1)
|
|
484
517
|
throw new Error('unknown envelope');
|
|
@@ -491,15 +524,13 @@ class E2eeMachine {
|
|
|
491
524
|
}
|
|
492
525
|
if (!from)
|
|
493
526
|
throw new Error('unknown sender device');
|
|
494
|
-
let plaintext = null;
|
|
495
|
-
let newSession = false;
|
|
496
527
|
const rec = await this.olmSessions(from.curve25519);
|
|
497
528
|
for (const s of rec.sessions) {
|
|
498
529
|
const sess = this.w.Session.from_pickle(s.pickle, this.key);
|
|
499
530
|
try {
|
|
500
|
-
plaintext = sess.decrypt(env.type, env.body);
|
|
531
|
+
const plaintext = sess.decrypt(env.type, env.body);
|
|
501
532
|
await this.saveOlm(from.curve25519, sess, rec);
|
|
502
|
-
|
|
533
|
+
return { from, plaintext, newSession: false };
|
|
503
534
|
}
|
|
504
535
|
catch {
|
|
505
536
|
// not this session
|
|
@@ -508,47 +539,248 @@ class E2eeMachine {
|
|
|
508
539
|
sess.free();
|
|
509
540
|
}
|
|
510
541
|
}
|
|
511
|
-
if (
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
acct.free();
|
|
526
|
-
}
|
|
542
|
+
if (env.type !== 0)
|
|
543
|
+
throw new Error('no session for this message');
|
|
544
|
+
const acct = this.account();
|
|
545
|
+
try {
|
|
546
|
+
const inbound = acct.create_inbound_session(from.curve25519, env.type, env.body);
|
|
547
|
+
const plaintext = inbound.plaintext;
|
|
548
|
+
const sess = inbound.take_session();
|
|
549
|
+
await this.saveDevice(acct); // its one-time key is spent
|
|
550
|
+
await this.saveOlm(from.curve25519, sess, rec);
|
|
551
|
+
sess.free();
|
|
552
|
+
return { from, plaintext, newSession: true };
|
|
553
|
+
}
|
|
554
|
+
finally {
|
|
555
|
+
acct.free();
|
|
527
556
|
}
|
|
528
|
-
|
|
529
|
-
|
|
557
|
+
}
|
|
558
|
+
async roomKey(k, me, from) {
|
|
559
|
+
const problem = roomKeyProblem(k, me, from);
|
|
530
560
|
if (problem)
|
|
531
561
|
throw new Error(problem);
|
|
532
562
|
const ib = new this.w.InboundGroupSession(k.sessionKey);
|
|
533
563
|
try {
|
|
534
564
|
if (ib.session_id !== k.sessionId)
|
|
535
565
|
throw new Error('session id mismatch');
|
|
536
|
-
|
|
537
|
-
if (existing) {
|
|
538
|
-
const e = JSON.parse(existing);
|
|
539
|
-
if (e.senderUserId !== from.userId || e.senderDeviceId !== from.deviceId || e.conversationId !== k.conversationId)
|
|
540
|
-
throw new Error('session claimed by another sender');
|
|
541
|
-
if (e.firstIndex <= ib.first_known_index)
|
|
542
|
-
return { added: 0, newSession };
|
|
543
|
-
}
|
|
544
|
-
const r = { pickle: ib.pickle(this.key), conversationId: k.conversationId, senderUserId: from.userId, senderDeviceId: from.deviceId, firstIndex: ib.first_known_index };
|
|
545
|
-
await this.store.set(`in:${k.sessionId}`, JSON.stringify(r));
|
|
546
|
-
return { added: 1, newSession };
|
|
566
|
+
return await this.keep(ib, k.conversationId, from.userId, from.deviceId);
|
|
547
567
|
}
|
|
548
568
|
finally {
|
|
549
569
|
ib.free();
|
|
550
570
|
}
|
|
551
571
|
}
|
|
572
|
+
/** Stores an inbound session unless we hold one that opens as much or more. */
|
|
573
|
+
async keep(ib, conversationId, senderUserId, senderDeviceId) {
|
|
574
|
+
const id = ib.session_id;
|
|
575
|
+
const existing = await this.store.get(`in:${id}`);
|
|
576
|
+
if (existing) {
|
|
577
|
+
const e = JSON.parse(existing);
|
|
578
|
+
if (e.senderUserId !== senderUserId || e.senderDeviceId !== senderDeviceId || e.conversationId !== conversationId)
|
|
579
|
+
throw new Error('session claimed by another sender');
|
|
580
|
+
if (e.firstIndex <= ib.first_known_index)
|
|
581
|
+
return 0;
|
|
582
|
+
}
|
|
583
|
+
const r = { pickle: ib.pickle(this.key), conversationId, senderUserId, senderDeviceId, firstIndex: ib.first_known_index };
|
|
584
|
+
await this.store.set(`in:${id}`, JSON.stringify(r));
|
|
585
|
+
return 1;
|
|
586
|
+
}
|
|
587
|
+
// ---- history for a new device of yours ----
|
|
588
|
+
async sendOlm(to, payload) {
|
|
589
|
+
const sessions = await this.olmTo(to);
|
|
590
|
+
const messages = [];
|
|
591
|
+
const sent = [];
|
|
592
|
+
for (const d of to) {
|
|
593
|
+
const s = sessions.get(d.curve25519);
|
|
594
|
+
if (!s)
|
|
595
|
+
continue;
|
|
596
|
+
const sealed = s.encrypt(JSON.stringify(payload(d)));
|
|
597
|
+
await this.saveOlm(d.curve25519, s);
|
|
598
|
+
s.free();
|
|
599
|
+
messages.push({ userId: d.userId, deviceId: d.deviceId, type: OLM, body: JSON.stringify({ v: 1, type: sealed.message_type, body: sealed.body }) });
|
|
600
|
+
sent.push(d.deviceId);
|
|
601
|
+
}
|
|
602
|
+
for (let i = 0; i < messages.length; i += 200)
|
|
603
|
+
await this.api.sendToDevice(this.dev.deviceId, messages.slice(i, i + 200));
|
|
604
|
+
return sent;
|
|
605
|
+
}
|
|
606
|
+
party(d) {
|
|
607
|
+
return { userId: d.userId, deviceId: d.deviceId, ed25519: d.ed25519 };
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Asks your other devices for the keys to messages sent before this one was set up. Each that answers agrees a code
|
|
611
|
+
* with this device; you allow it there after checking the codes match. Answers false when there's nobody to ask.
|
|
612
|
+
*/
|
|
613
|
+
requestHistory() {
|
|
614
|
+
return this.lock(async () => {
|
|
615
|
+
this.forgetDevices(this.userId);
|
|
616
|
+
const mine = (await this.devicesOf(this.userId)).filter((d) => d.deviceId !== this.dev.deviceId);
|
|
617
|
+
if (!mine.length) {
|
|
618
|
+
await this.store.set('history:out', JSON.stringify({ requestId: '', nonce: '', startedAt: new Date().toISOString(), asked: [], codes: {}, received: 0 }));
|
|
619
|
+
return false;
|
|
620
|
+
}
|
|
621
|
+
const req = { requestId: 'h_' + randomB64(12), nonce: randomB64(16), startedAt: new Date().toISOString(), asked: [], codes: {}, received: 0 };
|
|
622
|
+
const me = { userId: this.userId, deviceId: this.dev.deviceId, ed25519: this.ed };
|
|
623
|
+
req.asked = await this.sendOlm(mine, () => ({ t: 'yan.history_request', requestId: req.requestId, commitment: this.w.sha256_base64(req.nonce), requester: me }));
|
|
624
|
+
await this.store.set('history:out', JSON.stringify(req));
|
|
625
|
+
return req.asked.length > 0;
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
/** Where this device's request for history stands. */
|
|
629
|
+
historyStatus() {
|
|
630
|
+
return this.lock(async () => {
|
|
631
|
+
const raw = await this.store.get('history:out');
|
|
632
|
+
if (!raw)
|
|
633
|
+
return { state: 'none', codes: [], received: 0 };
|
|
634
|
+
const r = JSON.parse(raw);
|
|
635
|
+
if (!r.requestId)
|
|
636
|
+
return { state: 'alone', codes: [], received: 0 };
|
|
637
|
+
const names = new Map((await this.devicesOf(this.userId)).map((d) => [d.deviceId, d.name || 'Another device']));
|
|
638
|
+
return {
|
|
639
|
+
state: r.done ? 'received' : 'asked',
|
|
640
|
+
codes: Object.entries(r.codes).map(([deviceId, code]) => ({ deviceId, deviceName: names.get(deviceId) ?? 'Another device', code })),
|
|
641
|
+
received: r.received,
|
|
642
|
+
};
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
/** New devices of yours waiting for you to allow them here, each with the code it should be showing. */
|
|
646
|
+
pendingApprovals() {
|
|
647
|
+
return this.lock(async () => {
|
|
648
|
+
const out = [];
|
|
649
|
+
const devices = await this.devicesOf(this.userId);
|
|
650
|
+
for (const k of await this.store.keys('history:in:')) {
|
|
651
|
+
const r = JSON.parse((await this.store.get(k)));
|
|
652
|
+
if (r.state !== 'ready' || !r.code)
|
|
653
|
+
continue;
|
|
654
|
+
const d = devices.find((x) => x.deviceId === r.requester.deviceId && x.ed25519 === r.requester.ed25519);
|
|
655
|
+
if (!d)
|
|
656
|
+
continue; // signed out since
|
|
657
|
+
out.push({ requestId: r.requestId, deviceId: d.deviceId, deviceName: d.name || 'A new device', code: r.code, createdAt: r.createdAt });
|
|
658
|
+
}
|
|
659
|
+
return out;
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
/** You checked the codes match: send that device every session key this one holds. Answers how many. */
|
|
663
|
+
approveHistory(requestId) {
|
|
664
|
+
return this.lock(async () => {
|
|
665
|
+
const key = `history:in:${requestId}`;
|
|
666
|
+
const raw = await this.store.get(key);
|
|
667
|
+
if (!raw)
|
|
668
|
+
throw new Error('no such request');
|
|
669
|
+
const r = JSON.parse(raw);
|
|
670
|
+
if (r.state !== 'ready')
|
|
671
|
+
throw new Error('this request is not waiting');
|
|
672
|
+
const d = (await this.devicesOf(this.userId)).find((x) => x.deviceId === r.requester.deviceId && x.ed25519 === r.requester.ed25519);
|
|
673
|
+
if (!d)
|
|
674
|
+
throw new Error('that device is gone');
|
|
675
|
+
const keys = [];
|
|
676
|
+
for (const k of await this.store.keys('in:')) {
|
|
677
|
+
const rec = JSON.parse((await this.store.get(k)));
|
|
678
|
+
const ib = this.w.InboundGroupSession.from_pickle(rec.pickle, this.key);
|
|
679
|
+
keys.push({ conversationId: rec.conversationId, sessionId: ib.session_id, senderUserId: rec.senderUserId, senderDeviceId: rec.senderDeviceId, exported: ib.export() });
|
|
680
|
+
ib.free();
|
|
681
|
+
}
|
|
682
|
+
const me = { userId: this.userId, deviceId: this.dev.deviceId, ed25519: this.ed };
|
|
683
|
+
// at least one message, so the new device hears back even when there's nothing to forward
|
|
684
|
+
for (let i = 0; i === 0 || i < keys.length; i += KEYS_PER_MESSAGE) {
|
|
685
|
+
await this.sendOlm([d], () => ({ t: 'yan.forwarded_room_keys', requestId, keys: keys.slice(i, i + KEYS_PER_MESSAGE), sender: me, recipient: this.party(d) }));
|
|
686
|
+
}
|
|
687
|
+
r.state = 'sent';
|
|
688
|
+
await this.store.set(key, JSON.stringify(r));
|
|
689
|
+
return keys.length;
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
/** Not yours, or you don't want it to read your history: nothing is sent. */
|
|
693
|
+
denyHistory(requestId) {
|
|
694
|
+
return this.lock(async () => {
|
|
695
|
+
const key = `history:in:${requestId}`;
|
|
696
|
+
const raw = await this.store.get(key);
|
|
697
|
+
if (!raw)
|
|
698
|
+
return;
|
|
699
|
+
await this.store.set(key, JSON.stringify({ ...JSON.parse(raw), state: 'denied' }));
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
async history(m, me, sender, from) {
|
|
703
|
+
const requestId = typeof m['requestId'] === 'string' ? m['requestId'] : '';
|
|
704
|
+
if (!/^h_[A-Za-z0-9+/]{16}$/.test(requestId))
|
|
705
|
+
throw new Error('bad request id');
|
|
706
|
+
switch (m.t) {
|
|
707
|
+
case 'yan.history_request': {
|
|
708
|
+
// an existing device: a new one of yours asks; answer with our nonce, then wait for theirs
|
|
709
|
+
if (!same(m['requester'], sender))
|
|
710
|
+
throw new Error('requester mismatch');
|
|
711
|
+
const key = `history:in:${requestId}`;
|
|
712
|
+
if (await this.store.get(key))
|
|
713
|
+
return 0;
|
|
714
|
+
const r = { requestId, requester: sender, commitment: String(m['commitment'] ?? ''), nonce: randomB64(16), state: 'challenged', createdAt: new Date().toISOString() };
|
|
715
|
+
await this.store.set(key, JSON.stringify(r));
|
|
716
|
+
await this.sendOlm([from], () => ({ t: 'yan.history_challenge', requestId, nonce: r.nonce, approver: me, recipient: sender }));
|
|
717
|
+
return 0;
|
|
718
|
+
}
|
|
719
|
+
case 'yan.history_challenge': {
|
|
720
|
+
// the new device: an existing one answered; reveal our nonce, and show the code
|
|
721
|
+
if (!same(m['approver'], sender) || !same(m['recipient'], me))
|
|
722
|
+
throw new Error('challenge mismatch');
|
|
723
|
+
const raw = await this.store.get('history:out');
|
|
724
|
+
const out = raw ? JSON.parse(raw) : null;
|
|
725
|
+
if (!out || out.requestId !== requestId || out.codes[sender.deviceId])
|
|
726
|
+
return 0;
|
|
727
|
+
out.codes[sender.deviceId] = this.w.verification_code(verifyText(this.userId, me, sender, String(m['nonce']), out.nonce));
|
|
728
|
+
await this.store.set('history:out', JSON.stringify(out));
|
|
729
|
+
await this.sendOlm([from], () => ({ t: 'yan.history_reveal', requestId, nonce: out.nonce, requester: me, recipient: sender }));
|
|
730
|
+
return 0;
|
|
731
|
+
}
|
|
732
|
+
case 'yan.history_reveal': {
|
|
733
|
+
// an existing device: check the nonce against the commitment; the request now waits for you to allow it
|
|
734
|
+
if (!same(m['requester'], sender) || !same(m['recipient'], me))
|
|
735
|
+
throw new Error('reveal mismatch');
|
|
736
|
+
const key = `history:in:${requestId}`;
|
|
737
|
+
const raw = await this.store.get(key);
|
|
738
|
+
const r = raw ? JSON.parse(raw) : null;
|
|
739
|
+
if (!r || r.state !== 'challenged' || !same(r.requester, sender))
|
|
740
|
+
return 0;
|
|
741
|
+
const n2 = String(m['nonce'] ?? '');
|
|
742
|
+
if (this.w.sha256_base64(n2) !== r.commitment) {
|
|
743
|
+
r.state = 'denied';
|
|
744
|
+
await this.store.set(key, JSON.stringify(r));
|
|
745
|
+
throw new Error('the nonce does not match its commitment');
|
|
746
|
+
}
|
|
747
|
+
r.code = this.w.verification_code(verifyText(this.userId, sender, me, r.nonce, n2));
|
|
748
|
+
r.state = 'ready';
|
|
749
|
+
await this.store.set(key, JSON.stringify(r));
|
|
750
|
+
return 0;
|
|
751
|
+
}
|
|
752
|
+
case 'yan.forwarded_room_keys': {
|
|
753
|
+
// the new device: only from a device we agreed a code with, for our request
|
|
754
|
+
if (!same(m['sender'], sender) || !same(m['recipient'], me))
|
|
755
|
+
throw new Error('forward mismatch');
|
|
756
|
+
const raw = await this.store.get('history:out');
|
|
757
|
+
const out = raw ? JSON.parse(raw) : null;
|
|
758
|
+
if (!out || out.requestId !== requestId || !out.codes[sender.deviceId])
|
|
759
|
+
throw new Error('keys nobody asked for');
|
|
760
|
+
let added = 0;
|
|
761
|
+
for (const k of m['keys'] ?? []) {
|
|
762
|
+
let ib = null;
|
|
763
|
+
try {
|
|
764
|
+
ib = this.w.InboundGroupSession.import(k.exported);
|
|
765
|
+
if (ib.session_id !== k.sessionId)
|
|
766
|
+
continue;
|
|
767
|
+
added += await this.keep(ib, k.conversationId, k.senderUserId, k.senderDeviceId);
|
|
768
|
+
}
|
|
769
|
+
catch (e) {
|
|
770
|
+
this.log(`forwarded key ${k.sessionId}: ${e.message}`);
|
|
771
|
+
}
|
|
772
|
+
finally {
|
|
773
|
+
ib?.free();
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
out.received += added;
|
|
777
|
+
out.done = true;
|
|
778
|
+
await this.store.set('history:out', JSON.stringify(out));
|
|
779
|
+
return added;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return 0;
|
|
783
|
+
}
|
|
552
784
|
/** Decrypts a message; when its key hasn't arrived, reads the inbox once and tries again. */
|
|
553
785
|
decrypt(msg) {
|
|
554
786
|
return this.lock(async () => {
|
|
@@ -586,6 +818,11 @@ class E2eeMachine {
|
|
|
586
818
|
});
|
|
587
819
|
}
|
|
588
820
|
}
|
|
821
|
+
function randomB64(bytes) {
|
|
822
|
+
const b = new Uint8Array(bytes);
|
|
823
|
+
crypto.getRandomValues(b);
|
|
824
|
+
return btoa(String.fromCharCode(...b)).replace(/=+$/, '');
|
|
825
|
+
}
|
|
589
826
|
/** web_ + 16 random hex characters. */
|
|
590
827
|
function randomDeviceId() {
|
|
591
828
|
const b = new Uint8Array(8);
|
|
@@ -629,6 +866,10 @@ function idbStore(userId) {
|
|
|
629
866
|
get: (key) => req(KV, 'readonly', (s) => s.get(k(key))),
|
|
630
867
|
set: (key, value) => req(KV, 'readwrite', (s) => s.put(value, k(key))),
|
|
631
868
|
del: (key) => req(KV, 'readwrite', (s) => s.delete(k(key))),
|
|
869
|
+
keys: async (prefix) => {
|
|
870
|
+
const all = await req(KV, 'readonly', (s) => s.getAllKeys(IDBKeyRange.bound(k(prefix), k(prefix) + '\uffff')));
|
|
871
|
+
return all.map((x) => String(x).slice(userId.length + 1));
|
|
872
|
+
},
|
|
632
873
|
};
|
|
633
874
|
}
|
|
634
875
|
/** This person's pickle key on this browser, made on first use. */
|
|
@@ -840,6 +1081,10 @@ class MessagingApi {
|
|
|
840
1081
|
uploadKeys(deviceId, body) {
|
|
841
1082
|
return this.send('POST', `/devices/${encodeURIComponent(deviceId)}/keys`, body);
|
|
842
1083
|
}
|
|
1084
|
+
/** Signs this device out of messaging: its keys and inbox go from the server. */
|
|
1085
|
+
async revokeDevice(deviceId) {
|
|
1086
|
+
await this.send('DELETE', `/devices/${encodeURIComponent(deviceId)}`);
|
|
1087
|
+
}
|
|
843
1088
|
async keyCount(deviceId) {
|
|
844
1089
|
try {
|
|
845
1090
|
return await this.get(`/devices/${encodeURIComponent(deviceId)}/keys/count`);
|
|
@@ -902,8 +1147,12 @@ class MessagesStore {
|
|
|
902
1147
|
/** conversation id → who is typing there */
|
|
903
1148
|
this.typing = signal({}, ...(ngDevMode ? [{ debugName: "typing" }] : /* istanbul ignore next */ []));
|
|
904
1149
|
this.crypto = signal('idle', ...(ngDevMode ? [{ debugName: "crypto" }] : /* istanbul ignore next */ []));
|
|
905
|
-
/**
|
|
906
|
-
this.
|
|
1150
|
+
/** New devices of yours asking, here, for your message history. */
|
|
1151
|
+
this.approvals = signal([], ...(ngDevMode ? [{ debugName: "approvals" }] : /* istanbul ignore next */ []));
|
|
1152
|
+
/** This device's request for history (when it can't read older messages). */
|
|
1153
|
+
this.history = signal({ state: 'none', codes: [], received: 0 }, ...(ngDevMode ? [{ debugName: "history" }] : /* istanbul ignore next */ []));
|
|
1154
|
+
/** For the nav badge: unread conversations, waiting requests, and devices asking for your history. */
|
|
1155
|
+
this.badge = computed(() => this.inbox().reduce((n, c) => n + (c.unread > 0 ? 1 : 0), 0) + this.requests().length + this.approvals().length, ...(ngDevMode ? [{ debugName: "badge" }] : /* istanbul ignore next */ []));
|
|
907
1156
|
this.threads = new Map();
|
|
908
1157
|
this.machine = null;
|
|
909
1158
|
this.machineUser = null;
|
|
@@ -941,7 +1190,33 @@ class MessagesStore {
|
|
|
941
1190
|
if (this.machineUser && this.machineUser !== me)
|
|
942
1191
|
this.stop();
|
|
943
1192
|
void this.loadLists();
|
|
944
|
-
void this.ensureMachine().then(
|
|
1193
|
+
void this.ensureMachine().then(async (m) => {
|
|
1194
|
+
this.openSocket();
|
|
1195
|
+
// what arrived while this device was away (room keys, history requests)
|
|
1196
|
+
await m.sync().catch(() => 0);
|
|
1197
|
+
await this.refreshHistory();
|
|
1198
|
+
}, () => undefined);
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Signing out on purpose: this device leaves messaging (the server drops its keys and inbox) and the browser forgets
|
|
1202
|
+
* its keys, so nobody using it next can read your messages. Signing in again makes a new device, which reads older
|
|
1203
|
+
* messages once you allow it from another device.
|
|
1204
|
+
*/
|
|
1205
|
+
async signOutDevice() {
|
|
1206
|
+
const me = this.me();
|
|
1207
|
+
if (!me)
|
|
1208
|
+
return;
|
|
1209
|
+
this.stop();
|
|
1210
|
+
try {
|
|
1211
|
+
const raw = await idbStore(me).get('device');
|
|
1212
|
+
const id = raw ? JSON.parse(raw).deviceId : undefined;
|
|
1213
|
+
if (id)
|
|
1214
|
+
await this.api.revokeDevice(id).catch(() => undefined);
|
|
1215
|
+
await forget(me);
|
|
1216
|
+
}
|
|
1217
|
+
catch {
|
|
1218
|
+
// storage unavailable: nothing was kept here
|
|
1219
|
+
}
|
|
945
1220
|
}
|
|
946
1221
|
/** Signing out, or another person signing in. */
|
|
947
1222
|
stop() {
|
|
@@ -954,6 +1229,8 @@ class MessagesStore {
|
|
|
954
1229
|
this.inbox.set([]);
|
|
955
1230
|
this.requests.set([]);
|
|
956
1231
|
this.previews.set({});
|
|
1232
|
+
this.approvals.set([]);
|
|
1233
|
+
this.history.set({ state: 'none', codes: [], received: 0 });
|
|
957
1234
|
this.loaded.set(false);
|
|
958
1235
|
}
|
|
959
1236
|
/** This device's encryption: loads the wasm module, then opens (or makes and publishes) the device's keys. */
|
|
@@ -1074,6 +1351,8 @@ class MessagesStore {
|
|
|
1074
1351
|
const items = await this.open(page.items.slice().reverse());
|
|
1075
1352
|
t.set({ conv, items: this.keepPending(t().items, items), next: page.next, loading: false, failed: false, missing: false });
|
|
1076
1353
|
this.markRead(conv, items);
|
|
1354
|
+
if (items.some((b) => b.unreadable === 'missing-key'))
|
|
1355
|
+
void this.askForHistory();
|
|
1077
1356
|
}
|
|
1078
1357
|
catch (e) {
|
|
1079
1358
|
const status = e.status;
|
|
@@ -1216,6 +1495,35 @@ class MessagesStore {
|
|
|
1216
1495
|
this.requests.update((l) => l.filter((x) => x.id !== id));
|
|
1217
1496
|
this.threads.delete(id);
|
|
1218
1497
|
}
|
|
1498
|
+
// ---- history for a new device ----
|
|
1499
|
+
/** Asks your other devices for older messages' keys, once per page load unless you ask again. */
|
|
1500
|
+
async askForHistory(again = false) {
|
|
1501
|
+
const m = await this.machine?.catch(() => null);
|
|
1502
|
+
if (!m)
|
|
1503
|
+
return;
|
|
1504
|
+
const st = await m.historyStatus();
|
|
1505
|
+
if (!again && st.state !== 'none')
|
|
1506
|
+
return void this.history.set(st);
|
|
1507
|
+
await m.requestHistory().catch(() => false);
|
|
1508
|
+
await this.refreshHistory();
|
|
1509
|
+
}
|
|
1510
|
+
async approve(requestId) {
|
|
1511
|
+
const m = await this.ensureMachine();
|
|
1512
|
+
await m.approveHistory(requestId);
|
|
1513
|
+
await this.refreshHistory();
|
|
1514
|
+
}
|
|
1515
|
+
async deny(requestId) {
|
|
1516
|
+
const m = await this.ensureMachine();
|
|
1517
|
+
await m.denyHistory(requestId);
|
|
1518
|
+
await this.refreshHistory();
|
|
1519
|
+
}
|
|
1520
|
+
async refreshHistory() {
|
|
1521
|
+
const m = await this.machine?.catch(() => null);
|
|
1522
|
+
if (!m)
|
|
1523
|
+
return;
|
|
1524
|
+
this.approvals.set(await m.pendingApprovals().catch(() => []));
|
|
1525
|
+
this.history.set(await m.historyStatus().catch(() => this.history()));
|
|
1526
|
+
}
|
|
1219
1527
|
// ---- typing ----
|
|
1220
1528
|
typed(id) {
|
|
1221
1529
|
const now = Date.now();
|
|
@@ -1293,6 +1601,7 @@ class MessagesStore {
|
|
|
1293
1601
|
if (!m)
|
|
1294
1602
|
return;
|
|
1295
1603
|
await m.sync().catch(() => 0);
|
|
1604
|
+
await this.refreshHistory();
|
|
1296
1605
|
for (const t of this.threads.values()) {
|
|
1297
1606
|
if (!t().items.some((b) => b.state === 'unreadable'))
|
|
1298
1607
|
continue;
|
|
@@ -1354,4 +1663,4 @@ function randomHex(n) {
|
|
|
1354
1663
|
*/
|
|
1355
1664
|
|
|
1356
1665
|
export { E2EE_LABEL as E, MessagesStore as M, OLM as O, MESSAGES_HOST as a, personName as b, conversationName as c, errorText as d, expiresIn as e, errorCode as f, E2eeMachine as g, MEGOLM as h, MESSAGES_API_BASE as i, MESSAGES_E2EE_WASM as j, kindLabel as k, MESSAGES_ROUTES as l, MESSAGES_SOCIAL_BASE as m, MessagingApi as n, others as o, pendingFor as p, forget as q, requestsLine as r, seen as s };
|
|
1357
|
-
//# sourceMappingURL=chisquare-tech-yan-ngx-apps-messages-chisquare-tech-yan-ngx-apps-messages-
|
|
1666
|
+
//# sourceMappingURL=chisquare-tech-yan-ngx-apps-messages-chisquare-tech-yan-ngx-apps-messages-BBsDA0CG.mjs.map
|