@pomade/core 0.0.7 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts +11 -31
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +49 -27
- 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 +16 -57
- package/dist/signer.d.ts.map +1 -1
- package/dist/signer.js +211 -81
- 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 +10 -7
package/dist/signer.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { Lib } from "@frostr/bifrost";
|
|
2
2
|
import { randomBytes, bytesToHex } from "@noble/hashes/utils.js";
|
|
3
|
-
import { now, filter, removeUndefined, append, ms, uniq, between, call, int, ago, MINUTE, HOUR, YEAR, } from "@welshman/lib";
|
|
4
|
-
import {
|
|
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.",
|
|
@@ -149,9 +199,38 @@ export class Signer {
|
|
|
149
199
|
}));
|
|
150
200
|
}
|
|
151
201
|
}
|
|
202
|
+
async _addSession(client, session) {
|
|
203
|
+
await this.sessions.set(client, session);
|
|
204
|
+
if (session.email_hash) {
|
|
205
|
+
let index = await this.sessionsByEmailHash.get(session.email_hash);
|
|
206
|
+
if (!index) {
|
|
207
|
+
index = { clients: [] };
|
|
208
|
+
}
|
|
209
|
+
await this.sessionsByEmailHash.set(session.email_hash, {
|
|
210
|
+
clients: append(client, index.clients),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async _deleteSession(client) {
|
|
215
|
+
const session = await this.sessions.get(client);
|
|
216
|
+
if (session) {
|
|
217
|
+
if (session.email_hash) {
|
|
218
|
+
const index = await this.sessionsByEmailHash.get(session.email_hash);
|
|
219
|
+
if (index) {
|
|
220
|
+
const clients = remove(client, index.clients);
|
|
221
|
+
if (clients.length === 0) {
|
|
222
|
+
await this.sessionsByEmailHash.delete(session.email_hash);
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
await this.sessionsByEmailHash.set(session.email_hash, { clients });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
await this.sessions.delete(client);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
152
232
|
// Registration
|
|
153
233
|
async handleRegisterRequest({ payload, event }) {
|
|
154
|
-
debug("[signer]: attempting to register session", event.pubkey);
|
|
155
234
|
return this.options.storage.tx(async () => {
|
|
156
235
|
const { group, share, recovery } = payload;
|
|
157
236
|
const cb = (ok, message) => this.rpc
|
|
@@ -160,26 +239,26 @@ export class Signer {
|
|
|
160
239
|
if (await this._checkKeyReuse(event))
|
|
161
240
|
return;
|
|
162
241
|
if (!between([0, group.commits.length], group.threshold)) {
|
|
163
|
-
debug(
|
|
242
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: invalid group threshold`);
|
|
164
243
|
return cb(false, "Invalid group threshold.");
|
|
165
244
|
}
|
|
166
245
|
if (!Lib.is_group_member(group, share)) {
|
|
167
|
-
debug(
|
|
246
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: share does not belong to the provided group`);
|
|
168
247
|
return cb(false, "Share does not belong to the provided group.");
|
|
169
248
|
}
|
|
170
249
|
if (uniq(group.commits.map(c => c.idx)).length !== group.commits.length) {
|
|
171
|
-
debug(
|
|
250
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: group contains duplicate member indices`);
|
|
172
251
|
return cb(false, "Group contains duplicate member indices.");
|
|
173
252
|
}
|
|
174
253
|
if (!group.commits.find(c => c.idx === share.idx)) {
|
|
175
|
-
debug(
|
|
254
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: share index not found in group commits`);
|
|
176
255
|
return cb(false, "Share index not found in group commits.");
|
|
177
256
|
}
|
|
178
257
|
if (await this.sessions.get(event.pubkey)) {
|
|
179
|
-
debug(
|
|
258
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: client is already registered`);
|
|
180
259
|
return cb(false, "Client is already registered.");
|
|
181
260
|
}
|
|
182
|
-
await this.
|
|
261
|
+
await this._addSession(event.pubkey, {
|
|
183
262
|
client: event.pubkey,
|
|
184
263
|
event,
|
|
185
264
|
share,
|
|
@@ -187,7 +266,7 @@ export class Signer {
|
|
|
187
266
|
recovery,
|
|
188
267
|
last_activity: now(),
|
|
189
268
|
});
|
|
190
|
-
debug(
|
|
269
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: registered`);
|
|
191
270
|
return cb(true, "Your key has been registered");
|
|
192
271
|
});
|
|
193
272
|
}
|
|
@@ -196,7 +275,7 @@ export class Signer {
|
|
|
196
275
|
return this.options.storage.tx(async () => {
|
|
197
276
|
const session = await this.sessions.get(event.pubkey);
|
|
198
277
|
if (!session) {
|
|
199
|
-
debug(
|
|
278
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: no session found for recovery setup`);
|
|
200
279
|
return this.rpc.channel(event.pubkey, false).send(makeRecoverySetupResult({
|
|
201
280
|
ok: false,
|
|
202
281
|
message: "No session found.",
|
|
@@ -204,7 +283,7 @@ export class Signer {
|
|
|
204
283
|
}));
|
|
205
284
|
}
|
|
206
285
|
if (!session.recovery) {
|
|
207
|
-
debug(
|
|
286
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: recovery is disabled for session`);
|
|
208
287
|
return this.rpc.channel(event.pubkey, false).send(makeRecoverySetupResult({
|
|
209
288
|
ok: false,
|
|
210
289
|
message: "Recovery is disabled on this session.",
|
|
@@ -214,7 +293,7 @@ export class Signer {
|
|
|
214
293
|
// recovery method has to be bound at (or shorly after) session, otherwise an attacker with access
|
|
215
294
|
// to any session could escalate permissions by setting up their own recovery method
|
|
216
295
|
if (session.event.created_at < ago(15, MINUTE)) {
|
|
217
|
-
debug(
|
|
296
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: recovery method set too late`);
|
|
218
297
|
return this.rpc.channel(event.pubkey, false).send(makeRecoverySetupResult({
|
|
219
298
|
ok: false,
|
|
220
299
|
message: "Recovery method must be set within 5 minutes of session.",
|
|
@@ -222,7 +301,7 @@ export class Signer {
|
|
|
222
301
|
}));
|
|
223
302
|
}
|
|
224
303
|
if (session.email) {
|
|
225
|
-
debug(
|
|
304
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: recovery is already set`);
|
|
226
305
|
return this.rpc.channel(event.pubkey, false).send(makeRecoverySetupResult({
|
|
227
306
|
ok: false,
|
|
228
307
|
message: "Recovery has already been initialized.",
|
|
@@ -230,7 +309,7 @@ export class Signer {
|
|
|
230
309
|
}));
|
|
231
310
|
}
|
|
232
311
|
if (!payload.password_hash.match(/^[a-f0-9]{64}$/)) {
|
|
233
|
-
debug(
|
|
312
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: invalid password_hash provided on setup`);
|
|
234
313
|
return this.rpc.channel(event.pubkey, false).send(makeRecoverySetupResult({
|
|
235
314
|
ok: false,
|
|
236
315
|
message: "Recovery method password hash must be an argon2id hash of user email and password.",
|
|
@@ -238,17 +317,16 @@ export class Signer {
|
|
|
238
317
|
}));
|
|
239
318
|
}
|
|
240
319
|
const { email, password_hash } = payload;
|
|
241
|
-
const
|
|
242
|
-
const
|
|
243
|
-
await this.
|
|
320
|
+
const pubkey = await this.options.signer.getPubkey();
|
|
321
|
+
const email_hash = await hashEmail(email, pubkey);
|
|
322
|
+
await this._addSession(event.pubkey, {
|
|
244
323
|
...session,
|
|
245
324
|
last_activity: now(),
|
|
246
325
|
email,
|
|
247
326
|
email_hash,
|
|
248
327
|
password_hash,
|
|
249
328
|
});
|
|
250
|
-
|
|
251
|
-
debug("[signer]: recovery method initialized", event.pubkey);
|
|
329
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: recovery method initialized`);
|
|
252
330
|
this.rpc.channel(event.pubkey, false).send(makeRecoverySetupResult({
|
|
253
331
|
ok: true,
|
|
254
332
|
message: "Recovery method successfully initialized.",
|
|
@@ -257,16 +335,31 @@ export class Signer {
|
|
|
257
335
|
});
|
|
258
336
|
}
|
|
259
337
|
async handleChallengeRequest({ payload, event }) {
|
|
338
|
+
// Check rate limit for challenge requests per email_hash
|
|
339
|
+
const bucket = await this.rateLimitByChallengeHash.get(payload.email_hash);
|
|
340
|
+
if (isRateLimited(bucket, this.rateLimitConfig.challenge)) {
|
|
341
|
+
const resetTime = getRateLimitResetTime(bucket, this.rateLimitConfig.challenge);
|
|
342
|
+
debug(`[signer]: challenge rate limit exceeded for email_hash ${payload.email_hash.slice(0, 8)}, reset in ${resetTime}s`);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
260
345
|
const index = await this.sessionsByEmailHash.get(payload.email_hash);
|
|
261
346
|
if (index && index.clients.length > 0) {
|
|
262
347
|
const session = await this.sessions.get(index.clients[0]);
|
|
263
348
|
if (session?.email) {
|
|
264
|
-
|
|
265
|
-
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);
|
|
266
355
|
await this.challenges.set(payload.email_hash, { otp, event });
|
|
267
356
|
this.options.sendChallenge({ email: session.email, challenge });
|
|
357
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: challenge sent for ${payload.email_hash}`);
|
|
268
358
|
}
|
|
269
359
|
}
|
|
360
|
+
else {
|
|
361
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: no session found for ${payload.email_hash}`);
|
|
362
|
+
}
|
|
270
363
|
}
|
|
271
364
|
// Recovery
|
|
272
365
|
async handleRecoveryStart({ payload, event }) {
|
|
@@ -274,14 +367,14 @@ export class Signer {
|
|
|
274
367
|
return;
|
|
275
368
|
const sessions = await this._getAuthenticatedSessions(payload.auth);
|
|
276
369
|
if (sessions.length === 0) {
|
|
277
|
-
debug(
|
|
370
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: no sessions found for recovery`);
|
|
278
371
|
return this.rpc.channel(event.pubkey, false).send(makeRecoveryOptions({
|
|
279
372
|
ok: false,
|
|
280
373
|
message: "No sessions found.",
|
|
281
374
|
prev: event.id,
|
|
282
375
|
}));
|
|
283
376
|
}
|
|
284
|
-
debug(
|
|
377
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: sending recovery options`);
|
|
285
378
|
const clients = sessions.map(s => s.client);
|
|
286
379
|
const items = sessions.map(makeSessionItem);
|
|
287
380
|
await this.recoveries.set(event.pubkey, { event, clients });
|
|
@@ -295,7 +388,7 @@ export class Signer {
|
|
|
295
388
|
async handleRecoverySelect({ payload, event }) {
|
|
296
389
|
const recovery = await this.recoveries.get(event.pubkey);
|
|
297
390
|
if (!recovery) {
|
|
298
|
-
debug(
|
|
391
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: no active recovery found`);
|
|
299
392
|
return this.rpc.channel(event.pubkey, false).send(makeRecoveryResult({
|
|
300
393
|
ok: false,
|
|
301
394
|
message: `No active recovery found.`,
|
|
@@ -305,7 +398,7 @@ export class Signer {
|
|
|
305
398
|
// Cleanup right away
|
|
306
399
|
await this.recoveries.delete(event.pubkey);
|
|
307
400
|
if (!recovery.clients.includes(payload.client)) {
|
|
308
|
-
debug(
|
|
401
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: invalid session selected for recovery`);
|
|
309
402
|
return this.rpc.channel(event.pubkey, false).send(makeRecoveryResult({
|
|
310
403
|
ok: false,
|
|
311
404
|
message: `Invalid session selected for recovery.`,
|
|
@@ -314,7 +407,7 @@ export class Signer {
|
|
|
314
407
|
}
|
|
315
408
|
const session = await this.sessions.get(payload.client);
|
|
316
409
|
if (!session) {
|
|
317
|
-
debug(
|
|
410
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: recovery session not found`);
|
|
318
411
|
return this.rpc.channel(event.pubkey, false).send(makeRecoveryResult({
|
|
319
412
|
ok: false,
|
|
320
413
|
message: `Recovery session not found.`,
|
|
@@ -328,7 +421,7 @@ export class Signer {
|
|
|
328
421
|
share: session.share,
|
|
329
422
|
prev: event.id,
|
|
330
423
|
}));
|
|
331
|
-
debug(
|
|
424
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: recovery successfully completed`);
|
|
332
425
|
}
|
|
333
426
|
// Login
|
|
334
427
|
async handleLoginStart({ payload, event }) {
|
|
@@ -336,14 +429,14 @@ export class Signer {
|
|
|
336
429
|
return;
|
|
337
430
|
const sessions = await this._getAuthenticatedSessions(payload.auth);
|
|
338
431
|
if (sessions.length === 0) {
|
|
339
|
-
debug(
|
|
432
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: no sessions found for login`);
|
|
340
433
|
return this.rpc.channel(event.pubkey, false).send(makeLoginOptions({
|
|
341
434
|
ok: false,
|
|
342
435
|
message: "No sessions found.",
|
|
343
436
|
prev: event.id,
|
|
344
437
|
}));
|
|
345
438
|
}
|
|
346
|
-
debug(
|
|
439
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: sending login options`);
|
|
347
440
|
const clients = sessions.map(s => s.client);
|
|
348
441
|
const items = sessions.map(makeSessionItem);
|
|
349
442
|
await this.logins.set(event.pubkey, { event, clients });
|
|
@@ -357,7 +450,7 @@ export class Signer {
|
|
|
357
450
|
async handleLoginSelect({ payload, event }) {
|
|
358
451
|
const login = await this.logins.get(event.pubkey);
|
|
359
452
|
if (!login) {
|
|
360
|
-
debug(
|
|
453
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: no active login found`);
|
|
361
454
|
return this.rpc.channel(event.pubkey, false).send(makeLoginResult({
|
|
362
455
|
ok: false,
|
|
363
456
|
message: `No active login found.`,
|
|
@@ -367,7 +460,7 @@ export class Signer {
|
|
|
367
460
|
// Cleanup right away
|
|
368
461
|
await this.logins.delete(event.pubkey);
|
|
369
462
|
if (!login.clients.includes(payload.client)) {
|
|
370
|
-
debug(
|
|
463
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: invalid session selected for login`);
|
|
371
464
|
return this.rpc.channel(event.pubkey, false).send(makeLoginResult({
|
|
372
465
|
ok: false,
|
|
373
466
|
message: `Invalid session selected for login.`,
|
|
@@ -376,19 +469,22 @@ export class Signer {
|
|
|
376
469
|
}
|
|
377
470
|
const session = await this.sessions.get(payload.client);
|
|
378
471
|
if (!session) {
|
|
379
|
-
debug(
|
|
472
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: login session not found`);
|
|
380
473
|
return this.rpc.channel(event.pubkey, false).send(makeLoginResult({
|
|
381
474
|
ok: false,
|
|
382
475
|
message: `Login session not found.`,
|
|
383
476
|
prev: event.id,
|
|
384
477
|
}));
|
|
385
478
|
}
|
|
386
|
-
await this.
|
|
479
|
+
await this._addSession(event.pubkey, {
|
|
387
480
|
event,
|
|
388
481
|
recovery: true,
|
|
389
482
|
client: event.pubkey,
|
|
390
483
|
share: session.share,
|
|
391
484
|
group: session.group,
|
|
485
|
+
email: session.email,
|
|
486
|
+
email_hash: session.email_hash,
|
|
487
|
+
password_hash: session.password_hash,
|
|
392
488
|
last_activity: now(),
|
|
393
489
|
});
|
|
394
490
|
this.rpc.channel(event.pubkey, false).send(makeLoginResult({
|
|
@@ -397,62 +493,98 @@ export class Signer {
|
|
|
397
493
|
group: session.group,
|
|
398
494
|
prev: event.id,
|
|
399
495
|
}));
|
|
400
|
-
debug(
|
|
496
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: login successfully completed`);
|
|
401
497
|
}
|
|
402
498
|
// Signing
|
|
403
499
|
async handleSignRequest({ payload, event }) {
|
|
404
|
-
debug("[signer]: attempting signing flow", event.pubkey);
|
|
405
500
|
return this.options.storage.tx(async () => {
|
|
406
501
|
const session = await this.sessions.get(event.pubkey);
|
|
407
502
|
if (!session) {
|
|
408
|
-
debug(
|
|
503
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: signing failed - no session found`);
|
|
409
504
|
return this.rpc.channel(event.pubkey, false).send(makeSignResult({
|
|
410
505
|
ok: false,
|
|
411
506
|
message: "No session found for client",
|
|
412
507
|
prev: event.id,
|
|
413
508
|
}));
|
|
414
509
|
}
|
|
415
|
-
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
510
|
+
// Check rate limit
|
|
511
|
+
const allowed = await this._checkAndRecordRateLimit(event.pubkey);
|
|
512
|
+
if (!allowed) {
|
|
513
|
+
return this.rpc.channel(event.pubkey, false).send(makeSignResult({
|
|
514
|
+
ok: false,
|
|
515
|
+
message: "Rate limit exceeded. Please try again later.",
|
|
516
|
+
prev: event.id,
|
|
517
|
+
}));
|
|
518
|
+
}
|
|
519
|
+
try {
|
|
520
|
+
const ctx = Lib.get_session_ctx(session.group, payload.request);
|
|
521
|
+
const partialSignature = Lib.create_psig_pkg(ctx, session.share);
|
|
522
|
+
await this.sessions.set(event.pubkey, { ...session, last_activity: now() });
|
|
523
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: signing complete`);
|
|
524
|
+
this.rpc.channel(event.pubkey, false).send(makeSignResult({
|
|
525
|
+
result: partialSignature,
|
|
526
|
+
ok: true,
|
|
527
|
+
message: "Successfully signed event",
|
|
528
|
+
prev: event.id,
|
|
529
|
+
}));
|
|
530
|
+
}
|
|
531
|
+
catch (e) {
|
|
532
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: signing failed - ${e.message || e}`);
|
|
533
|
+
return this.rpc.channel(event.pubkey, false).send(makeSignResult({
|
|
534
|
+
ok: false,
|
|
535
|
+
message: "Failed to sign event",
|
|
536
|
+
prev: event.id,
|
|
537
|
+
}));
|
|
538
|
+
}
|
|
425
539
|
});
|
|
426
540
|
}
|
|
427
541
|
// Key exchange
|
|
428
542
|
async handleEcdhRequest({ payload, event }) {
|
|
429
|
-
debug("[signer]: attempting ecdh flow", event.pubkey);
|
|
430
543
|
return this.options.storage.tx(async () => {
|
|
431
544
|
const session = await this.sessions.get(event.pubkey);
|
|
432
545
|
if (!session) {
|
|
433
|
-
debug(
|
|
546
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: ecdh failed - no session found`);
|
|
434
547
|
return this.rpc.channel(event.pubkey, false).send(makeSignResult({
|
|
435
548
|
ok: false,
|
|
436
549
|
message: "No session found for client",
|
|
437
550
|
prev: event.id,
|
|
438
551
|
}));
|
|
439
552
|
}
|
|
553
|
+
// Check rate limit
|
|
554
|
+
const allowed = await this._checkAndRecordRateLimit(event.pubkey);
|
|
555
|
+
if (!allowed) {
|
|
556
|
+
return this.rpc.channel(event.pubkey, false).send(makeEcdhResult({
|
|
557
|
+
ok: false,
|
|
558
|
+
message: "Rate limit exceeded. Please try again later.",
|
|
559
|
+
prev: event.id,
|
|
560
|
+
}));
|
|
561
|
+
}
|
|
440
562
|
const { members, ecdh_pk } = payload;
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
563
|
+
try {
|
|
564
|
+
const ecdhPackage = Lib.create_ecdh_pkg(members, ecdh_pk, session.share);
|
|
565
|
+
await this.sessions.set(event.pubkey, { ...session, last_activity: now() });
|
|
566
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: ecdh complete`);
|
|
567
|
+
this.rpc.channel(event.pubkey, false).send(makeEcdhResult({
|
|
568
|
+
result: ecdhPackage,
|
|
569
|
+
ok: true,
|
|
570
|
+
message: "Successfully signed event",
|
|
571
|
+
prev: event.id,
|
|
572
|
+
}));
|
|
573
|
+
}
|
|
574
|
+
catch (e) {
|
|
575
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: ecdh failed - ${e.message || e}`);
|
|
576
|
+
return this.rpc.channel(event.pubkey, false).send(makeSignResult({
|
|
577
|
+
ok: false,
|
|
578
|
+
message: "Key derivation failed",
|
|
579
|
+
prev: event.id,
|
|
580
|
+
}));
|
|
581
|
+
}
|
|
450
582
|
});
|
|
451
583
|
}
|
|
452
584
|
// Session management
|
|
453
585
|
async handleSessionList({ payload, event }) {
|
|
454
|
-
if (
|
|
455
|
-
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`);
|
|
456
588
|
return this.rpc.channel(event.pubkey, false).send(makeSessionListResult({
|
|
457
589
|
items: [],
|
|
458
590
|
ok: false,
|
|
@@ -460,14 +592,13 @@ export class Signer {
|
|
|
460
592
|
prev: event.id,
|
|
461
593
|
}));
|
|
462
594
|
}
|
|
463
|
-
debug("[signer]: attempting to retrieve session list", event.pubkey);
|
|
464
595
|
const items = [];
|
|
465
596
|
for (const [_, session] of await this.sessions.entries()) {
|
|
466
597
|
if (session.group.group_pk.slice(2) === payload.auth.pubkey) {
|
|
467
598
|
items.push(makeSessionItem(session));
|
|
468
599
|
}
|
|
469
600
|
}
|
|
470
|
-
debug(
|
|
601
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: successfully retrieved session list`);
|
|
471
602
|
this.rpc.channel(event.pubkey, false).send(makeSessionListResult({
|
|
472
603
|
items,
|
|
473
604
|
ok: true,
|
|
@@ -476,20 +607,19 @@ export class Signer {
|
|
|
476
607
|
}));
|
|
477
608
|
}
|
|
478
609
|
async handleSessionDelete({ payload, event }) {
|
|
479
|
-
if (
|
|
480
|
-
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`);
|
|
481
612
|
return this.rpc.channel(event.pubkey, false).send(makeSessionDeleteResult({
|
|
482
613
|
ok: false,
|
|
483
614
|
message: "Failed to delete selected session.",
|
|
484
615
|
prev: event.id,
|
|
485
616
|
}));
|
|
486
617
|
}
|
|
487
|
-
debug("[signer]: attempting to delete session", event.pubkey);
|
|
488
618
|
return this.options.storage.tx(async () => {
|
|
489
619
|
const session = await this.sessions.get(payload.client);
|
|
490
620
|
if (session?.group.group_pk.slice(2) === payload.auth.pubkey) {
|
|
491
|
-
await this.
|
|
492
|
-
debug(
|
|
621
|
+
await this._deleteSession(payload.client);
|
|
622
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: deleted session`, payload.client);
|
|
493
623
|
this.rpc.channel(event.pubkey, false).send(makeSessionDeleteResult({
|
|
494
624
|
ok: true,
|
|
495
625
|
message: "Successfully deleted selected session.",
|
|
@@ -497,7 +627,7 @@ export class Signer {
|
|
|
497
627
|
}));
|
|
498
628
|
}
|
|
499
629
|
else {
|
|
500
|
-
debug(
|
|
630
|
+
debug(`[client ${event.pubkey.slice(0, 8)}]: failed to delete session`, payload.client);
|
|
501
631
|
return this.rpc.channel(event.pubkey, false).send(makeSessionDeleteResult({
|
|
502
632
|
ok: false,
|
|
503
633
|
message: "Failed to logout selected client.",
|