add ConnectURI class

This commit is contained in:
tiero
2023-01-03 00:52:04 +01:00
parent 140190699f
commit 0a85d00deb
9 changed files with 259 additions and 506 deletions

View File

@@ -2,7 +2,7 @@ import * as React from 'react';
import { useEffect, useState } from 'react';
import * as ReactDOM from 'react-dom';
import { NostrRPC } from '../src/request';
import { NostrRPC } from '../src/rpc';
class Server extends NostrRPC {

View File

@@ -1,142 +1,183 @@
import { Event, nip04, relayInit } from 'nostr-tools';
import { prepareRequest } from './event';
import { Session, SessionStatus } from './session';
import { getPublicKey, Event, nip04 } from 'nostr-tools';
import { isValidRequest, NostrRPC } from './rpc';
import EventEmitter from 'events';
export interface ConnectMessage {
type: ConnectMessageType;
value?: any;
requestID?: string;
export interface Metadata {
name: string;
url: string;
description?: string;
icons?: string[];
}
export enum ConnectMessageType {
PAIRED = 'paired',
UNPAIRED = 'unpaired',
GET_PUBLIC_KEY_REQUEST = 'getPublicKeyRequest',
GET_PUBLIC_KEY_RESPONSE = 'getPublicKeyResponse',
export enum SessionStatus {
Paired = 'paired',
Unpaired = 'unpaired',
}
export interface PairingACK extends ConnectMessage {
type: ConnectMessageType.PAIRED;
value: {
pubkey: string;
};
export class ConnectURI {
status: SessionStatus = SessionStatus.Unpaired;
target: string;
metadata: Metadata;
relayURL: string;
static fromURI(uri: string): ConnectURI {
const url = new URL(uri);
const target = url.searchParams.get('target');
if (!target) {
throw new Error('Invalid connect URI: missing target');
}
const relay = url.searchParams.get('relay');
if (!relay) {
throw new Error('Invalid connect URI: missing relay');
}
const metadata = url.searchParams.get('metadata');
if (!metadata) {
throw new Error('Invalid connect URI: missing metadata');
}
export interface PairingNACK extends ConnectMessage {
type: ConnectMessageType.UNPAIRED;
try {
const md = JSON.parse(metadata);
return new ConnectURI({ target: target, metadata: md, relayURL: relay });
} catch (ignore) {
throw new Error('Invalid connect URI: metadata is not valid JSON');
}
}
export interface GetPublicKeyRequest extends ConnectMessage {
type: ConnectMessageType.GET_PUBLIC_KEY_REQUEST;
constructor({
target,
metadata,
relayURL,
}: {
target: string;
metadata: Metadata;
relayURL: string;
}) {
this.target = target;
this.metadata = metadata;
this.relayURL = relayURL;
}
export interface GetPublicKeyResponse extends ConnectMessage {
type: ConnectMessageType.GET_PUBLIC_KEY_RESPONSE;
value: {
pubkey: string;
};
toString() {
return `nostr://connect?target=${this.target}&metadata=${JSON.stringify(
this.metadata
)}&relay=${this.relayURL}`;
}
export function responseTypeForRequestType(
type: ConnectMessageType
): ConnectMessageType {
switch (type) {
case ConnectMessageType.GET_PUBLIC_KEY_REQUEST:
return ConnectMessageType.GET_PUBLIC_KEY_RESPONSE;
default:
throw new Error('Invalid request type');
async approve(secretKey: string): Promise<void> {
const rpc = new NostrRPC({
relay: this.relayURL,
secretKey,
});
const response = await rpc.call({
target: this.target,
request: {
method: 'connect',
params: [getPublicKey(secretKey)],
},
});
if (!response) throw new Error('Invalid response from remote');
return;
}
async reject(secretKey: string): Promise<void> {
const rpc = new NostrRPC({
relay: this.relayURL,
secretKey,
});
const response = await rpc.call({
target: this.target,
request: {
method: 'disconnect',
params: [],
},
});
if (!response) throw new Error('Invalid response from remote');
return;
}
}
export enum ConnectStatus {
Connected = 'connected',
Disconnected = 'disconnected',
}
export class Connect {
session: Session;
private targetPrivateKey: string;
rpc: NostrRPC;
target?: string;
events = new EventEmitter();
status = ConnectStatus.Disconnected;
constructor({
session,
targetPrivateKey,
target,
relay,
secretKey,
}: {
session: Session;
targetPrivateKey: string;
secretKey: string;
target?: string;
relay?: string;
}) {
this.session = session;
this.targetPrivateKey = targetPrivateKey;
this.rpc = new NostrRPC({ relay, secretKey });
if (target) {
this.target = target;
this.status = ConnectStatus.Connected;
}
}
async sendMessage(message: ConnectMessage): Promise<ConnectMessage> {
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.remote) throw new Error('Remote is required');
const { target, remote } = this.session;
// send request to remote
const { event, requestID } = await prepareRequest(
target,
remote,
message,
this.targetPrivateKey
);
console.log(`sending message ${message.type} with requestID ${requestID}`);
const id = await this.session.sendEvent(event, this.targetPrivateKey);
if (!id) throw new Error('Failed to send message ' + message.type);
console.log('sent message with nostr id', id);
const relay = relayInit(this.session.relay);
await relay.connect();
return new Promise((resolve, reject) => {
relay.on('error', () => {
reject(`failed to connect to ${relay.url}`);
});
// waiting for response from remote
let sub = relay.sub([
{
kinds: [4],
authors: [remote],
//since: now,
'#p': [target],
limit: 1,
},
]);
async init() {
const sub = await this.rpc.listen();
sub.on('event', async (event: Event) => {
let payload;
try {
const plaintext = await nip04.decrypt(
this.targetPrivateKey,
this.rpc.self.secret,
event.pubkey,
event.content
);
console.log('plaintext', plaintext);
console.log('requestID', requestID);
const payload = JSON.parse(plaintext);
if (!payload) return;
if (
!Object.keys(payload).includes('requestID') ||
!Object.keys(payload).includes('message')
)
if (!plaintext) throw new Error('failed to decrypt event');
payload = JSON.parse(plaintext);
} catch (ignore) {
return;
if (payload.requestID !== requestID) return;
const msg = payload.message as ConnectMessage;
const responseType = responseTypeForRequestType(msg.type);
if (msg.type !== responseType) return;
resolve(msg);
});
}
// ignore all the events that are not NostrRPCRequest events
if (!isValidRequest(payload)) return;
sub.on('eose', () => {
sub.unsub();
});
// ignore all the request that are not connect
if (payload.method !== 'connect') return;
// ignore all the request that are not for us
if (!payload.params || payload.params.length !== 1) return;
const [pubkey] = payload.params;
this.status = ConnectStatus.Connected;
this.target = pubkey;
this.events.emit('connect', pubkey);
});
}
on(evt: 'connect' | 'disconnect', cb: (...args: any[]) => void) {
this.events.on(evt, cb);
}
off(evt: 'connect' | 'disconnect', cb: (...args: any[]) => void) {
this.events.off(evt, cb);
}
private isConnected() {
return this.status === ConnectStatus.Connected;
}
async getPublicKey(): Promise<string> {
const response: ConnectMessage = await this.sendMessage({
type: ConnectMessageType.GET_PUBLIC_KEY_REQUEST,
if (!this.target || !this.isConnected()) throw new Error('Not connected');
const response = await this.rpc.call({
target: this.target,
request: {
method: 'get_public_key',
params: [],
},
});
if (response.type !== ConnectMessageType.GET_PUBLIC_KEY_RESPONSE)
throw new Error('Invalid response type');
return response.value.pubkey;
return response as string;
}
async signEvent(_event: Event): Promise<Event> {
@@ -157,8 +198,4 @@ export class Connect {
throw new Error('Not implemented');
},
};
async request(_opts: { method: string; params: any }): Promise<any> {
throw new Error('Not implemented');
}
}

View File

@@ -1,56 +0,0 @@
import { nip04, Event } from 'nostr-tools';
import { ConnectMessage } from './connect';
export async function prepareRequest(
from: string,
to: string,
request: ConnectMessage,
fromSecretKey: string
) {
const now = Math.floor(Date.now() / 1000);
const requestID = Math.random()
.toString()
.slice(2);
const cipherText = await nip04.encrypt(
fromSecretKey,
to,
JSON.stringify({
requestID: requestID,
request,
})
);
const event: Event = {
kind: 4,
created_at: now,
pubkey: from,
tags: [['p', to]],
content: cipherText,
};
return { event, requestID };
}
export async function prepareResponse(
requestID: string,
from: string,
to: string,
response: ConnectMessage,
fromSecretKey: string
) {
const now = Math.floor(Date.now() / 1000);
const cipherText = await nip04.encrypt(
fromSecretKey,
to,
JSON.stringify({
requestID: requestID,
response,
})
);
const event: Event = {
kind: 4,
created_at: now,
pubkey: from,
tags: [['p', to]],
content: cipherText,
};
return { event, requestID };
}

View File

@@ -1,2 +1,2 @@
export * from './session';
export * from './connect';
export * from './rpc';

View File

@@ -35,6 +35,7 @@ export class NostrRPC {
secret: opts.secretKey,
};
}
async call({
target,
request: { id = randomID(), method, params = [] },
@@ -46,28 +47,32 @@ export class NostrRPC {
params?: any[];
};
}): Promise<any> {
// prepare request to be sent
const request = prepareRequest(id, method, params);
const event = await prepareEvent(this.self.secret, target, request);
// connect to relay
await this.relay.connect();
this.relay.on('error', () => {
throw new Error(`failed to connect to ${this.relay.url}`);
});
// prepare request to be sent
const body = prepareRequest(id, method, params);
const event = await prepareEvent(this.self.secret, target, body);
// send request via relay
await new Promise<void>((resolve, reject) => {
try {
await new Promise<void>(async (resolve, reject) => {
this.relay.on('error', () => {
reject(`failed to connect to ${this.relay.url}`);
});
const pub = this.relay.publish(event);
pub.on('failed', (reason: any) => {
reject(reason);
});
pub.on('seen', () => {
console.log(`seen`, event.id, request);
resolve();
});
});
} catch (err) {
throw err;
}
// TODO: reject after a timeout
// waiting for response from remote
return new Promise<void>((resolve, reject) => {
const queries = [
@@ -75,10 +80,9 @@ export class NostrRPC {
kinds: [4],
authors: [target],
'#p': [this.self.pubkey],
since: event.created_at - 1,
since: event.created_at,
},
];
let sub = this.relay.sub(queries);
sub.on('event', async (event: Event) => {
let payload;
@@ -100,8 +104,7 @@ export class NostrRPC {
// ignore all the events that are not for this request
if (payload.id !== id) return;
// unsubscribe from the stream
sub.unsub();
console.log(`response`, event.id, payload);
// if the response is an error, reject the promise
if (payload.error) {
@@ -113,25 +116,25 @@ export class NostrRPC {
resolve(payload.result);
}
});
sub.on('eose', () => {
sub.unsub();
});
});
}
async listen(): Promise<Sub> {
await this.relay.connect();
await new Promise<void>((resolve, reject) => {
this.relay.on('connect', resolve);
this.relay.on('error', reject);
this.relay.on('connect', () => {
resolve();
});
this.relay.on('error', () => {
reject(`not possible to connect to ${this.relay.url}`);
});
});
let sub = this.relay.sub([
{
kinds: [4],
'#p': [this.self.pubkey],
since: now(),
since: now() - 1,
},
]);
@@ -153,12 +156,15 @@ export class NostrRPC {
if (!isValidRequest(payload)) return;
// handle request
if (!this.hasOwnProperty(payload.method)) return;
const response = await this.handleRequest(payload);
const body = prepareResponse(
response.id,
response.result,
response.error
);
const responseEvent = await prepareEvent(
this.self.secret,
event.pubkey,
@@ -166,8 +172,15 @@ export class NostrRPC {
);
// send response via relay
this.relay.publish(responseEvent);
// TODO: handle errors when event is not seen
await new Promise<void>((resolve, reject) => {
const pub = this.relay.publish(responseEvent);
pub.on('failed', (reason: any) => {
reject(reason);
});
pub.on('seen', () => {
resolve();
});
});
});
return sub;
@@ -250,7 +263,7 @@ export async function prepareEvent(
return signedEvent;
}
function isValidRequest(payload: any): boolean {
export function isValidRequest(payload: any): boolean {
if (!payload) return false;
const keys = Object.keys(payload);
@@ -264,7 +277,7 @@ function isValidRequest(payload: any): boolean {
return true;
}
function isValidResponse(payload: any): boolean {
export function isValidResponse(payload: any): boolean {
if (!payload) return false;
const keys = Object.keys(payload);

View File

@@ -1,228 +0,0 @@
import {
validateEvent,
verifySignature,
signEvent,
getEventHash,
Event,
relayInit,
nip04,
getPublicKey,
} from 'nostr-tools';
import { ConnectMessage, ConnectMessageType, PairingACK } from './connect';
import { prepareResponse } from './event';
export interface Metadata {
name: string;
url: string;
description?: string;
icons?: string[];
}
export enum SessionStatus {
PROPOSED = 'PROPOSED',
PAIRED = 'PAIRED',
UNPAIRED = 'UNPAIRED',
}
export class Session {
metadata: Metadata;
relay: string;
target: string;
remote?: string;
connectURI: string;
status: SessionStatus = SessionStatus.PROPOSED;
listeners: Record<string, { [type: string]: Array<(value: any) => any> }>;
static fromConnectURI(uri: string): Session {
const url = new URL(uri);
const target = url.searchParams.get('target');
if (!target) {
throw new Error('Invalid connect URI: missing target');
}
const relay = url.searchParams.get('relay');
if (!relay) {
throw new Error('Invalid connect URI: missing relay');
}
const metadata = url.searchParams.get('metadata');
if (!metadata) {
throw new Error('Invalid connect URI: missing metadata');
}
try {
const md = JSON.parse(metadata);
return new Session({ target: target, metadata: md, relay });
} catch (ignore) {
throw new Error('Invalid connect URI: metadata is not valid JSON');
}
}
constructor({
target,
metadata,
relay,
}: {
target: string;
relay: string;
metadata: Metadata;
}) {
this.listeners = {};
this.target = target;
this.metadata = metadata;
this.relay = relay;
this.connectURI = `nostr://connect?target=${
this.target
}&metadata=${JSON.stringify(this.metadata)}&relay=${this.relay}`;
}
on(type: ConnectMessageType, cb: (value: any) => any): void {
const id = Math.random()
.toString()
.slice(2);
this.listeners[id] = this.listeners[id] || emptyListeners();
this.listeners[id][type].push(cb);
}
off(type: ConnectMessageType, cb: (value: any) => any): void {
for (const id in this.listeners) {
const idx = this.listeners[id][type].indexOf(cb);
if (idx > -1) {
this.listeners[id][type].splice(idx, 1);
}
}
}
// this is used to process messages to the given key and emit to listeners
async listen(secretKey: string): Promise<void> {
if (!secretKey) throw new Error('secret key is required');
const pubkey = getPublicKey(secretKey);
const relay = relayInit(this.relay);
await relay.connect();
relay.on('connect', () => {
console.log(`connected to ${relay.url}`);
});
relay.on('error', () => {
console.error(`failed to connect to ${relay.url}`);
});
let sub = relay.sub([
{
kinds: [4],
'#p': [pubkey],
},
]);
sub.on('event', async (event: Event) => {
const plaintext = await nip04.decrypt(
secretKey,
event.pubkey,
event.content
);
const payload = JSON.parse(plaintext);
if (!payload) return;
if (
!Object.keys(payload).includes('requestID') ||
!Object.keys(payload).includes('message')
)
return;
const msg = payload.message as ConnectMessage;
switch (msg.type) {
case ConnectMessageType.PAIRED: {
if (this.status === SessionStatus.PAIRED) return;
if (!msg.value) return;
if (!Object.keys(msg.value).includes('pubkey')) return;
this.status = SessionStatus.PAIRED;
const remote = msg.value.pubkey;
this.remote = remote;
this.emit(ConnectMessageType.PAIRED, msg);
break;
}
case ConnectMessageType.UNPAIRED: {
if (this.status !== SessionStatus.PAIRED) return;
this.status = SessionStatus.UNPAIRED;
this.emit(ConnectMessageType.UNPAIRED);
break;
}
case ConnectMessageType.GET_PUBLIC_KEY_REQUEST: {
if (this.status !== SessionStatus.PAIRED) return;
this.emit(ConnectMessageType.GET_PUBLIC_KEY_REQUEST, msg);
break;
}
case ConnectMessageType.GET_PUBLIC_KEY_RESPONSE: {
this.emit(ConnectMessageType.GET_PUBLIC_KEY_RESPONSE, msg);
break;
}
}
});
sub.on('eose', () => {
sub.unsub();
});
}
emit(type: ConnectMessageType, value?: any): void {
Object.values(this.listeners).forEach(listeners => {
if (listeners[type]) {
listeners[type].forEach(cb => cb(value));
}
});
}
async pair(remoteSignerPrivateKey: string): Promise<void> {
if (!remoteSignerPrivateKey)
throw new Error('Signer private key is required');
const remoteSignerPubKey = getPublicKey(remoteSignerPrivateKey);
this.remote = remoteSignerPubKey;
const message: PairingACK = {
type: ConnectMessageType.PAIRED,
value: { pubkey: this.remote },
};
const randomID = Math.random()
.toString()
.slice(2);
const { event } = await prepareResponse(
randomID,
this.remote,
this.target,
message,
remoteSignerPrivateKey
);
const id = await this.sendEvent(event, remoteSignerPrivateKey);
console.log('sent pairing response from mobile', id);
}
async sendEvent(event: Event, secretKey: string): Promise<string> {
const id = getEventHash(event);
const sig = signEvent(event, secretKey);
const signedEvent = { ...event, id, sig };
let ok = validateEvent(signedEvent);
let veryOk = verifySignature(signedEvent);
if (!ok || !veryOk) {
throw new Error('Event is not valid');
}
const relay = relayInit(this.relay);
await relay.connect();
relay.on('error', () => {
throw new Error(`failed to connect to ${relay.url}`);
});
return new Promise((resolve, reject) => {
const pub = relay.publish(signedEvent);
pub.on('failed', (reason: any) => reject(reason));
pub.on('seen', () => resolve(id));
});
}
}
function emptyListeners(): {} {
let data: any = {};
Object.values(ConnectMessageType).forEach(type => {
data[type] = [];
});
return data;
}

View File

@@ -1,26 +1,42 @@
import { getPublicKey } from 'nostr-tools';
import {
Connect,
ConnectMessageType,
GetPublicKeyResponse,
Session,
} from '../src/index';
import { Connect, ConnectURI, NostrRPC } from '../src';
import { sleep } from './utils';
jest.setTimeout(5000);
jest.setTimeout(8000);
describe('Nostr Connect', () => {
it('connect', async () => {
let resolvePaired: (arg0: boolean) => void;
let resolveGetPublicKey: (arg0: boolean) => void;
// web app (this is ephemeral and represents the currention session)
const webSK =
'5acff99d1ad3e1706360d213fd69203312d9b5e91a2d5f2e06100cc6f686e5b3';
const webPK = getPublicKey(webSK);
console.log('webPk', webPK);
const sessionWeb = new Session({
// mobile app with keys with the nostr identity
const mobileSK =
'ed779ff047f99c95f732b22c9f8f842afb870c740aab591776ebc7b64e83cf6c';
const mobilePK = getPublicKey(mobileSK);
console.log('mobilePK', mobilePK);
class MobileHandler extends NostrRPC {
async get_public_key(): Promise<string> {
return getPublicKey(this.self.secret);
}
}
describe('Nostr Connect', () => {
it('connect', async () => {
const testHandler = jest.fn();
// start listening for connect messages on the web app
const connect = new Connect({ secretKey: webSK });
connect.events.on('connect', testHandler);
await connect.init();
await sleep(100);
// send the connect message to the web app from the mobile
const connectURI = new ConnectURI({
target: webPK,
relay: 'wss://nostr.vulpem.com',
relayURL: 'wss://nostr.vulpem.com',
metadata: {
name: 'My Website',
description: 'lorem ipsum dolor sit amet',
@@ -28,58 +44,32 @@ describe('Nostr Connect', () => {
icons: ['https://vulpem.com/1000x860-p-500.422be1bc.png'],
},
});
sessionWeb.on(ConnectMessageType.PAIRED, (msg: any) => {
expect(msg).toBeDefined();
resolvePaired(true);
await connectURI.approve(mobileSK);
expect(testHandler).toBeCalledTimes(1);
});
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);
await sessionMobile.pair(mobileSK);
it('returns pubkey', async () => {
// start listening for connect messages on the mobile app
const remoteHandler = new MobileHandler({ secretKey: mobileSK });
await remoteHandler.listen();
// we define the behavior of the mobile app for each requests
sessionMobile.on(ConnectMessageType.GET_PUBLIC_KEY_REQUEST, async () => {
const message: GetPublicKeyResponse = {
type: ConnectMessageType.GET_PUBLIC_KEY_RESPONSE,
value: {
pubkey: mobilePK,
},
};
const event = await sessionMobile.eventToBeSentToTarget(
message,
mobileSK
);
await sessionMobile.sendEvent(event, mobileSK);
resolveGetPublicKey(true);
});
await sessionMobile.listen(mobileSK);
await sleep(1000);
// The WebApp send the request and wait for the response
// The WebApp fetch the public key sending request via session
// start listening for connect messages on the web app
const connect = new Connect({
session: sessionWeb,
targetPrivateKey: webSK,
secretKey: webSK,
target: mobilePK,
});
const response = await connect.sendMessage({
type: ConnectMessageType.GET_PUBLIC_KEY_REQUEST,
});
expect(response).toBeDefined();
await connect.init();
return expect(
Promise.all([
new Promise(resolve => {
resolvePaired = resolve;
}),
new Promise(resolve => {
resolveGetPublicKey = resolve;
}),
])
).resolves.toEqual([true, true]);
await sleep(1000);
// send the get_public_key message to the mobile app from the web
const pubkey = await connect.getPublicKey();
expect(pubkey).toBe(webPK);
});
});
/*
expect(handler).toBeCalledTimes(1);
@@ -114,5 +104,3 @@ describe('Nostr Connect', () => {
});
*/
});
});

View File

@@ -1,4 +1,5 @@
import { NostrRPC } from '../src/request';
import { NostrRPC } from '../src/rpc';
import { sleep } from './utils';
class Server extends NostrRPC {
async ping(): Promise<string> {
@@ -6,7 +7,7 @@ class Server extends NostrRPC {
}
}
jest.setTimeout(10000);
jest.setTimeout(5000);
describe('Nostr RPC', () => {
it('starts a server', async () => {
@@ -20,7 +21,6 @@ describe('Nostr RPC', () => {
secretKey:
'5acff99d1ad3e1706360d213fd69203312d9b5e91a2d5f2e06100cc6f686e5b3',
});
console.log(`from: ` + client.self.pubkey, `to: ` + server.self.pubkey);
await sleep(2000);
@@ -28,10 +28,6 @@ describe('Nostr RPC', () => {
target: server.self.pubkey,
request: { method: 'ping' },
});
console.log(result);
expect(result).toBe('pong');
});
});
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}

3
test/utils.ts Normal file
View File

@@ -0,0 +1,3 @@
export function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}