@pomade/core 0.0.7 → 0.0.9

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/signer.js CHANGED
@@ -1,11 +1,17 @@
1
1
  import { Lib } from "@frostr/bifrost";
2
2
  import { randomBytes, bytesToHex } from "@noble/hashes/utils.js";
3
- import { now, filter, removeUndefined, append, ms, uniq, between, call, int, ago, MINUTE, HOUR, YEAR, } from "@welshman/lib";
4
- import { getPubkey, verifyEvent, getTagValue, HTTP_AUTH } from "@welshman/util";
3
+ import { not, now, filter, remove, removeUndefined, append, ms, uniq, between, call, int, ago, MINUTE, HOUR, YEAR, } from "@welshman/lib";
4
+ import { verifyEvent, getTagValue, HTTP_AUTH } from "@welshman/util";
5
5
  import { Method, isPasswordAuth, isOTPAuth } from "./schema.js";
6
6
  import { RPC } from "./rpc.js";
7
7
  import { hashEmail, encodeChallenge, debug } from "./util.js";
8
8
  import { isChallengeRequest, isEcdhRequest, isLoginSelect, isLoginStart, isRecoverySetup, isRecoverySelect, isRecoveryStart, isRegisterRequest, isSessionDelete, isSessionList, isSignRequest, makeEcdhResult, makeLoginOptions, makeLoginResult, makeRecoverySetupResult, makeRecoveryOptions, makeRecoveryResult, makeRegisterResult, makeSessionDeleteResult, makeSessionListResult, makeSignResult, } from "./message.js";
9
+ import { isRateLimited, recordAttempt, getRateLimitResetTime, cleanupRateLimits, } from "./ratelimit.js";
10
+ // Rate limiting for client requests (sign + ecdh combined)
11
+ const CLIENT_REQUEST_RATE_LIMIT = {
12
+ maxAttempts: 100,
13
+ windowSeconds: int(1, MINUTE),
14
+ };
9
15
  // Utils
