@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/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 { 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
- import { Method, isPasswordAuth, isOTPAuth } from "./schema.js";
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.event.created_at,
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.event.created_at < ago(15, MINUTE))
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.event.created_at < ago(15, MINUTE))
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.event.created_at < ago(15, MINUTE))
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 _isNip98AuthValid(auth, method) {
163
- const pubkey = await this.options.signer.getPubkey();
164
- return (verifyEvent(auth) &&
165
- auth.kind === HTTP_AUTH &&
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(event.pubkey)) {
181
- debug(`[client ${event.pubkey.slice(0, 8)}]: recovery key re-used`);
182
- return this.rpc.channel(event.pubkey, false).send(makeRecoveryOptions({
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(event.pubkey)) {
189
- debug(`[client ${event.pubkey.slice(0, 8)}]: login key re-used`);
190
- return this.rpc.channel(event.pubkey, false).send(makeRecoveryOptions({
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
- // Registration
228
- async handleRegisterRequest({ payload, event }) {
186
+ // Handlers
187
+ async _handleRegister(auth, data) {
229
188
  return this.options.storage.tx(async () => {
230
- const { group, share, recovery } = payload;
231
- const cb = (ok, message) => this.rpc
232
- .channel(event.pubkey, false)
233
- .send(makeRegisterResult({ ok, message, prev: event.id }));
234
- if (await this._checkKeyReuse(event))
235
- return;
236
- if (getPow(event) < context.registerPow) {
237
- debug(`[client ${event.pubkey.slice(0, 8)}]: insufficient proof of work`);
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 ${event.pubkey.slice(0, 8)}]: invalid group threshold`);
242
- return cb(false, "Invalid group threshold.");
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 ${event.pubkey.slice(0, 8)}]: share does not belong to the provided group`);
246
- return cb(false, "Share does not belong to the provided group.");
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 ${event.pubkey.slice(0, 8)}]: group contains duplicate member indices`);
250
- return cb(false, "Group contains duplicate member indices.");
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 ${event.pubkey.slice(0, 8)}]: share index not found in group commits`);
254
- return cb(false, "Share index not found in group commits.");
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(event.pubkey)) {
257
- debug(`[client ${event.pubkey.slice(0, 8)}]: client is already registered`);
258
- return cb(false, "Client is already registered.");
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(event.pubkey, {
261
- client: event.pubkey,
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 ${event.pubkey.slice(0, 8)}]: registered`);
269
- return cb(true, "Your key has been registered");
226
+ debug(`[client ${client.slice(0, 8)}]: registered`);
227
+ return { ok: true, message: "Your key has been registered" };
270
228
  });
271
229
  }
272
- // Recovery setup
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(event.pubkey);
232
+ const session = await this.sessions.get(client);
276
233
  if (!session) {
277
- debug(`[client ${event.pubkey.slice(0, 8)}]: no session found for recovery setup`);
278
- return this.rpc.channel(event.pubkey, false).send(makeRecoverySetupResult({
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 ${event.pubkey.slice(0, 8)}]: recovery is disabled for session`);
286
- return this.rpc.channel(event.pubkey, false).send(makeRecoverySetupResult({
287
- ok: false,
288
- message: "Recovery is disabled on this session.",
289
- prev: event.id,
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 ${event.pubkey.slice(0, 8)}]: recovery is already set`);
304
- return this.rpc.channel(event.pubkey, false).send(makeRecoverySetupResult({
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 (!payload.password_hash.match(/^[a-f0-9]{64}$/)) {
311
- debug(`[client ${event.pubkey.slice(0, 8)}]: invalid password_hash provided on setup`);
312
- return this.rpc.channel(event.pubkey, false).send(makeRecoverySetupResult({
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
- prev: event.id,
316
- }));
254
+ };
317
255
  }
318
- const { email, password_hash } = payload;
319
- const pubkey = await this.options.signer.getPubkey();
320
- const email_hash = await hashEmail(email, pubkey);
321
- await this._addSession(event.pubkey, {
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 ${event.pubkey.slice(0, 8)}]: recovery method initialized`);
329
- this.rpc.channel(event.pubkey, false).send(makeRecoverySetupResult({
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 handleChallengeRequest({ payload, event }) {
337
- // Check rate limit for challenge requests per email_hash
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
- const resetTime = getRateLimitResetTime(bucket, EMAIL_RATE_LIMITS);
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(payload.email_hash);
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(payload.email_hash, recordAttempt(bucket, EMAIL_RATE_LIMITS));
349
- const otp = payload.prefix + randomInt(100000, 1000000).toString();
350
- await this.challenges.set(payload.email_hash, { otp, event });
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(`[client ${event.pubkey.slice(0, 8)}]: challenge sent for ${payload.email_hash}`);
283
+ debug(`[challenge]: sent for ${data.email_hash}`);
353
284
  }
354
285
  }
355
286
  else {
356
- debug(`[client ${event.pubkey.slice(0, 8)}]: no session found for ${payload.email_hash}`);
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
- // Recovery
360
- async handleRecoveryStart({ payload, event }) {
291
+ async _handleRecoveryStart({ pubkey: client }, data) {
361
292
  return this.options.storage.tx(async () => {
362
- if (await this._checkKeyReuse(event))
363
- return;
364
- const sessions = await this._getAuthenticatedSessions(payload.auth);
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 ${event.pubkey.slice(0, 8)}]: no sessions found for recovery`);
367
- return this.rpc.channel(event.pubkey, false).send(makeRecoveryOptions({
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 ${event.pubkey.slice(0, 8)}]: sending recovery options`);
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(event.pubkey, { event, clients });
377
- this.rpc.channel(event.pubkey, false).send(makeRecoveryOptions({
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 handleRecoverySelect({ payload, event }) {
386
- const recovery = await this.recoveries.get(event.pubkey);
308
+ async _handleRecoverySelect({ pubkey: client }, data) {
309
+ const recovery = await this.recoveries.get(client);
387
310
  if (!recovery) {
388
- debug(`[client ${event.pubkey.slice(0, 8)}]: no active recovery found`);
389
- return this.rpc.channel(event.pubkey, false).send(makeRecoveryResult({
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
- // Cleanup right away
396
- await this.recoveries.delete(event.pubkey);
397
- if (!recovery.clients.includes(payload.client)) {
398
- debug(`[client ${event.pubkey.slice(0, 8)}]: invalid session selected for recovery`);
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(payload.client);
319
+ const session = await this.sessions.get(data.client);
406
320
  if (!session) {
407
- debug(`[client ${event.pubkey.slice(0, 8)}]: recovery session not found`);
408
- return this.rpc.channel(event.pubkey, false).send(makeRecoveryResult({
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
- this.rpc.channel(event.pubkey, false).send(makeRecoveryResult({
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
- prev: event.id,
420
- }));
421
- debug(`[client ${event.pubkey.slice(0, 8)}]: recovery successfully completed`);
330
+ };
422
331
  }
423
- // Login
424
- async handleLoginStart({ payload, event }) {
332
+ async _handleLoginStart({ pubkey: client }, data) {
425
333
  return this.options.storage.tx(async () => {
426
- if (await this._checkKeyReuse(event))
427
- return;
428
- const sessions = await this._getAuthenticatedSessions(payload.auth);
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 ${event.pubkey.slice(0, 8)}]: no sessions found for login`);
431
- return this.rpc.channel(event.pubkey, false).send(makeLoginOptions({
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 ${event.pubkey.slice(0, 8)}]: sending login options`);
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(event.pubkey, { event, clients });
441
- this.rpc.channel(event.pubkey, false).send(makeLoginOptions({
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 handleLoginSelect({ payload, event }) {
450
- const login = await this.logins.get(event.pubkey);
349
+ async _handleLoginSelect({ pubkey: client }, data) {
350
+ const login = await this.logins.get(client);
451
351
  if (!login) {
452
- debug(`[client ${event.pubkey.slice(0, 8)}]: no active login found`);
453
- return this.rpc.channel(event.pubkey, false).send(makeLoginResult({
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
- // Cleanup right away
460
- await this.logins.delete(event.pubkey);
461
- if (!login.clients.includes(payload.client)) {
462
- debug(`[client ${event.pubkey.slice(0, 8)}]: invalid session selected for login`);
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(payload.client);
360
+ const session = await this.sessions.get(data.client);
470
361
  if (!session) {
471
- debug(`[client ${event.pubkey.slice(0, 8)}]: login session not found`);
472
- return this.rpc.channel(event.pubkey, false).send(makeLoginResult({
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(event.pubkey, {
479
- event,
365
+ await this._addSession(client, {
480
366
  recovery: true,
481
- client: event.pubkey,
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
- this.rpc.channel(event.pubkey, false).send(makeLoginResult({
490
- ok: true,
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
- // Signing
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(event.pubkey);
381
+ const session = await this.sessions.get(client);
501
382
  if (!session) {
502
- debug(`[client ${event.pubkey.slice(0, 8)}]: signing failed - no session found`);
503
- return this.rpc.channel(event.pubkey, false).send(makeSignResult({
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
- // Check rate limit
510
- const allowed = await this._checkAndRecordRateLimit(event.pubkey);
386
+ const allowed = await this._checkAndRecordRateLimit(client);
511
387
  if (!allowed) {
512
- return this.rpc.channel(event.pubkey, false).send(makeSignResult({
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, payload.request);
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(event.pubkey, { ...session, last_activity: now() });
522
- debug(`[client ${event.pubkey.slice(0, 8)}]: signing complete`);
523
- this.rpc.channel(event.pubkey, false).send(makeSignResult({
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
- debug(`[client ${event.pubkey.slice(0, 8)}]: signing failed - ${e.message || e}`);
532
- return this.rpc.channel(event.pubkey, false).send(makeSignResult({
533
- ok: false,
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
- // Key exchange
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(event.pubkey);
406
+ const session = await this.sessions.get(client);
544
407
  if (!session) {
545
- debug(`[client ${event.pubkey.slice(0, 8)}]: ecdh failed - no session found`);
546
- return this.rpc.channel(event.pubkey, false).send(makeSignResult({
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
- // Check rate limit
553
- const allowed = await this._checkAndRecordRateLimit(event.pubkey);
411
+ const allowed = await this._checkAndRecordRateLimit(client);
554
412
  if (!allowed) {
555
- return this.rpc.channel(event.pubkey, false).send(makeEcdhResult({
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(event.pubkey, { ...session, last_activity: now() });
565
- debug(`[client ${event.pubkey.slice(0, 8)}]: ecdh complete`);
566
- this.rpc.channel(event.pubkey, false).send(makeEcdhResult({
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
- debug(`[client ${event.pubkey.slice(0, 8)}]: ecdh failed - ${e.message || e}`);
575
- return this.rpc.channel(event.pubkey, false).send(makeSignResult({
576
- ok: false,
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
- // Session management
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) === payload.auth.pubkey) {
431
+ if (session.group.group_pk.slice(2) === pubkey) {
597
432
  items.push(makeSessionItem(session));
598
433
  }
599
434
  }
600
- debug(`[client ${event.pubkey.slice(0, 8)}]: successfully retrieved session list`);
601
- this.rpc.channel(event.pubkey, false).send(makeSessionListResult({
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 handleSessionDelete({ payload, event }) {
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(payload.client);
619
- if (session?.group.group_pk.slice(2) === payload.auth.pubkey) {
620
- await this._deleteSession(payload.client);
621
- debug(`[client ${event.pubkey.slice(0, 8)}]: deleted session`, payload.client);
622
- this.rpc.channel(event.pubkey, false).send(makeSessionDeleteResult({
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(`[client ${event.pubkey.slice(0, 8)}]: failed to delete session`, payload.client);
630
- return this.rpc.channel(event.pubkey, false).send(makeSessionDeleteResult({
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