This commit is contained in:
tiero
2023-01-02 13:10:45 +01:00
parent cd20b32f87
commit c6a3be420c
11 changed files with 323 additions and 613 deletions

View File

@@ -12,36 +12,6 @@ const App = () => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
/* const webSK = "5acff99d1ad3e1706360d213fd69203312d9b5e91a2d5f2e06100cc6f686e5b3";
const webPK = getPublicKey(webSK);
console.log('webPk', webPK);
const sessionWeb = new Session({
target: webPK,
relay: 'wss://nostr.vulpem.com',
metadata: {
name: 'My Website',
description: 'lorem ipsum dolor sit amet',
url: 'https://vulpem.com',
icons: ['https://vulpem.com/1000x860-p-500.422be1bc.png'],
}
});
sessionWeb.on(ConnectMessageType.PAIRED, (msg: any) => {
console.log('paired event', msg);
});
await sessionWeb.listen(webSK);
// mobile app (this can be a child key)
const sessionMobile = Session.fromConnectURI(sessionWeb.connectURI);// 'nostr://connect?target=...&metadata=...'
const mobileSK = "ed779ff047f99c95f732b22c9f8f842afb870c740aab591776ebc7b64e83cf6c";
const mobilePK = getPublicKey(mobileSK);
console.log('mobilePK', mobilePK);
// we define the behavior of the mobile app for each requests
await sessionMobile.listen(mobileSK);
await sessionMobile.pair(mobileSK);
*/
})(); })();
}, []); }, []);

View File

@@ -1,11 +1,11 @@
import { Event, nip04, relayInit } from "nostr-tools"; import { Event, nip04, relayInit } from 'nostr-tools';
import { prepareRequest, prepareResponse } from "./event"; import { prepareRequest } from './event';
import { Session, SessionStatus } from "./session"; import { Session, SessionStatus } from './session';
export interface ConnectMessage { export interface ConnectMessage {
type: ConnectMessageType, type: ConnectMessageType;
value?: any value?: any;
requestID?: string, requestID?: string;
} }
export enum ConnectMessageType { export enum ConnectMessageType {
@@ -16,28 +16,30 @@ export enum ConnectMessageType {
} }
export interface PairingACK extends ConnectMessage { export interface PairingACK extends ConnectMessage {
type: ConnectMessageType.PAIRED, type: ConnectMessageType.PAIRED;
value: { value: {
pubkey: string, pubkey: string;
} };
} }
export interface PairingNACK extends ConnectMessage { export interface PairingNACK extends ConnectMessage {
type: ConnectMessageType.UNPAIRED type: ConnectMessageType.UNPAIRED;
} }
export interface GetPublicKeyRequest extends ConnectMessage { export interface GetPublicKeyRequest extends ConnectMessage {
type: ConnectMessageType.GET_PUBLIC_KEY_REQUEST type: ConnectMessageType.GET_PUBLIC_KEY_REQUEST;
} }
export interface GetPublicKeyResponse extends ConnectMessage { export interface GetPublicKeyResponse extends ConnectMessage {
type: ConnectMessageType.GET_PUBLIC_KEY_RESPONSE, type: ConnectMessageType.GET_PUBLIC_KEY_RESPONSE;
value: { value: {
pubkey: string, pubkey: string;
} };
} }
export function responseTypeForRequestType(type: ConnectMessageType): ConnectMessageType { export function responseTypeForRequestType(
type: ConnectMessageType
): ConnectMessageType {
switch (type) { switch (type) {
case ConnectMessageType.GET_PUBLIC_KEY_REQUEST: case ConnectMessageType.GET_PUBLIC_KEY_REQUEST:
return ConnectMessageType.GET_PUBLIC_KEY_RESPONSE; return ConnectMessageType.GET_PUBLIC_KEY_RESPONSE;
@@ -62,14 +64,20 @@ export class Connect {
} }
async sendMessage(message: ConnectMessage): Promise<ConnectMessage> { async sendMessage(message: ConnectMessage): Promise<ConnectMessage> {
if (this.session.status !== SessionStatus.PAIRED) throw new Error('Session is not paired'); if (this.session.status !== SessionStatus.PAIRED)
throw new Error('Session is not paired');
if (!this.session.target) throw new Error('Target is required'); if (!this.session.target) throw new Error('Target is required');
if (!this.session.remote) throw new Error('Remote is required'); if (!this.session.remote) throw new Error('Remote is required');
const { target, remote } = this.session; const { target, remote } = this.session;
// send request to remote // send request to remote
const {event, requestID} = await prepareRequest(target, remote, message, this.targetPrivateKey); const { event, requestID } = await prepareRequest(
target,
remote,
message,
this.targetPrivateKey
);
console.log(`sending message ${message.type} with requestID ${requestID}`); console.log(`sending message ${message.type} with requestID ${requestID}`);
const id = await this.session.sendEvent(event, this.targetPrivateKey); const id = await this.session.sendEvent(event, this.targetPrivateKey);
if (!id) throw new Error('Failed to send message ' + message.type); if (!id) throw new Error('Failed to send message ' + message.type);
@@ -84,22 +92,31 @@ export class Connect {
}); });
// waiting for response from remote // waiting for response from remote
let sub = relay.sub([{ let sub = relay.sub([
{
kinds: [4], kinds: [4],
authors: [remote], authors: [remote],
//since: now, //since: now,
"#p": [target], '#p': [target],
limit: 1, limit: 1,
}]); },
]);
sub.on('event', async (event: Event) => { sub.on('event', async (event: Event) => {
const plaintext = await nip04.decrypt(this.targetPrivateKey, event.pubkey, event.content); const plaintext = await nip04.decrypt(
this.targetPrivateKey,
event.pubkey,
event.content
);
console.log('plaintext', plaintext); console.log('plaintext', plaintext);
console.log('requestID', requestID); console.log('requestID', requestID);
const payload = JSON.parse(plaintext); const payload = JSON.parse(plaintext);
if (!payload) return; if (!payload) return;
if (!Object.keys(payload).includes('requestID') || !Object.keys(payload).includes('message')) return; if (
!Object.keys(payload).includes('requestID') ||
!Object.keys(payload).includes('message')
)
return;
if (payload.requestID !== requestID) return; if (payload.requestID !== requestID) return;
const msg = payload.message as ConnectMessage; const msg = payload.message as ConnectMessage;
const responseType = responseTypeForRequestType(msg.type); const responseType = responseTypeForRequestType(msg.type);
@@ -111,16 +128,14 @@ export class Connect {
sub.unsub(); sub.unsub();
}); });
}); });
} }
async getPublicKey(): Promise<string> { async getPublicKey(): Promise<string> {
const response: ConnectMessage = await this.sendMessage({ const response: ConnectMessage = await this.sendMessage({
type: ConnectMessageType.GET_PUBLIC_KEY_REQUEST type: ConnectMessageType.GET_PUBLIC_KEY_REQUEST,
}); });
if (response.type !== ConnectMessageType.GET_PUBLIC_KEY_RESPONSE) throw new Error('Invalid response type'); if (response.type !== ConnectMessageType.GET_PUBLIC_KEY_RESPONSE)
throw new Error('Invalid response type');
return response.value.pubkey; return response.value.pubkey;
} }
@@ -128,7 +143,9 @@ export class Connect {
throw new Error('Not implemented'); throw new Error('Not implemented');
} }
async getRelays(): Promise<{ [url: string]: { read: boolean, write: boolean } }> { async getRelays(): Promise<{
[url: string]: { read: boolean; write: boolean };
}> {
throw new Error('Not implemented'); throw new Error('Not implemented');
} }
@@ -138,11 +155,10 @@ export class Connect {
}, },
decrypt: async (_pubkey: string, _ciphertext: string): Promise<string> => { decrypt: async (_pubkey: string, _ciphertext: string): Promise<string> => {
throw new Error('Not implemented'); throw new Error('Not implemented');
} },
} };
async request(_opts: { method: string, params: any }): Promise<any> { async request(_opts: { method: string; params: any }): Promise<any> {
throw new Error('Not implemented'); throw new Error('Not implemented');
} }
} }

