import crypto = require('libp2p-crypto'); import util = require('util'); import RsaPrivateKey = crypto.keys; import { Storage } from './storage'; import { IWebClient } from './webclient'; const TextEncoder = util.TextEncoder; import WebSocket from 'ws'; import { UploadItemParameters } from './upload-item-parameters'; import { encodeHex, mergeDeep, uuid } from './util'; import WebClientNode from './webclient-node'; import { reject, resolve } from 'bluebird'; import { ContentParams } from './content-params'; import { ContentItem } from './content-item'; export class BankClient { public static parseBankLink(bankLink: string) { if (!bankLink.startsWith('bank:')) { throw new Error('address must start with bank:'); } const deprefixed = bankLink.substring(5); let host: string | undefined; let address: string; let topic: string; if (deprefixed[0] === '/' && deprefixed[1] === '/') { [ host, address, topic ] = deprefixed.substring(2).split('/'); } else { [ address, topic ] = deprefixed.split('/'); } if (!address || !topic) { throw new Error('cannot parse address and topic'); } return { host, address, topic }; } private privateKey: RsaPrivateKey | undefined; private wsUrlBase: string; private bootstrapPromise: any; private bootstrapResult: any; constructor(private urlBase: string, private ipfsUrlBase: string, private storage: Storage = new Storage('bankClient'), private webClient: IWebClient = new WebClientNode()) { this.wsUrlBase = urlBase.replace(/^http/i, 'ws'); } public getPub(): Promise { return new Promise(async (resolve, reject) => { await this.bootstrap(); this.getPriv().id((idErr, pubHash) => { if (idErr) { return reject(idErr); } resolve(pubHash); }); }); } public bootstrap() { if (this.bootstrapResult) { return Promise.resolve(this.bootstrapResult); } if (this.bootstrapPromise) { return this.bootstrapPromise; } return this.bootstrapPromise = new Promise((resolve, reject) => { this.storage.get('notaprivatekey').then(privateKeyFromStorage => { if (privateKeyFromStorage == null) { console.log('no private key in storage. generating new'); crypto.keys.generateKeyPair('RSA', 2048, (generateErr, privateKey) => { if (generateErr) { return reject(generateErr); } privateKey.export('password', (exportErr, exportResult) => { if (exportErr) { return reject(exportErr); } this.storage.set('notaprivatekey', exportResult).then(err => { // whatever }).catch(reject); this.privateKey = privateKey; resolve(true); }); }); } else { // console.log('importing privatekey'); crypto.keys.import(privateKeyFromStorage, 'password', (err, importedPrivateKey) => { if (err) { return reject(err); } this.privateKey = importedPrivateKey; // console.log(this.getPublicKeyString()); // console.log(privateKeyFromStorage); resolve(true); }); } }).catch(reject); }); } public async getNonce() { const nonce = await this.webClient.request({ method: 'GET', url: this.urlBase + '/bank/nonce' }); return Number(nonce); } public async getBalance(): Promise { const nonce = await this.getNonce(); const retrieveRequest = await this.makePlaintextPayload(JSON.stringify({ _date: new Date().toISOString(), _nonce: nonce })); const topicURL = this.urlBase + '/bank/getbalance'; const postResponse:any = await this.webClient.requestJSON({ body: retrieveRequest, method: 'POST', url: topicURL }); return postResponse.balance; } public async upload(params: UploadItemParameters) { const url = this.urlBase + '/bank/upload'; const formData: any = {}; if (params.fileData) { formData.file = { value: params.fileData, options: { filename: params.fileName } }; } if (params.thumbFileData) { formData.thumb = { value: params.thumbFileData, options: { filename: params.thumbFileName } }; } if (params.links) { formData.links = JSON.stringify(params.links); } for (const attr of ['title', 'text', 'type']) { if (params[attr] != null) { formData[attr] = params[attr]; } } // console.log('formData', formData); const uploadResponse: any = await this.webClient.requestJSON({ formData, method: 'POST', url }); // console.log('uploadResponse', uploadResponse); return uploadResponse.hash; } public async uploadSlimJSON(item: any) { const url = this.urlBase + '/bank/upload/slim'; const uploadResponse: any = await this.webClient.requestJSON({ body: item, method: 'POST', url }); // console.log('uploadResponse', uploadResponse); return uploadResponse.hash; } public async uploadSlimText(item: string) { const url = this.urlBase + '/bank/upload/slim'; const uploadResponse: any = JSON.parse(await this.webClient.request({ body: item, headers: { 'content-type': 'text/plain' }, method: 'POST', url })); // console.log('uploadResponse', uploadResponse); return uploadResponse.hash; } public async appendBank(bankAddress: string, bankTopic: string, itemHash: string): Promise { const payload = await this.makePlaintextPayload(itemHash); const topicURL = this.urlBase + '/bank/private/' + encodeURIComponent(bankAddress) + '/' + encodeURIComponent(bankTopic); await this.webClient.requestJSON({ body: payload, method: 'PUT', url: topicURL }); } public async retrievePrivate(peerAddr: string, topic: string) { const nonce = await this.getNonce(); const retrieveRequest = await this.makePlaintextPayload(JSON.stringify({ _date: new Date().toISOString(), _nonce: nonce })); const topicURL = this.urlBase + '/bank/private/' + encodeURIComponent(peerAddr) + '/' + encodeURIComponent(topic); const result = await this.webClient.request({ body: JSON.stringify(retrieveRequest), headers: { 'content-type': 'application/json' }, method: 'POST', url: topicURL }); return result; } public subscribePrivate(peerAddr: string, topic: string, callback: () => void) { return new Promise(async (resolve, reject) => { const nonce = await this.getNonce(); const retrieveRequest: any = await this.makePlaintextPayload(JSON.stringify({ _date: new Date().toISOString(), _nonce: nonce, addr: peerAddr, topic: topic })); const ws = new WebSocket(this.wsUrlBase + '/bank/ws', { headers: { 'x-msg': retrieveRequest.msg, 'x-pub': retrieveRequest.pub, 'x-pubhash': retrieveRequest.pubHash, 'x-sig': retrieveRequest.sig } }); ws.on('open', function open() { console.log('opened ws'); resolve(); }); ws.on('message', data => { // console.log('ws message', data); callback(); }); ws.on('error', err => { reject(err); }); }); } public async appendPrivate(peerAddr: string, topic: string, hash: string, replaceHash?: string) { const nonce = await this.getNonce(); const payload = await this.makePlaintextPayload(JSON.stringify({ _date: new Date().toISOString(), _nonce: nonce, hash, replaceHash })); const topicURL = this.urlBase + '/bank/private/' + encodeURIComponent(peerAddr) + '/' + encodeURIComponent(topic); const result = await this.webClient.request({ body: JSON.stringify(payload), headers: { 'content-type': 'application/json' }, method: 'PUT', url: topicURL }); } public async getOrCreateContact(peerId: string, contactAddr: string) { const contactList = await this.retrievePrivate(peerId, '📇'); const itemList = await this.getItemsForCommaList(contactList); // console.log('contact hash for', contact, type, 'is', contactHash); const existing = itemList.filter(item => item.addrs && item.addrs.includes(contactAddr))[0]; if (existing != null) { return existing; } const newItem = { addrs: [ contactAddr ], id: uuid() }; const newItemHash = await this.uploadSlimJSON(newItem); await this.appendPrivate(peerId, '📇', newItemHash); return newItem; } public async getContactById(peerId: string, contactId: string) { const contactList = await this.retrievePrivate(peerId, '📇'); const itemList = await this.getItemsForCommaList(contactList); const existing = itemList.filter(item => item.id === contactId)[0]; if (!existing) { throw new Error('Cannot find contact with id ' + contactId); } return existing; } public async updateContact(peerId: string, contactId: string, newProperties: any) { const existing = await this.getContactById(peerId, contactId); const newProps: any = mergeDeep({}, newProperties); delete newProps.id; const newItem: any = mergeDeep(existing, newProps); delete newItem.hash; const newItemHash = await this.uploadSlimJSON(newItem); await this.appendPrivate(peerId, '📇', newItemHash, existing.hash); return await this.getContactById(peerId, contactId); } public async getContentItemByHash(hash: string): Promise { if (!hash.startsWith('/ipfs/')) { hash = '/ipfs/' + hash; } const contentParams = (await this.webClient.requestJSON({ method: 'get', url: this.ipfsUrlBase + hash + '/content.json' })) as ContentParams; return new ContentItem(contentParams); } private async getItemsForCommaList(commaList: string): Promise { const itemHashes = commaList.split(',').filter(x => x.trim() !== ''); const items: any[] = await Promise.all(itemHashes.map(itemId => { return this.webClient.requestJSON({ method: 'get', url: this.ipfsUrlBase + '/ipfs/' + itemId, }); })); for (const item of items) { item.hash = itemHashes.shift(); } return items; } private getPriv(): RsaPrivateKey { if (!this.privateKey) { throw new Error('missing private key'); } return this.privateKey; } private makePlaintextPayload(message: string) { const messageBytes = new TextEncoder().encode(message); return new Promise(async (resolve, reject) => { await this.bootstrap(); this.privateKey.sign(messageBytes, async (signErr, signatureBytes) => { if (signErr) { reject(signErr); return; } const publicDERBytes = this.privateKey.public.bytes; this.privateKey.id((idErr, pubHash) => { if (idErr) { reject(idErr); return; } const result = { date: new Date().toISOString(), msg: encodeHex(messageBytes), pub: encodeHex(publicDERBytes), pubHash, sig: encodeHex(signatureBytes), }; // console.log('result', result, signatureBytes); resolve(result); }); }); }); } }