@kraki/head 0.20.0 → 0.20.1
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/account-api.d.ts +10 -0
- package/dist/account-api.js +67 -15
- package/dist/account-api.js.map +1 -1
- package/dist/auth-backend.d.ts +16 -5
- package/dist/auth.d.ts +0 -1
- package/dist/auth.js +32 -20
- package/dist/auth.js.map +1 -1
- package/dist/cli.js +12 -1
- package/dist/cli.js.map +1 -1
- package/dist/lease-issuer.js +1 -1
- package/dist/lease-issuer.js.map +1 -1
- package/dist/local-auth-backend.d.ts +5 -1
- package/dist/local-auth-backend.js +30 -17
- package/dist/local-auth-backend.js.map +1 -1
- package/dist/pulse-hub.d.ts +8 -0
- package/dist/pulse-hub.js +51 -3
- package/dist/pulse-hub.js.map +1 -1
- package/dist/push/apns.js +19 -1
- package/dist/push/apns.js.map +1 -1
- package/dist/remote-auth-backend.d.ts +2 -1
- package/dist/remote-auth-backend.js +10 -2
- package/dist/remote-auth-backend.js.map +1 -1
- package/dist/server.d.ts +14 -7
- package/dist/server.js +196 -357
- package/dist/server.js.map +1 -1
- package/dist/storage.d.ts +17 -0
- package/dist/storage.js +83 -10
- package/dist/storage.js.map +1 -1
- package/dist/trace.d.ts +0 -1
- package/dist/trace.js +0 -3
- package/dist/trace.js.map +1 -1
- package/package.json +3 -4
package/dist/server.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import { RemoteAuthBackend } from './remote-auth-backend.js';
|
|
2
2
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
3
|
-
import {
|
|
4
|
-
import { v4 as uuid } from 'uuid';
|
|
5
|
-
import { HEAD_PULSE_TARGET } from '@kraki/protocol';
|
|
3
|
+
import { HEAD_PULSE_TARGET, ACCOUNT_DELETED_CLOSE_CODE, DEVICE_REPLACED_CLOSE_CODE } from '@kraki/protocol';
|
|
6
4
|
import { parseVoiceWordOps } from './voice-vocabulary.js';
|
|
7
5
|
import { PulseHub, PULSE_ACK_EVERY_BYTES } from './pulse-hub.js';
|
|
8
|
-
import {
|
|
6
|
+
import { LocalAuthBackend } from './local-auth-backend.js';
|
|
9
7
|
import { getLogger } from './logger.js';
|
|
10
8
|
import { trace, fp } from './trace.js';
|
|
11
9
|
import { clientIp } from './client-ip.js';
|
|
@@ -18,15 +16,6 @@ function publicPreferences(prefs) {
|
|
|
18
16
|
const { voiceVocabulary: _omitted, ...rest } = prefs;
|
|
19
17
|
return rest;
|
|
20
18
|
}
|
|
21
|
-
function importPublicKey(compactKey) {
|
|
22
|
-
const lines = compactKey.match(/.{1,64}/g) ?? [];
|
|
23
|
-
return `-----BEGIN PUBLIC KEY-----\n${lines.join('\n')}\n-----END PUBLIC KEY-----\n`;
|
|
24
|
-
}
|
|
25
|
-
function verifySignature(nonce, signature, publicKeyPem) {
|
|
26
|
-
const verify = createVerify('SHA256');
|
|
27
|
-
verify.update(nonce);
|
|
28
|
-
return verify.verify(publicKeyPem, signature, 'base64');
|
|
29
|
-
}
|
|
30
19
|
const DEFAULT_VOICE_LEASE_TTL_SEC = 86_400;
|
|
31
20
|
const DEFAULT_VOICE_DAILY_QUOTA_SEC = 7_200;
|
|
32
21
|
const MIN_VOICE_LEASE_GRANT_SEC = 1;
|
|
@@ -35,6 +24,8 @@ const DEFAULT_MAX_PAYLOAD = 10 * 1024 * 1024;
|
|
|
35
24
|
const DEFAULT_AUTH_TIMEOUT_MS = 15_000;
|
|
36
25
|
const DEFAULT_LOGIN_SCREEN_AUTH_TIMEOUT_MS = 10 * 60_000;
|
|
37
26
|
const MAX_MULTICAST_TARGETS = 64;
|
|
27
|
+
/** Send-buffer ceiling per socket before a stalled client is disconnected. */
|
|
28
|
+
const MAX_WS_BUFFERED_BYTES = 32 * 1024 * 1024;
|
|
38
29
|
/** How often the liveness sweep runs (ms). Faster than the 30s ping timer so
|
|
39
30
|
* device_pending detection latency tracks the recipient's ping cadence
|
|
40
31
|
* (~10s), not the slower liveness check cadence. */
|
|
@@ -70,7 +61,7 @@ export class HeadServer {
|
|
|
70
61
|
static PING_INTERVAL = 30_000;
|
|
71
62
|
// In-memory state
|
|
72
63
|
connections = new Map();
|
|
73
|
-
|
|
64
|
+
authBackend;
|
|
74
65
|
userByDevice = new Map();
|
|
75
66
|
clients = new Map();
|
|
76
67
|
pingTimer = null;
|
|
@@ -81,6 +72,21 @@ export class HeadServer {
|
|
|
81
72
|
constructor(storage, options) {
|
|
82
73
|
this.storage = storage;
|
|
83
74
|
this.options = options;
|
|
75
|
+
// One auth implementation for every deployment: without an explicit
|
|
76
|
+
// backend (standalone tests, self-hosted defaults) the server builds the
|
|
77
|
+
// same LocalAuthBackend production uses from its providers.
|
|
78
|
+
this.authBackend = options.authBackend ?? new LocalAuthBackend({
|
|
79
|
+
storage,
|
|
80
|
+
// A single `authProvider` answers every token method, as it always has.
|
|
81
|
+
authProviders: options.authProviders
|
|
82
|
+
?? (options.authProvider
|
|
83
|
+
? new Map(['open', 'apikey', 'github'].map((method) => [method, options.authProvider]))
|
|
84
|
+
: new Map()),
|
|
85
|
+
pairingEnabled: options.pairingEnabled,
|
|
86
|
+
pairingTtl: options.pairingTtl,
|
|
87
|
+
pushManager: options.pushManager,
|
|
88
|
+
region: options.region,
|
|
89
|
+
});
|
|
84
90
|
this.wss = new WebSocketServer({
|
|
85
91
|
noServer: true,
|
|
86
92
|
maxPayload: options.maxPayload ?? DEFAULT_MAX_PAYLOAD,
|
|
@@ -154,10 +160,7 @@ export class HeadServer {
|
|
|
154
160
|
}
|
|
155
161
|
}
|
|
156
162
|
// Sweep expired pairing tokens
|
|
157
|
-
|
|
158
|
-
if (now > data.expiresAt)
|
|
159
|
-
this.pairingTokens.delete(token);
|
|
160
|
-
}
|
|
163
|
+
this.authBackend.sweepPairingTokens?.();
|
|
161
164
|
// Periodic health snapshot — ALWAYS on (not trace-gated). Cheap (one line
|
|
162
165
|
// / 30s) and is the canary for the OOM / event-loop-stall class of bugs
|
|
163
166
|
// that recurred across the pulse PRs. rss/heap from process.memoryUsage;
|
|
@@ -226,22 +229,6 @@ export class HeadServer {
|
|
|
226
229
|
}
|
|
227
230
|
// ── End liveness sweep ──────────────────────────────────
|
|
228
231
|
// --- Auth provider helpers ---
|
|
229
|
-
getAuthProvider() {
|
|
230
|
-
if (this.options.authProviders?.size) {
|
|
231
|
-
return this.options.authProviders.values().next().value;
|
|
232
|
-
}
|
|
233
|
-
return this.options.authProvider;
|
|
234
|
-
}
|
|
235
|
-
getAuthProviderForMode(mode) {
|
|
236
|
-
if (mode && this.options.authProviders?.has(mode)) {
|
|
237
|
-
return this.options.authProviders.get(mode);
|
|
238
|
-
}
|
|
239
|
-
return this.getAuthProvider();
|
|
240
|
-
}
|
|
241
|
-
getGitHubClientId() {
|
|
242
|
-
const ghProvider = this.findGitHubProvider();
|
|
243
|
-
return ghProvider?.oauthConfigured ? ghProvider.getClientId() : undefined;
|
|
244
|
-
}
|
|
245
232
|
getVapidPublicKey() {
|
|
246
233
|
return this.options.pushManager?.getVapidPublicKey();
|
|
247
234
|
}
|
|
@@ -261,19 +248,6 @@ export class HeadServer {
|
|
|
261
248
|
return undefined;
|
|
262
249
|
return { brokerUrl: url, resource: 'voice/doubao' };
|
|
263
250
|
}
|
|
264
|
-
findGitHubProvider() {
|
|
265
|
-
const provider = this.options.authProviders?.get('github') ?? this.options.authProvider;
|
|
266
|
-
if (!provider)
|
|
267
|
-
return undefined;
|
|
268
|
-
if (provider instanceof GitHubAuthProvider)
|
|
269
|
-
return provider;
|
|
270
|
-
if ('inner' in provider) {
|
|
271
|
-
const inner = provider.inner;
|
|
272
|
-
if (inner instanceof GitHubAuthProvider)
|
|
273
|
-
return inner;
|
|
274
|
-
}
|
|
275
|
-
return undefined;
|
|
276
|
-
}
|
|
277
251
|
// --- Connection management ---
|
|
278
252
|
attach(server) {
|
|
279
253
|
server.on('upgrade', (req, socket, head) => {
|
|
@@ -313,6 +287,11 @@ export class HeadServer {
|
|
|
313
287
|
logger.debug('WebSocket connected', { ip });
|
|
314
288
|
ws.on('pong', () => { this.onPongReceived(state); });
|
|
315
289
|
ws.on('message', (data) => {
|
|
290
|
+
// An evicted socket stays open until its close handshake completes;
|
|
291
|
+
// nothing it sends may act for the device (its Pulse stream now
|
|
292
|
+
// belongs to the replacement connection).
|
|
293
|
+
if (state.evicted)
|
|
294
|
+
return;
|
|
316
295
|
state.lastInboundAt = Date.now();
|
|
317
296
|
try {
|
|
318
297
|
const msg = JSON.parse(data.toString());
|
|
@@ -389,37 +368,13 @@ export class HeadServer {
|
|
|
389
368
|
if (!state.authenticated) {
|
|
390
369
|
if (msg.type === 'auth_info') {
|
|
391
370
|
this.armAuthTimer(ws, state, this.options.loginScreenAuthTimeoutMs ?? DEFAULT_LOGIN_SCREEN_AUTH_TIMEOUT_MS);
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
}));
|
|
400
|
-
}
|
|
401
|
-
else {
|
|
402
|
-
const methods = [];
|
|
403
|
-
if (this.options.authProviders?.has('github')) {
|
|
404
|
-
methods.push('github_token');
|
|
405
|
-
const ghProvider = this.findGitHubProvider();
|
|
406
|
-
if (ghProvider?.oauthConfigured)
|
|
407
|
-
methods.push('github_oauth');
|
|
408
|
-
}
|
|
409
|
-
if (this.options.authProviders?.has('apikey'))
|
|
410
|
-
methods.push('apikey');
|
|
411
|
-
if (this.options.authProviders?.has('open'))
|
|
412
|
-
methods.push('open');
|
|
413
|
-
if (this.options.pairingEnabled !== false)
|
|
414
|
-
methods.push('pairing');
|
|
415
|
-
methods.push('challenge');
|
|
416
|
-
ws.send(JSON.stringify({
|
|
417
|
-
type: 'auth_info_response',
|
|
418
|
-
methods,
|
|
419
|
-
githubClientId: this.getGitHubClientId(),
|
|
420
|
-
vapidPublicKey: this.getVapidPublicKey(),
|
|
421
|
-
}));
|
|
422
|
-
}
|
|
371
|
+
const info = this.authBackend.getAuthInfo();
|
|
372
|
+
ws.send(JSON.stringify({
|
|
373
|
+
type: 'auth_info_response',
|
|
374
|
+
methods: info.methods,
|
|
375
|
+
githubClientId: info.githubClientId,
|
|
376
|
+
vapidPublicKey: info.vapidPublicKey ?? this.getVapidPublicKey(),
|
|
377
|
+
}));
|
|
423
378
|
return;
|
|
424
379
|
}
|
|
425
380
|
if (msg.type === 'auth') {
|
|
@@ -439,7 +394,7 @@ export class HeadServer {
|
|
|
439
394
|
}
|
|
440
395
|
// Authenticated messages
|
|
441
396
|
if (msg.type === 'create_pairing_token') {
|
|
442
|
-
this.handleCreatePairingToken(ws, state);
|
|
397
|
+
await this.handleCreatePairingToken(ws, state);
|
|
443
398
|
return;
|
|
444
399
|
}
|
|
445
400
|
if (msg.type === 'unicast') {
|
|
@@ -465,7 +420,10 @@ export class HeadServer {
|
|
|
465
420
|
// endpoint/disk-growth amplifier from a single client.
|
|
466
421
|
if (to !== '' && to !== HEAD_PULSE_TARGET) {
|
|
467
422
|
const target = this.storage.getDevice(to);
|
|
468
|
-
|
|
423
|
+
// Same account, and only across roles (app ⇄ tentacle): no feature
|
|
424
|
+
// sends app→app or tentacle→tentacle, so such a frame is not trusted.
|
|
425
|
+
const sender = this.storage.getDevice(state.deviceId);
|
|
426
|
+
if (!target || target.userId !== state.userId || !sender || target.role === sender.role) {
|
|
469
427
|
this.sendError(ws, 'unicast target is not reachable');
|
|
470
428
|
return;
|
|
471
429
|
}
|
|
@@ -526,8 +484,10 @@ export class HeadServer {
|
|
|
526
484
|
if (typeof msg.pulse === 'string' && state.deviceId) {
|
|
527
485
|
// A pushPreview may ride the same pulse envelope — fire it for offline
|
|
528
486
|
// devices (the reliable message itself is delivered by the hub).
|
|
529
|
-
|
|
487
|
+
// Push previews are tentacle-originated (same rule as dispatch_push).
|
|
488
|
+
if (msg.pushPreview && this.storage.getDevice(state.deviceId)?.role === 'tentacle') {
|
|
530
489
|
this.firePushPreview(state, msg.pushPreview);
|
|
490
|
+
}
|
|
531
491
|
this.pulseHub.onPulseEnvelope(state.deviceId, { pulse: msg.pulse });
|
|
532
492
|
return;
|
|
533
493
|
}
|
|
@@ -670,6 +630,14 @@ export class HeadServer {
|
|
|
670
630
|
trace('HUB-TX-WS', { to: deviceId, online, pulseB64Len: pulseB64.length });
|
|
671
631
|
if (!online)
|
|
672
632
|
return false;
|
|
633
|
+
// Backpressure: a connected but stalled client would otherwise grow the
|
|
634
|
+
// socket's send buffer without bound. Drop the connection instead; every
|
|
635
|
+
// reliable frame is still in the pulse outbox and resends on reconnect.
|
|
636
|
+
if (ws.bufferedAmount > MAX_WS_BUFFERED_BYTES) {
|
|
637
|
+
getLogger().warn('Closing stalled connection (send buffer full)', { deviceId, bufferedBytes: ws.bufferedAmount });
|
|
638
|
+
ws.terminate();
|
|
639
|
+
return false;
|
|
640
|
+
}
|
|
673
641
|
// `to` lets the receiver's own pulse layer know which stream this is; blob
|
|
674
642
|
// is empty because the payload (if any) is inside the pulse frame.
|
|
675
643
|
ws.send(JSON.stringify({ type: 'unicast', to: deviceId, pulse: pulseB64, blob: '', keys: {} }));
|
|
@@ -732,6 +700,9 @@ export class HeadServer {
|
|
|
732
700
|
case 'remove_device':
|
|
733
701
|
this.handleRemoveDevice(ws, state, msg.deviceId);
|
|
734
702
|
return true;
|
|
703
|
+
case 'delete_account':
|
|
704
|
+
void this.handleDeleteAccount(ws, state);
|
|
705
|
+
return true;
|
|
735
706
|
case 'register_push_token':
|
|
736
707
|
this.handleRegisterPushToken(ws, state, msg);
|
|
737
708
|
return true;
|
|
@@ -924,11 +895,21 @@ export class HeadServer {
|
|
|
924
895
|
state.pulseProgressAck = msg.device?.pulseProgressAck === true;
|
|
925
896
|
const logger = getLogger();
|
|
926
897
|
const { auth } = msg;
|
|
927
|
-
//
|
|
928
|
-
|
|
898
|
+
// One auth attempt per socket at a time: a second `auth` while the first
|
|
899
|
+
// is awaiting its backend would register the device twice.
|
|
900
|
+
if (state.authInFlight || state.pendingNonce) {
|
|
901
|
+
// Ignored rather than answered with auth_error: clients treat auth
|
|
902
|
+
// errors as credential failures, and this one is just a duplicate.
|
|
903
|
+
logger.warn('Ignoring duplicate auth while one is in progress', { ip: state.ip });
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
state.authInFlight = true;
|
|
907
|
+
try {
|
|
929
908
|
if (auth.method === 'challenge') {
|
|
930
909
|
// Challenge is multi-step — start here, verify in handleChallengeResponse
|
|
931
|
-
const result = await this.
|
|
910
|
+
const result = await this.authBackend.startChallenge(auth.deviceId, msg.device.encryptionKey, this.options.region);
|
|
911
|
+
if (!this.socketStillAuthenticating(ws, state))
|
|
912
|
+
return;
|
|
932
913
|
if (!result.ok) {
|
|
933
914
|
this.sendAuthError(ws, result.code, result.message, result.code === 'wrong_region' ? result.redirect : undefined);
|
|
934
915
|
return;
|
|
@@ -937,118 +918,24 @@ export class HeadServer {
|
|
|
937
918
|
state.pendingDeviceId = result.deviceId;
|
|
938
919
|
state.pendingDeviceInfo = { encryptionKey: msg.device.encryptionKey };
|
|
939
920
|
state.pendingAuthMethod = 'challenge';
|
|
940
|
-
logger.debug('Issuing auth challenge
|
|
921
|
+
logger.debug('Issuing auth challenge', { deviceId: auth.deviceId });
|
|
941
922
|
ws.send(JSON.stringify({ type: 'auth_challenge', nonce: result.nonce }));
|
|
942
923
|
return;
|
|
943
924
|
}
|
|
944
|
-
const result = await this.
|
|
925
|
+
const result = await this.authBackend.authenticate(auth, msg.device, this.options.region, state.ip);
|
|
926
|
+
if (!this.socketStillAuthenticating(ws, state))
|
|
927
|
+
return;
|
|
945
928
|
this.handleBackendAuthResult(ws, state, result);
|
|
946
|
-
return;
|
|
947
929
|
}
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
case 'pairing': {
|
|
951
|
-
this.handlePairingAuth(ws, state, auth.token, msg);
|
|
952
|
-
return;
|
|
953
|
-
}
|
|
954
|
-
case 'challenge': {
|
|
955
|
-
const device = this.storage.getDevice(auth.deviceId);
|
|
956
|
-
if (device && device.publicKey) {
|
|
957
|
-
const nonce = randomBytes(32).toString('hex');
|
|
958
|
-
state.pendingNonce = nonce;
|
|
959
|
-
state.pendingDeviceId = auth.deviceId;
|
|
960
|
-
state.pendingDeviceInfo = { encryptionKey: msg.device.encryptionKey };
|
|
961
|
-
state.pendingAuthMethod = 'challenge';
|
|
962
|
-
logger.debug('Issuing auth challenge', { deviceId: auth.deviceId });
|
|
963
|
-
ws.send(JSON.stringify({ type: 'auth_challenge', nonce }));
|
|
964
|
-
return;
|
|
965
|
-
}
|
|
966
|
-
this.sendAuthError(ws, 'unknown_device', 'Unknown device');
|
|
967
|
-
return;
|
|
968
|
-
}
|
|
969
|
-
case 'github_token': {
|
|
970
|
-
const provider = this.getAuthProviderForMode('github');
|
|
971
|
-
const result = await provider.authenticate({ token: auth.token, ip: state.ip });
|
|
972
|
-
if (!result.ok) {
|
|
973
|
-
logger.warn('Auth rejected', { method: 'github_token', ip: state.ip, reason: result.message });
|
|
974
|
-
// A GitHub outage or network error is not a bad credential (G4).
|
|
975
|
-
this.sendAuthError(ws, result.retryable ? 'service_unavailable' : 'auth_rejected', result.message);
|
|
976
|
-
return;
|
|
977
|
-
}
|
|
978
|
-
this.completeAuth(ws, state, result.user, msg, 'github_token');
|
|
979
|
-
return;
|
|
980
|
-
}
|
|
981
|
-
case 'github_oauth': {
|
|
982
|
-
const provider = this.getAuthProviderForMode('github');
|
|
983
|
-
const oauthAuth = auth;
|
|
984
|
-
const result = await provider.authenticate({
|
|
985
|
-
githubCode: oauthAuth.code,
|
|
986
|
-
codeVerifier: typeof oauthAuth.codeVerifier === 'string' ? oauthAuth.codeVerifier : undefined,
|
|
987
|
-
redirectUri: typeof oauthAuth.redirectUri === 'string' ? oauthAuth.redirectUri : undefined,
|
|
988
|
-
ip: state.ip,
|
|
989
|
-
});
|
|
990
|
-
if (!result.ok) {
|
|
991
|
-
logger.warn('Auth rejected', { method: 'github_oauth', ip: state.ip, reason: result.message });
|
|
992
|
-
// A GitHub outage or network error is not a bad credential (G4).
|
|
993
|
-
this.sendAuthError(ws, result.retryable ? 'service_unavailable' : 'auth_rejected', result.message);
|
|
994
|
-
return;
|
|
995
|
-
}
|
|
996
|
-
this.completeAuth(ws, state, result.user, msg, 'github_oauth');
|
|
997
|
-
return;
|
|
998
|
-
}
|
|
999
|
-
case 'apikey': {
|
|
1000
|
-
const provider = this.getAuthProviderForMode('apikey');
|
|
1001
|
-
const result = await provider.authenticate({ token: auth.key, ip: state.ip });
|
|
1002
|
-
if (!result.ok) {
|
|
1003
|
-
logger.warn('Auth rejected', { method: 'apikey', ip: state.ip, reason: result.message });
|
|
1004
|
-
// A GitHub outage or network error is not a bad credential (G4).
|
|
1005
|
-
this.sendAuthError(ws, result.retryable ? 'service_unavailable' : 'auth_rejected', result.message);
|
|
1006
|
-
return;
|
|
1007
|
-
}
|
|
1008
|
-
this.completeAuth(ws, state, result.user, msg, 'apikey');
|
|
1009
|
-
return;
|
|
1010
|
-
}
|
|
1011
|
-
case 'open': {
|
|
1012
|
-
const provider = this.getAuthProviderForMode('open');
|
|
1013
|
-
const result = await provider.authenticate({ token: auth.sharedKey, ip: state.ip });
|
|
1014
|
-
if (!result.ok) {
|
|
1015
|
-
logger.warn('Auth rejected', { method: 'open', ip: state.ip, reason: result.message });
|
|
1016
|
-
// A GitHub outage or network error is not a bad credential (G4).
|
|
1017
|
-
this.sendAuthError(ws, result.retryable ? 'service_unavailable' : 'auth_rejected', result.message);
|
|
1018
|
-
return;
|
|
1019
|
-
}
|
|
1020
|
-
this.completeAuth(ws, state, result.user, msg, 'open');
|
|
1021
|
-
return;
|
|
1022
|
-
}
|
|
1023
|
-
default: {
|
|
1024
|
-
const method = auth.method;
|
|
1025
|
-
this.sendAuthError(ws, 'unknown_auth_method', `Unknown auth method: ${method}`);
|
|
1026
|
-
}
|
|
930
|
+
finally {
|
|
931
|
+
state.authInFlight = false;
|
|
1027
932
|
}
|
|
1028
933
|
}
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
}
|
|
1035
|
-
const tokenData = this.pairingTokens.get(pairingToken);
|
|
1036
|
-
if (!tokenData || tokenData.expiresAt < Date.now()) {
|
|
1037
|
-
if (tokenData)
|
|
1038
|
-
this.pairingTokens.delete(pairingToken);
|
|
1039
|
-
logger.warn('Pairing auth failed: invalid or expired token', { ip: state.ip });
|
|
1040
|
-
this.sendAuthError(ws, 'invalid_pairing_token', 'Invalid or expired pairing token');
|
|
1041
|
-
return;
|
|
1042
|
-
}
|
|
1043
|
-
// Consume token (single-use)
|
|
1044
|
-
this.pairingTokens.delete(pairingToken);
|
|
1045
|
-
const user = this.storage.getUser(tokenData.userId);
|
|
1046
|
-
if (!user) {
|
|
1047
|
-
this.sendAuthError(ws, 'user_not_found', 'User not found');
|
|
1048
|
-
return;
|
|
1049
|
-
}
|
|
1050
|
-
const authUser = { id: user.userId, login: user.username, provider: user.provider, email: user.email };
|
|
1051
|
-
this.completeAuth(ws, state, authUser, msg, 'pairing');
|
|
934
|
+
/** After an awaited auth step: is this socket still open, still ours and
|
|
935
|
+
* still unauthenticated? The auth deadline may have closed it meanwhile;
|
|
936
|
+
* registering a dead socket would evict the device's live connection. */
|
|
937
|
+
socketStillAuthenticating(ws, state) {
|
|
938
|
+
return ws.readyState === WebSocket.OPEN && this.clients.get(ws) === state && !state.authenticated;
|
|
1052
939
|
}
|
|
1053
940
|
handleChallengeResponse(ws, state, msg) {
|
|
1054
941
|
const logger = getLogger();
|
|
@@ -1062,68 +949,25 @@ export class HeadServer {
|
|
|
1062
949
|
this.sendAuthError(ws, 'no_pending_challenge', 'No pending challenge');
|
|
1063
950
|
return;
|
|
1064
951
|
}
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
this.
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
952
|
+
state.authInFlight = true;
|
|
953
|
+
this.authBackend.verifyChallenge(deviceId, nonce, msg.signature, pendingInfo?.encryptionKey, this.options.region).then(result => {
|
|
954
|
+
if (!this.socketStillAuthenticating(ws, state))
|
|
955
|
+
return;
|
|
956
|
+
this.handleBackendAuthResult(ws, state, result);
|
|
957
|
+
}).catch(err => {
|
|
958
|
+
// A thrown error means the auth backend itself was unreachable / timed
|
|
959
|
+
// out (network, 5xx, abort) — NOT that the signature was invalid. A
|
|
960
|
+
// deterministic auth failure comes back as a resolved AuthError outcome
|
|
961
|
+
// above. Reporting this as auth_rejected made the arm treat a transient
|
|
962
|
+
// backend outage as a permanent credential failure and wipe the paired
|
|
963
|
+
// device, forcing a full re-pair. Surface it as auth_unavailable so the
|
|
964
|
+
// client retries instead of destroying a valid session.
|
|
965
|
+
logger.error('Auth backend verifyChallenge failed', { error: err.message });
|
|
966
|
+
if (this.socketStillAuthenticating(ws, state)) {
|
|
1078
967
|
this.sendAuthError(ws, 'service_unavailable', 'Authentication service unavailable');
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
// Legacy: inline challenge verification
|
|
1083
|
-
const device = this.storage.getDevice(deviceId);
|
|
1084
|
-
if (!device || !device.publicKey) {
|
|
1085
|
-
this.sendAuthError(ws, 'device_not_found', 'Device not found');
|
|
1086
|
-
return;
|
|
1087
|
-
}
|
|
1088
|
-
const publicKeyPem = importPublicKey(device.publicKey);
|
|
1089
|
-
const valid = verifySignature(nonce, msg.signature, publicKeyPem);
|
|
1090
|
-
if (!valid) {
|
|
1091
|
-
logger.warn('Challenge-response auth failed', { deviceId, ip: state.ip });
|
|
1092
|
-
this.sendAuthError(ws, 'invalid_signature', 'Invalid signature');
|
|
1093
|
-
return;
|
|
1094
|
-
}
|
|
1095
|
-
// Update encryption key if provided
|
|
1096
|
-
const encryptionKey = pendingInfo?.encryptionKey ?? device.encryptionKey ?? undefined;
|
|
1097
|
-
this.storage.upsertDevice(deviceId, device.userId, device.name, device.role, device.kind ?? undefined, device.publicKey ?? undefined, encryptionKey);
|
|
1098
|
-
const user = this.storage.getUser(device.userId);
|
|
1099
|
-
if (!user) {
|
|
1100
|
-
this.sendAuthError(ws, 'user_not_found', 'User not found');
|
|
1101
|
-
return;
|
|
1102
|
-
}
|
|
1103
|
-
// Register connection
|
|
1104
|
-
state.authenticated = true;
|
|
1105
|
-
state.authenticatedAt = Date.now();
|
|
1106
|
-
if (state.authTimer) {
|
|
1107
|
-
clearTimeout(state.authTimer);
|
|
1108
|
-
state.authTimer = undefined;
|
|
1109
|
-
}
|
|
1110
|
-
state.deviceId = deviceId;
|
|
1111
|
-
state.userId = user.userId;
|
|
1112
|
-
this.evictPreviousConnection(deviceId, ws);
|
|
1113
|
-
this.connections.set(deviceId, ws);
|
|
1114
|
-
this.userByDevice.set(deviceId, user.userId);
|
|
1115
|
-
trace('DEV-CONNECT', { device: deviceId, user: user.userId, auth: 'challenge' });
|
|
1116
|
-
logger.info('Device authenticated via challenge-response', { deviceId, ip: state.ip });
|
|
1117
|
-
this.completeAuthHandshake(ws, {
|
|
1118
|
-
deviceId,
|
|
1119
|
-
userId: user.userId,
|
|
1120
|
-
user: {
|
|
1121
|
-
id: user.userId,
|
|
1122
|
-
login: user.username,
|
|
1123
|
-
provider: user.provider,
|
|
1124
|
-
email: user.email,
|
|
1125
|
-
},
|
|
1126
|
-
authMethod: 'challenge',
|
|
968
|
+
}
|
|
969
|
+
}).finally(() => {
|
|
970
|
+
state.authInFlight = false;
|
|
1127
971
|
});
|
|
1128
972
|
}
|
|
1129
973
|
/**
|
|
@@ -1231,7 +1075,7 @@ export class HeadServer {
|
|
|
1231
1075
|
authMethod: params.authMethod,
|
|
1232
1076
|
user: userResponse,
|
|
1233
1077
|
devices,
|
|
1234
|
-
githubClientId: params.githubClientId ?? this.
|
|
1078
|
+
githubClientId: params.githubClientId ?? this.authBackend.getAuthInfo().githubClientId,
|
|
1235
1079
|
vapidPublicKey: params.vapidPublicKey ?? this.getVapidPublicKey(),
|
|
1236
1080
|
relayVersion: this.options.version,
|
|
1237
1081
|
pulseAckBytes: PULSE_ACK_EVERY_BYTES,
|
|
@@ -1275,59 +1119,26 @@ export class HeadServer {
|
|
|
1275
1119
|
// its guarded cleanup. Publish the Pulse disconnect here so capability and
|
|
1276
1120
|
// connection-scoped state cannot leak across the replacement boundary.
|
|
1277
1121
|
this.pulseHub.onDeviceDisconnected(deviceId);
|
|
1122
|
+
const previousState = this.clients.get(previous);
|
|
1123
|
+
if (previousState)
|
|
1124
|
+
previousState.evicted = true;
|
|
1278
1125
|
this.clients.delete(previous);
|
|
1126
|
+
// Say why (a bare terminate() reads as a network drop, and a client that
|
|
1127
|
+
// reconnects on it evicts this new connection in turn). Force the TCP
|
|
1128
|
+
// teardown shortly after in case the old peer never completes the close.
|
|
1279
1129
|
try {
|
|
1280
|
-
previous.
|
|
1130
|
+
previous.close(DEVICE_REPLACED_CLOSE_CODE, 'replaced');
|
|
1281
1131
|
}
|
|
1282
1132
|
catch { /* best effort */ }
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
const logger = getLogger();
|
|
1286
|
-
// Persist user
|
|
1287
|
-
this.storage.upsertUser(user.id, user.login, user.provider, user.email);
|
|
1288
|
-
// Register device
|
|
1289
|
-
const deviceId = msg.device.deviceId ?? `dev_${uuid().slice(0, 12)}`;
|
|
1290
|
-
try {
|
|
1291
|
-
this.storage.upsertDevice(deviceId, user.id, msg.device.name, msg.device.role, msg.device.kind, msg.device.publicKey, msg.device.encryptionKey);
|
|
1292
|
-
}
|
|
1293
|
-
catch (err) {
|
|
1294
|
-
logger.warn('Device registration failed', { ip: state.ip, error: err.message });
|
|
1295
|
-
this.sendAuthError(ws, 'device_registration_failed', err.message);
|
|
1296
|
-
return;
|
|
1297
|
-
}
|
|
1298
|
-
state.authenticated = true;
|
|
1299
|
-
state.authenticatedAt = Date.now();
|
|
1300
|
-
if (state.authTimer) {
|
|
1301
|
-
clearTimeout(state.authTimer);
|
|
1302
|
-
state.authTimer = undefined;
|
|
1133
|
+
const force = setTimeout(() => { try {
|
|
1134
|
+
previous.terminate();
|
|
1303
1135
|
}
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
this.connections.set(deviceId, ws);
|
|
1308
|
-
this.userByDevice.set(deviceId, user.id);
|
|
1309
|
-
trace('DEV-CONNECT', { device: deviceId, user: user.id, auth: 'inline' });
|
|
1310
|
-
logger.info('Device authenticated', {
|
|
1311
|
-
deviceId,
|
|
1312
|
-
name: msg.device.name,
|
|
1313
|
-
role: msg.device.role,
|
|
1314
|
-
user: user.login,
|
|
1315
|
-
ip: state.ip,
|
|
1316
|
-
});
|
|
1317
|
-
this.completeAuthHandshake(ws, {
|
|
1318
|
-
deviceId,
|
|
1319
|
-
userId: user.id,
|
|
1320
|
-
user: {
|
|
1321
|
-
id: user.id,
|
|
1322
|
-
login: user.login,
|
|
1323
|
-
provider: user.provider,
|
|
1324
|
-
email: user.email,
|
|
1325
|
-
},
|
|
1326
|
-
authMethod,
|
|
1327
|
-
});
|
|
1136
|
+
catch { /* already gone */ } }, 2_000);
|
|
1137
|
+
force.unref?.();
|
|
1138
|
+
previous.once('close', () => clearTimeout(force));
|
|
1328
1139
|
}
|
|
1329
1140
|
// --- Pairing tokens (in-memory) ---
|
|
1330
|
-
handleCreatePairingToken(ws, state) {
|
|
1141
|
+
async handleCreatePairingToken(ws, state) {
|
|
1331
1142
|
const logger = getLogger();
|
|
1332
1143
|
if (!(this.options.pairingEnabled ?? true)) {
|
|
1333
1144
|
this.sendError(ws, 'Pairing is disabled on this relay');
|
|
@@ -1337,30 +1148,23 @@ export class HeadServer {
|
|
|
1337
1148
|
this.sendError(ws, 'Not authenticated');
|
|
1338
1149
|
return;
|
|
1339
1150
|
}
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1151
|
+
try {
|
|
1152
|
+
// A remote (edge) backend creates tokens on the account service.
|
|
1153
|
+
const backend = this.authBackend;
|
|
1154
|
+
const result = backend.createPairingTokenAsync
|
|
1155
|
+
? await backend.createPairingTokenAsync(state.userId)
|
|
1156
|
+
: backend.createPairingToken(state.userId);
|
|
1157
|
+
if (!result?.token)
|
|
1158
|
+
throw new Error('no token returned');
|
|
1159
|
+
logger.info('Pairing token created', { userId: state.userId });
|
|
1160
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1344
1161
|
ws.send(JSON.stringify({ type: 'pairing_token_created', token: result.token, expiresIn: result.expiresIn }));
|
|
1345
1162
|
}
|
|
1346
|
-
catch {
|
|
1347
|
-
// Remote backend needs async — fall through to error
|
|
1348
|
-
this.sendError(ws, 'Pairing not available in remote mode');
|
|
1349
|
-
}
|
|
1350
|
-
return;
|
|
1351
1163
|
}
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
expiresAt: Date.now() + ttl * 1000,
|
|
1357
|
-
});
|
|
1358
|
-
logger.info('Pairing token created', { userId: state.userId, ttl });
|
|
1359
|
-
ws.send(JSON.stringify({
|
|
1360
|
-
type: 'pairing_token_created',
|
|
1361
|
-
token,
|
|
1362
|
-
expiresIn: ttl,
|
|
1363
|
-
}));
|
|
1164
|
+
catch (err) {
|
|
1165
|
+
logger.warn('Pairing token creation failed', { userId: state.userId, error: err.message });
|
|
1166
|
+
this.sendError(ws, 'Pairing is temporarily unavailable');
|
|
1167
|
+
}
|
|
1364
1168
|
}
|
|
1365
1169
|
async handleRequestPairingToken(ws, state, msg) {
|
|
1366
1170
|
const logger = getLogger();
|
|
@@ -1372,47 +1176,18 @@ export class HeadServer {
|
|
|
1372
1176
|
this.sendError(ws, 'Token required for pairing request');
|
|
1373
1177
|
return;
|
|
1374
1178
|
}
|
|
1375
|
-
|
|
1376
|
-
if (
|
|
1377
|
-
const result = await this.options.authBackend.requestPairingToken(msg.token, state.ip);
|
|
1378
|
-
if (!result.ok) {
|
|
1379
|
-
this.sendAuthError(ws, result.code, result.message);
|
|
1380
|
-
return;
|
|
1381
|
-
}
|
|
1382
|
-
logger.info('Pairing token created (one-shot, via backend)', { userId: result.userId });
|
|
1383
|
-
ws.send(JSON.stringify({
|
|
1384
|
-
type: 'pairing_token_created',
|
|
1385
|
-
token: result.pairingToken,
|
|
1386
|
-
expiresIn: result.expiresIn,
|
|
1387
|
-
}));
|
|
1179
|
+
const result = await this.authBackend.requestPairingToken(msg.token, state.ip);
|
|
1180
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
1388
1181
|
return;
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
let authResult = { ok: false, message: 'No auth provider accepted the token' };
|
|
1392
|
-
for (const provider of (this.options.authProviders?.values() ?? [])) {
|
|
1393
|
-
authResult = await provider.authenticate({ token: msg.token, ip: state.ip });
|
|
1394
|
-
if (authResult.ok)
|
|
1395
|
-
break;
|
|
1396
|
-
}
|
|
1397
|
-
if (!authResult.ok && this.options.authProvider && !this.options.authProviders?.size) {
|
|
1398
|
-
authResult = await this.options.authProvider.authenticate({ token: msg.token, ip: state.ip });
|
|
1399
|
-
}
|
|
1400
|
-
if (!authResult.ok) {
|
|
1401
|
-
this.sendAuthError(ws, 'auth_rejected', authResult.message);
|
|
1182
|
+
if (!result.ok) {
|
|
1183
|
+
this.sendAuthError(ws, result.code, result.message);
|
|
1402
1184
|
return;
|
|
1403
1185
|
}
|
|
1404
|
-
|
|
1405
|
-
const token = `pt_${randomBytes(32).toString('hex')}`;
|
|
1406
|
-
const ttl = this.options.pairingTtl ?? 300;
|
|
1407
|
-
this.pairingTokens.set(token, {
|
|
1408
|
-
userId: authResult.user.id,
|
|
1409
|
-
expiresAt: Date.now() + ttl * 1000,
|
|
1410
|
-
});
|
|
1411
|
-
logger.info('Pairing token created (one-shot)', { userId: authResult.user.id, ttl });
|
|
1186
|
+
logger.info('Pairing token created (one-shot)', { userId: result.userId });
|
|
1412
1187
|
ws.send(JSON.stringify({
|
|
1413
1188
|
type: 'pairing_token_created',
|
|
1414
|
-
token,
|
|
1415
|
-
expiresIn:
|
|
1189
|
+
token: result.pairingToken,
|
|
1190
|
+
expiresIn: result.expiresIn,
|
|
1416
1191
|
}));
|
|
1417
1192
|
}
|
|
1418
1193
|
// --- Device presence notifications ---
|
|
@@ -1447,7 +1222,7 @@ export class HeadServer {
|
|
|
1447
1222
|
// Edge mode: the account service owns the device list and every auth
|
|
1448
1223
|
// mirrors it back here, so the removal must reach it too (otherwise the
|
|
1449
1224
|
// device reappears after the next reconnect).
|
|
1450
|
-
const remote = this.
|
|
1225
|
+
const remote = this.authBackend;
|
|
1451
1226
|
if (remote instanceof RemoteAuthBackend) {
|
|
1452
1227
|
const userId = state.userId;
|
|
1453
1228
|
remote.removeDevice(userId, targetDeviceId).then((ok) => {
|
|
@@ -1460,6 +1235,65 @@ export class HeadServer {
|
|
|
1460
1235
|
// Broadcast removal to all remaining user devices
|
|
1461
1236
|
this.broadcastDeviceRemoved(state.userId, targetDeviceId);
|
|
1462
1237
|
}
|
|
1238
|
+
// --- Account deletion ---
|
|
1239
|
+
/** Users whose deletion is in flight (a double tap must not run it twice). */
|
|
1240
|
+
deletingAccounts = new Set();
|
|
1241
|
+
/**
|
|
1242
|
+
* Delete the requesting user's account: at the account store (the account
|
|
1243
|
+
* service in edge mode) and in this relay's own storage and pulse state.
|
|
1244
|
+
* Then tell every connected device and close it. Devices that were offline
|
|
1245
|
+
* get `auth_error` code `account_deleted` when they next connect.
|
|
1246
|
+
*/
|
|
1247
|
+
async handleDeleteAccount(ws, state) {
|
|
1248
|
+
const logger = getLogger();
|
|
1249
|
+
const userId = state.userId;
|
|
1250
|
+
const deviceId = state.deviceId;
|
|
1251
|
+
if (!userId || !deviceId) {
|
|
1252
|
+
this.sendError(ws, 'Not authenticated');
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
if (this.storage.getDevice(deviceId)?.role !== 'app') {
|
|
1256
|
+
this.sendError(ws, 'Only the Kraki app can delete the account');
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
if (this.deletingAccounts.has(userId))
|
|
1260
|
+
return;
|
|
1261
|
+
this.deletingAccounts.add(userId);
|
|
1262
|
+
try {
|
|
1263
|
+
const deviceIds = new Set(this.storage.getDevicesByUser(userId).map((d) => d.id));
|
|
1264
|
+
if (this.authBackend.deleteAccount) {
|
|
1265
|
+
for (const id of await this.authBackend.deleteAccount(userId))
|
|
1266
|
+
deviceIds.add(id);
|
|
1267
|
+
}
|
|
1268
|
+
// This relay's own rows (the edge mirror; already gone when the
|
|
1269
|
+
// account store is this relay's storage).
|
|
1270
|
+
for (const id of this.storage.deleteUser(userId))
|
|
1271
|
+
deviceIds.add(id);
|
|
1272
|
+
logger.info('Account deleted', { userId, devices: deviceIds.size, byDevice: deviceId });
|
|
1273
|
+
for (const id of deviceIds) {
|
|
1274
|
+
const socket = this.connections.get(id);
|
|
1275
|
+
if (socket) {
|
|
1276
|
+
// Detach before closing so the close handler doesn't announce
|
|
1277
|
+
// device_left or touch the deleted rows.
|
|
1278
|
+
this.connections.delete(id);
|
|
1279
|
+
this.userByDevice.delete(id);
|
|
1280
|
+
this.clients.delete(socket);
|
|
1281
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
1282
|
+
socket.send(JSON.stringify({ type: 'account_deleted' }));
|
|
1283
|
+
socket.close(ACCOUNT_DELETED_CLOSE_CODE, 'account_deleted');
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
this.pulseHub.forgetDevice(id);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
catch (err) {
|
|
1290
|
+
logger.error('Account deletion failed', { userId, error: err.message });
|
|
1291
|
+
this.sendError(ws, 'Could not delete the account. Try again.');
|
|
1292
|
+
}
|
|
1293
|
+
finally {
|
|
1294
|
+
this.deletingAccounts.delete(userId);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1463
1297
|
// --- Push token management ---
|
|
1464
1298
|
handleRegisterPushToken(ws, state, msg) {
|
|
1465
1299
|
const logger = getLogger();
|
|
@@ -1475,7 +1309,11 @@ export class HeadServer {
|
|
|
1475
1309
|
this.storage.upsertPushToken(state.deviceId, payload.provider, payload.token, payload.environment, payload.bundleId);
|
|
1476
1310
|
// Clean up push tokens from stale devices (offline >24h) to prevent
|
|
1477
1311
|
// duplicate notifications from abandoned PWA installs on the same phone.
|
|
1478
|
-
|
|
1312
|
+
// Only this user's devices matter; passing every connection on the relay
|
|
1313
|
+
// made each registration O(global connections) in SQL parameters.
|
|
1314
|
+
const onlineDeviceIds = this.storage.getDevicesByUser(state.userId)
|
|
1315
|
+
.map((d) => d.id)
|
|
1316
|
+
.filter((id) => this.connections.has(id));
|
|
1479
1317
|
const pruned = this.storage.deleteStaleUserPushTokens(state.userId, state.deviceId, onlineDeviceIds);
|
|
1480
1318
|
if (pruned > 0) {
|
|
1481
1319
|
logger.info('Pruned stale push tokens', { userId: state.userId, count: pruned });
|
|
@@ -1652,8 +1490,9 @@ export class HeadServer {
|
|
|
1652
1490
|
});
|
|
1653
1491
|
}
|
|
1654
1492
|
const allUsers = this.storage.getAllUsers();
|
|
1493
|
+
const devicesByUser = this.storage.getAllDevicesByUser();
|
|
1655
1494
|
const registeredUsers = allUsers.map(u => {
|
|
1656
|
-
const devices =
|
|
1495
|
+
const devices = devicesByUser.get(u.userId) ?? [];
|
|
1657
1496
|
return {
|
|
1658
1497
|
userId: u.userId,
|
|
1659
1498
|
username: u.username,
|