View File

@@ -1,16 +1,23 @@
import { nip04, Event } from "nostr-tools"; import { nip04, Event } from 'nostr-tools';
import { ConnectMessage } from "./connect"; import { ConnectMessage } from './connect';
export async function prepareRequest(from: string, to: string, request: ConnectMessage, fromSecretKey: string) { export async function prepareRequest(
from: string,
to: string,
request: ConnectMessage,
fromSecretKey: string
) {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const requestID = Math.random().toString().slice(2); const requestID = Math.random()
.toString()
.slice(2);
const cipherText = await nip04.encrypt( const cipherText = await nip04.encrypt(
fromSecretKey, fromSecretKey,
to, to,
JSON.stringify({ JSON.stringify({
requestID: requestID, requestID: requestID,
request, request,
}), })
); );
const event: Event = { const event: Event = {
kind: 4, kind: 4,
@@ -22,7 +29,13 @@ export async function prepareRequest(from: string, to: string, request: ConnectM
return { event, requestID }; return { event, requestID };
} }
export async function prepareResponse(requestID: string, from: string, to: string, response: ConnectMessage, fromSecretKey: string) { export async function prepareResponse(
requestID: string,
from: string,
to: string,
response: ConnectMessage,
fromSecretKey: string
) {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const cipherText = await nip04.encrypt( const cipherText = await nip04.encrypt(
fromSecretKey, fromSecretKey,
@@ -30,7 +43,7 @@ export async function prepareResponse(requestID: string, from: string, to: strin
JSON.stringify({ JSON.stringify({
requestID: requestID, requestID: requestID,
response, response,
}), })
); );
const event: Event = { const event: Event = {
kind: 4, kind: 4,

View File

@@ -1,314 +0,0 @@
"use strict";
var __assign = (this && this.__assign) || function () {
__assign = Object.assign || function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
exports.__esModule = true;
exports.prepareEvent = exports.prepareResponse = exports.prepareRequest = exports.randomID = exports.now = exports.NostrRPCServer = exports.NostrRPC = void 0;
var nostr_tools_1 = require("nostr-tools");
var NostrRPC = /** @class */ (function () {
function NostrRPC(opts) {
this.relay = (0, nostr_tools_1.relayInit)(opts.relay || "wss://nostr.vulpem.com");
this.target = opts.target;
this.self = {
pubkey: (0, nostr_tools_1.getPublicKey)(opts.secretKey),
secret: opts.secretKey
};
}
NostrRPC.prototype.call = function (_a) {
var _b = _a.id, id = _b === void 0 ? randomID() : _b, method = _a.method, _c = _a.params, params = _c === void 0 ? [] : _c;
return __awaiter(this, void 0, void 0, function () {
var body, event;
var _this = this;
return __generator(this, function (_d) {
switch (_d.label) {
case 0:
// connect to relay
return [4 /*yield*/, this.relay.connect()];
case 1:
// connect to relay
_d.sent();
this.relay.on('error', function () { throw new Error("failed to connect to ".concat(_this.relay.url)); });
body = prepareRequest(id, method, params);
return [4 /*yield*/, prepareEvent(this.self.secret, this.target, body)];
case 2:
event = _d.sent();
// send request via relay
return [4 /*yield*/, new Promise(function (resolve, reject) {
var pub = _this.relay.publish(event);
pub.on('failed', reject);
pub.on('seen', resolve);
})];
case 3:
// send request via relay
_d.sent();
console.log("sent request to nostr id: ".concat(event.id), { id: id, method: method, params: params });
return [2 /*return*/, new Promise(function (resolve, reject) {
// waiting for response from remote
// TODO: reject after a timeout
var sub = _this.relay.sub([{
kinds: [4],
authors: [_this.target],
"#p": [_this.self.pubkey]
}]);
sub.on('event', function (event) { return __awaiter(_this, void 0, void 0, function () {
var plaintext, payload, ignore_1;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
_a.trys.push([0, 2, , 3]);
return [4 /*yield*/, nostr_tools_1.nip04.decrypt(this.self.secret, event.pubkey, event.content)];
case 1:
plaintext = _a.sent();
payload = JSON.parse(plaintext);
return [3 /*break*/, 3];
case 2:
ignore_1 = _a.sent();
return [2 /*return*/];
case 3:
// ignore all the events that are not NostrRPCResponse events
if (!plaintext)
return [2 /*return*/];
if (!payload)
return [2 /*return*/];
if (!Object.keys(payload).includes('id') || !Object.keys(payload).includes('error') || !Object.keys(payload).includes('result'))
return [2 /*return*/];
// ignore all the events that are not for this request
if (payload.id !== id)
return [2 /*return*/];
// if the response is an error, reject the promise
if (payload.error) {
reject(payload.error);
}
// if the response is a result, resolve the promise
if (payload.result) {
resolve(payload.result);
}
return [2 /*return*/];
}
});
}); });
sub.on('eose', function () {
sub.unsub();
});
})];
}
});
});
};
return NostrRPC;
}());
exports.NostrRPC = NostrRPC;
var NostrRPCServer = /** @class */ (function () {
function NostrRPCServer(opts) {
this.relay = (0, nostr_tools_1.relayInit)((opts === null || opts === void 0 ? void 0 : opts.relay) || "wss://nostr.vulpem.com");
this.self = {
pubkey: (0, nostr_tools_1.getPublicKey)(opts.secretKey),
secret: opts.secretKey
};
}
NostrRPCServer.prototype.listen = function () {
return __awaiter(this, void 0, void 0, function () {
var sub;
var _this = this;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, this.relay.connect()];
case 1:
_a.sent();
return [4 /*yield*/, new Promise(function (resolve, reject) {
_this.relay.on('connect', resolve);
_this.relay.on('error', reject);
})];
case 2:
_a.sent();
sub = this.relay.sub([{
kinds: [4],
"#p": [this.self.pubkey],
since: now()
}]);
sub.on('event', function (event) { return __awaiter(_this, void 0, void 0, function () {
var plaintext, payload, ignore_2, response, body, responseEvent;
var _this = this;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
_a.trys.push([0, 2, , 3]);
return [4 /*yield*/, nostr_tools_1.nip04.decrypt(this.self.secret, event.pubkey, event.content)];
case 1:
plaintext = _a.sent();
payload = JSON.parse(plaintext);
return [3 /*break*/, 3];
case 2:
ignore_2 = _a.sent();
return [2 /*return*/];
case 3:
// ignore all the events that are not NostrRPCRequest events
if (!plaintext)
return [2 /*return*/];
if (!payload)
return [2 /*return*/];
if (!Object.keys(payload).includes('id') || !Object.keys(payload).includes('method') || !Object.keys(payload).includes('params'))
return [2 /*return*/];
return [4 /*yield*/, this.handleRequest(payload)];
case 4:
response = _a.sent();
body = prepareResponse(response.id, response.result, response.error);
return [4 /*yield*/, prepareEvent(this.self.secret, event.pubkey, body)];
case 5:
responseEvent = _a.sent();
console.log('response to be sent', responseEvent);
// send response via relay
return [4 /*yield*/, new Promise(function (resolve, reject) {
var pub = _this.relay.publish(responseEvent);
pub.on('failed', reject);
pub.on('seen', function () { return resolve(); });
})];
case 6:
// send response via relay
_a.sent();
return [2 /*return*/];
}
});
}); });
sub.on('eose', function () {
sub.unsub();
});
return [2 /*return*/];
}
});
});
};
NostrRPCServer.prototype.handleRequest = function (request) {
return __awaiter(this, void 0, void 0, function () {
var id, method, params, result, error, e_1;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
id = request.id, method = request.method, params = request.params;
result = null;
error = null;
_a.label = 1;
case 1:
_a.trys.push([1, 3, , 4]);
return [4 /*yield*/, this[method].apply(this, params)];
case 2:
result = _a.sent();
return [3 /*break*/, 4];
case 3:
e_1 = _a.sent();
if (e_1 instanceof Error) {
error = e_1.message;
}
else {
error = 'unknown error';
}
return [3 /*break*/, 4];
case 4: return [2 /*return*/, {
id: id,
result: result,
error: error
}];
}
});
});
};
return NostrRPCServer;
}());
exports.NostrRPCServer = NostrRPCServer;
function now() {
return Math.floor(Date.now() / 1000);
}
exports.now = now;
function randomID() {
return Math.random().toString().slice(2);
}
exports.randomID = randomID;
function prepareRequest(id, method, params) {
return JSON.stringify({
id: id,
method: method,
params: params
});
}
exports.prepareRequest = prepareRequest;
function prepareResponse(id, result, error) {
return JSON.stringify({
id: id,
result: result,
error: error
});
}
exports.prepareResponse = prepareResponse;
function prepareEvent(secretKey, pubkey, content) {
return __awaiter(this, void 0, void 0, function () {
var cipherText, event, id, sig, signedEvent, ok, veryOk;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, nostr_tools_1.nip04.encrypt(secretKey, pubkey, content)];
case 1:
cipherText = _a.sent();
event = {
kind: 4,
created_at: now(),
pubkey: (0, nostr_tools_1.getPublicKey)(secretKey),
tags: [['p', pubkey]],
content: cipherText
};
id = (0, nostr_tools_1.getEventHash)(event);
sig = (0, nostr_tools_1.signEvent)(event, secretKey);
signedEvent = __assign(__assign({}, event), { id: id, sig: sig });
ok = (0, nostr_tools_1.validateEvent)(signedEvent);
veryOk = (0, nostr_tools_1.verifySignature)(signedEvent);
if (!ok || !veryOk) {
throw new Error('Event is not valid');
}
return [2 /*return*/, signedEvent];
}
});
});
}
exports.prepareEvent = prepareEvent;

