@diva.exchange/i2p-sam
Version:
I2P SAM: peer-to-peer communication between applications over I2P
268 lines (234 loc) • 8.99 kB
text/typescript
/**
* Copyright 2021-2025 diva.exchange
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* Author/Maintainer: DIVA.EXCHANGE Association, https://diva.exchange
*/
import { base32 } from 'rfc4648';
import crypto from 'crypto';
import { EventEmitter } from 'events';
import { Config, Configuration } from './config.js';
import { Socket } from 'net';
const REPLY_HELLO: string = 'HELLOREPLY';
const REPLY_DEST: string = 'DESTREPLY';
const REPLY_SESSION: string = 'SESSIONSTATUS';
const REPLY_NAMING: string = 'NAMINGREPLY';
const REPLY_STREAM: string = 'STREAMSTATUS';
const KEY_RESULT: string = 'RESULT';
const KEY_PUB: string = 'PUB';
const KEY_PRIV: string = 'PRIV';
const KEY_DESTINATION: string = 'DESTINATION';
const KEY_VALUE: string = 'VALUE';
const VALUE_OK: string = 'OK';
export class I2pSam extends EventEmitter {
protected config: Config;
protected socketControl: Socket = {} as Socket;
protected timeout: number = 0;
// identity
private publicKey: string;
private privateKey: string;
protected internalEventEmitter: EventEmitter;
protected constructor(c: Configuration) {
super();
this.config = new Config(c);
this.timeout =
this.config.sam.timeout && this.config.sam.timeout >= 1 && this.config.sam.timeout <= 600
? this.config.sam.timeout
: 300;
this.publicKey = this.config.sam.publicKey || '';
this.privateKey = this.config.sam.privateKey || '';
this.internalEventEmitter = new EventEmitter();
}
protected async open(): Promise<I2pSam> {
this.socketControl = new Socket();
this.socketControl.on('data', (data: Buffer): void => {
this.parseReply(data);
});
this.socketControl.on('close', (): void => {
this.emit('close');
});
try {
await new Promise((resolve, reject): void => {
this.socketControl.once('error', reject);
this.socketControl.connect({ host: this.config.sam.host, port: this.config.sam.portTCP }, (): void => {
this.socketControl.removeAllListeners('error');
this.socketControl.on('error', (error: Error): void => {
this.emit('error', error);
});
resolve(this);
});
});
await this.hello(this.socketControl);
if (!this.publicKey || !this.privateKey) {
await this.generateDestination();
}
return this;
} catch (e: unknown) {
const error = e as string;
return Promise.reject(new Error(error.toString()));
}
}
protected close(): void {
this.internalEventEmitter.removeAllListeners();
if (Object.keys(this.socketControl).length) {
this.socketControl.destroy();
}
}
protected hello(socket: Socket): Promise<void> {
return new Promise((resolve, reject): void => {
const min: string | false = this.config.sam.versionMin || false;
const max: string | false = this.config.sam.versionMax || false;
this.internalEventEmitter.removeAllListeners();
this.internalEventEmitter.once('error', reject);
this.internalEventEmitter.once('hello', resolve);
socket.write(
`HELLO VERSION${min ? ' MIN=' + min : ''}${max ? ' MAX=' + max : ''}\n`,
(error: Error | undefined): void => {
if (error) {
reject(error);
}
}
);
});
}
protected initSession(type: string): Promise<I2pSam> {
return new Promise((resolve, reject): void => {
let s: string = `SESSION CREATE ID=${this.config.session.id} DESTINATION=${this.privateKey} `;
switch (type) {
case 'STREAM':
s += 'STYLE=STREAM';
break;
case 'DATAGRAM':
case 'RAW':
s += `STYLE=${type} PORT=${this.config.listen.portForward} HOST=${this.config.listen.hostForward}`;
break;
}
this.internalEventEmitter.removeAllListeners();
this.internalEventEmitter.once('error', reject);
this.internalEventEmitter.once('session', resolve);
s += (this.config.session.options ? ' ' + this.config.session.options : '') + '\n';
this.socketControl.write(s, (error: Error | undefined): void => {
if (error) {
reject(error);
}
});
});
}
protected parseReply(data: Buffer) {
const sData: string = data.toString().trim();
const [c, s] = sData.split(' ');
const oKeyValue = I2pSam.parseReplyKeyValue(sData);
// command reply handling
switch (c + s) {
case REPLY_HELLO:
return oKeyValue[KEY_RESULT] !== VALUE_OK
? this.internalEventEmitter.emit('error', new Error('HELLO failed: ' + sData))
: this.internalEventEmitter.emit('hello');
case REPLY_DEST:
this.publicKey = oKeyValue[KEY_PUB] || '';
this.privateKey = oKeyValue[KEY_PRIV] || '';
return !this.publicKey || !this.privateKey
? this.internalEventEmitter.emit('error', new Error('DEST failed: ' + sData))
: this.internalEventEmitter.emit('destination');
case REPLY_SESSION:
return oKeyValue[KEY_RESULT] !== VALUE_OK || !(oKeyValue[KEY_DESTINATION] || '')
? this.internalEventEmitter.emit('error', new Error('SESSION failed: ' + sData))
: this.internalEventEmitter.emit('session', this);
case REPLY_NAMING:
return oKeyValue[KEY_RESULT] !== VALUE_OK
? this.internalEventEmitter.emit('error', new Error('NAMING failed: ' + sData))
: this.internalEventEmitter.emit('naming', oKeyValue[KEY_VALUE]);
case REPLY_STREAM:
return oKeyValue[KEY_RESULT] !== VALUE_OK
? this.internalEventEmitter.emit('error', new Error('STREAM failed: ' + sData))
: this.internalEventEmitter.emit('stream');
default:
return;
}
}
private static parseReplyKeyValue(data: string): { [key: string]: string } {
const [...args] = data.split(' ');
const objResult: { [key: string]: string } = {};
for (const s of args.filter((s: string): boolean => s.indexOf('=') > -1)) {
const [k, v] = s.split('=');
objResult[k.trim()] = v.trim();
}
return objResult;
}
private generateDestination(): Promise<void> {
this.publicKey = '';
this.privateKey = '';
return new Promise((resolve, reject): void => {
this.internalEventEmitter.removeAllListeners();
this.internalEventEmitter.once('error', (error: Error): void => {
reject(error);
});
this.internalEventEmitter.once('destination', resolve);
this.socketControl.write('DEST GENERATE\n', (error: Error | undefined): void => {
if (error) {
this.internalEventEmitter.emit('error', error);
}
});
});
}
protected resolve(name: string): Promise<string> {
return new Promise((resolve, reject): void => {
if (!/\.i2p$/.test(name)) {
reject(new Error('Invalid I2P address: ' + name));
}
this.internalEventEmitter.removeAllListeners();
this.internalEventEmitter.once('error', (error: Error): void => {
reject(error);
});
this.internalEventEmitter.once('naming', resolve);
this.socketControl.write(`NAMING LOOKUP NAME=${name}\n`, (error: Error | undefined): void => {
if (error) {
this.internalEventEmitter.emit('error', error);
}
});
});
}
getB32Address(): string {
return I2pSam.toB32(this.publicKey) + '.b32.i2p';
}
getPublicKey(): string {
return this.publicKey;
}
getPrivateKey(): string {
return this.privateKey;
}
getKeyPair(): { public: string; private: string } {
return {
public: this.getPublicKey(),
private: this.getPrivateKey(),
};
}
static toB32(base64Destination: string): string {
const s: Buffer = Buffer.from(base64Destination.replace(/-/g, '+').replace(/~/g, '/'), 'base64');
return base32.stringify(crypto.createHash('sha256').update(s).digest(), { pad: false }).toLowerCase();
}
static async createLocalDestination(c: Configuration): Promise<{ address: string; public: string; private: string }> {
const sam: I2pSam = new I2pSam(c);
await sam.open();
sam.close();
return { address: sam.getB32Address(), public: sam.getPublicKey(), private: sam.getPrivateKey() };
}
static async lookup(c: Configuration, address: string): Promise<string> {
const sam: I2pSam = new I2pSam(c);
await sam.open();
const s: string = await sam.resolve(address);
sam.close();
return s;
}
}