index.ts 12 KB

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