@pomade/core 0.0.8 → 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, remove, 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 ${event.pubkey.slice(0, 8)}]: session key re-used`);
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 ${event.pubkey.slice(0, 8)}]: recovery key re-used`);
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 ${event.pubkey.slice(0, 8)}]: login key re-used`);
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.",
@@ -189,23 +239,23 @@ export class Signer {
189
239
  if (await this._checkKeyReuse(event))
190
240
  return;
191
241
  if (!between([0, group.commits.length], group.threshold)) {
192
- debug(`[signer ${event.pubkey.slice(0, 8)}]: invalid group threshold`);
242
+ debug(`[client ${event.pubkey.slice(0, 8)}]: invalid group threshold`);
193
243
  return cb(false, "Invalid group threshold.");
194
244
  }
195
245
  if (!Lib.is_group_member(group, share)) {
196
- debug(`[signer ${event.pubkey.slice(0, 8)}]: share does not belong to the provided group`);
246
+ debug(`[client ${event.pubkey.slice(0, 8)}]: share does not belong to the provided group`);
197
247
  return cb(false, "Share does not belong to the provided group.");
198
248
  }
199
249
  if (uniq(group.commits.map(c => c.idx)).length !== group.commits.length) {
200
- debug(`[signer ${event.pubkey.slice(0, 8)}]: group contains duplicate member indices`);
250
+ debug(`[client ${event.pubkey.slice(0, 8)}]: group contains duplicate member indices`);
201
251
  return cb(false, "Group contains duplicate member indices.");
202
252
  }
203
253
  if (!group.commits.find(c => c.idx === share.idx)) {
204
- debug(`[signer ${event.pubkey.slice(0, 8)}]: share index not found in group commits`);
254
+ debug(`[client ${event.pubkey.slice(0, 8)}]: share index not found in group commits`);
205
255
  return cb(false, "Share index not found in group commits.");
206
256
  }
207
257
  if (await this.sessions.get(event.pubkey)) {
208
- debug(`[signer ${event.pubkey.slice(0, 8)}]: client is already registered`);
258
+ debug(`[client ${event.pubkey.slice(0, 8)}]: client is already registered`);
209
259
  return cb(false, "Client is already registered.");
210
260
  }
211
261
  await this._addSession(event.pubkey, {
@@ -216,7 +266,7 @@ export class Signer {
216
266
  recovery,
217
267
  last_activity: now(),
218
268
  });
219
- debug(`[signer ${event.pubkey.slice(0, 8)}]: registered`);
269
+ debug(`[client ${event.pubkey.slice(0, 8)}]: registered`);
220
270
  return cb(true, "Your key has been registered");
221
271
  });
222
272
  }
@@ -225,7 +275,7 @@ export class Signer {
225
275
  return this.options.storage.tx(async () => {
226
276
  const session = await this.sessions.get(event.pubkey);
227
277
  if (!session) {
228
- debug(`[signer ${event.pubkey.slice(0, 8)}]: no session found for recovery setup`);
278
+ debug(`[client ${event.pubkey.slice(0, 8)}]: no session found for recovery setup`);
229
279
  return this.rpc.channel(event.pubkey, false).send(makeRecoverySetupResult({
230
280
  ok: false,
231
281
  message: "No session found.",
@@ -233,7 +283,7 @@ export class Signer {
233
283
  }));
234
284
  }
235
285
  if (!session.recovery) {
236
- debug(`[signer ${event.pubkey.slice(0, 8)}]: recovery is disabled for session`);
286
+ debug(`[client ${event.pubkey.slice(0, 8)}]: recovery is disabled for session`);
237
287
  return this.rpc.channel(event.pubkey, false).send(makeRecoverySetupResult({
238
288
  ok: false,
239
289
  message: "Recovery is disabled on this session.",
@@ -243,7 +293,7 @@ export class Signer {
243
293
  // recovery method has to be bound at (or shorly after) session, otherwise an attacker with access
244
294
  // to any session could escalate permissions by setting up their own recovery method
245
295
  if (session.event.created_at < ago(15, MINUTE)) {
246
- debug(`[signer ${event.pubkey.slice(0, 8)}]: recovery method set too late`);
296
+ debug(`[client ${event.pubkey.slice(0, 8)}]: recovery method set too late`);
247
297
  return this.rpc.channel(event.pubkey, false).send(makeRecoverySetupResult({
248
298
  ok: false,
249
299
  message: "Recovery method must be set within 5 minutes of session.",
@@ -251,7 +301,7 @@ export class Signer {
251
301
  }));
252
302
  }
253
303
  if (session.email) {
254
- debug(`[signer ${event.pubkey.slice(0, 8)}]: recovery is already set`);
304
+ debug(`[client ${event.pubkey.slice(0, 8)}]: recovery is already set`);
255
305
  return this.rpc.channel(event.pubkey, false).send(makeRecoverySetupResult({
256
306
  ok: false,
257
307
  message: "Recovery has already been initialized.",
@@ -259,7 +309,7 @@ export class Signer {
259
309
  }));
260
310
  }
261
311
  if (!payload.password_hash.match(/^[a-f0-9]{64}$/)) {
262
- debug(`[signer ${event.pubkey.slice(0, 8)}]: invalid password_hash provided on setup`);
312
+ debug(`[client ${event.pubkey.slice(0, 8)}]: invalid password_hash provided on setup`);
263
313
  return this.rpc.channel(event.pubkey, false).send(makeRecoverySetupResult({
264
314
  ok: false,
265
315
  message: "Recovery method password hash must be an argon2id hash of user email and password.",
@@ -267,7 +317,8 @@ export class Signer {
267
317
  }));
268
318
  }
269
319
  const { email, password_hash } = payload;
270
- const email_hash = await hashEmail(email, this.pubkey);
320
+ const pubkey = await this.options.signer.getPubkey();
321
+ const email_hash = await hashEmail(email, pubkey);
271
322
  await this._addSession(event.pubkey, {
272
323
  ...session,
273
324
  last_activity: now(),
@@ -275,7 +326,7 @@ export class Signer {
275
326
  email_hash,
276
327
  password_hash,
277
328
  });
278
- debug(`[signer ${event.pubkey.slice(0, 8)}]: recovery method initialized`);
329
+ debug(`[client ${event.pubkey.slice(0, 8)}]: recovery method initialized`);
279
330
  this.rpc.channel(event.pubkey, false).send(makeRecoverySetupResult({
280
331
  ok: true,
281
332
  message: "Recovery method successfully initialized.",
@@ -284,16 +335,31 @@ export class Signer {
284
335
  });
285
336
  }
286
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
+ }
287
345
  const index = await this.sessionsByEmailHash.get(payload.email_hash);
288
346
  if (index && index.clients.length > 0) {
289
347
  const session = await this.sessions.get(index.clients[0]);
290
348
  if (session?.email) {
291
- const otp = bytesToHex(randomBytes(12));
292
- 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);
293
355
  await this.challenges.set(payload.email_hash, { otp, event });
294
356
  this.options.sendChallenge({ email: session.email, challenge });
357
+ debug(`[client ${event.pubkey.slice(0, 8)}]: challenge sent for ${payload.email_hash}`);
295
358
  }
296
359
  }
360
+ else {
361
+ debug(`[client ${event.pubkey.slice(0, 8)}]: no session found for ${payload.email_hash}`);
362
+ }
297
363
  }
298
364
  // Recovery
299
365
  async handleRecoveryStart({ payload, event }) {
@@ -301,14 +367,14 @@ export class Signer {
301
367
  return;
302
368
  const sessions = await this._getAuthenticatedSessions(payload.auth);
303
369
  if (sessions.length === 0) {
304
- debug(`[signer ${event.pubkey.slice(0, 8)}]: no sessions found for recovery`);
370
+ debug(`[client ${event.pubkey.slice(0, 8)}]: no sessions found for recovery`);
305
371
  return this.rpc.channel(event.pubkey, false).send(makeRecoveryOptions({
306
372
  ok: false,
307
373
  message: "No sessions found.",
308
374
  prev: event.id,
309
375
  }));
310
376
  }
311
- debug(`[signer ${event.pubkey.slice(0, 8)}]: sending recovery options`);
377
+ debug(`[client ${event.pubkey.slice(0, 8)}]: sending recovery options`);
312
378
  const clients = sessions.map(s => s.client);
313
379
  const items = sessions.map(makeSessionItem);
314
380
  await this.recoveries.set(event.pubkey, { event, clients });
@@ -322,7 +388,7 @@ export class Signer {
322
388
  async handleRecoverySelect({ payload, event }) {
323
389
  const recovery = await this.recoveries.get(event.pubkey);
324
390
  if (!recovery) {
325
- debug(`[signer ${event.pubkey.slice(0, 8)}]: no active recovery found`);
391
+ debug(`[client ${event.pubkey.slice(0, 8)}]: no active recovery found`);
326
392
  return this.rpc.channel(event.pubkey, false).send(makeRecoveryResult({
327
393
  ok: false,
328
394
  message: `No active recovery found.`,
@@ -332,7 +398,7 @@ export class Signer {
332
398
  // Cleanup right away
333
399
  await this.recoveries.delete(event.pubkey);
334
400
  if (!recovery.clients.includes(payload.client)) {
335
- debug(`[signer ${event.pubkey.slice(0, 8)}]: invalid session selected for recovery`);
401
+ debug(`[client ${event.pubkey.slice(0, 8)}]: invalid session selected for recovery`);
336
402
  return this.rpc.channel(event.pubkey, false).send(makeRecoveryResult({
337
403
  ok: false,
338
404
  message: `Invalid session selected for recovery.`,
@@ -341,7 +407,7 @@ export class Signer {
341
407
  }
342
408
  const session = await this.sessions.get(payload.client);
343
409
  if (!session) {
344
- debug(`[signer ${event.pubkey.slice(0, 8)}]: recovery session not found`);
410
+ debug(`[client ${event.pubkey.slice(0, 8)}]: recovery session not found`);
345
411
  return this.rpc.channel(event.pubkey, false).send(makeRecoveryResult({
346
412
  ok: false,
347
413
  message: `Recovery session not found.`,
@@ -355,7 +421,7 @@ export class Signer {
355
421
  share: session.share,
356
422
  prev: event.id,
357
423
  }));
358
- debug(`[signer ${event.pubkey.slice(0, 8)}]: recovery successfully completed`);
424
+ debug(`[client ${event.pubkey.slice(0, 8)}]: recovery successfully completed`);
359
425
  }
360
426
  // Login
361
427
  async handleLoginStart({ payload, event }) {
@@ -363,14 +429,14 @@ export class Signer {
363
429
  return;
364
430
  const sessions = await this._getAuthenticatedSessions(payload.auth);
365
431
  if (sessions.length === 0) {
366
- debug(`[signer ${event.pubkey.slice(0, 8)}]: no sessions found for login`);
432
+ debug(`[client ${event.pubkey.slice(0, 8)}]: no sessions found for login`);
367
433
  return this.rpc.channel(event.pubkey, false).send(makeLoginOptions({
368
434
  ok: false,
369
435
  message: "No sessions found.",
370
436
  prev: event.id,
371
437
  }));
372
438
  }
373
- debug(`[signer ${event.pubkey.slice(0, 8)}]: sending login options`);
439
+ debug(`[client ${event.pubkey.slice(0, 8)}]: sending login options`);
374
440
  const clients = sessions.map(s => s.client);
375
441
  const items = sessions.map(makeSessionItem);
376
442
  await this.logins.set(event.pubkey, { event, clients });
@@ -384,7 +450,7 @@ export class Signer {
384
450
  async handleLoginSelect({ payload, event }) {
385
451
  const login = await this.logins.get(event.pubkey);
386
452
  if (!login) {
387
- debug(`[signer ${event.pubkey.slice(0, 8)}]: no active login found`);
453
+ debug(`[client ${event.pubkey.slice(0, 8)}]: no active login found`);
388
454
  return this.rpc.channel(event.pubkey, false).send(makeLoginResult({
389
455
  ok: false,
390
456
  message: `No active login found.`,
@@ -394,7 +460,7 @@ export class Signer {
394
460
  // Cleanup right away
395
461
  await this.logins.delete(event.pubkey);
396
462
  if (!login.clients.includes(payload.client)) {
397
- debug(`[signer ${event.pubkey.slice(0, 8)}]: invalid session selected for login`);
463
+ debug(`[client ${event.pubkey.slice(0, 8)}]: invalid session selected for login`);
398
464
  return this.rpc.channel(event.pubkey, false).send(makeLoginResult({
399
465
  ok: false,
400
466
  message: `Invalid session selected for login.`,
@@ -403,7 +469,7 @@ export class Signer {
403
469
  }
404
470
  const session = await this.sessions.get(payload.client);
405
471
  if (!session) {
406
- debug(`[signer ${event.pubkey.slice(0, 8)}]: login session not found`);
472
+ debug(`[client ${event.pubkey.slice(0, 8)}]: login session not found`);
407
473
  return this.rpc.channel(event.pubkey, false).send(makeLoginResult({
408
474
  ok: false,
409
475
  message: `Login session not found.`,
@@ -427,25 +493,34 @@ export class Signer {
427
493
  group: session.group,
428
494
  prev: event.id,
429
495
  }));
430
- debug(`[signer ${event.pubkey.slice(0, 8)}]: login successfully completed`);
496
+ debug(`[client ${event.pubkey.slice(0, 8)}]: login successfully completed`);
431
497
  }
432
498
  // Signing
433
499
  async handleSignRequest({ payload, event }) {
434
500
  return this.options.storage.tx(async () => {
435
501
  const session = await this.sessions.get(event.pubkey);
436
502
  if (!session) {
437
- debug(`[signer ${event.pubkey.slice(0, 8)}]: signing failed - no session found`);
503
+ debug(`[client ${event.pubkey.slice(0, 8)}]: signing failed - no session found`);
438
504
  return this.rpc.channel(event.pubkey, false).send(makeSignResult({
439
505
  ok: false,
440
506
  message: "No session found for client",
441
507
  prev: event.id,
442
508
  }));
443
509
  }
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
+ }
444
519
  try {
445
520
  const ctx = Lib.get_session_ctx(session.group, payload.request);
446
521
  const partialSignature = Lib.create_psig_pkg(ctx, session.share);
447
522
  await this.sessions.set(event.pubkey, { ...session, last_activity: now() });
448
- debug(`[signer ${event.pubkey.slice(0, 8)}]: signing complete`);
523
+ debug(`[client ${event.pubkey.slice(0, 8)}]: signing complete`);
449
524
  this.rpc.channel(event.pubkey, false).send(makeSignResult({
450
525
  result: partialSignature,
451
526
  ok: true,
@@ -454,7 +529,7 @@ export class Signer {
454
529
  }));
455
530
  }
456
531
  catch (e) {
457
- debug(`[signer ${event.pubkey.slice(0, 8)}]: signing failed - ${e.message || e}`);
532
+ debug(`[client ${event.pubkey.slice(0, 8)}]: signing failed - ${e.message || e}`);
458
533
  return this.rpc.channel(event.pubkey, false).send(makeSignResult({
459
534
  ok: false,
460
535
  message: "Failed to sign event",
@@ -468,18 +543,27 @@ export class Signer {
468
543
  return this.options.storage.tx(async () => {
469
544
  const session = await this.sessions.get(event.pubkey);
470
545
  if (!session) {
471
- debug(`[signer ${event.pubkey.slice(0, 8)}]: ecdh failed - no session found`);
546
+ debug(`[client ${event.pubkey.slice(0, 8)}]: ecdh failed - no session found`);
472
547
  return this.rpc.channel(event.pubkey, false).send(makeSignResult({
473
548
  ok: false,
474
549
  message: "No session found for client",
475
550
  prev: event.id,
476
551
  }));
477
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
+ }
478
562
  const { members, ecdh_pk } = payload;
479
563
  try {
480
564
  const ecdhPackage = Lib.create_ecdh_pkg(members, ecdh_pk, session.share);
481
565
  await this.sessions.set(event.pubkey, { ...session, last_activity: now() });
482
- debug(`[signer ${event.pubkey.slice(0, 8)}]: ecdh complete`);
566
+ debug(`[client ${event.pubkey.slice(0, 8)}]: ecdh complete`);
483
567
  this.rpc.channel(event.pubkey, false).send(makeEcdhResult({
484
568
  result: ecdhPackage,
485
569
  ok: true,
@@ -488,7 +572,7 @@ export class Signer {
488
572
  }));
489
573
  }
490
574
  catch (e) {
491
- debug(`[signer ${event.pubkey.slice(0, 8)}]: ecdh failed - ${e.message || e}`);
575
+ debug(`[client ${event.pubkey.slice(0, 8)}]: ecdh failed - ${e.message || e}`);
492
576
  return this.rpc.channel(event.pubkey, false).send(makeSignResult({
493
577
  ok: false,
494
578
  message: "Key derivation failed",
@@ -499,8 +583,8 @@ export class Signer {
499
583
  }
500
584
  // Session management
501
585
  async handleSessionList({ payload, event }) {
502
- if (!this._isNip98AuthValid(payload.auth, Method.SessionList)) {
503
- debug(`[signer ${event.pubkey.slice(0, 8)}]: invalid auth event for session list`);
586
+ if (not(await this._isNip98AuthValid(payload.auth, Method.SessionList))) {
587
+ debug(`[client ${event.pubkey.slice(0, 8)}]: invalid auth event for session list`);
504
588
  return this.rpc.channel(event.pubkey, false).send(makeSessionListResult({
505
589
  items: [],
506
590
  ok: false,
@@ -514,7 +598,7 @@ export class Signer {
514
598
  items.push(makeSessionItem(session));
515
599
  }
516
600
  }
517
- debug(`[signer ${event.pubkey.slice(0, 8)}]: successfully retrieved session list`);
601
+ debug(`[client ${event.pubkey.slice(0, 8)}]: successfully retrieved session list`);
518
602
  this.rpc.channel(event.pubkey, false).send(makeSessionListResult({
519
603
  items,
520
604
  ok: true,
@@ -523,8 +607,8 @@ export class Signer {
523
607
  }));
524
608
  }
525
609
  async handleSessionDelete({ payload, event }) {
526
- if (!this._isNip98AuthValid(payload.auth, Method.SessionDelete)) {
527
- debug(`[signer ${event.pubkey.slice(0, 8)}]: invalid auth event for session deletion`);
610
+ if (not(await this._isNip98AuthValid(payload.auth, Method.SessionDelete))) {
611
+ debug(`[client ${event.pubkey.slice(0, 8)}]: invalid auth event for session deletion`);
528
612
  return this.rpc.channel(event.pubkey, false).send(makeSessionDeleteResult({
529
613
  ok: false,
530
614
  message: "Failed to delete selected session.",
@@ -535,7 +619,7 @@ export class Signer {
535
619
  const session = await this.sessions.get(payload.client);
536
620
  if (session?.group.group_pk.slice(2) === payload.auth.pubkey) {
537
621
  await this._deleteSession(payload.client);
538
- debug(`[signer ${event.pubkey.slice(0, 8)}]: deleted session`, payload.client);
622
+ debug(`[client ${event.pubkey.slice(0, 8)}]: deleted session`, payload.client);
539
623
  this.rpc.channel(event.pubkey, false).send(makeSessionDeleteResult({
540
624
  ok: true,
541
625
  message: "Successfully deleted selected session.",
@@ -543,7 +627,7 @@ export class Signer {
543
627
  }));
544
628
  }
545
629
  else {
546
- debug(`[signer ${event.pubkey.slice(0, 8)}]: failed to delete session`, payload.client);
630
+ debug(`[client ${event.pubkey.slice(0, 8)}]: failed to delete session`, payload.client);
547
631
  return this.rpc.channel(event.pubkey, false).send(makeSessionDeleteResult({
548
632
  ok: false,
549
633
  message: "Failed to logout selected client.",