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

@@ -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;
export interface PairingNACK extends ConnectMessage {
type: ConnectMessageType.UNPAIRED;
}
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 GetPublicKeyRequest extends ConnectMessage {
type: ConnectMessageType.GET_PUBLIC_KEY_REQUEST;
}
export interface GetPublicKeyResponse extends ConnectMessage {
type: ConnectMessageType.GET_PUBLIC_KEY_RESPONSE;
value: {
pubkey: string;
};
}
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');
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');
}
}
constructor({
target,
metadata,
relayURL,
}: {
target: string;
metadata: Metadata;
relayURL: string;
}) {
this.target = target;
this.metadata = metadata;
this.relayURL = relayURL;
}
toString() {
return `nostr://connect?target=${this.target}&metadata=${JSON.stringify(
this.metadata
)}&relay=${this.relayURL}`;
}
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,
},
]);
sub.on('event', async (event: Event) => {
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')
)
return;
if (payload.requestID !== requestID) return;
const msg = payload.message as ConnectMessage;
const responseType = responseTypeForRequestType(msg.type);
if (msg.type !== responseType) return;
resolve(msg);
});
if (!plaintext) throw new Error('failed to decrypt event');
payload = JSON.parse(plaintext);
} catch (ignore) {
return;
}
// 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) => {
const pub = this.relay.publish(event);
pub.on('failed', (reason: any) => {
reject(reason);
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();
});
});
pub.on('seen', () => {
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;
}