@pomade/core 0.0.8 → 0.0.11

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