index.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  1. import crypto = require('libp2p-crypto');
  2. import util = require('util');
  3. import RsaPrivateKey = crypto.keys;
  4. import { Storage } from './storage';
  5. import { IWebClient } from './webclient';
  6. const TextEncoder = util.TextEncoder;
  7. import WebSocket from 'ws';
  8. import { ContentItem } from './content-item';
  9. import { ContentParams } from './content-params';
  10. import { UploadItemParameters } from './upload-item-parameters';
  11. import { encodeHex, mergeDeep, uuid } from './util';
  12. import WebClientNode from './webclient-node';
  13. export class BankClient {
  14. public static parseBankLink(bankLink: string) {
  15. if (!bankLink.startsWith('bank:')) {
  16. throw new Error('address must start with bank:');
  17. }
  18. const deprefixed = bankLink.substring(5);
  19. let host: string | undefined;
  20. let address: string;
  21. let topic: string;
  22. if (deprefixed[0] === '/' && deprefixed[1] === '/') {
  23. [ host, address, topic ] = deprefixed.substring(2).split('/');
  24. } else {
  25. [ address, topic ] = deprefixed.split('/');
  26. }
  27. if (!address || !topic) {
  28. throw new Error('cannot parse address and topic');
  29. }
  30. return { host, address, topic };
  31. }
  32. private privateKey: RsaPrivateKey | undefined;
  33. private wsUrlBase: string;
  34. private bootstrapPromise: any;
  35. private bootstrapResult: any;
  36. constructor(private urlBase: string, private ipfsUrlBase: string, private storage: Storage = new Storage('bankClient'), private webClient: IWebClient = new WebClientNode()) {
  37. this.wsUrlBase = urlBase.replace(/^http/i, 'ws');
  38. }
  39. public getPub(): Promise<string> {
  40. return new Promise(async (resolve, reject) => {
  41. await this.bootstrap();
  42. this.getPriv().id((idErr, pubHash) => {
  43. if (idErr) {
  44. return reject(idErr);
  45. }
  46. resolve(pubHash);
  47. });
  48. });
  49. }
  50. public bootstrap() {
  51. if (this.bootstrapResult) {
  52. return Promise.resolve(this.bootstrapResult);
  53. }
  54. if (this.bootstrapPromise) {
  55. return this.bootstrapPromise;
  56. }
  57. return this.bootstrapPromise = new Promise((resolve, reject) => {
  58. this.storage.get('notaprivatekey').then(privateKeyFromStorage => {
  59. if (privateKeyFromStorage == null) {
  60. console.log('no private key in storage. generating new');
  61. crypto.keys.generateKeyPair('RSA', 2048, (generateErr, privateKey) => {
  62. if (generateErr) {
  63. return reject(generateErr);
  64. }
  65. privateKey.export('password', (exportErr, exportResult) => {
  66. if (exportErr) {
  67. return reject(exportErr);
  68. }
  69. this.storage.set('notaprivatekey', exportResult).then(err => {
  70. // whatever
  71. }).catch(reject);
  72. this.privateKey = privateKey;
  73. resolve(true);
  74. });
  75. });
  76. } else {
  77. // console.log('importing privatekey');
  78. crypto.keys.import(privateKeyFromStorage, 'password', (err, importedPrivateKey) => {
  79. if (err) {
  80. return reject(err);
  81. }
  82. this.privateKey = importedPrivateKey;
  83. // console.log(this.getPublicKeyString());
  84. // console.log(privateKeyFromStorage);
  85. resolve(true);
  86. });
  87. }
  88. }).catch(reject);
  89. });
  90. }
  91. public async getNonce() {
  92. const nonce = await this.webClient.request({
  93. method: 'GET',
  94. url: this.urlBase + '/bank/nonce'
  95. });
  96. return Number(nonce);
  97. }
  98. public async getBalance(): Promise<number> {
  99. const nonce = await this.getNonce();
  100. const retrieveRequest = await this.makePlaintextPayload(JSON.stringify({
  101. _date: new Date().toISOString(),
  102. _nonce: nonce
  103. }));
  104. const topicURL = this.urlBase + '/bank/getbalance';
  105. const postResponse:any = await this.webClient.requestJSON({
  106. body: retrieveRequest,
  107. method: 'POST',
  108. url: topicURL
  109. });
  110. return postResponse.balance;
  111. }
  112. public async upload(params: UploadItemParameters) {
  113. const url = this.urlBase + '/bank/upload';
  114. const formData: any = {};
  115. if (params.fileData) {
  116. formData.file = {
  117. value: params.fileData,
  118. options: {
  119. filename: params.fileName
  120. }
  121. };
  122. }
  123. if (params.thumbFileData) {
  124. formData.thumb = {
  125. value: params.thumbFileData,
  126. options: {
  127. filename: params.thumbFileName
  128. }
  129. };
  130. }
  131. if (params.links) {
  132. formData.links = JSON.stringify(params.links);
  133. }
  134. for (const attr of ['title', 'text', 'type']) {
  135. if (params[attr] != null) {
  136. formData[attr] = params[attr];
  137. }
  138. }
  139. // console.log('formData', formData);
  140. const uploadResponse: any = await this.webClient.requestJSON({
  141. formData,
  142. method: 'POST',
  143. url
  144. });
  145. // console.log('uploadResponse', uploadResponse);
  146. return uploadResponse.hash;
  147. }
  148. public async uploadSlimJSON(item: any) {
  149. const url = this.urlBase + '/bank/upload/slim';
  150. const uploadResponse: any = await this.webClient.requestJSON({
  151. body: item,
  152. method: 'POST',
  153. url
  154. });
  155. // console.log('uploadResponse', uploadResponse);
  156. return uploadResponse.hash;
  157. }
  158. public async uploadSlimText(item: string) {
  159. const url = this.urlBase + '/bank/upload/slim';
  160. const uploadResponse: any = JSON.parse(await this.webClient.request({
  161. body: item,
  162. headers: {
  163. 'content-type': 'text/plain'
  164. },
  165. method: 'POST',
  166. url
  167. }));
  168. // console.log('uploadResponse', uploadResponse);
  169. return uploadResponse.hash;
  170. }
  171. public async appendBank(bankAddress: string, bankTopic: string, itemHash: string): Promise<void> {
  172. const payload = await this.makePlaintextPayload(itemHash);
  173. const topicURL = this.urlBase + '/bank/private/' + encodeURIComponent(bankAddress) + '/' + encodeURIComponent(bankTopic);
  174. await this.webClient.requestJSON({
  175. body: payload,
  176. method: 'PUT',
  177. url: topicURL
  178. });
  179. }
  180. public async retrievePrivate(peerAddr: string, topic: string) {
  181. const nonce = await this.getNonce();
  182. const retrieveRequest = await this.makePlaintextPayload(JSON.stringify({
  183. _date: new Date().toISOString(),
  184. _nonce: nonce
  185. }));
  186. const topicURL = this.urlBase + '/bank/private/' + encodeURIComponent(peerAddr) + '/' + encodeURIComponent(topic);
  187. const result = await this.webClient.request({
  188. body: JSON.stringify(retrieveRequest),
  189. headers: {
  190. 'content-type': 'application/json'
  191. },
  192. method: 'POST',
  193. url: topicURL
  194. });
  195. return result;
  196. }
  197. public async subscribePrivate(peerAddr: string, topic: string, connectCallback: () => void, messageCallback: (data: any) => void) {
  198. await this.connectWebsocket(peerAddr, topic, connectCallback, messageCallback);
  199. }
  200. public async appendPrivate(peerAddr: string, topic: string, hash?: string, replaceHash?: string, deleteHash?: string) {
  201. const nonce = await this.getNonce();
  202. const payload = await this.makePlaintextPayload(JSON.stringify({
  203. _date: new Date().toISOString(),
  204. _nonce: nonce,
  205. deleteHash,
  206. hash,
  207. replaceHash,
  208. }));
  209. const topicURL = this.urlBase + '/bank/private/' + encodeURIComponent(peerAddr) + '/' + encodeURIComponent(topic);
  210. const result = await this.webClient.request({
  211. body: JSON.stringify(payload),
  212. headers: {
  213. 'content-type': 'application/json'
  214. },
  215. method: 'PUT',
  216. url: topicURL
  217. });
  218. }
  219. public async getOrCreateContact(peerId: string, contactAddr: string) {
  220. const contactList = await this.retrievePrivate(peerId, '📇');
  221. const itemList = await this.getItemsForCommaList(contactList);
  222. // console.log('contact hash for', contact, type, 'is', contactHash);
  223. const existing = itemList.filter(item => item.addrs && item.addrs.includes(contactAddr))[0];
  224. if (existing != null) {
  225. return existing;
  226. }
  227. const newItem = {
  228. addrs: [
  229. contactAddr
  230. ],
  231. id: uuid()
  232. };
  233. const newItemHash = await this.uploadSlimJSON(newItem);
  234. await this.appendPrivate(peerId, '📇', newItemHash);
  235. return newItem;
  236. }
  237. public async getContactById(peerId: string, contactId: string) {
  238. const contactList = await this.retrievePrivate(peerId, '📇');
  239. const itemList = await this.getItemsForCommaList(contactList);
  240. const existing = itemList.filter(item => item.id === contactId)[0];
  241. if (!existing) {
  242. throw new Error('Cannot find contact with id ' + contactId);
  243. }
  244. return existing;
  245. }
  246. public async updateContact(peerId: string, contactId: string, newProperties: any) {
  247. const existing = await this.getContactById(peerId, contactId);
  248. const newProps: any = mergeDeep({}, newProperties);
  249. delete newProps.id;
  250. const newItem: any = mergeDeep(existing, newProps);
  251. delete newItem.hash;
  252. const newItemHash = await this.uploadSlimJSON(newItem);
  253. await this.appendPrivate(peerId, '📇', newItemHash, existing.hash);
  254. return await this.getContactById(peerId, contactId);
  255. }
  256. public async getContentItemByHash(hash: string): Promise<ContentItem> {
  257. if (hash.startsWith('/ipfs/')) {
  258. hash = hash.split('/').pop() as string;
  259. }
  260. const contentParams = (await this.webClient.requestJSON({
  261. method: 'get',
  262. url: this.ipfsUrlBase + '/ipfs/' + hash + '/content.json'
  263. })) as ContentParams;
  264. return new ContentItem(hash, contentParams);
  265. }
  266. private connectWebsocket(peerAddr: string, topic: string, connectCallback: () => void, messageCallback: (data: any) => void) {
  267. return new Promise(async (resolve, reject) => {
  268. console.log('trying to connect');
  269. const nonce = await this.getNonce();
  270. const retrieveRequest: any = await this.makePlaintextPayload(JSON.stringify({
  271. _date: new Date().toISOString(),
  272. _nonce: nonce,
  273. addr: peerAddr,
  274. topic: topic
  275. }));
  276. const jsonOutput = JSON.stringify(retrieveRequest);
  277. const base64ed = Buffer.from(jsonOutput).toString('base64');
  278. const encoded = encodeURIComponent(base64ed);
  279. console.log('encoded', encoded);
  280. const ws = new WebSocket(this.wsUrlBase + '/bank/ws?arg=' + encoded);
  281. ws.on('open', () => {
  282. connectCallback();
  283. });
  284. ws.on('message', data => {
  285. messageCallback(data);
  286. });
  287. const reconnect = () => {
  288. console.log('reconnect');
  289. try {
  290. ws.terminate();
  291. } finally {
  292. console.log('reconnecting in 5s');
  293. setTimeout(() => {
  294. this.connectWebsocket(peerAddr, topic, connectCallback, messageCallback);
  295. }, 5000);
  296. }
  297. };
  298. ws.on('error', err => {
  299. console.error('websocket error', err);
  300. });
  301. ws.on('close', err => {
  302. reconnect();
  303. });
  304. resolve();
  305. });
  306. }
  307. private async getItemsForCommaList(commaList: string): Promise<any[]> {
  308. const itemHashes = commaList.split(',').filter(x => x.trim() !== '');
  309. const items: any[] = await Promise.all(itemHashes.map(itemId => {
  310. return this.webClient.requestJSON({
  311. method: 'get',
  312. url: this.ipfsUrlBase + '/ipfs/' + itemId,
  313. });
  314. }));
  315. for (const item of items) {
  316. item.hash = itemHashes.shift();
  317. }
  318. return items;
  319. }
  320. private getPriv(): RsaPrivateKey {
  321. if (!this.privateKey) {
  322. throw new Error('missing private key');
  323. }
  324. return this.privateKey;
  325. }
  326. private makePlaintextPayload(message: string) {
  327. const messageBytes = new TextEncoder().encode(message);
  328. return new Promise(async (resolve, reject) => {
  329. await this.bootstrap();
  330. this.privateKey.sign(messageBytes, async (signErr, signatureBytes) => {
  331. if (signErr) {
  332. reject(signErr);
  333. return;
  334. }
  335. const publicDERBytes = this.privateKey.public.bytes;
  336. this.privateKey.id((idErr, pubHash) => {
  337. if (idErr) {
  338. reject(idErr);
  339. return;
  340. }
  341. const result = {
  342. date: new Date().toISOString(),
  343. msg: encodeHex(messageBytes),
  344. pub: encodeHex(publicDERBytes),
  345. pubHash,
  346. sig: encodeHex(signatureBytes),
  347. };
  348. // console.log('result', result, signatureBytes);
  349. resolve(result);
  350. });
  351. });
  352. });
  353. }
  354. }