@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/client.d.ts +11 -31
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +24 -24
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/pomade-signer.d.ts +20 -0
- package/dist/pomade-signer.d.ts.map +1 -0
- package/dist/pomade-signer.js +51 -0
- package/dist/pomade-signer.js.map +1 -0
- package/dist/ratelimit.d.ts +33 -0
- package/dist/ratelimit.d.ts.map +1 -0
- package/dist/ratelimit.js +61 -0
- package/dist/ratelimit.js.map +1 -0
- package/dist/rpc.d.ts +16 -40
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +47 -37
- package/dist/rpc.js.map +1 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +19 -8
- package/dist/schema.js.map +1 -1
- package/dist/signer.d.ts +14 -57
- package/dist/signer.d.ts.map +1 -1
- package/dist/signer.js +139 -55
- package/dist/signer.js.map +1 -1
- package/dist/util.d.ts +1 -1
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +5 -3
- package/dist/util.js.map +1 -1
- package/package.json +5 -4
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 {
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
155
|
+
sessions = removeUndefined(await Promise.all(index.clients.map(client => this.sessions.get(client))));
|
|
112
156
|
}
|
|
113
157
|
}
|
|
114
158
|
}
|
|
115
159
|
}
|
|
116
|
-
|
|
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) ===
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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
|
|
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(`[
|
|
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
|
-
|
|
292
|
-
const
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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 (
|
|
503
|
-
debug(`[
|
|
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(`[
|
|
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 (
|
|
527
|
-
debug(`[
|
|
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(`[
|
|
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(`[
|
|
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.",
|