@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.
- package/dist/client.d.ts +13 -31
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +67 -54
- 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/message.d.ts +1 -0
- package/dist/message.d.ts.map +1 -1
- package/dist/pomade-signer.d.ts +21 -0
- package/dist/pomade-signer.d.ts.map +1 -0
- package/dist/pomade-signer.js +54 -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 +1 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +20 -8
- package/dist/schema.js.map +1 -1
- package/dist/signer.d.ts +10 -70
- package/dist/signer.d.ts.map +1 -1
- package/dist/signer.js +171 -93
- package/dist/signer.js.map +1 -1
- package/dist/util.d.ts +1 -6
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +5 -19
- package/dist/util.js.map +1 -1
- package/package.json +5 -5
package/dist/signer.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"signer.d.ts","sourceRoot":"","sources":["../src/signer.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAC,YAAY,EAAE,YAAY,EAAC,MAAM,iBAAiB,CAAA;
|
|
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
|
|
3
|
-
import { now, filter, remove, removeUndefined, append, ms, uniq, between, call, int, ago, MINUTE, HOUR, YEAR, } from "@welshman/lib";
|
|
4
|
-
import {
|
|
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,
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
150
|
+
sessions = removeUndefined(await Promise.all(index.clients.map(client => this.sessions.get(client))));
|
|
112
151
|
}
|
|
113
152
|
}
|
|
114
153
|
}
|
|
115
154
|
}
|
|
116
|
-
|
|
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) ===
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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
|
|
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(`[
|
|
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(`[
|
|
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
|
|
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(`[
|
|
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
|
-
|
|
292
|
-
const
|
|
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,
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
416
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: recovery successfully completed`);
|
|
359
417
|
}
|
|
360
418
|
// Login
|
|
361
419
|
async handleLoginStart({ payload, event }) {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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 (
|
|
503
|
-
debug(`[
|
|
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(`[
|
|
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 (
|
|
527
|
-
debug(`[
|
|
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(`[
|
|
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(`[
|
|
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.",
|