Skip to content

Commit 6f25639

Browse files
committed
feat: add web of trust check
1 parent 0fdbb65 commit 6f25639

File tree

6 files changed

+108
-50
lines changed

6 files changed

+108
-50
lines changed

src/decorators/field.decorators.ts

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
import _ from 'lodash';
2626

2727
import { ApiEnumProperty, ApiUUIDProperty } from './property.decorators';
28-
import { PhoneNumberSerializer, ToArray, ToBoolean, ToLowerCase, ToUpperCase, Trim } from './transform.decorators';
28+
import {ToArray, ToBoolean, ToLowerCase, ToUpperCase, Trim } from './transform.decorators';
2929
import { IsPassword, IsPhoneNumber, IsTmpKey } from './validator.decorators';
3030

3131
interface IStringFieldOptions {
@@ -218,23 +218,6 @@ export function EmailFieldOptional(
218218
return applyDecorators(IsOptional(), EmailField({ required: false, ...options }));
219219
}
220220

221-
export function PhoneField(
222-
options: Omit<ApiPropertyOptions, 'type'> & Partial<{ swagger: boolean }> = {},
223-
): PropertyDecorator {
224-
const decorators = [IsPhoneNumber(), PhoneNumberSerializer()];
225-
226-
if (options.swagger !== false) {
227-
decorators.push(ApiProperty({ type: String, ...options }));
228-
}
229-
230-
return applyDecorators(...decorators);
231-
}
232-
233-
export function PhoneFieldOptional(
234-
options: Omit<ApiPropertyOptions, 'type' | 'required'> & Partial<{ swagger: boolean }> = {},
235-
): PropertyDecorator {
236-
return applyDecorators(IsOptional(), PhoneField({ required: false, ...options }));
237-
}
238221

239222
export function UUIDField(
240223
options: Omit<ApiPropertyOptions, 'type' | 'format'> & Partial<{ each: boolean; swagger: boolean }> = {},

src/modules/auth/guards/nip98-auth.guard.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { AuthGuard } from '@nestjs/passport';
33

44
@Injectable()
55
export class Nip98AuthGuard extends AuthGuard('nip98') {
6-
handleRequest(err: unknown, user: unknown) {
6+
handleRequest(err: any, user: any) {
77
if (err || !user) {
88
throw err || new UnauthorizedException();
99
}

src/modules/auth/strategies/nip-98.strategy.ts

Lines changed: 14 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,46 @@
11
import { Injectable, UnauthorizedException } from '@nestjs/common';
22
import { PassportStrategy } from '@nestjs/passport';
3-
import type { Request } from 'express';
4-
import type { Event } from 'nostr-tools';
5-
import { verifyEvent } from 'nostr-tools';
3+
import { Event, verifyEvent } from 'nostr-tools';
4+
import { Request } from 'express';
65
import { Strategy } from 'passport-strategy';
76

87
@Injectable()
98
export class Nip98Strategy extends PassportStrategy(Strategy, 'nip98') {
109
public Scheme = 'Nostr';
1110

11+
constructor() {
12+
super();
13+
}
1214

1315
authenticate(req: unknown) {
14-
const request = req as Request;
15-
const authHeader = request.headers.authorization;
16+
const request = req as Request
17+
const authHeader = request.headers['authorization'];
1618

1719
if (!authHeader) {
18-
this.fail(new UnauthorizedException('Missing Authorization header'), 401);
19-
20-
return;
20+
return this.fail(new UnauthorizedException('Missing Authorization header'), 401);
2121
}
2222

2323
if (authHeader.slice(0, 5) !== this.Scheme) {
24-
this.fail(new UnauthorizedException('Invalid auth scheme'), 401);
25-
26-
return;
24+
return this.fail(new UnauthorizedException('Invalid auth scheme'), 401);
2725
}
2826

2927
const token = authHeader.slice(6);
3028

3129
const bToken = Buffer.from(token, 'base64').toString('utf-8');
3230

33-
if (!bToken || bToken.length === 0 || !bToken.startsWith('{')) {
34-
this.fail(new UnauthorizedException('Invalid token'), 401);
35-
36-
return;
31+
if (!bToken || bToken.length === 0 || bToken[0] != '{') {
32+
return this.fail(new UnauthorizedException('Invalid token'), 401);
3733
}
3834

3935
const ev = JSON.parse(bToken) as Event;
4036

4137
const isValidEvent = verifyEvent(ev);
42-
4338
if (!isValidEvent) {
44-
this.fail(new UnauthorizedException('Invalid event'), 401);
45-
46-
return;
39+
return this.fail(new UnauthorizedException('Invalid event'), 401);
4740
}
4841

4942
if (ev.kind != 27_235) {
50-
this.fail(new UnauthorizedException('Invalid nostr event, wrong kind'), 401);
51-
52-
return;
43+
return this.fail(new UnauthorizedException('Invalid nostr event, wrong kind'), 401);
5344
}
5445

5546
const now = Date.now();
@@ -63,11 +54,8 @@ export class Nip98Strategy extends PassportStrategy(Strategy, 'nip98') {
6354
const methodTag = ev.tags[1]?.[1];
6455
const a = new URL(urlTag!).pathname;
6556
console.log(new URL(urlTag!).pathname == request.path);
66-
6757
if (!urlTag || new URL(urlTag).pathname !== request.path) {
68-
this.fail(new UnauthorizedException('Invalid nostr event, URL tag invalid'), 401);
69-
70-
return;
58+
return this.fail(new UnauthorizedException('Invalid nostr event, URL tag invalid'), 401);
7159
}
7260

7361
if (!methodTag || methodTag.toLowerCase() !== request.method.toLowerCase()) {

src/modules/notification/transporters/nostr.transporter.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,8 @@ if (semver.lt(nodeVersion, '20.0.0')) {
1515
global.WebSocket = require('isomorphic-ws');
1616
} else {
1717
// polyfills for node 20
18-
// @ts-expect-error
1918
if (!globalThis.crypto) {
20-
globalThis.crypto = webcrypto;
19+
globalThis.crypto = webcrypto as unknown as Crypto;
2120
}
2221

2322
global.WebSocket = require('isomorphic-ws');

src/modules/subscriptions/subscriptions.service.ts

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { InjectRedis } from '@nestjs-modules/ioredis';
1010
import { hexToBytes } from '@noble/hashes/utils';
1111
import axios from 'axios';
1212
import Redis from 'ioredis';
13-
import { nip19 } from 'nostr-tools';
13+
import { Event, finalizeEvent, kinds, nip19, Relay, SimplePool } from 'nostr-tools';
1414
import { lastValueFrom } from 'rxjs';
1515
import { ObjectId } from 'typeorm';
1616
import type { MongoFindOneOptions } from 'typeorm/find-options/mongodb/MongoFindOneOptions';
@@ -29,6 +29,24 @@ import type { SubscriptionEntity } from './entities/subscription.entity';
2929
import { SubscriptionStatusEnum } from './enums/subscription-status.enum';
3030
import { SubscriptionTemplate } from './notify-template';
3131
import { SubscriptionRepository } from './subscriptions.repository';
32+
import { webcrypto } from 'node:crypto';
33+
34+
const semver = require('semver');
35+
36+
const nodeVersion = process.version;
37+
38+
if (semver.lt(nodeVersion, '20.0.0')) {
39+
// polyfills for node 18
40+
global.crypto = require('node:crypto');
41+
global.WebSocket = require('isomorphic-ws');
42+
} else {
43+
// polyfills for node 20
44+
if (!globalThis.crypto) {
45+
globalThis.crypto = webcrypto as unknown as Crypto;
46+
}
47+
48+
global.WebSocket = require('isomorphic-ws');
49+
}
3250

3351
@Injectable()
3452
export class SubscriptionsService {
@@ -97,6 +115,71 @@ export class SubscriptionsService {
97115
throw new BadRequestException('invalid npub');
98116
}
99117

118+
const wotConf = this.apiConfig.webOfTrustConfig;
119+
const createdAt = Math.floor(Date.now() / 1000);
120+
121+
const event = {
122+
kind: 5312,
123+
created_at: createdAt,
124+
tags: [
125+
['param', 'target', pubkey],
126+
['param', 'limit', '1'],
127+
],
128+
content: '',
129+
};
130+
131+
const signedEvent = finalizeEvent(event, hexToBytes(this.apiConfig.getNostrConfig.privateKey));
132+
133+
const relay = await Relay.connect(wotConf.relay);
134+
await relay.publish(signedEvent);
135+
136+
await new Promise<void>((resolve, reject) => {
137+
const pool = new SimplePool();
138+
let resolved = false;
139+
140+
const timeout = setTimeout(() => {
141+
if (!resolved) {
142+
resolved = true;
143+
pool.close([wotConf.relay]);
144+
resolve();
145+
}
146+
}, 4000); // 4 seconds
147+
148+
pool.subscribeMany(
149+
[wotConf.relay],
150+
[
151+
{
152+
'#p': [signedEvent.pubkey],
153+
'#e': [signedEvent.id],
154+
},
155+
],
156+
{
157+
onevent(event: Event) {
158+
if (resolved) return;
159+
160+
try {
161+
const { rank } = JSON.parse(event.content)[0] as { rank: string };
162+
163+
pool.close([wotConf.relay]);
164+
clearTimeout(timeout);
165+
resolved = true;
166+
167+
if (parseFloat(rank) < parseFloat(wotConf.relayMinRank)) {
168+
reject(new BadRequestException('pubkey rank is too low'));
169+
} else {
170+
resolve();
171+
}
172+
} catch (err) {
173+
pool.close([wotConf.relay]);
174+
clearTimeout(timeout);
175+
resolved = true;
176+
reject(new Error('Failed to parse rank event'));
177+
}
178+
},
179+
},
180+
);
181+
});
182+
100183
const subscription = await this.getSubscriptionPlan(planId);
101184

102185
const headers = {
@@ -118,11 +201,9 @@ export class SubscriptionsService {
118201

119202
try {
120203
const response = await axios.post(this.apiUrl, data, { headers });
121-
122204
return response.data.url;
123205
} catch (error) {
124206
this.logger.error('Error generating checkout session:', error.response?.data || error.message);
125-
126207
throw new Error('Could not generate checkout session');
127208
}
128209
}

src/shared/services/api-config.service.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ export class ApiConfigService {
116116
};
117117
}
118118

119+
get webOfTrustConfig() {
120+
return {
121+
relay: this.getString('WOT_RELAY'),
122+
relayMinRank: this.getString('WOT_RELAY_MIN_RANK'),
123+
};
124+
}
125+
119126
get grpcConfig() {
120127
return {
121128
port: this.getString('GRPC_PORT'),

0 commit comments

Comments
 (0)