ciphermesh 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,7 @@ export class Connection extends EventEmitter {
11
11
  #connected;
12
12
  #pinStore;
13
13
  #host;
14
+ #caValidated = false;
14
15
 
15
16
  constructor(url) {
16
17
  super();
@@ -28,6 +29,12 @@ export class Connection extends EventEmitter {
28
29
 
29
30
  // Trust-on-first-use pin of the server TLS certificate. Emits 'cert-pinned'
30
31
  // on first sight and 'cert-mismatch' if it later changes (possible MITM).
32
+ //
33
+ // A publicly hosted relay presents a cert signed by a real CA (Let's Encrypt
34
+ // et al). Node still runs the full chain + hostname check even with
35
+ // rejectUnauthorized:false, so `socket.authorized` tells us which world we
36
+ // are in: CA-valid needs no TOFU window at all (strictly stronger), while a
37
+ // self-signed LAN cert keeps the pin-on-first-use behaviour.
31
38
  #checkCertPin() {
32
39
  if (!this.#url.startsWith('wss://')) {
33
40
  return;
@@ -35,6 +42,18 @@ export class Connection extends EventEmitter {
35
42
  const socket = this.#ws?._socket;
36
43
  const cert = socket?.getPeerCertificate?.();
37
44
  const fingerprint = cert?.fingerprint256 || null;
45
+
46
+ if (socket?.authorized === true) {
47
+ this.#caValidated = true;
48
+ // Remember it: from now on this host is verified strictly, and a
49
+ // legitimate CA cert renewal no longer trips the TOFU alarm.
50
+ this.#pinStore.markCAValidated(this.#host);
51
+ this.#pinStore.repin(this.#host, fingerprint);
52
+ this.emit('cert-ca-valid', { host: this.#host, issuer: cert?.issuer?.O || 'CA' });
53
+ return;
54
+ }
55
+
56
+ this.#caValidated = false;
38
57
  const result = this.#pinStore.check(this.#host, fingerprint);
39
58
  if (result === PinResult.PINNED) {
40
59
  this.emit('cert-pinned', { host: this.#host, fingerprint });
@@ -53,7 +72,13 @@ export class Connection extends EventEmitter {
53
72
  }
54
73
 
55
74
  #createSocket() {
56
- const opts = this.#url.startsWith('wss://') ? { rejectUnauthorized: false } : {};
75
+ // Hosts that already proved they have a CA-signed certificate are verified
76
+ // strictly from then on — an attacker cannot downgrade a hosted relay to a
77
+ // self-signed cert. Everything else (LAN/Tailscale self-signed) connects
78
+ // with verification relaxed and is protected by TOFU pinning instead.
79
+ const opts = this.#url.startsWith('wss://')
80
+ ? { rejectUnauthorized: this.#pinStore.requiresStrictTLS(this.#host) }
81
+ : {};
57
82
  this.#ws = new WebSocket(this.#url, opts);
58
83
 
59
84
  this.#ws.on('open', () => {
@@ -104,6 +129,11 @@ export class Connection extends EventEmitter {
104
129
  }, this.#reconnectDelay);
105
130
  }
106
131
 
132
+ /** True when the server's TLS cert validated against a public CA. */
133
+ get isCAValidated() {
134
+ return this.#caValidated;
135
+ }
136
+
107
137
  send(msg) {
108
138
  if (this.#connected && this.#ws.readyState === WebSocket.OPEN) {
109
139
  this.#ws.send(JSON.stringify(msg));
@@ -71,7 +71,38 @@ export class FileTransfer {
71
71
  // Compute SHA-256
72
72
  const sha256 = await this.#computeSHA256(absPath);
73
73
 
74
- // Send file_offer
74
+ // Register the transfer BEFORE announcing it. The peer can accept the
75
+ // instant the offer lands — on a fast LAN that easily beats reading a
76
+ // large file off disk — and an accept that finds no transfer would be
77
+ // dropped silently, killing the transfer at the accept timeout.
78
+ let resolveP;
79
+ const done = new Promise((r) => {
80
+ resolveP = r;
81
+ });
82
+
83
+ const acceptTimer = setTimeout(() => {
84
+ this.#outgoing.delete(transferId);
85
+ callbacks.onError(`${fileName}: offer was not accepted in time`);
86
+ resolveP();
87
+ }, this.#acceptTimeoutMs);
88
+ if (acceptTimer.unref) {
89
+ acceptTimer.unref();
90
+ }
91
+
92
+ this.#outgoing.set(transferId, {
93
+ interval: null,
94
+ resolve: resolveP,
95
+ chunks: null, // filled in once the file is read
96
+ skip: new Set(),
97
+ pending: true,
98
+ acceptedEarly: false, // accepted while we were still reading
99
+ acceptTimer,
100
+ broadcastFn,
101
+ callbacks,
102
+ fileName,
103
+ totalChunks,
104
+ });
105
+
75
106
  broadcastFn({
76
107
  action: 'file_offer',
77
108
  transferId,
@@ -87,29 +118,19 @@ export class FileTransfer {
87
118
  // (so files are never pushed without consent).
88
119
  const chunks = await this.#readChunks(absPath);
89
120
 
90
- return new Promise((resolveP) => {
91
- const acceptTimer = setTimeout(() => {
92
- this.#outgoing.delete(transferId);
93
- callbacks.onError(`${fileName}: offer was not accepted in time`);
94
- resolveP();
95
- }, this.#acceptTimeoutMs);
96
- if (acceptTimer.unref) {
97
- acceptTimer.unref();
98
- }
121
+ const transfer = this.#outgoing.get(transferId);
122
+ if (!transfer) {
123
+ return done; // rejected or timed out while we were reading
124
+ }
125
+ transfer.chunks = chunks;
99
126
 
100
- this.#outgoing.set(transferId, {
101
- interval: null,
102
- resolve: resolveP,
103
- chunks,
104
- skip: new Set(),
105
- pending: true,
106
- acceptTimer,
107
- broadcastFn,
108
- callbacks,
109
- fileName,
110
- totalChunks,
111
- });
112
- });
127
+ // The peer already said yes — start now that the bytes are ready.
128
+ if (transfer.acceptedEarly) {
129
+ transfer.pending = false;
130
+ this.#beginStreaming(transferId);
131
+ }
132
+
133
+ return done;
113
134
  }
114
135
 
115
136
  /** Receiver accepted the offer — start streaming (honouring resume `have`). */
@@ -118,7 +139,6 @@ export class FileTransfer {
118
139
  if (!transfer || !transfer.pending) {
119
140
  return;
120
141
  }
121
- transfer.pending = false;
122
142
  clearTimeout(transfer.acceptTimer);
123
143
  if (Array.isArray(data.have)) {
124
144
  for (const i of data.have) {
@@ -127,6 +147,15 @@ export class FileTransfer {
127
147
  }
128
148
  }
129
149
  }
150
+
151
+ // Accepted before the file finished being read: remember the consent and
152
+ // let initSend start streaming as soon as the chunks are ready.
153
+ if (!transfer.chunks) {
154
+ transfer.acceptedEarly = true;
155
+ return;
156
+ }
157
+
158
+ transfer.pending = false;
130
159
  this.#beginStreaming(data.transferId);
131
160
  }
132
161
 
package/src/client/UI.js CHANGED
@@ -19,7 +19,10 @@ const COMMAND_INFO = [
19
19
  ['/users', 'List online users'],
20
20
  ['/msg', 'Private message (DM)'],
21
21
  ['/reply', 'Reply to the last message'],
22
+ ['/me', 'Third-person action message'],
23
+ ['/topic', 'Show or set the room topic'],
22
24
  ['/mentions', 'Recent mentions of you'],
25
+ ['/watch', 'Alert on a keyword in any room'],
23
26
  ['/contacts', 'Contact book — aliases for peers'],
24
27
  ['/away', 'Mark yourself as away'],
25
28
  ['/back', 'Clear away status'],
@@ -41,7 +44,8 @@ const COMMAND_INFO = [
41
44
  ['/img', 'Render a high-resolution image'],
42
45
  ['/sound', 'Sound notifications'],
43
46
  ['/notify', 'Desktop notifications'],
44
- ['/search', 'Search history'],
47
+ ['/search', 'Search history (on disk)'],
48
+ ['/find', 'Find in this room and jump to it'],
45
49
  ['/history', 'Recent messages from history'],
46
50
  ['/export', 'Export history'],
47
51
  ['/backup', 'Back up identity + trust'],
@@ -89,7 +93,9 @@ export const COMMANDS = [
89
93
  '/sound',
90
94
  '/msg',
91
95
  '/reply',
96
+ '/me',
92
97
  '/mentions',
98
+ '/watch',
93
99
  '/contacts',
94
100
  '/away',
95
101
  '/back',
@@ -99,11 +105,13 @@ export const COMMANDS = [
99
105
  '/dnd',
100
106
  '/join',
101
107
  '/leave',
108
+ '/topic',
102
109
  '/create',
103
110
  '/rooms',
104
111
  '/room',
105
112
  '/invite',
106
113
  '/search',
114
+ '/find',
107
115
  '/history',
108
116
  '/export',
109
117
  '/backup',
@@ -314,6 +322,39 @@ export function renderMarkdown(text) {
314
322
  return out.join('\n');
315
323
  }
316
324
 
325
+ /**
326
+ * Find `query` in rendered chat lines, returning where each hit is plus a
327
+ * readable preview. Blessed tags are stripped before matching so a search for
328
+ * "fg" never matches colour markup, and the preview is centred on the hit
329
+ * instead of always showing the start of a long line. Pure — exported for
330
+ * testing.
331
+ *
332
+ * @returns {Array<{ lineIndex: number, preview: string }>} newest last
333
+ */
334
+ export function findInLines(lines, query, maxHits = 200) {
335
+ const needle = String(query || '')
336
+ .trim()
337
+ .toLowerCase();
338
+ if (!needle || !Array.isArray(lines)) {
339
+ return [];
340
+ }
341
+ const hits = [];
342
+ for (let i = 0; i < lines.length && hits.length < maxHits; i++) {
343
+ const plain = String(lines[i]).replace(/\{[^{}]*\}/g, '');
344
+ const at = plain.toLowerCase().indexOf(needle);
345
+ if (at === -1) {
346
+ continue;
347
+ }
348
+ const start = Math.max(0, at - 24);
349
+ const slice = plain.slice(start, start + 76).trim();
350
+ hits.push({
351
+ lineIndex: i,
352
+ preview: `${start > 0 ? '…' : ''}${blessed.escape(slice)}`,
353
+ });
354
+ }
355
+ return hits;
356
+ }
357
+
317
358
  // Sanitizes pasted text while PRESERVING its line structure — the input box is
318
359
  // multi-line and fenced code blocks render in markdown, so pasted code must
319
360
  // keep its newlines. Normalizes CRLF/CR, turns tabs into spaces and strips the
@@ -504,6 +545,12 @@ export class UI extends EventEmitter {
504
545
  #activeBuffer; // name of the buffer currently on screen
505
546
  #redirecting; // true while add* calls are being written to an inactive buffer
506
547
  #bufferBar; // [{ room, active, unread, private }] for the status bar
548
+ #topic; // current room topic, shown in the status bar
549
+ #finder; // Ctrl+F scrollback search overlay
550
+ #finderOpen;
551
+ #finderQuery;
552
+ #finderHits; // [{ lineIndex, preview }]
553
+ #finderMark; // line index currently highlighted by a jump
507
554
 
508
555
  constructor(nickname) {
509
556
  super();
@@ -557,6 +604,11 @@ export class UI extends EventEmitter {
557
604
  this.#activeBuffer = 'general';
558
605
  this.#redirecting = false;
559
606
  this.#bufferBar = [];
607
+ this.#topic = null;
608
+ this.#finderOpen = false;
609
+ this.#finderQuery = '';
610
+ this.#finderHits = [];
611
+ this.#finderMark = null;
560
612
 
561
613
  // blessed's terminfo parser can't compile the modern Setulc (underline
562
614
  // colour) capability that terminals like ghostty ship, so it dumps a
@@ -658,6 +710,24 @@ export class UI extends EventEmitter {
658
710
  },
659
711
  });
660
712
 
713
+ // ── Scrollback finder (Ctrl+F) ───────────────────────
714
+ this.#finder = blessed.list({
715
+ parent: this.#screen,
716
+ hidden: true,
717
+ top: 'center',
718
+ left: 'center',
719
+ width: '80%',
720
+ height: '55%',
721
+ tags: true,
722
+ border: { type: 'line' },
723
+ label: ' Find in this room (Ctrl+F) ',
724
+ style: {
725
+ border: { fg: 'cyan' },
726
+ selected: { bg: 'cyan', fg: 'black' },
727
+ item: { fg: 'white' },
728
+ },
729
+ });
730
+
661
731
  // ── Emoji picker (Ctrl+E) ────────────────────────────
662
732
  this.#emojiPicker = blessed.list({
663
733
  parent: this.#screen,
@@ -732,6 +802,11 @@ export class UI extends EventEmitter {
732
802
  return;
733
803
  }
734
804
 
805
+ if (this.#finderOpen) {
806
+ this.#handleFinderKey(ch, key);
807
+ return;
808
+ }
809
+
735
810
  this.#handleKey(ch, key);
736
811
  });
737
812
 
@@ -773,6 +848,12 @@ export class UI extends EventEmitter {
773
848
  return;
774
849
  }
775
850
 
851
+ // Ctrl+F — find in the current room's scrollback
852
+ if (key.ctrl && name === 'f') {
853
+ this.openFinder();
854
+ return;
855
+ }
856
+
776
857
  // Tab — autocomplete
777
858
  if (name === 'tab') {
778
859
  this.#handleTab();
@@ -1142,6 +1223,104 @@ export class UI extends EventEmitter {
1142
1223
  }
1143
1224
  }
1144
1225
 
1226
+ // ── Scrollback finder (Ctrl+F) ───────────────────────
1227
+ // Search what is on screen in THIS room and jump to the hit. Unlike
1228
+ // /search (which queries the encrypted history on disk, possibly from other
1229
+ // sessions), every result here has a real line to scroll to.
1230
+
1231
+ /** Open the finder, optionally pre-filled (used by /search results). */
1232
+ openFinder(query = '') {
1233
+ this.#finderOpen = true;
1234
+ this.#finderQuery = query;
1235
+ this.#refreshFinder();
1236
+ this.#finder.show();
1237
+ this.#finder.setFront();
1238
+ this.#screen.render();
1239
+ }
1240
+
1241
+ #closeFinder() {
1242
+ this.#finderOpen = false;
1243
+ this.#finder.hide();
1244
+ this.#screen.render();
1245
+ }
1246
+
1247
+ #refreshFinder() {
1248
+ this.#finderHits = findInLines(this.#lines, this.#finderQuery);
1249
+ this.#finder.setItems(
1250
+ this.#finderHits.length > 0
1251
+ ? this.#finderHits.map((h) => ` {#8888aa-fg}${h.lineIndex + 1}{/#8888aa-fg} ${h.preview}`)
1252
+ : [
1253
+ this.#finderQuery
1254
+ ? ' {#8888aa-fg}no match in this room{/#8888aa-fg}'
1255
+ : ' {#8888aa-fg}type to search…{/#8888aa-fg}',
1256
+ ],
1257
+ );
1258
+ this.#finder.select(0);
1259
+ const q = this.#finderQuery ? ` › ${this.#finderQuery}` : '';
1260
+ const n = this.#finderHits.length ? ` — ${this.#finderHits.length} hit(s)` : '';
1261
+ this.#finder.setLabel(` Find in this room (Ctrl+F)${q}${n} `);
1262
+ this.#screen.render();
1263
+ }
1264
+
1265
+ /** Scroll the chat to `lineIndex` and mark it so the eye finds it. */
1266
+ jumpToLine(lineIndex) {
1267
+ if (lineIndex < 0 || lineIndex >= this.#lines.length) {
1268
+ return false;
1269
+ }
1270
+ this.#clearJumpMark();
1271
+ const original = this.#lines[lineIndex];
1272
+ this.#finderMark = { lineIndex, original };
1273
+ this.#lines[lineIndex] = `{yellow-fg}▶{/yellow-fg}${original}`;
1274
+ this.#chatLog.setContent(this.#lines.join('\n'));
1275
+ // Put the hit a few lines from the top so its context stays visible.
1276
+ this.#chatLog.scrollTo(Math.max(0, lineIndex - 3));
1277
+ this.#screen.render();
1278
+ this.#syncScrollState();
1279
+ return true;
1280
+ }
1281
+
1282
+ #clearJumpMark() {
1283
+ if (!this.#finderMark) {
1284
+ return;
1285
+ }
1286
+ const { lineIndex, original } = this.#finderMark;
1287
+ if (this.#lines[lineIndex] !== undefined) {
1288
+ this.#lines[lineIndex] = original;
1289
+ }
1290
+ this.#finderMark = null;
1291
+ }
1292
+
1293
+ #handleFinderKey(ch, key) {
1294
+ const name = key.name || '';
1295
+ if (name === 'escape' || (key.ctrl && name === 'c')) {
1296
+ this.#closeFinder();
1297
+ return;
1298
+ }
1299
+ if (name === 'up' || name === 'down') {
1300
+ this.#finder[name === 'up' ? 'up' : 'down'](1);
1301
+ this.#screen.render();
1302
+ return;
1303
+ }
1304
+ if (name === 'return' || name === 'enter') {
1305
+ const hit = this.#finderHits[this.#finder.selected];
1306
+ this.#closeFinder();
1307
+ if (hit) {
1308
+ this.jumpToLine(hit.lineIndex);
1309
+ }
1310
+ return;
1311
+ }
1312
+ if (name === 'backspace') {
1313
+ this.#finderQuery = this.#finderQuery.slice(0, -1);
1314
+ this.#refreshFinder();
1315
+ return;
1316
+ }
1317
+ const code = ch ? ch.charCodeAt(0) : 0;
1318
+ if (ch && ch.length === 1 && !key.ctrl && !key.meta && code > 0x1f && code !== 0x7f) {
1319
+ this.#finderQuery += ch;
1320
+ this.#refreshFinder();
1321
+ }
1322
+ }
1323
+
1145
1324
  // ── Emoji picker (Ctrl+E, fuzzy) ─────────────────────
1146
1325
  #openEmoji() {
1147
1326
  this.#emojiOpen = true;
@@ -1416,14 +1595,22 @@ export class UI extends EventEmitter {
1416
1595
  } else {
1417
1596
  room = `{cyan-fg}#${this.#statusRoom}{/cyan-fg}`;
1418
1597
  }
1419
- const fp = this.#statusFingerprint
1420
- ? ` {#8888aa-fg}🔑 ${this.#statusFingerprint}{/#8888aa-fg}`
1598
+ // The topic earns its place next to the room name; it is what tells you
1599
+ // what a room is FOR. Truncated so it can never push the hints off-screen.
1600
+ const topic = this.#topic
1601
+ ? ` {#9a9ad0-fg}📋 ${blessed.escape(
1602
+ this.#topic.length > 60 ? `${this.#topic.slice(0, 57)}…` : this.#topic,
1603
+ )}{/#9a9ad0-fg}`
1421
1604
  : '';
1605
+ const fp =
1606
+ this.#statusFingerprint && !this.#topic
1607
+ ? ` {#8888aa-fg}🔑 ${this.#statusFingerprint}{/#8888aa-fg}`
1608
+ : '';
1422
1609
  const hint =
1423
1610
  this.#bufferBar.length > 1
1424
1611
  ? '{#7777aa-fg}Alt+1..9 buffers · Ctrl+K commands · /help{/#7777aa-fg}'
1425
1612
  : '{#7777aa-fg}Tab · Ctrl+K commands · Ctrl+E emoji · PgUp/PgDn scroll · /help · Ctrl+C quit{/#7777aa-fg}';
1426
- return ` ${room}${fp} {|} ${hint} `;
1613
+ return ` ${room}${topic}${fp} {|} ${hint} `;
1427
1614
  }
1428
1615
 
1429
1616
  #updateStatusBar() {
@@ -1433,6 +1620,12 @@ export class UI extends EventEmitter {
1433
1620
  }
1434
1621
  }
1435
1622
 
1623
+ /** Set (or clear with null) the topic shown in the status bar. */
1624
+ setTopic(text) {
1625
+ this.#topic = text || null;
1626
+ this.#updateStatusBar();
1627
+ }
1628
+
1436
1629
  setFingerprint(fingerprint) {
1437
1630
  // short prefix of the fingerprint as a persistent identity anchor
1438
1631
  this.#statusFingerprint = (fingerprint || '').slice(0, 17);
@@ -1673,6 +1866,20 @@ export class UI extends EventEmitter {
1673
1866
  this.updateLine(lineIndex, line);
1674
1867
  }
1675
1868
 
1869
+ // Third-person action (/me). Rendered as a distinct italic line so it never
1870
+ // reads like someone quoting themselves.
1871
+ addActionMessage(nickname, text) {
1872
+ this.#lastSender = null; // an action breaks message grouping
1873
+ const line = ` {white-fg}[${time()}]{/white-fg} {magenta-fg}✦ {bold}${blessed.escape(
1874
+ nickname,
1875
+ )}{/bold} ${renderMarkdown(text)}{/magenta-fg}`;
1876
+ this.#lines.push(line);
1877
+ this.#chatLog.log(line);
1878
+ this.#screen.render();
1879
+ this.#noteIncoming();
1880
+ return { lineIndex: this.#lines.length - 1 };
1881
+ }
1882
+
1676
1883
  addSystemMessage(text) {
1677
1884
  this.#lastSender = null; // interrupts message grouping
1678
1885
  const line = ` {white-fg}[${time()}] * ${blessed.escape(text)}{/white-fg}`;
@@ -17,6 +17,7 @@ export const PinResult = {
17
17
  export class CertPinStore {
18
18
  #path;
19
19
  #store; // Map<host, sha256Fingerprint>
20
+ #caHosts; // Set<host> — hosts that already presented a CA-valid certificate
20
21
 
21
22
  constructor(baseDir = process.cwd()) {
22
23
  const dir = join(baseDir, PIN_DIR);
@@ -25,6 +26,7 @@ export class CertPinStore {
25
26
  }
26
27
  this.#path = join(dir, PIN_FILE);
27
28
  this.#store = new Map();
29
+ this.#caHosts = new Set();
28
30
  this.#load();
29
31
  }
30
32
 
@@ -32,22 +34,56 @@ export class CertPinStore {
32
34
  try {
33
35
  if (existsSync(this.#path)) {
34
36
  const data = JSON.parse(readFileSync(this.#path, 'utf-8'));
35
- for (const [host, fp] of Object.entries(data)) {
36
- this.#store.set(host, fp);
37
+ for (const [host, entry] of Object.entries(data)) {
38
+ // v1 stored a bare fingerprint string; v2 stores { fp, ca }.
39
+ if (typeof entry === 'string') {
40
+ this.#store.set(host, entry);
41
+ } else if (entry && typeof entry === 'object') {
42
+ if (typeof entry.fp === 'string') {
43
+ this.#store.set(host, entry.fp);
44
+ }
45
+ if (entry.ca) {
46
+ this.#caHosts.add(host);
47
+ }
48
+ }
37
49
  }
38
50
  }
39
51
  } catch {
40
52
  this.#store = new Map();
53
+ this.#caHosts = new Set();
41
54
  }
42
55
  }
43
56
 
44
57
  #save() {
45
- writeFileSync(this.#path, JSON.stringify(Object.fromEntries(this.#store), null, 2), {
58
+ const obj = {};
59
+ // Union of both maps: a host can be CA-validated before (or without) ever
60
+ // having a pinned fingerprint, and that flag must survive a restart.
61
+ for (const host of new Set([...this.#store.keys(), ...this.#caHosts])) {
62
+ obj[host] = { fp: this.#store.get(host) || null, ca: this.#caHosts.has(host) };
63
+ }
64
+ writeFileSync(this.#path, JSON.stringify(obj, null, 2), {
46
65
  encoding: 'utf-8',
47
66
  mode: 0o600,
48
67
  });
49
68
  }
50
69
 
70
+ /**
71
+ * Remember that a host served a certificate that chains to a public CA.
72
+ * Subsequent connections to it demand full TLS verification, so a hosted
73
+ * relay can never be silently downgraded to a self-signed (attacker) cert.
74
+ */
75
+ markCAValidated(host) {
76
+ if (!this.#caHosts.has(host)) {
77
+ this.#caHosts.add(host);
78
+ this.#save();
79
+ }
80
+ }
81
+
82
+ /** True if this host must be verified strictly (it was CA-valid before). */
83
+ requiresStrictTLS(host) {
84
+ return this.#caHosts.has(host);
85
+ }
86
+
51
87
  /**
52
88
  * Check a server's cert fingerprint against the pinned one (pinning on first use).
53
89
  * @param {string} host - e.g. "100.73.206.23:3600"