bitchat-node 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/README.md +223 -0
- package/dist/bin/bitchat.d.ts +7 -0
- package/dist/bin/bitchat.d.ts.map +1 -0
- package/dist/bin/bitchat.js +69 -0
- package/dist/bin/bitchat.js.map +1 -0
- package/dist/client.d.ts +77 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +411 -0
- package/dist/client.js.map +1 -0
- package/dist/crypto/index.d.ts +6 -0
- package/dist/crypto/index.d.ts.map +1 -0
- package/dist/crypto/index.js +6 -0
- package/dist/crypto/index.js.map +1 -0
- package/dist/crypto/noise.d.ts +72 -0
- package/dist/crypto/noise.d.ts.map +1 -0
- package/dist/crypto/noise.js +470 -0
- package/dist/crypto/noise.js.map +1 -0
- package/dist/crypto/signing.d.ts +34 -0
- package/dist/crypto/signing.d.ts.map +1 -0
- package/dist/crypto/signing.js +56 -0
- package/dist/crypto/signing.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/dist/mesh/deduplicator.d.ts +48 -0
- package/dist/mesh/deduplicator.d.ts.map +1 -0
- package/dist/mesh/deduplicator.js +107 -0
- package/dist/mesh/deduplicator.js.map +1 -0
- package/dist/mesh/index.d.ts +6 -0
- package/dist/mesh/index.d.ts.map +1 -0
- package/dist/mesh/index.js +6 -0
- package/dist/mesh/index.js.map +1 -0
- package/dist/mesh/router.d.ts +90 -0
- package/dist/mesh/router.d.ts.map +1 -0
- package/dist/mesh/router.js +204 -0
- package/dist/mesh/router.js.map +1 -0
- package/dist/protocol/binary.d.ts +37 -0
- package/dist/protocol/binary.d.ts.map +1 -0
- package/dist/protocol/binary.js +310 -0
- package/dist/protocol/binary.js.map +1 -0
- package/dist/protocol/constants.d.ts +30 -0
- package/dist/protocol/constants.d.ts.map +1 -0
- package/dist/protocol/constants.js +37 -0
- package/dist/protocol/constants.js.map +1 -0
- package/dist/protocol/index.d.ts +8 -0
- package/dist/protocol/index.d.ts.map +1 -0
- package/dist/protocol/index.js +8 -0
- package/dist/protocol/index.js.map +1 -0
- package/dist/protocol/packets.d.ts +38 -0
- package/dist/protocol/packets.d.ts.map +1 -0
- package/dist/protocol/packets.js +177 -0
- package/dist/protocol/packets.js.map +1 -0
- package/dist/protocol/types.d.ts +134 -0
- package/dist/protocol/types.d.ts.map +1 -0
- package/dist/protocol/types.js +108 -0
- package/dist/protocol/types.js.map +1 -0
- package/dist/session/index.d.ts +5 -0
- package/dist/session/index.d.ts.map +1 -0
- package/dist/session/index.js +5 -0
- package/dist/session/index.js.map +1 -0
- package/dist/session/manager.d.ts +113 -0
- package/dist/session/manager.d.ts.map +1 -0
- package/dist/session/manager.js +371 -0
- package/dist/session/manager.js.map +1 -0
- package/dist/transport/ble.d.ts +92 -0
- package/dist/transport/ble.d.ts.map +1 -0
- package/dist/transport/ble.js +434 -0
- package/dist/transport/ble.js.map +1 -0
- package/dist/transport/index.d.ts +5 -0
- package/dist/transport/index.d.ts.map +1 -0
- package/dist/transport/index.js +5 -0
- package/dist/transport/index.js.map +1 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +2 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/server.d.ts +16 -0
- package/dist/ui/server.d.ts.map +1 -0
- package/dist/ui/server.js +510 -0
- package/dist/ui/server.js.map +1 -0
- package/package.json +79 -0
- package/src/bin/bitchat.ts +87 -0
- package/src/client.ts +519 -0
- package/src/crypto/index.ts +22 -0
- package/src/crypto/noise.ts +574 -0
- package/src/crypto/signing.ts +66 -0
- package/src/index.ts +95 -0
- package/src/mesh/deduplicator.ts +129 -0
- package/src/mesh/index.ts +6 -0
- package/src/mesh/router.ts +258 -0
- package/src/protocol/binary.ts +345 -0
- package/src/protocol/constants.ts +43 -0
- package/src/protocol/index.ts +15 -0
- package/src/protocol/packets.ts +223 -0
- package/src/protocol/types.ts +182 -0
- package/src/session/index.ts +9 -0
- package/src/session/manager.ts +476 -0
- package/src/transport/ble.ts +553 -0
- package/src/transport/index.ts +10 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/server.ts +569 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bitchat Protocol Types
|
|
3
|
+
* From: bitchat/Protocols/BitchatProtocol.swift
|
|
4
|
+
* bitchat/Models/BitchatPacket.swift
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { hash as sha256 } from '@stablelib/sha256';
|
|
8
|
+
import { SENDER_ID_SIZE } from './constants.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Message types from BitchatProtocol.swift
|
|
12
|
+
* Reduced from 24 types to 8 essential ones.
|
|
13
|
+
*/
|
|
14
|
+
export enum MessageType {
|
|
15
|
+
// Public messages (unencrypted)
|
|
16
|
+
ANNOUNCE = 0x01, // "I'm here" with nickname
|
|
17
|
+
MESSAGE = 0x02, // Public chat message
|
|
18
|
+
LEAVE = 0x03, // "I'm leaving"
|
|
19
|
+
REQUEST_SYNC = 0x21, // GCS filter-based sync request
|
|
20
|
+
|
|
21
|
+
// Noise encryption
|
|
22
|
+
NOISE_HANDSHAKE = 0x10, // Handshake (init or response)
|
|
23
|
+
NOISE_ENCRYPTED = 0x11, // All encrypted payloads
|
|
24
|
+
|
|
25
|
+
// Fragmentation
|
|
26
|
+
FRAGMENT = 0x20, // Fragment for large messages
|
|
27
|
+
FILE_TRANSFER = 0x22, // Binary file/audio/image
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Noise payload types (first byte of decrypted payload)
|
|
32
|
+
*/
|
|
33
|
+
export enum NoisePayloadType {
|
|
34
|
+
PRIVATE_MESSAGE = 0x01,
|
|
35
|
+
READ_RECEIPT = 0x02,
|
|
36
|
+
DELIVERED = 0x03,
|
|
37
|
+
VERIFY_CHALLENGE = 0x10,
|
|
38
|
+
VERIFY_RESPONSE = 0x11,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Binary protocol flags
|
|
43
|
+
*/
|
|
44
|
+
export const PacketFlags = {
|
|
45
|
+
HAS_RECIPIENT: 0x01,
|
|
46
|
+
HAS_SIGNATURE: 0x02,
|
|
47
|
+
IS_COMPRESSED: 0x04,
|
|
48
|
+
HAS_ROUTE: 0x08, // v2+ only
|
|
49
|
+
IS_RSR: 0x10, // Request-Sync-Response
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Peer identifier (8 bytes, displayed as 16-char hex)
|
|
54
|
+
*/
|
|
55
|
+
export class PeerID {
|
|
56
|
+
private readonly bytes: Uint8Array;
|
|
57
|
+
|
|
58
|
+
constructor(bytes: Uint8Array | string) {
|
|
59
|
+
if (typeof bytes === 'string') {
|
|
60
|
+
// Parse hex string
|
|
61
|
+
const hex = bytes.replace(/^0x/, '').toLowerCase();
|
|
62
|
+
const padded = hex.padStart(16, '0').slice(0, 16);
|
|
63
|
+
this.bytes = new Uint8Array(SENDER_ID_SIZE);
|
|
64
|
+
for (let i = 0; i < SENDER_ID_SIZE; i++) {
|
|
65
|
+
this.bytes[i] = parseInt(padded.slice(i * 2, i * 2 + 2), 16);
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
this.bytes = new Uint8Array(SENDER_ID_SIZE);
|
|
69
|
+
this.bytes.set(bytes.subarray(0, SENDER_ID_SIZE));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static fromPublicKey(publicKey: Uint8Array): PeerID {
|
|
74
|
+
// SHA256 of public key, first 8 bytes
|
|
75
|
+
const hash = sha256(publicKey);
|
|
76
|
+
return new PeerID(hash.subarray(0, SENDER_ID_SIZE));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
toBytes(): Uint8Array {
|
|
80
|
+
return new Uint8Array(this.bytes);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
toHex(): string {
|
|
84
|
+
return Array.from(this.bytes)
|
|
85
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
86
|
+
.join('');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
toString(): string {
|
|
90
|
+
return this.toHex();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
equals(other: PeerID): boolean {
|
|
94
|
+
if (this.bytes.length !== other.bytes.length) return false;
|
|
95
|
+
for (let i = 0; i < this.bytes.length; i++) {
|
|
96
|
+
if (this.bytes[i] !== other.bytes[i]) return false;
|
|
97
|
+
}
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
get isEmpty(): boolean {
|
|
102
|
+
return this.bytes.every((b) => b === 0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
static readonly BROADCAST = new PeerID(new Uint8Array(SENDER_ID_SIZE).fill(0xff));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Core packet structure
|
|
110
|
+
* From: bitchat/Models/BitchatPacket.swift
|
|
111
|
+
*/
|
|
112
|
+
export interface BitchatPacket {
|
|
113
|
+
version: number; // Protocol version (1 or 2)
|
|
114
|
+
type: MessageType;
|
|
115
|
+
ttl: number; // Time-to-live for mesh routing
|
|
116
|
+
timestamp: bigint; // Milliseconds since epoch
|
|
117
|
+
senderID: PeerID;
|
|
118
|
+
recipientID?: PeerID; // Optional for directed messages
|
|
119
|
+
payload: Uint8Array;
|
|
120
|
+
signature?: Uint8Array; // Ed25519 signature
|
|
121
|
+
route?: PeerID[]; // Source routing (v2+)
|
|
122
|
+
isRSR: boolean; // Request-Sync-Response flag
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Delivery status for messages
|
|
127
|
+
*/
|
|
128
|
+
export type DeliveryStatus =
|
|
129
|
+
| { type: 'sending' }
|
|
130
|
+
| { type: 'sent' }
|
|
131
|
+
| { type: 'delivered'; to: PeerID; at: Date }
|
|
132
|
+
| { type: 'read'; by: PeerID; at: Date }
|
|
133
|
+
| { type: 'failed'; reason: string };
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Handshake state for lazy Noise sessions
|
|
137
|
+
*/
|
|
138
|
+
export enum HandshakeState {
|
|
139
|
+
NONE = 'none',
|
|
140
|
+
QUEUED = 'queued',
|
|
141
|
+
HANDSHAKING = 'handshaking',
|
|
142
|
+
ESTABLISHED = 'established',
|
|
143
|
+
FAILED = 'failed',
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Connected peer information
|
|
148
|
+
*/
|
|
149
|
+
export interface PeerInfo {
|
|
150
|
+
peerID: PeerID;
|
|
151
|
+
nickname: string;
|
|
152
|
+
isConnected: boolean;
|
|
153
|
+
noisePublicKey?: Uint8Array;
|
|
154
|
+
signingPublicKey?: Uint8Array;
|
|
155
|
+
isVerified: boolean;
|
|
156
|
+
lastSeen: Date;
|
|
157
|
+
handshakeState: HandshakeState;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Chat message (application layer)
|
|
162
|
+
*/
|
|
163
|
+
export interface ChatMessage {
|
|
164
|
+
id: string;
|
|
165
|
+
sender: PeerID;
|
|
166
|
+
senderNickname: string;
|
|
167
|
+
content: string;
|
|
168
|
+
timestamp: Date;
|
|
169
|
+
isPrivate: boolean;
|
|
170
|
+
recipientNickname?: string;
|
|
171
|
+
originalSender?: PeerID; // For relayed messages
|
|
172
|
+
deliveryStatus: DeliveryStatus;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Read receipt
|
|
177
|
+
*/
|
|
178
|
+
export interface ReadReceipt {
|
|
179
|
+
originalMessageID: string;
|
|
180
|
+
readBy: PeerID;
|
|
181
|
+
readAt: Date;
|
|
182
|
+
}
|
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Manager
|
|
3
|
+
* Manages Noise sessions with peers, handling handshakes and encryption
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { EventEmitter } from 'node:events';
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import {
|
|
11
|
+
generateKeyPair,
|
|
12
|
+
HandshakeState as NoiseHandshake,
|
|
13
|
+
type NoiseKeyPair,
|
|
14
|
+
type NoiseRole,
|
|
15
|
+
NoiseSession,
|
|
16
|
+
} from '../crypto/noise.js';
|
|
17
|
+
import { fingerprint, generateSigningKeyPair, type SigningKeyPair } from '../crypto/signing.js';
|
|
18
|
+
import { HandshakeState, PeerID, type PeerInfo } from '../protocol/types.js';
|
|
19
|
+
|
|
20
|
+
// Key persistence path
|
|
21
|
+
const KEY_DIR = join(homedir(), '.bitchat-node');
|
|
22
|
+
const KEY_FILE = join(KEY_DIR, 'identity.json');
|
|
23
|
+
|
|
24
|
+
interface PersistedKeys {
|
|
25
|
+
noisePublicKey: string;
|
|
26
|
+
noiseSecretKey: string;
|
|
27
|
+
signingPublicKey: string;
|
|
28
|
+
signingSecretKey: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function loadOrCreateKeys(): { static: NoiseKeyPair; signing: SigningKeyPair } {
|
|
32
|
+
try {
|
|
33
|
+
if (existsSync(KEY_FILE)) {
|
|
34
|
+
const data = JSON.parse(readFileSync(KEY_FILE, 'utf-8')) as PersistedKeys;
|
|
35
|
+
console.log('[Keys] Loaded persistent identity from', KEY_FILE);
|
|
36
|
+
return {
|
|
37
|
+
static: {
|
|
38
|
+
publicKey: Buffer.from(data.noisePublicKey, 'hex'),
|
|
39
|
+
secretKey: Buffer.from(data.noiseSecretKey, 'hex'),
|
|
40
|
+
},
|
|
41
|
+
signing: {
|
|
42
|
+
publicKey: Buffer.from(data.signingPublicKey, 'hex'),
|
|
43
|
+
secretKey: Buffer.from(data.signingSecretKey, 'hex'),
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
} catch (e) {
|
|
48
|
+
console.log('[Keys] Failed to load keys, generating new ones:', (e as Error).message);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Generate new keys
|
|
52
|
+
const staticKP = generateKeyPair();
|
|
53
|
+
const signingKP = generateSigningKeyPair();
|
|
54
|
+
|
|
55
|
+
// Persist them
|
|
56
|
+
try {
|
|
57
|
+
mkdirSync(KEY_DIR, { recursive: true });
|
|
58
|
+
const data: PersistedKeys = {
|
|
59
|
+
noisePublicKey: Buffer.from(staticKP.publicKey).toString('hex'),
|
|
60
|
+
noiseSecretKey: Buffer.from(staticKP.secretKey).toString('hex'),
|
|
61
|
+
signingPublicKey: Buffer.from(signingKP.publicKey).toString('hex'),
|
|
62
|
+
signingSecretKey: Buffer.from(signingKP.secretKey).toString('hex'),
|
|
63
|
+
};
|
|
64
|
+
writeFileSync(KEY_FILE, JSON.stringify(data, null, 2));
|
|
65
|
+
console.log('[Keys] Generated and saved new identity to', KEY_FILE);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
console.log('[Keys] Warning: Failed to persist keys:', (e as Error).message);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { static: staticKP, signing: signingKP };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface SessionManagerConfig {
|
|
74
|
+
autoHandshake: boolean;
|
|
75
|
+
handshakeTimeoutMs: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const DEFAULT_CONFIG: SessionManagerConfig = {
|
|
79
|
+
autoHandshake: true,
|
|
80
|
+
handshakeTimeoutMs: 30000,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
interface PendingHandshake {
|
|
84
|
+
state: NoiseHandshake;
|
|
85
|
+
role: NoiseRole;
|
|
86
|
+
startedAt: number;
|
|
87
|
+
timeoutHandle?: ReturnType<typeof setTimeout>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface EstablishedSession {
|
|
91
|
+
session: NoiseSession;
|
|
92
|
+
establishedAt: number;
|
|
93
|
+
remotePublicKey: Uint8Array;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface SessionManagerEvents {
|
|
97
|
+
'session:established': (peerID: PeerID, session: NoiseSession) => void;
|
|
98
|
+
'session:failed': (peerID: PeerID, error: Error) => void;
|
|
99
|
+
'session:closed': (peerID: PeerID) => void;
|
|
100
|
+
'handshake:message': (peerID: PeerID, message: Uint8Array) => void;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Manages Noise sessions with peers
|
|
105
|
+
*/
|
|
106
|
+
export class SessionManager extends EventEmitter {
|
|
107
|
+
private readonly config: SessionManagerConfig;
|
|
108
|
+
|
|
109
|
+
// Our keys
|
|
110
|
+
readonly staticKeyPair: NoiseKeyPair;
|
|
111
|
+
readonly signingKeyPair: SigningKeyPair;
|
|
112
|
+
readonly myPeerID: PeerID;
|
|
113
|
+
readonly fingerprint: string;
|
|
114
|
+
|
|
115
|
+
// Session state
|
|
116
|
+
private pendingHandshakes = new Map<string, PendingHandshake>();
|
|
117
|
+
private sessions = new Map<string, EstablishedSession>();
|
|
118
|
+
|
|
119
|
+
// Peer info
|
|
120
|
+
private peers = new Map<string, PeerInfo>();
|
|
121
|
+
|
|
122
|
+
// Messages queued for after handshake completion
|
|
123
|
+
private pendingMessages = new Map<string, Array<{ data: Uint8Array; timestamp: number }>>();
|
|
124
|
+
|
|
125
|
+
constructor(
|
|
126
|
+
config: Partial<SessionManagerConfig> = {},
|
|
127
|
+
existingKeys?: { static?: NoiseKeyPair; signing?: SigningKeyPair }
|
|
128
|
+
) {
|
|
129
|
+
super();
|
|
130
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
131
|
+
|
|
132
|
+
// Initialize with provided keys, persisted keys, or generate new ones
|
|
133
|
+
if (existingKeys?.static && existingKeys?.signing) {
|
|
134
|
+
this.staticKeyPair = existingKeys.static;
|
|
135
|
+
this.signingKeyPair = existingKeys.signing;
|
|
136
|
+
} else {
|
|
137
|
+
const loaded = loadOrCreateKeys();
|
|
138
|
+
this.staticKeyPair = loaded.static;
|
|
139
|
+
this.signingKeyPair = loaded.signing;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Derive peer ID from static public key
|
|
143
|
+
this.myPeerID = PeerID.fromPublicKey(this.staticKeyPair.publicKey);
|
|
144
|
+
this.fingerprint = fingerprint(this.staticKeyPair.publicKey);
|
|
145
|
+
|
|
146
|
+
console.log('[SessionManager] Identity:');
|
|
147
|
+
console.log(' PeerID:', this.myPeerID.toHex());
|
|
148
|
+
console.log(' Noise pubkey:', Buffer.from(this.staticKeyPair.publicKey).toString('hex'));
|
|
149
|
+
console.log(' Signing pubkey:', Buffer.from(this.signingKeyPair.publicKey).toString('hex'));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Check if we have an established session with a peer
|
|
154
|
+
*/
|
|
155
|
+
hasSession(peerID: PeerID): boolean {
|
|
156
|
+
return this.sessions.has(peerID.toHex());
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Check if we're in the middle of a handshake
|
|
161
|
+
*/
|
|
162
|
+
hasHandshakeInProgress(peerID: PeerID): boolean {
|
|
163
|
+
return this.pendingHandshakes.has(peerID.toHex());
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Get handshake state for a peer
|
|
168
|
+
*/
|
|
169
|
+
getHandshakeState(peerID: PeerID): HandshakeState {
|
|
170
|
+
if (this.sessions.has(peerID.toHex())) {
|
|
171
|
+
return HandshakeState.ESTABLISHED;
|
|
172
|
+
}
|
|
173
|
+
if (this.pendingHandshakes.has(peerID.toHex())) {
|
|
174
|
+
return HandshakeState.HANDSHAKING;
|
|
175
|
+
}
|
|
176
|
+
return HandshakeState.NONE;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Get established session
|
|
181
|
+
*/
|
|
182
|
+
getSession(peerID: PeerID): NoiseSession | undefined {
|
|
183
|
+
return this.sessions.get(peerID.toHex())?.session;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Initiate a handshake with a peer
|
|
188
|
+
*/
|
|
189
|
+
initiateHandshake(peerID: PeerID): Uint8Array {
|
|
190
|
+
const key = peerID.toHex();
|
|
191
|
+
|
|
192
|
+
// Check if already have session or handshake in progress
|
|
193
|
+
if (this.sessions.has(key)) {
|
|
194
|
+
throw new Error('Session already established');
|
|
195
|
+
}
|
|
196
|
+
if (this.pendingHandshakes.has(key)) {
|
|
197
|
+
throw new Error('Handshake already in progress');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Create handshake state
|
|
201
|
+
const state = new NoiseHandshake('initiator', this.staticKeyPair);
|
|
202
|
+
|
|
203
|
+
// Write first message
|
|
204
|
+
const message = state.writeMessage();
|
|
205
|
+
|
|
206
|
+
// Store pending handshake
|
|
207
|
+
const pending: PendingHandshake = {
|
|
208
|
+
state,
|
|
209
|
+
role: 'initiator',
|
|
210
|
+
startedAt: Date.now(),
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// Set timeout
|
|
214
|
+
pending.timeoutHandle = setTimeout(() => {
|
|
215
|
+
this.handleHandshakeTimeout(peerID);
|
|
216
|
+
}, this.config.handshakeTimeoutMs);
|
|
217
|
+
|
|
218
|
+
this.pendingHandshakes.set(key, pending);
|
|
219
|
+
|
|
220
|
+
return message;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Handle incoming handshake message
|
|
225
|
+
*/
|
|
226
|
+
handleHandshakeMessage(peerID: PeerID, message: Uint8Array): Uint8Array | null {
|
|
227
|
+
const key = peerID.toHex();
|
|
228
|
+
|
|
229
|
+
let pending = this.pendingHandshakes.get(key);
|
|
230
|
+
|
|
231
|
+
if (!pending) {
|
|
232
|
+
// No handshake in progress - we're the responder
|
|
233
|
+
const state = new NoiseHandshake('responder', this.staticKeyPair);
|
|
234
|
+
pending = {
|
|
235
|
+
state,
|
|
236
|
+
role: 'responder',
|
|
237
|
+
startedAt: Date.now(),
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// Set timeout
|
|
241
|
+
pending.timeoutHandle = setTimeout(() => {
|
|
242
|
+
this.handleHandshakeTimeout(peerID);
|
|
243
|
+
}, this.config.handshakeTimeoutMs);
|
|
244
|
+
|
|
245
|
+
this.pendingHandshakes.set(key, pending);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
// Process incoming message
|
|
250
|
+
pending.state.readMessage(message);
|
|
251
|
+
|
|
252
|
+
// Check if handshake is complete
|
|
253
|
+
if (pending.state.isComplete()) {
|
|
254
|
+
this.completeHandshake(peerID, pending);
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Write response
|
|
259
|
+
const response = pending.state.writeMessage();
|
|
260
|
+
|
|
261
|
+
// Check again after writing
|
|
262
|
+
if (pending.state.isComplete()) {
|
|
263
|
+
this.completeHandshake(peerID, pending);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return response;
|
|
267
|
+
} catch (error) {
|
|
268
|
+
// Handshake failed
|
|
269
|
+
this.handleHandshakeFailure(peerID, error as Error);
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Complete a successful handshake
|
|
276
|
+
*/
|
|
277
|
+
private completeHandshake(peerID: PeerID, pending: PendingHandshake): void {
|
|
278
|
+
const key = peerID.toHex();
|
|
279
|
+
|
|
280
|
+
// Clear timeout
|
|
281
|
+
if (pending.timeoutHandle) {
|
|
282
|
+
clearTimeout(pending.timeoutHandle);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Get remote public key
|
|
286
|
+
const remotePublicKey = pending.state.getRemoteStaticPublicKey();
|
|
287
|
+
if (!remotePublicKey) {
|
|
288
|
+
this.handleHandshakeFailure(peerID, new Error('No remote public key'));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Create session
|
|
293
|
+
const { send, receive, hash } = pending.state.split();
|
|
294
|
+
const session = new NoiseSession(send, receive, hash, remotePublicKey);
|
|
295
|
+
|
|
296
|
+
// Store session
|
|
297
|
+
this.sessions.set(key, {
|
|
298
|
+
session,
|
|
299
|
+
establishedAt: Date.now(),
|
|
300
|
+
remotePublicKey,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Clean up pending
|
|
304
|
+
this.pendingHandshakes.delete(key);
|
|
305
|
+
|
|
306
|
+
// Update peer info
|
|
307
|
+
this.updatePeerInfo(peerID, { noisePublicKey: remotePublicKey });
|
|
308
|
+
|
|
309
|
+
// Emit event
|
|
310
|
+
this.emit('session:established', peerID, session);
|
|
311
|
+
|
|
312
|
+
// Send any queued messages
|
|
313
|
+
this.flushPendingMessages(peerID);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Handle handshake timeout
|
|
318
|
+
*/
|
|
319
|
+
private handleHandshakeTimeout(peerID: PeerID): void {
|
|
320
|
+
this.handleHandshakeFailure(peerID, new Error('Handshake timeout'));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Handle handshake failure
|
|
325
|
+
*/
|
|
326
|
+
private handleHandshakeFailure(peerID: PeerID, error: Error): void {
|
|
327
|
+
const key = peerID.toHex();
|
|
328
|
+
const pending = this.pendingHandshakes.get(key);
|
|
329
|
+
|
|
330
|
+
if (pending?.timeoutHandle) {
|
|
331
|
+
clearTimeout(pending.timeoutHandle);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
this.pendingHandshakes.delete(key);
|
|
335
|
+
this.pendingMessages.delete(key);
|
|
336
|
+
|
|
337
|
+
this.emit('session:failed', peerID, error);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Encrypt data for a peer
|
|
342
|
+
*/
|
|
343
|
+
encrypt(peerID: PeerID, plaintext: Uint8Array): Uint8Array {
|
|
344
|
+
const session = this.sessions.get(peerID.toHex())?.session;
|
|
345
|
+
if (!session) {
|
|
346
|
+
throw new Error('No session with peer');
|
|
347
|
+
}
|
|
348
|
+
return session.encrypt(plaintext);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Decrypt data from a peer
|
|
353
|
+
*/
|
|
354
|
+
decrypt(peerID: PeerID, ciphertext: Uint8Array): Uint8Array {
|
|
355
|
+
const session = this.sessions.get(peerID.toHex())?.session;
|
|
356
|
+
if (!session) {
|
|
357
|
+
throw new Error('No session with peer');
|
|
358
|
+
}
|
|
359
|
+
return session.decrypt(ciphertext);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Queue a message for after handshake completion
|
|
364
|
+
*/
|
|
365
|
+
queueMessage(peerID: PeerID, data: Uint8Array): void {
|
|
366
|
+
const key = peerID.toHex();
|
|
367
|
+
const queue = this.pendingMessages.get(key) ?? [];
|
|
368
|
+
queue.push({ data, timestamp: Date.now() });
|
|
369
|
+
this.pendingMessages.set(key, queue);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Flush pending messages for a peer
|
|
374
|
+
*/
|
|
375
|
+
private flushPendingMessages(peerID: PeerID): void {
|
|
376
|
+
const key = peerID.toHex();
|
|
377
|
+
const queue = this.pendingMessages.get(key);
|
|
378
|
+
if (!queue || queue.length === 0) return;
|
|
379
|
+
|
|
380
|
+
this.pendingMessages.delete(key);
|
|
381
|
+
|
|
382
|
+
for (const { data } of queue) {
|
|
383
|
+
try {
|
|
384
|
+
const encrypted = this.encrypt(peerID, data);
|
|
385
|
+
this.emit('handshake:message', peerID, encrypted);
|
|
386
|
+
} catch (error) {
|
|
387
|
+
console.error('Failed to encrypt queued message:', error);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Close session with a peer
|
|
394
|
+
*/
|
|
395
|
+
closeSession(peerID: PeerID): void {
|
|
396
|
+
const key = peerID.toHex();
|
|
397
|
+
|
|
398
|
+
const established = this.sessions.get(key);
|
|
399
|
+
if (established) {
|
|
400
|
+
established.session.clear();
|
|
401
|
+
this.sessions.delete(key);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const pending = this.pendingHandshakes.get(key);
|
|
405
|
+
if (pending?.timeoutHandle) {
|
|
406
|
+
clearTimeout(pending.timeoutHandle);
|
|
407
|
+
}
|
|
408
|
+
this.pendingHandshakes.delete(key);
|
|
409
|
+
|
|
410
|
+
this.pendingMessages.delete(key);
|
|
411
|
+
|
|
412
|
+
this.emit('session:closed', peerID);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Update peer info
|
|
417
|
+
*/
|
|
418
|
+
updatePeerInfo(peerID: PeerID, updates: Partial<PeerInfo>): void {
|
|
419
|
+
const key = peerID.toHex();
|
|
420
|
+
const existing = this.peers.get(key) ?? {
|
|
421
|
+
peerID,
|
|
422
|
+
nickname: 'anon',
|
|
423
|
+
isConnected: false,
|
|
424
|
+
isVerified: false,
|
|
425
|
+
lastSeen: new Date(),
|
|
426
|
+
handshakeState: HandshakeState.NONE,
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
this.peers.set(key, { ...existing, ...updates });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Get peer info
|
|
434
|
+
*/
|
|
435
|
+
getPeerInfo(peerID: PeerID): PeerInfo | undefined {
|
|
436
|
+
return this.peers.get(peerID.toHex());
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Get all known peers
|
|
441
|
+
*/
|
|
442
|
+
getAllPeers(): PeerInfo[] {
|
|
443
|
+
return Array.from(this.peers.values());
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Get all peers with established sessions
|
|
448
|
+
*/
|
|
449
|
+
getConnectedPeers(): PeerInfo[] {
|
|
450
|
+
const connectedKeys = new Set(this.sessions.keys());
|
|
451
|
+
return Array.from(this.peers.values()).filter((p) => connectedKeys.has(p.peerID.toHex()));
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Clean up
|
|
456
|
+
*/
|
|
457
|
+
destroy(): void {
|
|
458
|
+
// Clear all sessions
|
|
459
|
+
for (const [, established] of this.sessions) {
|
|
460
|
+
established.session.clear();
|
|
461
|
+
}
|
|
462
|
+
this.sessions.clear();
|
|
463
|
+
|
|
464
|
+
// Clear pending handshakes
|
|
465
|
+
for (const [, pending] of this.pendingHandshakes) {
|
|
466
|
+
if (pending.timeoutHandle) {
|
|
467
|
+
clearTimeout(pending.timeoutHandle);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
this.pendingHandshakes.clear();
|
|
471
|
+
|
|
472
|
+
this.pendingMessages.clear();
|
|
473
|
+
this.peers.clear();
|
|
474
|
+
this.removeAllListeners();
|
|
475
|
+
}
|
|
476
|
+
}
|