10
16
  function makeSessionItem(session) {
11
17
  return {
@@ -22,27 +28,41 @@ function makeSessionItem(session) {
22
28
  export class Signer {
23
29
  options;
24
30
  rpc;
25
- pubkey;
26
31
  intervals;
27
32
  logins;
28
33
  sessions;
29
34
  recoveries;
30
35
  challenges;
31
36
  sessionsByEmailHash;
37
+ rateLimitByEmailHash;
38
+ rateLimitByChallengeHash;
39
+ rateLimitByClient;
40
+ rateLimitConfig;
32
41
  constructor(options) {
33
42
  this.options = options;
34
- this.pubkey = getPubkey(options.secret);
35
43
  this.logins = options.storage.collection("logins");
36
44
  this.sessions = options.storage.collection("sessions");
37
45
  this.recoveries = options.storage.collection("recoveries");
38
46
  this.challenges = options.storage.collection("challenges");
39
47
  this.sessionsByEmailHash = options.storage.collection("sessionsByEmailHash");
40
- this.rpc = new RPC(options.secret, options.relays);
48
+ this.rateLimitByEmailHash = options.storage.collection("rateLimitByEmailHash");
49
+ this.rateLimitByChallengeHash = options.storage.collection("rateLimitByChallengeHash");
50
+ this.rateLimitByClient = options.storage.collection("rateLimitByClient");
51
+ // Default rate limits (conservative but reasonable)
52
+ this.rateLimitConfig = {
53
+ // 5 auth attempts per email_hash per 5 minutes
54
+ auth: { maxAttempts: 5, windowSeconds: int(5, MINUTE) },
55
+ // 3 challenge requests per email_hash per 5 minutes
56
+ challenge: { maxAttempts: 3, windowSeconds: int(5, MINUTE) },
57
+ ...options.rateLimits,
58
+ };
59
+ this.rpc = new RPC(options.signer, options.relays);
41
60
  this.rpc.subscribe(message => {
42
61
  // Ignore events with weird timestamps
43
62
  if (!between([now() - int(1, HOUR), now() + int(1, HOUR)], message.event.created_at)) {
44
63
  return debug("[signer]: ignoring event", message.event.id);
45
64
  }
65
+ debug("[signer]: received event", message);
46
66
  if (isRegisterRequest(message))
47
67
  this.handleRegisterRequest(message);
48
68
  if (isRecoverySetup(message))
@@ -66,10 +86,10 @@ export class Signer {
66
86
  if (isSessionDelete(message))
67
87
  this.handleSessionDelete(message);
68
88
  });
69
- // Periodically clean up recovery requests
89
+ // Periodically clean up recovery requests and rate limits
70
90
  this.intervals = [
71
91
  setInterval(async () => {
72
- debug("[signer]: cleaning up logins and recoveries");
92
+ debug("[signer]: cleaning up logins, recoveries, and rate limits");
73
93
  for (const [client, recovery] of await this.recoveries.entries()) {
74
94
  if (recovery.event.created_at < ago(15, MINUTE))
75
95
  await this.recoveries.delete(client);
@@ -82,6 +102,10 @@ export class Signer {
82
102
  if (challenge.event.created_at < ago(15, MINUTE))
83
103
  await this.challenges.delete(client);
84
104
  }
105
+ // Clean up rate limit buckets
106
+ await cleanupRateLimits(this.rateLimitByEmailHash, this.rateLimitConfig.auth.windowSeconds);
107
+ await cleanupRateLimits(this.rateLimitByChallengeHash, this.rateLimitConfig.challenge.windowSeconds);
108
+ await cleanupRateLimits(this.rateLimitByClient, CLIENT_REQUEST_RATE_LIMIT.windowSeconds);
85
109
  }, ms(int(5, MINUTE))),
86
110
  ];
87
111
  // Immediately clean up old sessions
@@ -97,35 +121,61 @@ export class Signer {
97
121
  this.intervals.forEach(clearInterval);
98
122
  }
99
123
  // Internal utils
124
+ async _checkAndRecordRateLimit(client) {
125
+ const bucket = await this.rateLimitByClient.get(client);
126
+ if (isRateLimited(bucket, CLIENT_REQUEST_RATE_LIMIT)) {
127
+ const resetTime = getRateLimitResetTime(bucket, CLIENT_REQUEST_RATE_LIMIT);
128
+ debug(`[signer]: rate limit exceeded for client ${client.slice(0, 8)}, reset in ${resetTime}s`);
129
+ return false;
130
+ }
131
+ // Record the attempt
132
+ const updatedBucket = recordAttempt(bucket, CLIENT_REQUEST_RATE_LIMIT);
133
+ await this.rateLimitByClient.set(client, updatedBucket);
134
+ return true;
135
+ }
100
136
  async _getAuthenticatedSessions(auth) {
137
+ // Check rate limit for auth attempts
138
+ const bucket = await this.rateLimitByEmailHash.get(auth.email_hash);
139
+ if (isRateLimited(bucket, this.rateLimitConfig.auth)) {
140
+ const resetTime = getRateLimitResetTime(bucket, this.rateLimitConfig.auth);
141
+ debug(`[signer]: rate limit exceeded for email_hash ${auth.email_hash.slice(0, 8)}, reset in ${resetTime}s`);
142
+ return [];
143
+ }
101
144
  const index = await this.sessionsByEmailHash.get(auth.email_hash);
145
+ let sessions = [];
102
146
  if (index) {
103
147
  if (isPasswordAuth(auth)) {
104
- return filter(session => session?.password_hash === auth.password_hash, await Promise.all(index.clients.map(client => this.sessions.get(client))));
148
+ sessions = filter(session => session?.password_hash === auth.password_hash, await Promise.all(index.clients.map(client => this.sessions.get(client))));
105
149
  }
106
150
  if (isOTPAuth(auth)) {
107
151
  const challenge = await this.challenges.get(auth.email_hash);
108
152
  if (challenge) {
109
153
  await this.challenges.delete(auth.email_hash);
110
154
  if (auth.otp === challenge.otp) {
111
- return removeUndefined(await Promise.all(index.clients.map(client => this.sessions.get(client))));
155
+ sessions = removeUndefined(await Promise.all(index.clients.map(client => this.sessions.get(client))));
112
156
  }
113
157
  }
114
158
  }
115
159
  }
116
- return [];
160
+ // Record failed authentication attempt for rate limiting
161
+ if (sessions.length === 0) {
162
+ const updatedBucket = recordAttempt(bucket, this.rateLimitConfig.auth);
163
+ await this.rateLimitByEmailHash.set(auth.email_hash, updatedBucket);
164
+ }
165
+ return sessions;
117
166
  }
118
- _isNip98AuthValid(auth, method) {
167
+ async _isNip98AuthValid(auth, method) {
168
+ const pubkey = await this.options.signer.getPubkey();
119
169
  return (verifyEvent(auth) &&
120
170
  auth.kind === HTTP_AUTH &&
121
171
  auth.created_at > ago(15) &&
122
172
  auth.created_at < now() + 5 &&
123
- getTagValue("u", auth.tags) === this.pubkey &&
173
+ getTagValue("u", auth.tags) === pubkey &&
124
174
  getTagValue("method", auth.tags) === method);
125
175
  }
126
176
  async _checkKeyReuse(event) {
127
177
  if (await this.sessions.get(event.pubkey)) {
128
- debug("[signer]: session key re-used", event.pubkey);
178
+ debug(`[client ${event.pubkey.slice(0, 8)}]: session key re-used`);
129
179
  return this.rpc.channel(event.pubkey, false).send(makeRecoveryOptions({
130
180
  ok: false,
131
181
  message: "Do not re-use session keys.",
@@ -133,7 +183,7 @@ export class Signer {
133
183
  }));
134
184
  }
135
185
  if (await this.recoveries.get(event.pubkey)) {
136
- debug("[signer]: recovery key re-used", event.pubkey);
186
+ debug(`[client ${event.pubkey.slice(0, 8)}]: recovery key re-used`);
137
187
  return this.rpc.channel(event.pubkey, false).send(makeRecoveryOptions({
138
188
  ok: false,
139
189
  message: "Do not re-use recovery keys.",
@@ -141,7 +191,7 @@ export class Signer {
141
191
  }));
142
192
  }
143
193
  if (await this.logins.get(event.pubkey)) {
144
- debug("[signer]: login key re-used", event.pubkey);
194
+ debug(`[client ${event.pubkey.slice(0, 8)}]: login key re-used`);
145
195
  return this.rpc.channel(event.pubkey, false).send(makeRecoveryOptions({
146
196
  ok: false,
147
197
  message: "Do not re-use login keys.",
@@ -149,9 +199,38 @@ export class Signer {
149
199
  }));
150
200
  }
151
201
  }
202
+ async _addSession(client, session) {
203
+ await this.sessions.set(client, session);
204
+ if (session.email_hash) {
205
+ let index = await this.sessionsByEmailHash.get(session.email_hash);
206
+ if (!index) {
207
+ index = { clients: [] };
208
+ }
209
+ await this.sessionsByEmailHash.set(session.email_hash, {
210
+ clients: append(client, index.clients),
211
+ });
212
+ }
213
+ }
214
+ async _deleteSession(client) {
215
+ const session = await this.sessions.get(client);
216
+ if (session) {
217
+ if (session.email_hash) {
218
+ const index = await this.sessionsByEmailHash.get(session.email_hash);
219
+ if (index) {
220
+ const clients = remove(client, index.clients);
221
+ if (clients.length === 0) {
222
+ await this.sessionsByEmailHash.delete(session.email_hash);
223
+ }
224
+ else {
225
+ await this.sessionsByEmailHash.set(session.email_hash, { clients });
226
+ }
227
+ }
228
+ }
229
+ await this.sessions.delete(client);
230
+ }
231
+ }
152
232
  // Registration
153
233
  async handleRegisterRequest({ payload, event }) {
154
- debug("[signer]: attempting to register session", event.pubkey);
155
234
  return this.options.storage.tx(async () => {
156
235
  const { group, share, recovery } = payload;
157
236
  const cb = (ok, message) => this.rpc
@@ -160,26 +239,26 @@ export class Signer {
160
239
  if (await this._checkKeyReuse(event))
161
240
  return;
162
241
  if (!between([0, group.commits.length], group.threshold)) {
163
- debug("[signer]: invalid group threshold", event.pubkey);
242
+ debug(`[client ${event.pubkey.slice(0, 8)}]: invalid group threshold`);
164
243
  return cb(false, "Invalid group threshold.");
165
244
  }
166
245
  if (!Lib.is_group_member(group, share)) {
167
- debug("[signer]: share does not belong to the provided group", event.pubkey);
246
+ debug(`[client ${event.pubkey.slice(0, 8)}]: share does not belong to the provided group`);
168
247
  return cb(false, "Share does not belong to the provided group.");
169
248
  }
170
249
  if (uniq(group.commits.map(c => c.idx)).length !== group.commits.length) {
171
- debug("[signer]: group contains duplicate member indices", event.pubkey);
250
+ debug(`[client ${event.pubkey.slice(0, 8)}]: group contains duplicate member indices`);
172
251
  return cb(false, "Group contains duplicate member indices.");
173
252
  }
174
253
  if (!group.commits.find(c => c.idx === share.idx)) {
175
- debug("[signer]: share index not found in group commits", event.pubkey);
254
+ debug(`[client ${event.pubkey.slice(0, 8)}]: share index not found in group commits`);
176
255
  return cb(false, "Share index not found in group commits.");
177
256
  }
178
257
  if (await this.sessions.get(event.pubkey)) {
179
- debug("[signer]: client is already registered", event.pubkey);
258
+ debug(`[client ${event.pubkey.slice(0, 8)}]: client is already registered`);
180
259
  return cb(false, "Client is already registered.");
181
260
  }
182
- await this.sessions.set(event.pubkey, {
261
+ await this._addSession(event.pubkey, {
183
262
  client: event.pubkey,
184
263
  event,
185
264
  share,
@@ -187,7 +266,7 @@ export class Signer {
187
266
  recovery,
188
267
  last_activity: now(),
189
268
  });
190
- debug("[signer]: registered", event.pubkey);
269
+ debug(`[client ${event.pubkey.slice(0, 8)}]: registered`);
191
270
  return cb(true, "Your key has been registered");
192
271
  });
193
272
  }
@@ -196,7 +275,7 @@ export class Signer {
196
275
  return this.options.storage.tx(async () => {
197
276
  const session = await this.sessions.get(event.pubkey);
198
277
  if (!session) {
199
- debug("[signer]: no session found for recovery setup", event.pubkey);
278
+ debug(`[client ${event.pubkey.slice(0, 8)}]: no session found for recovery setup`);
200
279
  return this.rpc.channel(event.pubkey, false).send(makeRecoverySetupResult({
201
280
  ok: false,
202
281
  message: "No session found.",
@@ -204,7 +283,7 @@ export class Signer {
204
283
  }));
205
284
  }
206
285
  if (!session.recovery) {
207
- debug("[signer]: recovery is disabled for session", event.pubkey);
286
+ debug(`[client ${event.pubkey.slice(0, 8)}]: recovery is disabled for session`);
208
287
  return this.rpc.channel(event.pubkey, false).send(makeRecoverySetupResult({
209
288
  ok: false,
210
289
  message: "Recovery is disabled on this session.",
@@ -214,7 +293,7 @@ export class Signer {
214
293
  // recovery method has to be bound at (or shorly after) session, otherwise an attacker with access
215
294
  // to any session could escalate permissions by setting up their own recovery method
216
295
  if (session.event.created_at < ago(15, MINUTE)) {
217
- debug("[signer]: recovery method set too late", event.pubkey);
296
+ debug(`[client ${event.pubkey.slice(0, 8)}]: recovery method set too late`);
218
297
  return this.rpc.channel(event.pubkey, false).send(makeRecoverySetupResult({
219
298
  ok: false,
220
299
  message: "Recovery method must be set within 5 minutes of session.",
@@ -222,7 +301,7 @@ export class Signer {
222
301
  }));
223
302
  }
224
303
  if (session.email) {
225
- debug("[signer]: recovery is already set", event.pubkey);
304
+ debug(`[client ${event.pubkey.slice(0, 8)}]: recovery is already set`);
226
305
  return this.rpc.channel(event.pubkey, false).send(makeRecoverySetupResult({
227
306
  ok: false,
228
307
  message: "Recovery has already been initialized.",
@@ -230,7 +309,7 @@ export class Signer {
230
309
  }));
231
310
  }
232
311
  if (!payload.password_hash.match(/^[a-f0-9]{64}$/)) {
233
- debug("[signer]: invalid password_hash provided on setup", event.pubkey);
312
+ debug(`[client ${event.pubkey.slice(0, 8)}]: invalid password_hash provided on setup`);
234
313
  return this.rpc.channel(event.pubkey, false).send(makeRecoverySetupResult({
235
314
  ok: false,
236
315
  message: "Recovery method password hash must be an argon2id hash of user email and password.",
@@ -238,17 +317,16 @@ export class Signer {
238
317
  }));
239
318
  }
240
319
  const { email, password_hash } = payload;
241
- const email_hash = await hashEmail(email, this.pubkey);
242
- const index = (await this.sessionsByEmailHash.get(email_hash)) || { clients: [] };
243
- await this.sessions.set(event.pubkey, {
320
+ const pubkey = await this.options.signer.getPubkey();
321
+ const email_hash = await hashEmail(email, pubkey);
322
+ await this._addSession(event.pubkey, {
244
323
  ...session,
245
324
  last_activity: now(),
246
325
  email,
247
326
  email_hash,
248
327
  password_hash,
249
328
  });
250
- await this.sessionsByEmailHash.set(email_hash, { clients: append(event.pubkey, index.clients) });
251
- debug("[signer]: recovery method initialized", event.pubkey);
329
+ debug(`[client ${event.pubkey.slice(0, 8)}]: recovery method initialized`);
252
330
  this.rpc.channel(event.pubkey, false).send(makeRecoverySetupResult({
253
331
  ok: true,
254
332
  message: "Recovery method successfully initialized.",
@@ -257,16 +335,31 @@ export class Signer {
257
335
  });
258
336
  }
259
337
  async handleChallengeRequest({ payload, event }) {
338
+ // Check rate limit for challenge requests per email_hash
339
+ const bucket = await this.rateLimitByChallengeHash.get(payload.email_hash);
340
+ if (isRateLimited(bucket, this.rateLimitConfig.challenge)) {
341
+ const resetTime = getRateLimitResetTime(bucket, this.rateLimitConfig.challenge);
342
+ debug(`[signer]: challenge rate limit exceeded for email_hash ${payload.email_hash.slice(0, 8)}, reset in ${resetTime}s`);
343
+ return;
344
+ }
260
345
  const index = await this.sessionsByEmailHash.get(payload.email_hash);
261
346
  if (index && index.clients.length > 0) {
262
347
  const session = await this.sessions.get(index.clients[0]);
263
348
  if (session?.email) {
264
- const otp = bytesToHex(randomBytes(12));
265
- const challenge = encodeChallenge(this.pubkey, otp);
349
+ // Record challenge request for rate limiting
350
+ const updatedBucket = recordAttempt(bucket, this.rateLimitConfig.challenge);
351
+ await this.rateLimitByChallengeHash.set(payload.email_hash, updatedBucket);
352
+ const otp = bytesToHex(randomBytes(8));
353
+ const pubkey = await this.options.signer.getPubkey();
354
+ const challenge = encodeChallenge(pubkey, otp);
266
355
  await this.challenges.set(payload.email_hash, { otp, event });
267
356
  this.options.sendChallenge({ email: session.email, challenge });
357
+ debug(`[client ${event.pubkey.slice(0, 8)}]: challenge sent for ${payload.email_hash}`);
268
358
  }
269
359
  }
360
+ else {
361
+ debug(`[client ${event.pubkey.slice(0, 8)}]: no session found for ${payload.email_hash}`);
362
+ }
270
363
  }
271
364
  // Recovery
272
365
  async handleRecoveryStart({ payload, event }) {
@@ -274,14 +367,14 @@ export class Signer {
274
367
  return;
275
368
  const sessions = await this._getAuthenticatedSessions(payload.auth);
276
369
  if (sessions.length === 0) {
277
- debug("[signer]: no sessions found for recovery", event.pubkey);
370
+ debug(`[client ${event.pubkey.slice(0, 8)}]: no sessions found for recovery`);
278
371
  return this.rpc.channel(event.pubkey, false).send(makeRecoveryOptions({
279
372
  ok: false,
280
373
  message: "No sessions found.",
281
374
  prev: event.id,
282
375
  }));
283
376
  }
284
- debug("[signer]: sending recovery options", event.pubkey);
377
+ debug(`[client ${event.pubkey.slice(0, 8)}]: sending recovery options`);
285
378
  const clients = sessions.map(s => s.client);
286
379
  const items = sessions.map(makeSessionItem);
287
380
  await this.recoveries.set(event.pubkey, { event, clients });
@@ -295,7 +388,7 @@ export class Signer {
295
388
  async handleRecoverySelect({ payload, event }) {
296
389
  const recovery = await this.recoveries.get(event.pubkey);
297
390
  if (!recovery) {
298
- debug("[signer]: no active recovery found", event.pubkey);
391
+ debug(`[client ${event.pubkey.slice(0, 8)}]: no active recovery found`);
299
392
  return this.rpc.channel(event.pubkey, false).send(makeRecoveryResult({
300
393
  ok: false,
301
394
  message: `No active recovery found.`,
@@ -305,7 +398,7 @@ export class Signer {
305
398
  // Cleanup right away
306
399
  await this.recoveries.delete(event.pubkey);
307
400
  if (!recovery.clients.includes(payload.client)) {
308
- debug("[signer]: invalid session selected for recovery", event.pubkey);
401
+ debug(`[client ${event.pubkey.slice(0, 8)}]: invalid session selected for recovery`);
309
402
  return this.rpc.channel(event.pubkey, false).send(makeRecoveryResult({
310
403
  ok: false,
311
404
  message: `Invalid session selected for recovery.`,
@@ -314,7 +407,7 @@ export class Signer {
314
407
  }
315
408
  const session = await this.sessions.get(payload.client);
316
409
  if (!session) {
317
- debug("[signer]: recovery session not found", event.pubkey);
410
+ debug(`[client ${event.pubkey.slice(0, 8)}]: recovery session not found`);
318
411
  return this.rpc.channel(event.pubkey, false).send(makeRecoveryResult({
319
412
  ok: false,
320
413
  message: `Recovery session not found.`,
@@ -328,7 +421,7 @@ export class Signer {
328
421
  share: session.share,
329
422
  prev: event.id,
330
423
  }));
331
- debug("[signer]: recovery successfully completed", event.pubkey);
424
+ debug(`[client ${event.pubkey.slice(0, 8)}]: recovery successfully completed`);
332
425
  }
333
426
  // Login
334
427
  async handleLoginStart({ payload, event }) {
@@ -336,14 +429,14 @@ export class Signer {
336
429
  return;
337
430
  const sessions = await this._getAuthenticatedSessions(payload.auth);
338
431
  if (sessions.length === 0) {
339
- debug("[signer]: no sessions found for login", event.pubkey);
432
+ debug(`[client ${event.pubkey.slice(0, 8)}]: no sessions found for login`);
340
433
  return this.rpc.channel(event.pubkey, false).send(makeLoginOptions({
341
434
  ok: false,
342
435
  message: "No sessions found.",
343
436
  prev: event.id,
344
437
  }));
345
438
  }
346
- debug("[signer]: sending login options", event.pubkey);
439
+ debug(`[client ${event.pubkey.slice(0, 8)}]: sending login options`);
347
440
  const clients = sessions.map(s => s.client);
348
441
  const items = sessions.map(makeSessionItem);
349
442
  await this.logins.set(event.pubkey, { event, clients });
@@ -357,7 +450,7 @@ export class Signer {
357
450
  async handleLoginSelect({ payload, event }) {
358
451
  const login = await this.logins.get(event.pubkey);
359
452
  if (!login) {
360
- debug("[signer]: no active login found", event.pubkey);
453
+ debug(`[client ${event.pubkey.slice(0, 8)}]: no active login found`);
361
454
  return this.rpc.channel(event.pubkey, false).send(makeLoginResult({
362
455
  ok: false,
363
456
  message: `No active login found.`,
@@ -367,7 +460,7 @@ export class Signer {
367
460
  // Cleanup right away
368
461
  await this.logins.delete(event.pubkey);
369
462
  if (!login.clients.includes(payload.client)) {
370
- debug("[signer]: invalid session selected for login", event.pubkey);
463
+ debug(`[client ${event.pubkey.slice(0, 8)}]: invalid session selected for login`);
371
464
  return this.rpc.channel(event.pubkey, false).send(makeLoginResult({
372
465
  ok: false,
373
466
  message: `Invalid session selected for login.`,
@@ -376,19 +469,22 @@ export class Signer {
376
469
  }
377
470
  const session = await this.sessions.get(payload.client);
378
471
  if (!session) {
379
- debug("[signer]: login session not found", event.pubkey);
472
+ debug(`[client ${event.pubkey.slice(0, 8)}]: login session not found`);
380
473
  return this.rpc.channel(event.pubkey, false).send(makeLoginResult({
381
474
  ok: false,
382
475
  message: `Login session not found.`,
383
476
  prev: event.id,
384
477
  }));
385
478
  }
386
- await this.sessions.set(event.pubkey, {
479
+ await this._addSession(event.pubkey, {
387
480
  event,
388
481
  recovery: true,
389
482
  client: event.pubkey,
390
483
  share: session.share,
391
484
  group: session.group,
485
+ email: session.email,
486
+ email_hash: session.email_hash,
487
+ password_hash: session.password_hash,
392
488
  last_activity: now(),
393
489
  });
394
490
  this.rpc.channel(event.pubkey, false).send(makeLoginResult({
@@ -397,62 +493,98 @@ export class Signer {
397
493
  group: session.group,
398
494
  prev: event.id,
399
495
  }));
400
- debug("[signer]: login successfully completed", event.pubkey);
496
+ debug(`[client ${event.pubkey.slice(0, 8)}]: login successfully completed`);
401
497
  }
402
498
  // Signing
403
499
  async handleSignRequest({ payload, event }) {
404
- debug("[signer]: attempting signing flow", event.pubkey);
405
500
  return this.options.storage.tx(async () => {
406
501
  const session = await this.sessions.get(event.pubkey);
407
502
  if (!session) {
408
- debug("[signer]: signing failed", event.pubkey);
503
+ debug(`[client ${event.pubkey.slice(0, 8)}]: signing failed - no session found`);
409
504
  return this.rpc.channel(event.pubkey, false).send(makeSignResult({
410
505
  ok: false,
411
506
  message: "No session found for client",
412
507
  prev: event.id,
413
508
  }));
414
509
  }
415
- const ctx = Lib.get_session_ctx(session.group, payload.request);
416
- const partialSignature = Lib.create_psig_pkg(ctx, session.share);
417
- await this.sessions.set(event.pubkey, { ...session, last_activity: now() });
418
- debug("[signer]: signing complete", event.pubkey);
419
- this.rpc.channel(event.pubkey, false).send(makeSignResult({
420
- result: partialSignature,
421
- ok: true,
422
- message: "Successfully signed event",
423
- prev: event.id,
424
- }));
510
+ // Check rate limit
511
+ const allowed = await this._checkAndRecordRateLimit(event.pubkey);
512
+ if (!allowed) {
513
+ return this.rpc.channel(event.pubkey, false).send(makeSignResult({
514
+ ok: false,
515
+ message: "Rate limit exceeded. Please try again later.",
516
+ prev: event.id,
517
+ }));
518
+ }
519
+ try {
520
+ const ctx = Lib.get_session_ctx(session.group, payload.request);
521
+ const partialSignature = Lib.create_psig_pkg(ctx, session.share);
522
+ await this.sessions.set(event.pubkey, { ...session, last_activity: now() });
523
+ debug(`[client ${event.pubkey.slice(0, 8)}]: signing complete`);
524
+ this.rpc.channel(event.pubkey, false).send(makeSignResult({
525
+ result: partialSignature,
526
+ ok: true,
527
+ message: "Successfully signed event",
528
+ prev: event.id,
529
+ }));
530
+ }
531
+ catch (e) {
532
+ debug(`[client ${event.pubkey.slice(0, 8)}]: signing failed - ${e.message || e}`);
533
+ return this.rpc.channel(event.pubkey, false).send(makeSignResult({
534
+ ok: false,
535
+ message: "Failed to sign event",
536
+ prev: event.id,
537
+ }));
538
+ }
425
539
  });
426
540
  }
427
541
  // Key exchange
428
542
  async handleEcdhRequest({ payload, event }) {
429
- debug("[signer]: attempting ecdh flow", event.pubkey);
430
543
  return this.options.storage.tx(async () => {
431
544
  const session = await this.sessions.get(event.pubkey);
432
545
  if (!session) {
433
- debug("[signer]: ecdh failed", event.pubkey);
546
+ debug(`[client ${event.pubkey.slice(0, 8)}]: ecdh failed - no session found`);
434
547
  return this.rpc.channel(event.pubkey, false).send(makeSignResult({
435
548
  ok: false,
436
549
  message: "No session found for client",
437
550
  prev: event.id,
438
551
  }));
439
552
  }
553
+ // Check rate limit
554
+ const allowed = await this._checkAndRecordRateLimit(event.pubkey);
555
+ if (!allowed) {
556
+ return this.rpc.channel(event.pubkey, false).send(makeEcdhResult({
557
+ ok: false,
558
+ message: "Rate limit exceeded. Please try again later.",
559
+ prev: event.id,
560
+ }));
561
+ }
440
562
  const { members, ecdh_pk } = payload;
441
- const ecdhPackage = Lib.create_ecdh_pkg(members, ecdh_pk, session.share);
442
- await this.sessions.set(event.pubkey, { ...session, last_activity: now() });
443
- debug("[signer]: ecdh complete", event.pubkey);
444
- this.rpc.channel(event.pubkey, false).send(makeEcdhResult({
445
- result: ecdhPackage,
446
- ok: true,
447
- message: "Successfully signed event",
448
- prev: event.id,
449
- }));
563
+ try {
564
+ const ecdhPackage = Lib.create_ecdh_pkg(members, ecdh_pk, session.share);
565
+ await this.sessions.set(event.pubkey, { ...session, last_activity: now() });
566
+ debug(`[client ${event.pubkey.slice(0, 8)}]: ecdh complete`);
567
+ this.rpc.channel(event.pubkey, false).send(makeEcdhResult({
568
+ result: ecdhPackage,
569
+ ok: true,
570
+ message: "Successfully signed event",
571
+ prev: event.id,
572
+ }));
573
+ }
574
+ catch (e) {
575
+ debug(`[client ${event.pubkey.slice(0, 8)}]: ecdh failed - ${e.message || e}`);
576
+ return this.rpc.channel(event.pubkey, false).send(makeSignResult({
577
+ ok: false,
578
+ message: "Key derivation failed",
579
+ prev: event.id,
580
+ }));
581
+ }
450
582
  });
451
583
  }
452
584
  // Session management
453
585
  async handleSessionList({ payload, event }) {
454
- if (!this._isNip98AuthValid(payload.auth, Method.SessionList)) {
455
- debug("[signer]: invalid auth event for session list", event.pubkey);
586
+ if (not(await this._isNip98AuthValid(payload.auth, Method.SessionList))) {
587
+ debug(`[client ${event.pubkey.slice(0, 8)}]: invalid auth event for session list`);
456
588
  return this.rpc.channel(event.pubkey, false).send(makeSessionListResult({
457
589
  items: [],
458
590
  ok: false,
@@ -460,14 +592,13 @@ export class Signer {
460
592
  prev: event.id,
461
593
  }));
462
594
  }
463
- debug("[signer]: attempting to retrieve session list", event.pubkey);
464
595
  const items = [];
465
596
  for (const [_, session] of await this.sessions.entries()) {
466
597
  if (session.group.group_pk.slice(2) === payload.auth.pubkey) {
467
598
  items.push(makeSessionItem(session));
468
599
  }
469
600
  }
470
- debug("[signer]: successfully retrieved session list", event.pubkey);
601
+ debug(`[client ${event.pubkey.slice(0, 8)}]: successfully retrieved session list`);
471
602
  this.rpc.channel(event.pubkey, false).send(makeSessionListResult({
472
603
  items,
473
604
  ok: true,
@@ -476,20 +607,19 @@ export class Signer {
476
607
  }));
477
608
  }
478
609
  async handleSessionDelete({ payload, event }) {
479
- if (!this._isNip98AuthValid(payload.auth, Method.SessionDelete)) {
480
- debug("[signer]: invalid auth event for session deletion", event.pubkey);
610
+ if (not(await this._isNip98AuthValid(payload.auth, Method.SessionDelete))) {
611
+ debug(`[client ${event.pubkey.slice(0, 8)}]: invalid auth event for session deletion`);
481
612
  return this.rpc.channel(event.pubkey, false).send(makeSessionDeleteResult({
482
613
  ok: false,
483
614
  message: "Failed to delete selected session.",
484
615
  prev: event.id,
485
616
  }));
486
617
  }
487
- debug("[signer]: attempting to delete session", event.pubkey);
488
618
  return this.options.storage.tx(async () => {
489
619
  const session = await this.sessions.get(payload.client);
490
620
  if (session?.group.group_pk.slice(2) === payload.auth.pubkey) {
491
- await this.sessions.delete(payload.client);
492
- debug("[signer]: deleted session", event.pubkey);
621
+ await this._deleteSession(payload.client);
622
+ debug(`[client ${event.pubkey.slice(0, 8)}]: deleted session`, payload.client);
493
623
  this.rpc.channel(event.pubkey, false).send(makeSessionDeleteResult({
494
624
  ok: true,
495
625
  message: "Successfully deleted selected session.",
@@ -497,7 +627,7 @@ export class Signer {
497
627
  }));
498
628
  }
499
629
  else {
500
- debug("[signer]: failed to delete session", event.pubkey);
630
+ debug(`[client ${event.pubkey.slice(0, 8)}]: failed to delete session`, payload.client);
501
631
  return this.rpc.channel(event.pubkey, false).send(makeSessionDeleteResult({
502
632
  ok: false,
503
633
  message: "Failed to logout selected client.",