pyre-cli 2.0.0 → 3.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/src/p2p/client.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import net from 'node:net';
2
+ import tls from 'node:tls';
3
+ import fs from 'node:fs';
4
+ import crypto from 'node:crypto';
2
5
  import chalk from 'chalk';
3
- import { encodeAuth, encodePing, encodePong, encodeDisconnect } from './protocol.js';
6
+ import { encodeChallenge, encodeAuth, encodePing, encodePong, encodeDisconnect, hashPassword, computeHMAC, verifyHMAC, signMessage, decodeMessage, generateNonce } from './protocol.js';
4
7
  import type { P2PClientOptions } from './types.js';
5
8
  import type { StatsData } from '../monitors/types.js';
6
9
 
@@ -11,7 +14,7 @@ interface ReadState {
11
14
  }
12
15
 
13
16
  export class P2PClient {
14
- private socket: net.Socket | null = null;
17
+ private socket: net.Socket | tls.TLSSocket | null = null;
15
18
  private options: P2PClientOptions;
16
19
  private running = false;
17
20
  private readState: ReadState = { buffer: Buffer.alloc(0), expectingHeader: true, messageLength: 0 };
@@ -19,9 +22,13 @@ export class P2PClient {
19
22
  private onStatusCallback: ((msg: string) => void) | null = null;
20
23
  private reconnectTimer: NodeJS.Timeout | null = null;
21
24
  private pingHandle: NodeJS.Timeout | null = null;
25
+ private pendingNonce: string | null = null;
26
+ private auditStream: fs.WriteStream | null = null;
27
+ private hmacKey: string;
22
28
 
23
29
  constructor(options: P2PClientOptions) {
24
30
  this.options = options;
31
+ this.hmacKey = deriveHMACKey(options.password);
25
32
  }
26
33
 
27
34
  setOnData(cb: (data: StatsData) => void): void {
@@ -33,27 +40,50 @@ export class P2PClient {
33
40
  }
34
41
 
35
42
  async connect(): Promise<void> {
43
+ if (this.options.auditLog) {
44
+ try {
45
+ fs.mkdirSync(this.options.auditLog, { recursive: true });
46
+ const logFile = `${this.options.auditLog}/p2p-client-audit.log`;
47
+ this.auditStream = fs.createWriteStream(logFile, { flags: 'a' });
48
+ } catch {
49
+ console.log(chalk.yellow('Warning: could not open audit log'));
50
+ }
51
+ }
52
+
36
53
  return new Promise((resolve, reject) => {
37
- this.socket = new net.Socket();
54
+ const useTLS = !!this.options.tls;
55
+
56
+ if (useTLS) {
57
+ const tlsOptions: tls.ConnectionOptions = {
58
+ host: this.options.host,
59
+ port: this.options.port,
60
+ rejectUnauthorized: !this.options.tlsInsecure,
61
+ };
62
+ if (this.options.tlsCA) {
63
+ tlsOptions.ca = fs.readFileSync(this.options.tlsCA);
64
+ }
65
+ this.socket = tls.connect(tlsOptions, () => {
66
+ this.status('Connecting...');
67
+ });
68
+ } else {
69
+ this.socket = new net.Socket();
70
+ this.socket.on('data', (data) => this.onData(data));
71
+ this.socket.on('close', () => this.onClose());
72
+ this.socket.on('error', (err) => this.onError(err));
73
+ this.socket.on('timeout', () => this.onTimeout());
74
+ this.socket.setTimeout(10000);
75
+ this.socket.connect(this.options.port, this.options.host, () => {
76
+ this.status('Connecting...');
77
+ });
78
+ this.socket.once('error', (err) => {
79
+ reject(err);
80
+ });
81
+ }
38
82
 
39
83
  this.socket.on('data', (data) => this.onData(data));
40
84
  this.socket.on('close', () => this.onClose());
41
85
  this.socket.on('error', (err) => this.onError(err));
42
86
  this.socket.on('timeout', () => this.onTimeout());
43
-
44
- this.socket.setTimeout(10000);
45
-
46
- this.socket.connect(this.options.port, this.options.host, () => {
47
- this.running = true;
48
- this.socket?.setTimeout(0);
49
- this.socket?.write(encodeAuth(this.options.password));
50
- this.status('Connecting...');
51
- resolve();
52
- });
53
-
54
- this.socket.once('error', (err) => {
55
- reject(err);
56
- });
57
87
  });
58
88
  }
59
89
 
@@ -69,13 +99,17 @@ export class P2PClient {
69
99
  }
70
100
  if (this.socket) {
71
101
  try {
72
- this.socket.write(encodeDisconnect('Client closing'));
102
+ this.sendSigned(encodeDisconnect('Client closing'));
73
103
  } catch {
74
104
  // ignore
75
105
  }
76
106
  this.socket.destroy();
77
107
  this.socket = null;
78
108
  }
109
+ if (this.auditStream) {
110
+ this.auditStream.end();
111
+ this.auditStream = null;
112
+ }
79
113
  this.status('Disconnected');
80
114
  }
81
115
 
@@ -108,29 +142,49 @@ export class P2PClient {
108
142
 
109
143
  private handleMessage(body: Buffer): void {
110
144
  try {
111
- const msg = JSON.parse(body.toString('utf-8'));
145
+ const raw = body.toString('utf-8');
146
+ const msg = decodeMessage(body);
147
+
148
+ if (!verifyHMAC(raw, msg.hmac ?? '', this.hmacKey)) {
149
+ this.status('Message integrity check failed');
150
+ return;
151
+ }
112
152
 
113
153
  switch (msg.kind) {
154
+ case 'challenge':
155
+ const challengePayload = msg.payload as { nonce?: string };
156
+ if (challengePayload.nonce) {
157
+ this.pendingNonce = challengePayload.nonce;
158
+ const passwordHash = hashPassword(this.options.password, challengePayload.nonce);
159
+ this.sendSigned(signMessage('auth', { passwordHash, nonce: challengePayload.nonce }, this.hmacKey));
160
+ this.status('Authenticating...');
161
+ }
162
+ break;
163
+
114
164
  case 'auth-ok':
115
165
  this.status('Authenticated — receiving live data');
116
166
  this.startPing();
117
167
  break;
118
168
 
119
169
  case 'auth-fail':
120
- this.status(`Authentication failed: ${(msg.payload as { reason: string }).reason}`);
170
+ const failPayload = msg.payload as { reason?: string };
171
+ this.status(`Authentication failed: ${failPayload.reason || 'unknown'}`);
121
172
  this.disconnect();
122
173
  break;
123
174
 
124
175
  case 'data':
125
- if (this.onDataCallback && msg.payload?.stats) {
126
- this.onDataCallback(msg.payload.stats as StatsData);
176
+ if (this.onDataCallback) {
177
+ const dataPayload = msg.payload as { stats?: StatsData };
178
+ if (dataPayload.stats) {
179
+ this.onDataCallback(dataPayload.stats);
180
+ }
127
181
  }
128
182
  break;
129
183
 
130
184
  case 'ping':
131
185
  if (this.socket && !this.socket.destroyed) {
132
186
  const ts = (msg.payload as { ts: number }).ts;
133
- this.socket.write(encodePong(ts));
187
+ this.sendSigned(signMessage('pong', { ts }, this.hmacKey));
134
188
  }
135
189
  break;
136
190
 
@@ -138,7 +192,8 @@ export class P2PClient {
138
192
  break;
139
193
 
140
194
  case 'disconnect':
141
- this.status(`Server disconnected: ${(msg.payload as { reason: string }).reason}`);
195
+ const disconnectPayload = msg.payload as { reason?: string };
196
+ this.status(`Server disconnected: ${disconnectPayload.reason || 'unknown'}`);
142
197
  this.disconnect();
143
198
  break;
144
199
 
@@ -150,10 +205,16 @@ export class P2PClient {
150
205
  }
151
206
  }
152
207
 
208
+ private sendSigned(message: Buffer): void {
209
+ if (this.socket && !this.socket.destroyed) {
210
+ this.socket.write(message);
211
+ }
212
+ }
213
+
153
214
  private startPing(): void {
154
215
  this.pingHandle = setInterval(() => {
155
216
  if (this.socket && !this.socket.destroyed) {
156
- this.socket.write(encodePing());
217
+ this.sendSigned(signMessage('ping', { ts: Date.now() }, this.hmacKey));
157
218
  }
158
219
  }, 15000);
159
220
  }
@@ -173,6 +234,8 @@ export class P2PClient {
173
234
  this.status('Connection refused — is the server running?');
174
235
  } else if (code === 'ETIMEDOUT') {
175
236
  this.status('Connection timed out');
237
+ } else if (code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
238
+ this.status('TLS certificate verification failed — use --p2p-insecure to skip');
176
239
  } else {
177
240
  this.status(`Connection error: ${err.message}`);
178
241
  }
@@ -188,6 +251,17 @@ export class P2PClient {
188
251
  this.onStatusCallback(msg);
189
252
  }
190
253
  }
254
+
255
+ audit(event: string, detail: string): void {
256
+ if (!this.auditStream) return;
257
+ const ts = new Date().toISOString();
258
+ const ip = this.options.host;
259
+ this.auditStream.write(`${ts} ${ip} ${event} ${detail}\n`);
260
+ }
261
+ }
262
+
263
+ function deriveHMACKey(password: string): string {
264
+ return crypto.createHash('sha256').update(password).digest('hex').slice(0, 32);
191
265
  }
192
266
 
193
267
  export async function connectP2PClient(options: P2PClientOptions): Promise<P2PClient> {
@@ -1,19 +1,53 @@
1
- import type { P2PMessage, P2PMessageKind, P2PPayload } from './types.js';
1
+ import crypto from 'node:crypto';
2
+ import type { P2PMessage, P2PMessageKind, P2PPayload, ChallengeMessage, AuthMessage } from './types.js';
2
3
  import type { StatsData } from '../monitors/types.js';
3
4
 
4
5
  const HEADER_SIZE = 4;
5
6
  const MAX_MESSAGE_SIZE = 16 * 1024 * 1024;
7
+ const HMAC_ALGO = 'sha256';
6
8
 
7
- function buildMessage(kind: P2PMessageKind, payload: P2PPayload): Buffer {
9
+ function buildMessage(kind: P2PMessageKind, payload: P2PPayload, hmac?: string): Buffer {
8
10
  const envelope: P2PMessage = { kind, payload };
11
+ if (hmac) envelope.hmac = hmac;
9
12
  const body = Buffer.from(JSON.stringify(envelope), 'utf-8');
10
13
  const header = Buffer.alloc(HEADER_SIZE);
11
14
  header.writeUInt32BE(body.length, 0);
12
15
  return Buffer.concat([header, body]);
13
16
  }
14
17
 
15
- export function encodeAuth(password: string): Buffer {
16
- return buildMessage('auth', { password });
18
+ export function generateNonce(): string {
19
+ return crypto.randomBytes(32).toString('hex');
20
+ }
21
+
22
+ export function hashPassword(password: string, nonce: string): string {
23
+ return crypto.createHash('sha256').update(`${password}:${nonce}`).digest('hex');
24
+ }
25
+
26
+ export function computeHMAC(body: string, key?: string): string | undefined {
27
+ if (!key) return undefined;
28
+ return crypto.createHmac(HMAC_ALGO, key).update(body).digest('hex');
29
+ }
30
+
31
+ export function verifyHMAC(body: string, expectedHmac: string, key?: string): boolean {
32
+ if (!key) return expectedHmac === undefined;
33
+ if (!expectedHmac) return false;
34
+ try {
35
+ const parsed = JSON.parse(body) as P2PMessage;
36
+ const payloadWithoutHmac = { ...parsed, hmac: undefined };
37
+ const canonical = JSON.stringify(payloadWithoutHmac);
38
+ const computed = crypto.createHmac(HMAC_ALGO, key).update(canonical).digest('hex');
39
+ return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(expectedHmac));
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ export function encodeChallenge(nonce: string): Buffer {
46
+ return buildMessage('challenge', { nonce });
47
+ }
48
+
49
+ export function encodeAuth(passwordHash: string, nonce: string): Buffer {
50
+ return buildMessage('auth', { passwordHash, nonce });
17
51
  }
18
52
 
19
53
  export function encodeAuthOk(): Buffer {
@@ -46,6 +80,13 @@ export function decodeMessage(buf: Buffer): P2PMessage {
46
80
  return msg;
47
81
  }
48
82
 
83
+ export function signMessage(kind: P2PMessageKind, payload: P2PPayload, key?: string): Buffer {
84
+ const envelope: P2PMessage = { kind, payload };
85
+ const bodyJson = JSON.stringify(envelope);
86
+ const hmac = computeHMAC(bodyJson, key);
87
+ return buildMessage(kind, payload, hmac);
88
+ }
89
+
49
90
  export function readMessage(stream: { read(n: number): Buffer | null }): Buffer | null {
50
91
  const header = stream.read(HEADER_SIZE);
51
92
  if (!header || header.length < HEADER_SIZE) return null;
package/src/p2p/server.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import net from 'node:net';
2
+ import tls from 'node:tls';
3
+ import fs from 'node:fs';
4
+ import crypto from 'node:crypto';
2
5
  import chalk from 'chalk';
3
6
  import { collectAll } from '../monitors/index.js';
4
- import { encodeAuthOk, encodeAuthFail, encodeData, encodePing, encodePong, encodeDisconnect } from './protocol.js';
7
+ import { encodeAuthOk, encodeAuthFail, encodeData, encodePing, encodePong, encodeDisconnect, hashPassword, computeHMAC, verifyHMAC, signMessage, decodeMessage, generateNonce } from './protocol.js';
5
8
  import type { P2PServerOptions, P2PConnectionInfo } from './types.js';
6
9
 
7
10
  interface Peer {
@@ -12,9 +15,14 @@ interface Peer {
12
15
  readBuffer: Buffer;
13
16
  readExpectingHeader: boolean;
14
17
  readMessageLength: number;
18
+ ip: string;
19
+ pendingNonce: string | null;
15
20
  }
16
21
 
17
22
  const HEADER_SIZE = 4;
23
+ const DEFAULT_RATE_LIMIT_MAX = 5;
24
+ const DEFAULT_RATE_LIMIT_WINDOW = 60000;
25
+ const HMAC_KEY_LENGTH = 32;
18
26
 
19
27
  function readMessageFromPeer(peer: Peer): Buffer | null {
20
28
  while (peer.readBuffer.length > 0) {
@@ -42,14 +50,67 @@ function readMessageFromPeer(peer: Peer): Buffer | null {
42
50
  return null;
43
51
  }
44
52
 
53
+ function isIPAllowed(ip: string, allowedIPs: string[], deniedIPs: string[]): boolean {
54
+ for (const denied of deniedIPs) {
55
+ if (ip === denied || ip.startsWith(denied)) {
56
+ return false;
57
+ }
58
+ }
59
+ if (allowedIPs.length === 0) {
60
+ return true;
61
+ }
62
+ for (const allowed of allowedIPs) {
63
+ if (ip === allowed || ip.startsWith(allowed)) {
64
+ return true;
65
+ }
66
+ }
67
+ return false;
68
+ }
69
+
70
+ interface RateLimitEntry {
71
+ attempts: number;
72
+ firstAttempt: number;
73
+ }
74
+
75
+ function deriveHMACKey(password: string): string {
76
+ return crypto.createHash('sha256').update(password).digest('hex').slice(0, HMAC_KEY_LENGTH);
77
+ }
78
+
45
79
  export class P2PServer {
46
- private server: net.Server | null = null;
80
+ private server: net.Server | tls.Server | null = null;
47
81
  private peers: Map<net.Socket, Peer> = new Map();
48
82
  private options: P2PServerOptions;
49
83
  private running = false;
84
+ private rateLimitMap: Map<string, RateLimitEntry> = new Map();
85
+ private auditStream: fs.WriteStream | null = null;
86
+ private hmacKey: string;
87
+ private peerEvents: { type: string; detail: string; ts: number }[] = [];
88
+ private boundAddress = '';
50
89
 
51
90
  constructor(options: P2PServerOptions) {
52
91
  this.options = options;
92
+ this.hmacKey = deriveHMACKey(options.password);
93
+ }
94
+
95
+ get peerEventHistory(): { type: string; detail: string; ts: number }[] {
96
+ return this.peerEvents;
97
+ }
98
+
99
+ private log(msg: string): void {
100
+ if (this.options.onLog) {
101
+ this.options.onLog(msg.replace(/\x1b\[[0-9;]*m/g, ''));
102
+ } else {
103
+ console.log(msg);
104
+ }
105
+ }
106
+
107
+ private emitPeerEvent(type: string, detail: string): void {
108
+ const evt = { type, detail, ts: Date.now() };
109
+ this.peerEvents.push(evt);
110
+ if (this.peerEvents.length > 200) this.peerEvents.shift();
111
+ if (this.options.onPeerEvent) {
112
+ this.options.onPeerEvent(evt as any);
113
+ }
53
114
  }
54
115
 
55
116
  get info(): P2PConnectionInfo {
@@ -61,16 +122,47 @@ export class P2PServer {
61
122
  }
62
123
 
63
124
  async start(): Promise<void> {
125
+ if (this.options.auditLog) {
126
+ try {
127
+ fs.mkdirSync(this.options.auditLog, { recursive: true });
128
+ const logFile = `${this.options.auditLog}/p2p-audit.log`;
129
+ this.auditStream = fs.createWriteStream(logFile, { flags: 'a' });
130
+ } catch {
131
+ this.log(chalk.yellow('Warning: could not open audit log'));
132
+ }
133
+ }
134
+
64
135
  return new Promise((resolve, reject) => {
65
- this.server = net.createServer((socket) => this.onConnection(socket));
66
- this.server.on('error', (err) => reject(err));
136
+ const useTLS = !!(this.options.tlsCert && this.options.tlsKey);
137
+
138
+ if (useTLS) {
139
+ const tlsOptions: tls.SecureContextOptions = {
140
+ key: fs.readFileSync(this.options.tlsKey!),
141
+ cert: fs.readFileSync(this.options.tlsCert!),
142
+ };
143
+ if (this.options.tlsCA) {
144
+ tlsOptions.ca = fs.readFileSync(this.options.tlsCA!);
145
+ }
146
+ this.server = tls.createServer(tlsOptions, (socket) => this.onConnection(socket));
147
+ } else {
148
+ this.server = net.createServer((socket) => this.onConnection(socket));
149
+ }
150
+
151
+ this.server.on('error', (err: Error) => reject(err));
67
152
  this.server.listen(this.options.port, this.options.host, () => {
68
153
  this.running = true;
69
154
  const addr = this.server?.address();
70
155
  const bound = addr ? (typeof addr === 'object' ? `${addr.address}:${addr.port}` : String(addr)) : 'unknown';
71
- console.log(chalk.green(`P2P server listening on ${bound}`));
72
- console.log(chalk.dim(` Password: ${this.options.password}`));
73
- console.log(chalk.dim(` Interval: ${this.options.intervalMs}ms`));
156
+ this.boundAddress = typeof addr === 'object' && addr ? addr.address : String(addr);
157
+ const protocol = useTLS ? 'TLS' : 'TCP';
158
+ this.log(chalk.green(`P2P server listening on ${bound} (${protocol})`));
159
+ this.log(chalk.dim(` Password: ${this.options.password}`));
160
+ this.log(chalk.dim(` Interval: ${this.options.intervalMs}ms`));
161
+ const allowedIPs = this.options.allowedIPs ?? [];
162
+ const deniedIPs = this.options.deniedIPs ?? [];
163
+ if (allowedIPs.length > 0) this.log(chalk.dim(` Allowed IPs: ${allowedIPs.join(', ')}`));
164
+ if (deniedIPs.length > 0) this.log(chalk.dim(` Denied IPs: ${deniedIPs.join(', ')}`));
165
+ this.emitPeerEvent('connect', `Server listening on ${bound}`);
74
166
  resolve();
75
167
  });
76
168
  });
@@ -82,13 +174,82 @@ export class P2PServer {
82
174
  this.disconnectPeer(peer, 'Server shutting down');
83
175
  }
84
176
  this.peers.clear();
177
+ this.rateLimitMap.clear();
178
+ this.peerEvents = [];
85
179
  if (this.server) {
86
180
  this.server.close();
87
181
  this.server = null;
88
182
  }
183
+ if (this.auditStream) {
184
+ this.auditStream.end();
185
+ this.auditStream = null;
186
+ }
187
+ }
188
+
189
+ private audit(ip: string, event: string, detail: string): void {
190
+ if (!this.auditStream) return;
191
+ const ts = new Date().toISOString();
192
+ this.auditStream.write(`${ts} ${ip} ${event} ${detail}\n`);
193
+ }
194
+
195
+ private getPeerIP(socket: net.Socket): string {
196
+ const addr = socket.remoteAddress;
197
+ if (!addr) return 'unknown';
198
+ if (addr.startsWith('::ffff:')) {
199
+ return addr.slice(7);
200
+ }
201
+ return addr;
202
+ }
203
+
204
+ private checkRateLimit(ip: string): boolean {
205
+ const maxAttempts = this.options.rateLimitMaxAttempts ?? DEFAULT_RATE_LIMIT_MAX;
206
+ const windowMs = this.options.rateLimitWindowMs ?? DEFAULT_RATE_LIMIT_WINDOW;
207
+ const now = Date.now();
208
+
209
+ const entry = this.rateLimitMap.get(ip);
210
+ if (!entry) {
211
+ this.rateLimitMap.set(ip, { attempts: 1, firstAttempt: now });
212
+ return true;
213
+ }
214
+
215
+ if (now - entry.firstAttempt > windowMs) {
216
+ this.rateLimitMap.set(ip, { attempts: 1, firstAttempt: now });
217
+ return true;
218
+ }
219
+
220
+ entry.attempts++;
221
+ if (entry.attempts > maxAttempts) {
222
+ return false;
223
+ }
224
+ return true;
225
+ }
226
+
227
+ private cleanupRateLimit(ip: string): void {
228
+ this.rateLimitMap.delete(ip);
89
229
  }
90
230
 
91
231
  private onConnection(socket: net.Socket): void {
232
+ const ip = this.getPeerIP(socket);
233
+
234
+ const allowedIPs = this.options.allowedIPs ?? [];
235
+ const deniedIPs = this.options.deniedIPs ?? [];
236
+
237
+ if (!isIPAllowed(ip, allowedIPs, deniedIPs)) {
238
+ this.audit(ip, 'BLOCKED', 'IP not allowed');
239
+ this.log(chalk.yellow(`Blocked IP: ${ip}`));
240
+ this.emitPeerEvent('blocked', ip);
241
+ socket.destroy();
242
+ return;
243
+ }
244
+
245
+ if (!this.checkRateLimit(ip)) {
246
+ this.audit(ip, 'RATE_LIMITED', 'Too many auth attempts');
247
+ this.log(chalk.yellow(`Rate limit exceeded for IP: ${ip}`));
248
+ this.emitPeerEvent('rate_limited', ip);
249
+ socket.destroy();
250
+ return;
251
+ }
252
+
92
253
  const peer: Peer = {
93
254
  socket,
94
255
  authenticated: false,
@@ -97,13 +258,17 @@ export class P2PServer {
97
258
  readBuffer: Buffer.alloc(0),
98
259
  readExpectingHeader: true,
99
260
  readMessageLength: 0,
261
+ ip,
262
+ pendingNonce: null,
100
263
  };
101
264
  this.peers.set(socket, peer);
102
265
 
103
266
  const remoteAddr = socket.remoteAddress
104
267
  ? `${socket.remoteAddress}:${socket.remotePort}`
105
268
  : 'unknown';
106
- console.log(chalk.cyan(`Peer connected: ${remoteAddr}`));
269
+ this.log(chalk.cyan(`Peer connected: ${remoteAddr}`));
270
+ this.audit(ip, 'CONNECT', remoteAddr);
271
+ this.emitPeerEvent('connect', remoteAddr);
107
272
 
108
273
  socket.on('data', (data) => this.onData(peer, data));
109
274
  socket.on('close', () => this.onPeerClose(peer));
@@ -115,6 +280,10 @@ export class P2PServer {
115
280
  this.disconnectPeer(peer, 'Authentication timeout');
116
281
  }
117
282
  });
283
+
284
+ const nonce = generateNonce();
285
+ this.sendSigned(peer, signMessage('challenge', { nonce }, this.hmacKey));
286
+ peer.pendingNonce = nonce;
118
287
  }
119
288
 
120
289
  private onData(peer: Peer, data: Buffer): void {
@@ -131,34 +300,67 @@ export class P2PServer {
131
300
 
132
301
  private handleAuthMessage(peer: Peer, body: Buffer): void {
133
302
  try {
134
- const msg = JSON.parse(body.toString('utf-8'));
135
- if (msg.kind === 'auth' && typeof msg.payload?.password === 'string') {
136
- if (msg.payload.password === this.options.password) {
137
- peer.authenticated = true;
138
- peer.socket.write(encodeAuthOk());
139
- console.log(chalk.green(`Peer authenticated: ${peer.socket.remoteAddress}`));
140
- this.startDataStream(peer);
141
- this.startPing(peer);
142
- } else {
143
- peer.socket.write(encodeAuthFail('Invalid password'));
144
- this.disconnectPeer(peer, 'Authentication failed');
145
- }
146
- } else {
147
- peer.socket.write(encodeAuthFail('Expected auth message first'));
303
+ const raw = body.toString('utf-8');
304
+ const msg = decodeMessage(body);
305
+ if (msg.kind !== 'auth') {
306
+ peer.socket.write(signMessage('auth-fail', { ok: false, reason: 'Expected auth message first' }, this.hmacKey));
148
307
  this.disconnectPeer(peer, 'Expected auth message');
308
+ this.emitPeerEvent('error', `Expected auth message from ${peer.ip}`);
309
+ return;
310
+ }
311
+
312
+ const authPayload = msg.payload as { passwordHash?: string; nonce?: string };
313
+ if (!authPayload.passwordHash || !authPayload.nonce) {
314
+ peer.socket.write(signMessage('auth-fail', { ok: false, reason: 'Invalid auth payload' }, this.hmacKey));
315
+ this.disconnectPeer(peer, 'Invalid auth payload');
316
+ this.emitPeerEvent('error', `Invalid auth payload from ${peer.ip}`);
317
+ return;
149
318
  }
319
+
320
+ if (peer.pendingNonce && authPayload.nonce !== peer.pendingNonce) {
321
+ this.audit(peer.ip, 'AUTH_FAIL', 'Nonce mismatch');
322
+ peer.socket.write(signMessage('auth-fail', { ok: false, reason: 'Invalid nonce' }, this.hmacKey));
323
+ this.disconnectPeer(peer, 'Invalid nonce');
324
+ this.emitPeerEvent('error', `Invalid nonce from ${peer.ip}`);
325
+ return;
326
+ }
327
+
328
+ const expectedHash = hashPassword(this.options.password, authPayload.nonce);
329
+ if (authPayload.passwordHash !== expectedHash) {
330
+ this.audit(peer.ip, 'AUTH_FAIL', 'Invalid password hash');
331
+ peer.socket.write(signMessage('auth-fail', { ok: false, reason: 'Invalid password' }, this.hmacKey));
332
+ this.disconnectPeer(peer, 'Authentication failed');
333
+ this.emitPeerEvent('error', `Invalid password from ${peer.ip}`);
334
+ return;
335
+ }
336
+
337
+ peer.authenticated = true;
338
+ this.cleanupRateLimit(peer.ip);
339
+ peer.pendingNonce = null;
340
+ peer.socket.write(signMessage('auth-ok', { ok: true }, this.hmacKey));
341
+ this.log(chalk.green(`Peer authenticated: ${peer.socket.remoteAddress}`));
342
+ this.audit(peer.ip, 'AUTH_OK', 'Challenge-response successful');
343
+ this.emitPeerEvent('auth', peer.socket.remoteAddress ?? peer.ip);
344
+ this.startDataStream(peer);
345
+ this.startPing(peer);
150
346
  } catch {
151
- peer.socket.write(encodeAuthFail('Invalid message format'));
347
+ peer.socket.write(signMessage('auth-fail', { ok: false, reason: 'Invalid message format' }, this.hmacKey));
152
348
  this.disconnectPeer(peer, 'Invalid message format');
153
349
  }
154
350
  }
155
351
 
352
+ private sendSigned(peer: Peer, message: Buffer): void {
353
+ if (!peer.socket.destroyed) {
354
+ peer.socket.write(message);
355
+ }
356
+ }
357
+
156
358
  private startDataStream(peer: Peer): void {
157
359
  const send = async () => {
158
360
  if (!peer.authenticated || peer.socket.destroyed) return;
159
361
  try {
160
362
  const data = await collectAll({ detailed: this.options.detailed });
161
- peer.socket.write(encodeData(data));
363
+ this.sendSigned(peer, signMessage('data', { stats: data }, this.hmacKey));
162
364
  } catch {
163
365
  // skip failed tick
164
366
  }
@@ -171,28 +373,34 @@ export class P2PServer {
171
373
  private startPing(peer: Peer): void {
172
374
  peer.pingHandle = setInterval(() => {
173
375
  if (peer.socket.destroyed) return;
174
- peer.socket.write(encodePing());
376
+ this.sendSigned(peer, signMessage('ping', { ts: Date.now() }, this.hmacKey));
175
377
  }, 5000);
176
378
  }
177
379
 
178
380
  private onPeerClose(peer: Peer): void {
179
381
  this.cleanupPeer(peer);
180
- console.log(chalk.yellow(`Peer disconnected: ${peer.socket.remoteAddress}`));
382
+ this.log(chalk.yellow(`Peer disconnected: ${peer.socket.remoteAddress}`));
383
+ this.emitPeerEvent('disconnect', peer.socket.remoteAddress ?? peer.ip);
384
+ this.audit(peer.ip, 'DISCONNECT', 'Peer closed connection');
181
385
  }
182
386
 
183
387
  private onPeerError(peer: Peer, err: Error): void {
184
388
  const code = (err as any).code;
185
389
  if (code === 'ECONNRESET') {
186
- console.log(chalk.yellow(`Peer connection reset: ${peer.socket.remoteAddress}`));
390
+ this.log(chalk.yellow(`Peer connection reset: ${peer.socket.remoteAddress}`));
391
+ this.audit(peer.ip, 'ERROR', 'Connection reset');
392
+ this.emitPeerEvent('error', `connection reset: ${peer.socket.remoteAddress}`);
187
393
  } else {
188
- console.log(chalk.red(`Peer error: ${err.message}`));
394
+ this.log(chalk.red(`Peer error: ${err.message}`));
395
+ this.audit(peer.ip, 'ERROR', err.message);
396
+ this.emitPeerEvent('error', err.message);
189
397
  }
190
398
  this.cleanupPeer(peer);
191
399
  }
192
400
 
193
401
  private disconnectPeer(peer: Peer, reason: string): void {
194
402
  try {
195
- peer.socket.write(encodeDisconnect(reason));
403
+ this.sendSigned(peer, signMessage('disconnect', { reason }, this.hmacKey));
196
404
  } catch {
197
405
  // socket may already be closed
198
406
  }