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