View File

@@ -1,5 +1,15 @@
import { relayInit, Relay, getEventHash, signEvent, validateEvent, verifySignature, getPublicKey, nip04, Event, Sub } from "nostr-tools"; import {
relayInit,
Relay,
getEventHash,
signEvent,
validateEvent,
verifySignature,
getPublicKey,
nip04,
Event,
Sub,
} from 'nostr-tools';
export interface NostrRPCRequest { export interface NostrRPCRequest {
id: string; id: string;
@@ -12,37 +22,39 @@ export interface NostrRPCResponse {
error: any; error: any;
} }
export class NostrRPC { export class NostrRPC {
relay: Relay; relay: Relay;
self: { pubkey: string, secret: string }; self: { pubkey: string; secret: string };
target: string; // this is for implementing the response handlers for each method
[key: string]: any;
constructor(opts: { relay?: string, target: string, secretKey: string }) { constructor(opts: { relay?: string; secretKey: string }) {
this.relay = relayInit(opts.relay || "wss://nostr.vulpem.com"); this.relay = relayInit(opts.relay || 'wss://nostr.vulpem.com');
this.target = opts.target;
this.self = { this.self = {
pubkey: getPublicKey(opts.secretKey), pubkey: getPublicKey(opts.secretKey),
secret: opts.secretKey, secret: opts.secretKey,
}; };
} }
async call({ async call({
id = randomID(), target,
method, request: { id = randomID(), method, params = [] },
params = [],
}: { }: {
id?: string, target: string;
method: string, request: {
params?: any[], id?: string;
method: string;
params?: any[];
};
}): Promise<any> { }): Promise<any> {
// connect to relay // connect to relay
await this.relay.connect(); await this.relay.connect();
this.relay.on('error', () => { throw new Error(`failed to connect to ${this.relay.url}`) }); this.relay.on('error', () => {
throw new Error(`failed to connect to ${this.relay.url}`);
});
// prepare request to be sent // prepare request to be sent
const body = prepareRequest(id, method, params); const body = prepareRequest(id, method, params);
const event = await prepareEvent(this.self.secret, this.target, body); const event = await prepareEvent(this.self.secret, target, body);
// send request via relay // send request via relay
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -51,34 +63,37 @@ export class NostrRPC {
pub.on('seen', resolve); pub.on('seen', resolve);
}); });
console.log(`sent request to nostr id: ${event.id}`, { id, method, params }) console.log(`request: nostr id: ${event.id}`, { id, method, params });
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
// waiting for response from remote // waiting for response from remote
// TODO: reject after a timeout // TODO: reject after a timeout
let sub = this.relay.sub([{ let sub = this.relay.sub([
{
kinds: [4], kinds: [4],
authors: [this.target], authors: [this.target],
"#p": [this.self.pubkey], '#p': [this.self.pubkey],
}]); },
]);
sub.on('event', async (event: Event) => { sub.on('event', async (event: Event) => {
let plaintext;
let payload; let payload;
try { try {
plaintext = await nip04.decrypt(this.self.secret, event.pubkey, event.content); const plaintext = await nip04.decrypt(
this.self.secret,
event.pubkey,
event.content
);
if (!plaintext) throw new Error('failed to decrypt event');
payload = JSON.parse(plaintext); payload = JSON.parse(plaintext);
} catch (ignore) { } catch (ignore) {
return; return;
} }
// ignore all the events that are not NostrRPCResponse events // ignore all the events that are not NostrRPCResponse events
if (!plaintext) return; if (!isValidResponse(payload)) return;
if (!payload) return;
if (!Object.keys(payload).includes('id') || !Object.keys(payload).includes('error') || !Object.keys(payload).includes('result')) return;
console.log(`received response from nostr id: ${event.id}`, payload) console.log(`received response from nostr id: ${event.id}`, payload);
// ignore all the events that are not for this request // ignore all the events that are not for this request
if (payload.id !== id) return; if (payload.id !== id) return;
@@ -99,20 +114,7 @@ export class NostrRPC {
}); });
}); });
} }
}
export class NostrRPCServer {
relay: Relay;
self: { pubkey: string, secret: string };
[key: string]: any; // TODO: remove this [key: string]
constructor(opts: { relay?: string, secretKey: string }) {
this.relay = relayInit(opts?.relay || "wss://nostr.vulpem.com");
this.self = {
pubkey: getPublicKey(opts.secretKey),
secret: opts.secretKey,
};
}
async listen(): Promise<Sub> { async listen(): Promise<Sub> {
await this.relay.connect(); await this.relay.connect();
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -120,33 +122,45 @@ export class NostrRPCServer {
this.relay.on('error', reject); this.relay.on('error', reject);
}); });
let sub = this.relay.sub([{ let sub = this.relay.sub([
{
kinds: [4], kinds: [4],
"#p": [this.self.pubkey], '#p': [this.self.pubkey],
since: now(), since: now(),
}]); },
]);
sub.on('event', async (event: Event) => { sub.on('event', async (event: Event) => {
let plaintext;
let payload; let payload;
try { try {
plaintext = await nip04.decrypt(this.self.secret, event.pubkey, event.content); const plaintext = await nip04.decrypt(
this.self.secret,
event.pubkey,
event.content
);
if (!plaintext) throw new Error('failed to decrypt event');
payload = JSON.parse(plaintext); payload = JSON.parse(plaintext);
} catch (ignore) { } catch (ignore) {
return; return;
} }
// ignore all the events that are not NostrRPCRequest events // ignore all the events that are not NostrRPCRequest events
if (!plaintext) return; if (!isValidRequest(payload)) return;
if (!payload) return;
if (!Object.keys(payload).includes('id') || !Object.keys(payload).includes('method') || !Object.keys(payload).includes('params')) return;
// handle request // handle request
const response = await this.handleRequest(payload); const response = await this.handleRequest(payload);
const body = prepareResponse(response.id, response.result, response.error); const body = prepareResponse(
const responseEvent = await prepareEvent(this.self.secret, event.pubkey, body); response.id,
response.result,
response.error
);
const responseEvent = await prepareEvent(
this.self.secret,
event.pubkey,
body
);
console.log('response to be sent', responseEvent) console.log('response to be sent', responseEvent);
// send response via relay // send response via relay
const pub = this.relay.publish(responseEvent); const pub = this.relay.publish(responseEvent);
pub.on('failed', console.error); pub.on('failed', console.error);
@@ -158,17 +172,20 @@ export class NostrRPCServer {
return sub; return sub;
} }
async handleRequest(request: NostrRPCRequest): Promise<NostrRPCResponse> {
private async handleRequest(
request: NostrRPCRequest
): Promise<NostrRPCResponse> {
const { id, method, params } = request; const { id, method, params } = request;
let result = null; let result = null;
let error = null; let error = null;
try { try {
result = await this[method](...params); result = await this[method](...params);
} catch(e: any) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) {
error = e.message; error = e.message;
} else { } else {
error = 'unknown error' error = 'unknown error';
} }
} }
return { return {
@@ -183,9 +200,15 @@ export function now(): number {
return Math.floor(Date.now() / 1000); return Math.floor(Date.now() / 1000);
} }
export function randomID(): string { export function randomID(): string {
return Math.random().toString().slice(2); return Math.random()
.toString()
.slice(2);
} }
export function prepareRequest(id: string, method: string, params: any[]): string { export function prepareRequest(
id: string,
method: string,
params: any[]
): string {
return JSON.stringify({ return JSON.stringify({
id, id,
method: method, method: method,
@@ -199,12 +222,12 @@ export function prepareResponse(id: string, result: any, error: any): string {
error: error, error: error,
}); });
} }
export async function prepareEvent(secretKey: string, pubkey: string, content: string): Promise<Event> { export async function prepareEvent(
const cipherText = await nip04.encrypt( secretKey: string,
secretKey, pubkey: string,
pubkey, content: string
content, ): Promise<Event> {
); const cipherText = await nip04.encrypt(secretKey, pubkey, content);
const event: Event = { const event: Event = {
kind: 4, kind: 4,
@@ -212,7 +235,7 @@ export async function prepareEvent(secretKey: string, pubkey: string, content: s
pubkey: getPublicKey(secretKey), pubkey: getPublicKey(secretKey),
tags: [['p', pubkey]], tags: [['p', pubkey]],
content: cipherText, content: cipherText,
} };
const id = getEventHash(event); const id = getEventHash(event);
const sig = signEvent(event, secretKey); const sig = signEvent(event, secretKey);
@@ -226,3 +249,31 @@ export async function prepareEvent(secretKey: string, pubkey: string, content: s
return signedEvent; return signedEvent;
} }
function isValidRequest(payload: any): boolean {
if (!payload) return false;
const keys = Object.keys(payload);
if (
!keys.includes('id') ||
!keys.includes('method') ||
!keys.includes('params')
)
return false;
return true;
}
function isValidResponse(payload: any): boolean {
if (!payload) return false;
const keys = Object.keys(payload);
if (
!keys.includes('id') ||
!keys.includes('result') ||
!keys.includes('error')
)
return false;
return true;
}

View File

@@ -7,15 +7,15 @@ import {
relayInit, relayInit,
nip04, nip04,
getPublicKey, getPublicKey,
} from 'nostr-tools' } from 'nostr-tools';
import { ConnectMessage, ConnectMessageType, PairingACK } from './connect'; import { ConnectMessage, ConnectMessageType, PairingACK } from './connect';
import { prepareResponse } from './event'; import { prepareResponse } from './event';
export interface Metadata { export interface Metadata {
name: string, name: string;
url: string, url: string;
description?: string description?: string;
icons?: string[], icons?: string[];
} }
export enum SessionStatus { export enum SessionStatus {
@@ -56,7 +56,6 @@ export class Session {
} }
} }
constructor({ constructor({
target, target,
metadata, metadata,
@@ -64,20 +63,21 @@ export class Session {
}: { }: {
target: string; target: string;
relay: string; relay: string;
metadata: Metadata metadata: Metadata;
}) { }) {
this.listeners = {}; this.listeners = {};
this.target = target; this.target = target;
this.metadata = metadata; this.metadata = metadata;
this.relay = relay; this.relay = relay;
this.connectURI = `nostr://connect?target=${this.target}&metadata=${JSON.stringify(this.metadata)}&relay=${this.relay}`; this.connectURI = `nostr://connect?target=${
this.target
}&metadata=${JSON.stringify(this.metadata)}&relay=${this.relay}`;
} }
on( on(type: ConnectMessageType, cb: (value: any) => any): void {
type: ConnectMessageType, const id = Math.random()
cb: (value: any) => any .toString()
): void { .slice(2);
const id = Math.random().toString().slice(2);
this.listeners[id] = this.listeners[id] || emptyListeners(); this.listeners[id] = this.listeners[id] || emptyListeners();
this.listeners[id][type].push(cb); this.listeners[id][type].push(cb);
} }
@@ -96,22 +96,34 @@ export class Session {
if (!secretKey) throw new Error('secret key is required'); if (!secretKey) throw new Error('secret key is required');
const pubkey = getPublicKey(secretKey); const pubkey = getPublicKey(secretKey);
const relay = relayInit(this.relay); const relay = relayInit(this.relay);
await relay.connect(); await relay.connect();
relay.on('connect', () => { console.log(`connected to ${relay.url}`) }); relay.on('connect', () => {
relay.on('error', () => { console.error(`failed to connect to ${relay.url}`) }); console.log(`connected to ${relay.url}`);
});
relay.on('error', () => {
console.error(`failed to connect to ${relay.url}`);
});
let sub = relay.sub([{ let sub = relay.sub([
{
kinds: [4], kinds: [4],
"#p": [pubkey], '#p': [pubkey],
}]); },
]);
sub.on('event', async (event: Event) => { sub.on('event', async (event: Event) => {
const plaintext = await nip04.decrypt(secretKey, event.pubkey, event.content); const plaintext = await nip04.decrypt(
secretKey,
event.pubkey,
event.content
);
const payload = JSON.parse(plaintext); const payload = JSON.parse(plaintext);
if (!payload) return; if (!payload) return;
if (!Object.keys(payload).includes('requestID') || !Object.keys(payload).includes('message')) return; if (
!Object.keys(payload).includes('requestID') ||
!Object.keys(payload).includes('message')
)
return;
const msg = payload.message as ConnectMessage; const msg = payload.message as ConnectMessage;
@@ -143,25 +155,24 @@ export class Session {
break; break;
} }
} }
});
})
sub.on('eose', () => { sub.on('eose', () => {
sub.unsub() sub.unsub();
}) });
} }
emit(type: ConnectMessageType, value?: any): void { emit(type: ConnectMessageType, value?: any): void {
Object.values(this.listeners).forEach((listeners) => { Object.values(this.listeners).forEach(listeners => {
if (listeners[type]) { if (listeners[type]) {
listeners[type].forEach(cb => cb(value)); listeners[type].forEach(cb => cb(value));
} }
}); });
} }
async pair(remoteSignerPrivateKey: string): Promise<void> { async pair(remoteSignerPrivateKey: string): Promise<void> {
if (!remoteSignerPrivateKey) throw new Error('Signer private key is required'); if (!remoteSignerPrivateKey)
throw new Error('Signer private key is required');
const remoteSignerPubKey = getPublicKey(remoteSignerPrivateKey); const remoteSignerPubKey = getPublicKey(remoteSignerPrivateKey);
this.remote = remoteSignerPubKey; this.remote = remoteSignerPubKey;
@@ -169,13 +180,20 @@ export class Session {
type: ConnectMessageType.PAIRED, type: ConnectMessageType.PAIRED,
value: { pubkey: this.remote }, value: { pubkey: this.remote },
}; };
const randomID = Math.random().toString().slice(2); const randomID = Math.random()
const {event} = await prepareResponse(randomID, this.remote, this.target, message, remoteSignerPrivateKey); .toString()
.slice(2);
const { event } = await prepareResponse(
randomID,
this.remote,
this.target,
message,
remoteSignerPrivateKey
);
const id = await this.sendEvent(event, remoteSignerPrivateKey); const id = await this.sendEvent(event, remoteSignerPrivateKey);
console.log('sent pairing response from mobile', id); console.log('sent pairing response from mobile', id);
} }
async sendEvent(event: Event, secretKey: string): Promise<string> { async sendEvent(event: Event, secretKey: string): Promise<string> {
const id = getEventHash(event); const id = getEventHash(event);
const sig = signEvent(event, secretKey); const sig = signEvent(event, secretKey);
@@ -189,7 +207,9 @@ export class Session {
const relay = relayInit(this.relay); const relay = relayInit(this.relay);
await relay.connect(); await relay.connect();
relay.on('error', () => { throw new Error(`failed to connect to ${relay.url}`) }); relay.on('error', () => {
throw new Error(`failed to connect to ${relay.url}`);
});
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const pub = relay.publish(signedEvent); const pub = relay.publish(signedEvent);
@@ -201,7 +221,7 @@ export class Session {
function emptyListeners(): {} { function emptyListeners(): {} {
let data: any = {}; let data: any = {};
Object.values(ConnectMessageType).forEach((type) => { Object.values(ConnectMessageType).forEach(type => {
data[type] = []; data[type] = [];
}); });
return data; return data;

View File

@@ -1,5 +1,10 @@
import { getPublicKey } from "nostr-tools"; import { getPublicKey } from 'nostr-tools';
import { Connect, ConnectMessageType, GetPublicKeyResponse, Session } from "../src/index"; import {
Connect,
ConnectMessageType,
GetPublicKeyResponse,
Session,
} from '../src/index';
jest.setTimeout(5000); jest.setTimeout(5000);
@@ -8,12 +13,11 @@ describe('Nostr Connect', () => {
let resolvePaired: (arg0: boolean) => void; let resolvePaired: (arg0: boolean) => void;
let resolveGetPublicKey: (arg0: boolean) => void; let resolveGetPublicKey: (arg0: boolean) => void;
// web app (this is ephemeral and represents the currention session) // web app (this is ephemeral and represents the currention session)
const webSK = "5acff99d1ad3e1706360d213fd69203312d9b5e91a2d5f2e06100cc6f686e5b3"; const webSK =
'5acff99d1ad3e1706360d213fd69203312d9b5e91a2d5f2e06100cc6f686e5b3';
const webPK = getPublicKey(webSK); const webPK = getPublicKey(webSK);
console.log('webPk', webPK); console.log('webPk', webPK);
const sessionWeb = new Session({ const sessionWeb = new Session({
target: webPK, target: webPK,
relay: 'wss://nostr.vulpem.com', relay: 'wss://nostr.vulpem.com',
@@ -22,7 +26,7 @@ describe('Nostr Connect', () => {
description: 'lorem ipsum dolor sit amet', description: 'lorem ipsum dolor sit amet',
url: 'https://vulpem.com', url: 'https://vulpem.com',
icons: ['https://vulpem.com/1000x860-p-500.422be1bc.png'], icons: ['https://vulpem.com/1000x860-p-500.422be1bc.png'],
} },
}); });
sessionWeb.on(ConnectMessageType.PAIRED, (msg: any) => { sessionWeb.on(ConnectMessageType.PAIRED, (msg: any) => {
expect(msg).toBeDefined(); expect(msg).toBeDefined();
@@ -32,7 +36,8 @@ describe('Nostr Connect', () => {
// mobile app (this can be a child key) // mobile app (this can be a child key)
const sessionMobile = Session.fromConnectURI(sessionWeb.connectURI); // 'nostr://connect?target=...&metadata=...' const sessionMobile = Session.fromConnectURI(sessionWeb.connectURI); // 'nostr://connect?target=...&metadata=...'
const mobileSK = "ed779ff047f99c95f732b22c9f8f842afb870c740aab591776ebc7b64e83cf6c"; const mobileSK =
'ed779ff047f99c95f732b22c9f8f842afb870c740aab591776ebc7b64e83cf6c';
const mobilePK = getPublicKey(mobileSK); const mobilePK = getPublicKey(mobileSK);
console.log('mobilePK', mobilePK); console.log('mobilePK', mobilePK);
await sessionMobile.pair(mobileSK); await sessionMobile.pair(mobileSK);
@@ -43,16 +48,13 @@ describe('Nostr Connect', () => {
type: ConnectMessageType.GET_PUBLIC_KEY_RESPONSE, type: ConnectMessageType.GET_PUBLIC_KEY_RESPONSE,
value: { value: {
pubkey: mobilePK, pubkey: mobilePK,
} },
}; };
const event = await sessionMobile.eventToBeSentToTarget( const event = await sessionMobile.eventToBeSentToTarget(
message, message,
mobileSK mobileSK
); );
await sessionMobile.sendEvent( await sessionMobile.sendEvent(event, mobileSK);
event,
mobileSK
);
resolveGetPublicKey(true); resolveGetPublicKey(true);
}); });
await sessionMobile.listen(mobileSK); await sessionMobile.listen(mobileSK);
@@ -71,13 +73,13 @@ describe('Nostr Connect', () => {
return expect( return expect(
Promise.all([ Promise.all([
new Promise(resolve => { new Promise(resolve => {
resolvePaired = resolve resolvePaired = resolve;
}), }),
new Promise(resolve => { new Promise(resolve => {
resolveGetPublicKey = resolve resolveGetPublicKey = resolve;
}) }),
]) ])
).resolves.toEqual([true, true]) ).resolves.toEqual([true, true]);
/* /*
expect(handler).toBeCalledTimes(1); expect(handler).toBeCalledTimes(1);

View File

@@ -1,7 +1,6 @@
import { nip04, Event } from 'nostr-tools'; import { NostrRPC } from '../src/request';
import { NostrRPC, NostrRPCServer, prepareEvent, prepareResponse } from '../src/request';
class Example extends NostrRPCServer { class Server extends NostrRPC {
async ping(): Promise<string> { async ping(): Promise<string> {
return 'pong'; return 'pong';
} }
@@ -11,56 +10,27 @@ jest.setTimeout(10000);
describe('Nostr RPC', () => { describe('Nostr RPC', () => {
it('starts a server', async () => { it('starts a server', async () => {
const server = new Example({ const server = new Server({
secretKey: "ed779ff047f99c95f732b22c9f8f842afb870c740aab591776ebc7b64e83cf6c", secretKey:
}); 'ed779ff047f99c95f732b22c9f8f842afb870c740aab591776ebc7b64e83cf6c',
const sub = await server.listen();
sub.on('event', async (event: Event) => {
let plaintext;
let payload;
try {
plaintext = await nip04.decrypt(server.self.secret, event.pubkey, event.content);
payload = JSON.parse(plaintext);
} catch(ignore) {
return;
}
// ignore all the events that are not NostrRPCRequest events
if (!plaintext) return;
if (!payload) return;
if (!Object.keys(payload).includes('id') || !Object.keys(payload).includes('method') || !Object.keys(payload).includes('params')) return;
// handle request
const response = {
id: payload.id,
result: "pong",
error: null,
}
const body = prepareResponse(response.id, response.result, response.error);
const responseEvent = await prepareEvent(server.self.secret, event.pubkey, body);
console.log('response to be sent', responseEvent)
// send response via relay
const pub = server.relay.publish(responseEvent);
pub.on('failed', console.error);
});
sub.on('eose', () => {
sub.unsub();
}); });
await server.listen();
const client = new NostrRPC({ const client = new NostrRPC({
secretKey: "5acff99d1ad3e1706360d213fd69203312d9b5e91a2d5f2e06100cc6f686e5b3", secretKey:
target: server.self.pubkey, '5acff99d1ad3e1706360d213fd69203312d9b5e91a2d5f2e06100cc6f686e5b3',
}); });
console.log(`from: ` + client.self.pubkey, `to: ` + server.self.pubkey); console.log(`from: ` + client.self.pubkey, `to: ` + server.self.pubkey);
await sleep(2000); await sleep(2000);
const result = await client.call({ method: 'ping' }); const result = await client.call({
target: server.self.pubkey,
request: { method: 'ping' },
});
console.log(result); console.log(result);
}) });
}) });
function sleep(ms: number) { function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));

View File

@@ -1,18 +0,0 @@
const {NostrRPCServer} = require('../src/request.js');
class Example extends NostrRPCServer {
constructor(opts) {
super(opts);
}
async ping() {
return 'pong';
}
}
async function main() {
const server = new Example({
secretKey: "ed779ff047f99c95f732b22c9f8f842afb870c740aab591776ebc7b64e83cf6c",
});
await server.listen();
console.log('Server listening on port 3000');
}

View File

@@ -1,10 +1,10 @@
//setupTests.tsx //setupTests.tsx
import crypto from "crypto"; import crypto from 'crypto';
import { TextEncoder, TextDecoder } from "util"; import { TextEncoder, TextDecoder } from 'util';
global.TextEncoder = TextEncoder; global.TextEncoder = TextEncoder;
(global as any).TextDecoder = TextDecoder; (global as any).TextDecoder = TextDecoder;
Object.defineProperty(global.self, 'crypto', { Object.defineProperty(global.self, 'crypto', {
value: crypto.webcrypto value: crypto.webcrypto,
}); });