forked from nkuntz1934/matrix-workers
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathkeys.ts
More file actions
1184 lines (1007 loc) · 43.4 KB
/
keys.ts
File metadata and controls
1184 lines (1007 loc) · 43.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
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
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Matrix key management endpoints (E2EE)
// Implements: https://spec.matrix.org/v1.12/client-server-api/#end-to-end-encryption
//
// This module handles:
// - Device key upload/query
// - One-time key management
// - Cross-signing keys (master, self-signing, user-signing)
// - Key change tracking
//
// IMPORTANT: Cross-signing keys use Durable Objects for strong consistency.
// Per the Cloudflare blog: "Some operations can't tolerate eventual consistency"
// D1 has eventual consistency across read replicas, which breaks E2EE bootstrap.
import { Hono } from 'hono';
import type { AppEnv, Env } from '../types';
import { Errors } from '../utils/errors';
import { requireAuth } from '../middleware/auth';
import { verifyPassword } from '../utils/crypto';
import { generateOpaqueId } from '../utils/ids';
import { getPasswordHash } from '../services/database';
const app = new Hono<AppEnv>();
// Helper to get the UserKeys Durable Object stub for a user
function getUserKeysDO(env: Env, userId: string): DurableObjectStub {
const id = env.USER_KEYS.idFromName(userId);
return env.USER_KEYS.get(id);
}
// Fetch cross-signing keys from Durable Object (strongly consistent)
async function getCrossSigningKeysFromDO(env: Env, userId: string): Promise<{
master?: any;
self_signing?: any;
user_signing?: any;
}> {
const stub = getUserKeysDO(env, userId);
const response = await stub.fetch(new Request('http://internal/cross-signing/get'));
if (!response.ok) {
const errorText = await response.text().catch(() => 'unknown error');
console.error('[keys] DO cross-signing get failed:', response.status, errorText);
throw new Error(`DO cross-signing get failed: ${response.status} - ${errorText}`);
}
return await response.json();
}
// Store cross-signing keys in Durable Object (strongly consistent)
async function putCrossSigningKeysToDO(env: Env, userId: string, keys: {
master?: any;
self_signing?: any;
user_signing?: any;
}): Promise<void> {
const stub = getUserKeysDO(env, userId);
const response = await stub.fetch(new Request('http://internal/cross-signing/put', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(keys),
}));
if (!response.ok) {
const errorText = await response.text().catch(() => 'unknown error');
console.error('[keys] DO cross-signing put failed:', response.status, errorText);
throw new Error(`DO cross-signing put failed: ${response.status} - ${errorText}`);
}
}
// Fetch device keys from Durable Object (strongly consistent)
async function getDeviceKeysFromDO(env: Env, userId: string, deviceId?: string): Promise<any> {
const stub = getUserKeysDO(env, userId);
const url = deviceId
? `http://internal/device-keys/get?device_id=${encodeURIComponent(deviceId)}`
: 'http://internal/device-keys/get';
const response = await stub.fetch(new Request(url));
if (!response.ok) {
const errorText = await response.text().catch(() => 'unknown error');
console.error('[keys] DO device-keys get failed:', response.status, errorText, 'deviceId:', deviceId);
throw new Error(`DO device-keys get failed: ${response.status} - ${errorText}`);
}
return await response.json();
}
// Store device keys in Durable Object (strongly consistent)
async function putDeviceKeysToDO(env: Env, userId: string, deviceId: string, keys: any): Promise<void> {
const stub = getUserKeysDO(env, userId);
const response = await stub.fetch(new Request('http://internal/device-keys/put', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_id: deviceId, keys }),
}));
if (!response.ok) {
const errorText = await response.text().catch(() => 'unknown error');
console.error('[keys] DO device-keys put failed:', response.status, errorText, 'deviceId:', deviceId);
throw new Error(`DO device-keys put failed: ${response.status} - ${errorText}`);
}
}
// ============================================
// Helper Functions
// ============================================
async function getNextStreamPosition(db: D1Database, streamName: string): Promise<number> {
await db.prepare(`
UPDATE stream_positions SET position = position + 1 WHERE stream_name = ?
`).bind(streamName).run();
const result = await db.prepare(`
SELECT position FROM stream_positions WHERE stream_name = ?
`).bind(streamName).first<{ position: number }>();
return result?.position || 1;
}
async function recordKeyChange(db: D1Database, userId: string, deviceId: string | null, changeType: string): Promise<void> {
const streamPosition = await getNextStreamPosition(db, 'device_keys');
await db.prepare(`
INSERT INTO device_key_changes (user_id, device_id, change_type, stream_position)
VALUES (?, ?, ?, ?)
`).bind(userId, deviceId, changeType, streamPosition).run();
}
// ============================================
// Device Keys
// ============================================
// POST /_matrix/client/v3/keys/upload - Upload device keys and one-time keys
app.post('/_matrix/client/v3/keys/upload', requireAuth(), async (c) => {
const userId = c.get('userId');
const deviceId = c.get('deviceId');
const db = c.env.DB;
let body: any;
try {
body = await c.req.json();
} catch {
return Errors.badJson().toResponse();
}
const { device_keys, one_time_keys, fallback_keys } = body;
// Store device keys with strong consistency
if (device_keys) {
// Validate device_keys structure
console.log('[keys/upload] Validating device_keys:', {
authUserId: userId,
authDeviceId: deviceId,
bodyUserId: device_keys.user_id,
bodyDeviceId: device_keys.device_id,
userMatch: device_keys.user_id === userId,
deviceMatch: device_keys.device_id === deviceId,
});
if (device_keys.user_id !== userId || device_keys.device_id !== deviceId) {
console.log('[keys/upload] MISMATCH - returning 400');
return c.json({
errcode: 'M_INVALID_PARAM',
error: `device_keys.user_id and device_keys.device_id must match authenticated user. Got user_id=${device_keys.user_id} (expected ${userId}), device_id=${device_keys.device_id} (expected ${deviceId})`,
}, 400);
}
// Write to Durable Object first (primary - strongly consistent)
// This is critical for E2EE bootstrap where client uploads then immediately queries
await putDeviceKeysToDO(c.env, userId, deviceId!, device_keys);
// Also write to KV as backup/cache
await c.env.DEVICE_KEYS.put(
`device:${userId}:${deviceId}`,
JSON.stringify(device_keys)
);
// Record key change for /keys/changes
await recordKeyChange(db, userId, deviceId, 'update');
// Queue outbound m.device_list_update EDUs to federated servers
try {
const { getServersInRoomsWithUser } = await import('../services/database');
const remoteServers = await getServersInRoomsWithUser(db, userId);
const localServer = c.env.SERVER_NAME;
const uniqueServers = [...new Set(remoteServers)].filter(s => s !== localServer);
for (const server of uniqueServers) {
const fedDO = c.env.FEDERATION.get(c.env.FEDERATION.idFromName(server));
await fedDO.fetch(new Request('http://internal/send-edu', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
destination: server,
edu_type: 'm.device_list_update',
content: {
user_id: userId,
device_id: deviceId,
device_display_name: device_keys.unsigned?.device_display_name,
stream_id: Date.now(),
keys: device_keys,
deleted: false,
},
}),
}));
}
} catch (fedErr) {
console.warn('[keys] Failed to queue device list EDUs:', fedErr);
}
}
// Store one-time keys in KV for fast access
const oneTimeKeyCounts: Record<string, number> = {};
if (one_time_keys) {
// Get existing keys from KV
const existingKeys = await c.env.ONE_TIME_KEYS.get(
`otk:${userId}:${deviceId}`,
'json'
) as Record<string, { keyId: string; keyData: any; claimed: boolean }[]> | null || {};
for (const [keyId, keyData] of Object.entries(one_time_keys)) {
const [algorithm] = keyId.split(':');
if (!existingKeys[algorithm]) {
existingKeys[algorithm] = [];
}
// Check if key already exists
const existingIndex = existingKeys[algorithm].findIndex(k => k.keyId === keyId);
if (existingIndex >= 0) {
existingKeys[algorithm][existingIndex] = { keyId, keyData, claimed: false };
} else {
existingKeys[algorithm].push({ keyId, keyData, claimed: false });
}
// Also write to D1 as backup
await db.prepare(`
INSERT INTO one_time_keys (user_id, device_id, algorithm, key_id, key_data)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (user_id, device_id, algorithm, key_id) DO UPDATE SET
key_data = excluded.key_data
`).bind(
userId,
deviceId,
algorithm,
keyId,
JSON.stringify(keyData)
).run();
}
// Save back to KV
await c.env.ONE_TIME_KEYS.put(
`otk:${userId}:${deviceId}`,
JSON.stringify(existingKeys)
);
// Count unclaimed keys
for (const [algorithm, keys] of Object.entries(existingKeys)) {
oneTimeKeyCounts[algorithm] = keys.filter(k => !k.claimed).length;
}
} else {
// Just get counts from KV
const existingKeys = await c.env.ONE_TIME_KEYS.get(
`otk:${userId}:${deviceId}`,
'json'
) as Record<string, { keyId: string; keyData: any; claimed: boolean }[]> | null;
if (existingKeys) {
for (const [algorithm, keys] of Object.entries(existingKeys)) {
oneTimeKeyCounts[algorithm] = keys.filter(k => !k.claimed).length;
}
}
}
// Store fallback keys
if (fallback_keys) {
for (const [keyId, keyData] of Object.entries(fallback_keys)) {
const [algorithm] = keyId.split(':');
await db.prepare(`
INSERT INTO fallback_keys (user_id, device_id, algorithm, key_id, key_data)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (user_id, device_id, algorithm) DO UPDATE SET
key_id = excluded.key_id,
key_data = excluded.key_data,
used = 0
`).bind(userId, deviceId, algorithm, keyId, JSON.stringify(keyData)).run();
}
}
return c.json({
one_time_key_counts: oneTimeKeyCounts,
});
});
// POST /_matrix/client/v3/keys/query - Query device keys for users
app.post('/_matrix/client/v3/keys/query', requireAuth(), async (c) => {
const db = c.env.DB;
let body: any;
try {
body = await c.req.json();
} catch {
return Errors.badJson().toResponse();
}
const { device_keys: requestedKeys } = body;
const deviceKeys: Record<string, Record<string, any>> = {};
const masterKeys: Record<string, any> = {};
const selfSigningKeys: Record<string, any> = {};
const userSigningKeys: Record<string, any> = {};
const failures: Record<string, any> = {};
// Helper function to merge signatures from DB into device keys
async function mergeSignaturesForDevice(userId: string, deviceId: string, deviceKey: any): Promise<any> {
// Get any additional signatures from the database
const dbSignatures = await db.prepare(`
SELECT signer_user_id, signer_key_id, signature
FROM cross_signing_signatures
WHERE user_id = ? AND key_id = ?
`).bind(userId, deviceId).all<{
signer_user_id: string;
signer_key_id: string;
signature: string;
}>();
if (dbSignatures.results.length > 0) {
deviceKey.signatures = deviceKey.signatures || {};
for (const sig of dbSignatures.results) {
deviceKey.signatures[sig.signer_user_id] = deviceKey.signatures[sig.signer_user_id] || {};
deviceKey.signatures[sig.signer_user_id][sig.signer_key_id] = sig.signature;
}
}
return deviceKey;
}
if (requestedKeys) {
for (const [userId, devices] of Object.entries(requestedKeys)) {
deviceKeys[userId] = {};
// Get device keys from Durable Object (strongly consistent)
// Critical for E2EE bootstrap where client uploads then immediately queries
const requestedDevices = Array.isArray(devices) && devices.length > 0 ? devices : null;
if (requestedDevices === null || requestedDevices.length === 0) {
// Get all devices for this user from Durable Object
const allDeviceKeys = await getDeviceKeysFromDO(c.env, userId);
for (const [deviceId, keys] of Object.entries(allDeviceKeys)) {
if (keys) {
// Merge any DB signatures into the device keys
deviceKeys[userId][deviceId] = await mergeSignaturesForDevice(userId, deviceId, keys);
}
}
} else {
// Get specific devices from Durable Object
for (const deviceId of requestedDevices) {
const keys = await getDeviceKeysFromDO(c.env, userId, deviceId);
if (keys) {
// Merge any DB signatures into the device keys
deviceKeys[userId][deviceId] = await mergeSignaturesForDevice(userId, deviceId, keys);
}
}
}
// Get cross-signing keys from Durable Object (strongly consistent)
// Per Cloudflare blog: D1 has eventual consistency across read replicas.
// Durable Objects provide single-threaded, atomic storage - critical for
// E2EE bootstrap where client uploads then immediately queries keys.
const requestingUserId = c.get('userId');
const csKeys = await getCrossSigningKeysFromDO(c.env, userId);
if (csKeys.master) {
masterKeys[userId] = csKeys.master;
}
if (csKeys.self_signing) {
selfSigningKeys[userId] = csKeys.self_signing;
}
// Only return user_signing key if querying own keys
if (csKeys.user_signing && userId === requestingUserId) {
userSigningKeys[userId] = csKeys.user_signing;
}
}
}
return c.json({
device_keys: deviceKeys,
master_keys: masterKeys,
self_signing_keys: selfSigningKeys,
user_signing_keys: userSigningKeys,
failures,
});
});
// POST /_matrix/client/v3/keys/claim - Claim one-time keys for establishing sessions
app.post('/_matrix/client/v3/keys/claim', requireAuth(), async (c) => {
const db = c.env.DB;
let body: any;
try {
body = await c.req.json();
} catch {
return Errors.badJson().toResponse();
}
const { one_time_keys: requestedKeys } = body;
const oneTimeKeys: Record<string, Record<string, Record<string, any>>> = {};
const failures: Record<string, any> = {};
if (requestedKeys) {
for (const [userId, devices] of Object.entries(requestedKeys)) {
oneTimeKeys[userId] = {};
for (const [deviceId, algorithm] of Object.entries(devices as Record<string, string>)) {
// Try to claim a one-time key from KV first
const existingKeys = await c.env.ONE_TIME_KEYS.get(
`otk:${userId}:${deviceId}`,
'json'
) as Record<string, { keyId: string; keyData: any; claimed: boolean }[]> | null;
let foundKey = false;
if (existingKeys && existingKeys[algorithm]) {
// Find first unclaimed key
const keyIndex = existingKeys[algorithm].findIndex(k => !k.claimed);
if (keyIndex >= 0) {
const key = existingKeys[algorithm][keyIndex];
// Mark as claimed
existingKeys[algorithm][keyIndex].claimed = true;
// Save back to KV
await c.env.ONE_TIME_KEYS.put(
`otk:${userId}:${deviceId}`,
JSON.stringify(existingKeys)
);
// Also mark in D1
await db.prepare(`
UPDATE one_time_keys SET claimed = 1, claimed_at = ?
WHERE user_id = ? AND device_id = ? AND key_id = ?
`).bind(Date.now(), userId, deviceId, key.keyId).run();
oneTimeKeys[userId][deviceId] = {
[key.keyId]: key.keyData,
};
foundKey = true;
}
}
if (!foundKey) {
// Fallback to D1 for legacy keys
const otk = await db.prepare(`
SELECT id, key_id, key_data FROM one_time_keys
WHERE user_id = ? AND device_id = ? AND algorithm = ? AND claimed = 0
LIMIT 1
`).bind(userId, deviceId, algorithm).first<{
id: number;
key_id: string;
key_data: string;
}>();
if (otk) {
// Mark as claimed
await db.prepare(`
UPDATE one_time_keys SET claimed = 1, claimed_at = ? WHERE id = ?
`).bind(Date.now(), otk.id).run();
oneTimeKeys[userId][deviceId] = {
[otk.key_id]: JSON.parse(otk.key_data),
};
foundKey = true;
}
}
if (!foundKey) {
// Try fallback key
const fallback = await db.prepare(`
SELECT key_id, key_data, used FROM fallback_keys
WHERE user_id = ? AND device_id = ? AND algorithm = ?
`).bind(userId, deviceId, algorithm).first<{
key_id: string;
key_data: string;
used: number;
}>();
if (fallback) {
// Mark fallback as used
await db.prepare(`
UPDATE fallback_keys SET used = 1 WHERE user_id = ? AND device_id = ? AND algorithm = ?
`).bind(userId, deviceId, algorithm).run();
const keyData = JSON.parse(fallback.key_data);
oneTimeKeys[userId][deviceId] = {
[fallback.key_id]: {
...keyData,
fallback: true,
},
};
}
}
}
}
}
return c.json({
one_time_keys: oneTimeKeys,
failures,
});
});
// GET /_matrix/client/v3/keys/changes - Get users whose keys have changed
app.get('/_matrix/client/v3/keys/changes', requireAuth(), async (c) => {
const userId = c.get('userId');
const from = c.req.query('from');
const to = c.req.query('to');
const db = c.env.DB;
if (!from || !to) {
return Errors.missingParam('from and to required').toResponse();
}
const fromPosition = parseInt(from, 10) || 0;
const toPosition = parseInt(to, 10) || Number.MAX_SAFE_INTEGER;
// Get users whose keys changed in this range
// Only return users that share rooms with the requesting user
const changes = await db.prepare(`
SELECT DISTINCT dkc.user_id, dkc.change_type
FROM device_key_changes dkc
WHERE dkc.stream_position > ? AND dkc.stream_position <= ?
AND dkc.user_id IN (
SELECT DISTINCT rm2.user_id
FROM room_memberships rm1
JOIN room_memberships rm2 ON rm1.room_id = rm2.room_id
WHERE rm1.user_id = ? AND rm1.membership = 'join' AND rm2.membership = 'join'
)
`).bind(fromPosition, toPosition, userId).all<{
user_id: string;
change_type: string;
}>();
const changed: string[] = [];
const left: string[] = [];
for (const change of changes.results) {
if (change.change_type === 'delete') {
left.push(change.user_id);
} else {
changed.push(change.user_id);
}
}
return c.json({
changed: [...new Set(changed)],
left: [...new Set(left)],
});
});
// ============================================
// Cross-Signing Keys
// ============================================
// Helper: Check if user has OIDC/SSO link (logged in via external IdP)
async function isOIDCUser(db: D1Database, userId: string): Promise<boolean> {
const result = await db.prepare(`
SELECT COUNT(*) as count FROM idp_user_links WHERE user_id = ?
`).bind(userId).first<{ count: number }>();
return (result?.count || 0) > 0;
}
// Helper: Check if user has password set
async function hasPassword(db: D1Database, userId: string): Promise<boolean> {
const hash = await getPasswordHash(db, userId);
return hash !== null && hash.length > 0;
}
// POST /_matrix/client/v3/keys/device_signing/upload - Upload cross-signing keys
// Spec: https://spec.matrix.org/v1.12/client-server-api/#post_matrixclientv3keysdevice_signingupload
// This endpoint requires UIA (User-Interactive Authentication)
//
// For OIDC users (users linked to external IdP), we support:
// - m.login.sso: Redirect to OAuth authorize for re-authentication
// - m.login.token: Token-based authentication (fallback)
// For password users, we support:
// - m.login.password: Password-based authentication
app.post('/_matrix/client/v3/keys/device_signing/upload', requireAuth(), async (c) => {
const userId = c.get('userId');
const db = c.env.DB;
let body: any;
try {
body = await c.req.json();
} catch {
return Errors.badJson().toResponse();
}
const { master_key, self_signing_key, user_signing_key, auth } = body;
// Debug logging for cross-signing key uploads
console.log('[keys] Cross-signing upload for user:', userId);
console.log('[keys] Auth provided:', auth ? JSON.stringify(auth) : 'none');
if (master_key) console.log('[keys] Master key:', JSON.stringify(master_key));
if (self_signing_key) console.log('[keys] Self-signing key:', JSON.stringify(self_signing_key));
if (user_signing_key) console.log('[keys] User-signing key:', JSON.stringify(user_signing_key));
// Check if user already has cross-signing keys set up
const existingKeys = await db.prepare(`
SELECT COUNT(*) as count FROM cross_signing_keys WHERE user_id = ?
`).bind(userId).first<{ count: number }>();
const hasExistingKeys = (existingKeys?.count || 0) > 0;
console.log('[keys] User has existing keys:', hasExistingKeys);
// Check user's authentication capabilities
const userIsOIDC = await isOIDCUser(db, userId);
const userHasPassword = await hasPassword(db, userId);
// Log auth method type without exposing sensitive details
console.log('[keys] User auth method: OIDC=' + (userIsOIDC ? 'yes' : 'no') + ', password=' + (userHasPassword ? 'yes' : 'no'));
// MSC3967: Do not require UIA when first uploading cross-signing keys
// Per Matrix spec v1.11+, if user has NO existing cross-signing keys, skip UIA for first-time setup
// If user HAS existing keys, require authentication
if (!hasExistingKeys) {
// First-time cross-signing setup - skip UIA per MSC3967
console.log('[keys] First-time cross-signing setup - skipping UIA per MSC3967');
} else if (!auth) {
// User has existing keys but no auth provided - return UIA challenge
const sessionId = await generateOpaqueId(16);
// Build available flows based on user's authentication method
const flows: Array<{ stages: string[] }> = [];
const serverName = c.env.SERVER_NAME;
const baseUrl = `https://${serverName}`;
const params: Record<string, any> = {};
if (userIsOIDC) {
// OIDC users: Use org.matrix.cross_signing_reset per MSC4312
// This is the unstable identifier; stable is m.oauth
// Per MSC4312: "To prevent breaking clients that have implemented the unstable identifier,
// servers SHOULD offer two flows (one with each of m.oauth and org.matrix.cross_signing_reset)"
const unstableStage = 'org.matrix.cross_signing_reset';
const stableStage = 'm.oauth';
// Offer both flows for compatibility during migration
flows.push({ stages: [unstableStage] });
flows.push({ stages: [stableStage] });
// The URL points to authorization server's account management UI
// where the user can approve the cross-signing reset
const approvalUrl = `${baseUrl}/oauth/authorize/uia?session=${sessionId}&action=org.matrix.cross_signing_reset`;
// Both stages use the same params with 'url' pointing to approval page
params[unstableStage] = { url: approvalUrl };
params[stableStage] = { url: approvalUrl };
}
if (userHasPassword) {
// Password users: Offer password flow
flows.push({ stages: ['m.login.password'] });
}
// Fallback: If user has neither (shouldn't happen), offer password flow
if (flows.length === 0) {
flows.push({ stages: ['m.login.password'] });
}
// Store session in KV for validation
await c.env.CACHE.put(
`uia_session:${sessionId}`,
JSON.stringify({
user_id: userId,
created_at: Date.now(),
type: 'device_signing_upload',
completed_stages: [],
is_oidc_user: userIsOIDC,
has_password: userHasPassword,
}),
{ expirationTtl: 300 } // 5 minute session
);
console.log('[keys] UIA required (existing keys), returning challenge with session:', sessionId, 'flows:', flows);
// Return UIA challenge
return c.json({
flows,
params,
session: sessionId,
}, 401);
} else {
// Auth provided for key replacement - validate it
console.log('[keys] Auth type:', auth.type);
if (auth.type === 'm.login.password') {
// Validate password
const storedHash = await getPasswordHash(db, userId);
if (!storedHash) {
console.log('[keys] No password hash found for user');
return Errors.forbidden('No password set for user').toResponse();
}
if (!auth.password) {
console.log('[keys] No password in auth object');
return Errors.missingParam('auth.password').toResponse();
}
const valid = await verifyPassword(auth.password, storedHash);
if (!valid) {
console.log('[keys] Invalid password');
return Errors.forbidden('Invalid password').toResponse();
}
console.log('[keys] Password validated successfully');
} else if (auth.type === 'org.matrix.cross_signing_reset' || auth.type === 'm.oauth' ||
auth.type === 'm.login.oauth' || auth.type === 'm.login.sso' || auth.type === 'm.login.token' ||
!auth.type) {
// MSC4312 cross-signing reset flow for OIDC users
// Supports:
// - org.matrix.cross_signing_reset (unstable per MSC4312)
// - m.oauth (stable per MSC4312)
// - m.login.oauth, m.login.sso, m.login.token (legacy compatibility)
// - No type at all (per MSC4312: client just sends session)
const sessionId = auth.session;
if (!sessionId) {
console.log('[keys] No session ID in OAuth/cross-signing auth');
return Errors.missingParam('auth.session').toResponse();
}
const sessionJson = await c.env.CACHE.get(`uia_session:${sessionId}`);
if (!sessionJson) {
console.log('[keys] UIA session not found or expired');
return c.json({
errcode: 'M_UNKNOWN',
error: 'UIA session not found or expired',
}, 401);
}
const session = JSON.parse(sessionJson);
// Check if session belongs to this user
if (session.user_id !== userId) {
console.log('[keys] UIA session user mismatch');
return Errors.forbidden('Session user mismatch').toResponse();
}
// Check if the cross-signing reset has been approved via OAuth flow
// Accept any of the stage names that indicate completion
const completedStages = session.completed_stages || [];
const hasOAuthApproval = completedStages.includes('org.matrix.cross_signing_reset') ||
completedStages.includes('m.oauth') ||
completedStages.includes('m.login.oauth') ||
completedStages.includes('m.login.sso') ||
completedStages.includes('m.login.token');
if (!hasOAuthApproval) {
console.log('[keys] Cross-signing reset not approved for this session');
return c.json({
errcode: 'M_UNAUTHORIZED',
error: 'Cross-signing reset not approved. Please approve the request at the provided URL.',
}, 401);
}
console.log('[keys] Cross-signing reset approved via OAuth flow');
// Clean up the session
await c.env.CACHE.delete(`uia_session:${sessionId}`);
} else {
// Unknown auth type
console.log('[keys] Unknown auth type:', auth.type);
return c.json({
errcode: 'M_UNRECOGNIZED',
error: `Unrecognized auth type: ${auth.type}`,
}, 400);
}
}
// UIA passed - check if SSSS is set up (for logging purposes only)
// We allow cross-signing key uploads even without SSSS, as Element X may set up
// SSSS immediately after uploading cross-signing keys during the bootstrap flow.
// The "confirm your identity" screen in Element X is EXPECTED for new users -
// it prompts them to set up recovery/SSSS.
const ssssDefault = await c.env.ACCOUNT_DATA.get(
`global:${userId}:m.secret_storage.default_key`,
'json'
) as { key?: string } | null;
let hasValidSSS = !!(ssssDefault && ssssDefault.key);
if (!hasValidSSS) {
// Also check D1 as fallback
const d1Ssss = await db.prepare(`
SELECT content FROM account_data
WHERE user_id = ? AND event_type = 'm.secret_storage.default_key' AND room_id = ''
`).bind(userId).first<{ content: string }>();
if (d1Ssss) {
try {
const parsed = JSON.parse(d1Ssss.content);
hasValidSSS = !!parsed.key;
} catch {
hasValidSSS = false;
}
}
}
if (!hasValidSSS) {
// SSSS is not set up yet - this is OK, Element X will prompt user to set up recovery
// Cross-signing keys can be uploaded before SSSS during initial bootstrap
console.log('[keys] SSSS not configured for user', userId, '- allowing cross-signing upload (client will prompt for recovery setup)');
} else {
console.log('[keys] SSSS is configured, proceeding to store cross-signing keys');
}
// Get existing keys from Durable Object (strongly consistent)
const existingCSKeys = await getCrossSigningKeysFromDO(c.env, userId);
// Merge new keys with existing
const csKeys = { ...existingCSKeys };
if (master_key) csKeys.master = master_key;
if (self_signing_key) csKeys.self_signing = self_signing_key;
if (user_signing_key) csKeys.user_signing = user_signing_key;
// Write to Durable Object (primary - strongly consistent)
// This is critical for E2EE bootstrap where client uploads then immediately queries
await putCrossSigningKeysToDO(c.env, userId, csKeys);
console.log('[keys] Cross-signing keys stored in Durable Object for user:', userId);
// Also write to D1 as backup (for durability/recovery)
// These writes are eventually consistent but serve as backup storage
if (master_key) {
const keyId = Object.keys(master_key.keys || {})[0] || '';
await db.prepare(`
INSERT INTO cross_signing_keys (user_id, key_type, key_id, key_data)
VALUES (?, 'master', ?, ?)
ON CONFLICT (user_id, key_type) DO UPDATE SET
key_id = excluded.key_id,
key_data = excluded.key_data
`).bind(userId, keyId, JSON.stringify(master_key)).run();
await recordKeyChange(db, userId, null, 'update');
}
if (self_signing_key) {
const keyId = Object.keys(self_signing_key.keys || {})[0] || '';
await db.prepare(`
INSERT INTO cross_signing_keys (user_id, key_type, key_id, key_data)
VALUES (?, 'self_signing', ?, ?)
ON CONFLICT (user_id, key_type) DO UPDATE SET
key_id = excluded.key_id,
key_data = excluded.key_data
`).bind(userId, keyId, JSON.stringify(self_signing_key)).run();
}
if (user_signing_key) {
const keyId = Object.keys(user_signing_key.keys || {})[0] || '';
await db.prepare(`
INSERT INTO cross_signing_keys (user_id, key_type, key_id, key_data)
VALUES (?, 'user_signing', ?, ?)
ON CONFLICT (user_id, key_type) DO UPDATE SET
key_id = excluded.key_id,
key_data = excluded.key_data
`).bind(userId, keyId, JSON.stringify(user_signing_key)).run();
}
// Write to KV as cache (eventually consistent, for performance)
await c.env.CROSS_SIGNING_KEYS.put(`user:${userId}`, JSON.stringify(csKeys));
return c.json({});
});
// POST /_matrix/client/v3/keys/signatures/upload - Upload signatures for keys
// Spec: https://spec.matrix.org/v1.12/client-server-api/#post_matrixclientv3keyssignaturesupload
// Body format: { user_id: { key_id: signed_key_object } }
// - For device keys, key_id is the device_id (e.g., "JLAFKJWSCS")
// - For cross-signing keys, key_id is the base64 public key
app.post('/_matrix/client/v3/keys/signatures/upload', requireAuth(), async (c) => {
const signerUserId = c.get('userId');
const db = c.env.DB;
let body: any;
try {
body = await c.req.json();
} catch {
return Errors.badJson().toResponse();
}
console.log('[signatures/upload] Request from:', signerUserId);
console.log('[signatures/upload] Body:', JSON.stringify(body));
// body is a map of user_id -> key_id -> signed_key_object
const failures: Record<string, Record<string, { errcode: string; error: string }>> = {};
for (const [userId, keys] of Object.entries(body)) {
for (const [keyId, signedKey] of Object.entries(keys as Record<string, any>)) {
try {
const signedKeyObj = signedKey as any;
// Extract signatures from the signed key object
const signatures = signedKeyObj.signatures?.[signerUserId] || {};
console.log('[signatures/upload] Processing:', { userId, keyId, hasDeviceId: !!signedKeyObj.device_id });
console.log('[signatures/upload] Signatures to store:', JSON.stringify(signatures));
// Store all signatures in the database
for (const [signerKeyId, signature] of Object.entries(signatures)) {
// Use the device_id as key_id for device keys, otherwise use the provided keyId
const effectiveKeyId = signedKeyObj.device_id || keyId;
await db.prepare(`
INSERT INTO cross_signing_signatures (
user_id, key_id, signer_user_id, signer_key_id, signature
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (user_id, key_id, signer_user_id, signer_key_id) DO UPDATE SET
signature = excluded.signature
`).bind(userId, effectiveKeyId, signerUserId, signerKeyId, signature as string).run();
console.log('[signatures/upload] Stored signature:', {
userId,
effectiveKeyId,
signerUserId,
signerKeyId
});
}
// If this is a device key (has device_id field), update the device key in KV
if (signedKeyObj.device_id) {
const deviceId = signedKeyObj.device_id;
console.log('[signatures/upload] Updating device key for device:', deviceId);
// Read from Durable Object (strongly consistent)
const existingKey = await getDeviceKeysFromDO(c.env, userId, deviceId);
if (existingKey) {
// Merge new signatures into existing signatures
existingKey.signatures = existingKey.signatures || {};
existingKey.signatures[signerUserId] = {
...existingKey.signatures[signerUserId],
...signatures,
};
// Write to Durable Object (primary - strongly consistent)
await putDeviceKeysToDO(c.env, userId, deviceId, existingKey);
// Also update KV as backup/cache
await c.env.DEVICE_KEYS.put(
`device:${userId}:${deviceId}`,
JSON.stringify(existingKey)
);
console.log('[signatures/upload] Updated device key signatures:', {
deviceId,
newSignatures: Object.keys(signatures)
});
} else {
console.log('[signatures/upload] Device key not found:', deviceId);
}
}
// Record key change for sync notifications
await recordKeyChange(db, userId, signedKeyObj.device_id || null, 'update');
} catch (err) {
console.error('[signatures/upload] Error processing signature:', err);
if (!failures[userId]) failures[userId] = {};
failures[userId][keyId] = {
errcode: 'M_UNKNOWN',
error: 'Failed to store signature',
};
}
}
}
console.log('[signatures/upload] Completed, failures:', Object.keys(failures).length > 0 ? failures : 'none');
return c.json({ failures });
});
// ============================================
// UIA SSO Flow Endpoints (for OIDC users)
// ============================================
// GET /_matrix/client/v3/auth/m.login.sso/redirect - Redirect to SSO for UIA
// This endpoint is used by clients to initiate SSO authentication during UIA
// Spec: https://spec.matrix.org/v1.12/client-server-api/#get_matrixclientv3authmlloginssofallbackweb
app.get('/_matrix/client/v3/auth/m.login.sso/redirect', async (c) => {
const sessionId = c.req.query('session');
const redirectUrl = c.req.query('redirectUrl');
if (!sessionId) {
return c.json({
errcode: 'M_MISSING_PARAM',
error: 'Missing session parameter',
}, 400);
}
// Verify the UIA session exists
const sessionJson = await c.env.CACHE.get(`uia_session:${sessionId}`);
if (!sessionJson) {
return c.json({
errcode: 'M_UNKNOWN',
error: 'UIA session not found or expired',
}, 404);