@drop2p/cli 0.1.0 → 0.2.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.
Files changed (2) hide show
  1. package/dist/cli.js +244 -10
  2. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -61,6 +61,16 @@ async function deriveFileKey(master, salt) {
61
61
  ["encrypt", "decrypt"]
62
62
  );
63
63
  }
64
+ async function deriveSas(master, salt) {
65
+ const ikm = await crypto.subtle.importKey("raw", bs(master), "HKDF", false, ["deriveBits"]);
66
+ const bits = await crypto.subtle.deriveBits(
67
+ { name: "HKDF", hash: "SHA-256", salt: bs(salt), info: bs(TEXT.encode("DSTP/1 sas")) },
68
+ ikm,
69
+ 32
70
+ );
71
+ const n = new DataView(bits).getUint32(0) % 1e6;
72
+ return String(n).padStart(6, "0");
73
+ }
64
74
  var VAULT_SALT_BYTES = 16;
65
75
  var VAULT_VERIFIER_COUNTER = 4294967295;
66
76
  var VAULT_MARKER = TEXT.encode("DSTP/1 vault ok");
@@ -94,6 +104,184 @@ async function sha256(...parts) {
94
104
  }
95
105
  return new Uint8Array(await crypto.subtle.digest("SHA-256", bs(buf)));
96
106
  }
107
+ var SHA256_K = new Uint32Array([
108
+ 1116352408,
109
+ 1899447441,
110
+ 3049323471,
111
+ 3921009573,
112
+ 961987163,
113
+ 1508970993,
114
+ 2453635748,
115
+ 2870763221,
116
+ 3624381080,
117
+ 310598401,
118
+ 607225278,
119
+ 1426881987,
120
+ 1925078388,
121
+ 2162078206,
122
+ 2614888103,
123
+ 3248222580,
124
+ 3835390401,
125
+ 4022224774,
126
+ 264347078,
127
+ 604807628,
128
+ 770255983,
129
+ 1249150122,
130
+ 1555081692,
131
+ 1996064986,
132
+ 2554220882,
133
+ 2821834349,
134
+ 2952996808,
135
+ 3210313671,
136
+ 3336571891,
137
+ 3584528711,
138
+ 113926993,
139
+ 338241895,
140
+ 666307205,
141
+ 773529912,
142
+ 1294757372,
143
+ 1396182291,
144
+ 1695183700,
145
+ 1986661051,
146
+ 2177026350,
147
+ 2456956037,
148
+ 2730485921,
149
+ 2820302411,
150
+ 3259730800,
151
+ 3345764771,
152
+ 3516065817,
153
+ 3600352804,
154
+ 4094571909,
155
+ 275423344,
156
+ 430227734,
157
+ 506948616,
158
+ 659060556,
159
+ 883997877,
160
+ 958139571,
161
+ 1322822218,
162
+ 1537002063,
163
+ 1747873779,
164
+ 1955562222,
165
+ 2024104815,
166
+ 2227730452,
167
+ 2361852424,
168
+ 2428436474,
169
+ 2756734187,
170
+ 3204031479,
171
+ 3329325298
172
+ ]);
173
+ var rotr = (x, n) => x >>> n | x << 32 - n;
174
+ var Sha256 = class {
175
+ h = new Uint32Array([
176
+ 1779033703,
177
+ 3144134277,
178
+ 1013904242,
179
+ 2773480762,
180
+ 1359893119,
181
+ 2600822924,
182
+ 528734635,
183
+ 1541459225
184
+ ]);
185
+ block = new Uint8Array(64);
186
+ w = new Uint32Array(64);
187
+ blockLen = 0;
188
+ bytes = 0;
189
+ update(data) {
190
+ this.bytes += data.length;
191
+ let i = 0;
192
+ if (this.blockLen > 0) {
193
+ while (i < data.length && this.blockLen < 64) this.block[this.blockLen++] = data[i++];
194
+ if (this.blockLen === 64) {
195
+ this.process(this.block, 0);
196
+ this.blockLen = 0;
197
+ }
198
+ }
199
+ while (i + 64 <= data.length) {
200
+ this.process(data, i);
201
+ i += 64;
202
+ }
203
+ while (i < data.length) this.block[this.blockLen++] = data[i++];
204
+ return this;
205
+ }
206
+ digest() {
207
+ const bitLen = this.bytes * 8;
208
+ this.block[this.blockLen++] = 128;
209
+ if (this.blockLen > 56) {
210
+ while (this.blockLen < 64) this.block[this.blockLen++] = 0;
211
+ this.process(this.block, 0);
212
+ this.blockLen = 0;
213
+ }
214
+ while (this.blockLen < 56) this.block[this.blockLen++] = 0;
215
+ const dv = new DataView(this.block.buffer);
216
+ dv.setUint32(56, Math.floor(bitLen / 4294967296) >>> 0, false);
217
+ dv.setUint32(60, bitLen >>> 0, false);
218
+ this.process(this.block, 0);
219
+ const out3 = new Uint8Array(32);
220
+ const odv = new DataView(out3.buffer);
221
+ for (let i = 0; i < 8; i++) odv.setUint32(i * 4, this.h[i], false);
222
+ return out3;
223
+ }
224
+ // Hot loop: every index below is provably in-bounds (a fixed 64-byte block and
225
+ // a 64-entry schedule), so the non-null assertions are safe — they exist only
226
+ // to satisfy `noUncheckedIndexedAccess` on the typed-array reads.
227
+ process(p, off) {
228
+ const w = this.w;
229
+ for (let i = 0; i < 16; i++) {
230
+ const j = off + i * 4;
231
+ w[i] = (p[j] << 24 | p[j + 1] << 16 | p[j + 2] << 8 | p[j + 3]) >>> 0;
232
+ }
233
+ for (let i = 16; i < 64; i++) {
234
+ const x = w[i - 15];
235
+ const y = w[i - 2];
236
+ const s0 = rotr(x, 7) ^ rotr(x, 18) ^ x >>> 3;
237
+ const s1 = rotr(y, 17) ^ rotr(y, 19) ^ y >>> 10;
238
+ w[i] = w[i - 16] + s0 + w[i - 7] + s1 | 0;
239
+ }
240
+ let a = this.h[0];
241
+ let b = this.h[1];
242
+ let c = this.h[2];
243
+ let d = this.h[3];
244
+ let e = this.h[4];
245
+ let f = this.h[5];
246
+ let g = this.h[6];
247
+ let hh = this.h[7];
248
+ for (let i = 0; i < 64; i++) {
249
+ const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
250
+ const ch = e & f ^ ~e & g;
251
+ const t1 = hh + S1 + ch + SHA256_K[i] + w[i] | 0;
252
+ const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
253
+ const maj = a & b ^ a & c ^ b & c;
254
+ const t2 = S0 + maj | 0;
255
+ hh = g;
256
+ g = f;
257
+ f = e;
258
+ e = d + t1 | 0;
259
+ d = c;
260
+ c = b;
261
+ b = a;
262
+ a = t1 + t2 | 0;
263
+ }
264
+ this.h[0] = this.h[0] + a | 0;
265
+ this.h[1] = this.h[1] + b | 0;
266
+ this.h[2] = this.h[2] + c | 0;
267
+ this.h[3] = this.h[3] + d | 0;
268
+ this.h[4] = this.h[4] + e | 0;
269
+ this.h[5] = this.h[5] + f | 0;
270
+ this.h[6] = this.h[6] + g | 0;
271
+ this.h[7] = this.h[7] + hh | 0;
272
+ }
273
+ };
274
+ function digestsEqual(a, b) {
275
+ if (a.length !== b.length) return false;
276
+ let diff = 0;
277
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
278
+ return diff === 0;
279
+ }
280
+ function parseDtlsFingerprint(sdp) {
281
+ if (!sdp) return null;
282
+ const m = /^a=fingerprint:sha-256\s+([0-9a-fA-F:]+)\s*$/im.exec(sdp);
283
+ return m ? m[1].replace(/:/g, "").toLowerCase() : null;
284
+ }
97
285
  function nonce(counter) {
98
286
  const n = new Uint8Array(12);
99
287
  new DataView(n.buffer).setBigUint64(4, BigInt(counter));
@@ -165,6 +353,13 @@ var TransferManager = class {
165
353
  fileKey;
166
354
  sendCounter = 0;
167
355
  recvCounter = 0;
356
+ // Transcript binding (MITM detection): fold both peers' DTLS fingerprints into
357
+ // the key-derivation salt. Negotiated — only active when BOTH sides advertise
358
+ // support AND both fingerprints parse, so mixed-version pairs fall back safely.
359
+ localFp = null;
360
+ remoteFp = null;
361
+ bindLocal = false;
362
+ peerBind = false;
168
363
  // Ultimate "double encryption": optional passphrase-derived (Argon2id) layer
169
364
  // applied to file content *inside* the DSTP file key.
170
365
  passphrase;
@@ -192,6 +387,8 @@ var TransferManager = class {
192
387
  sink;
193
388
  recvMeta;
194
389
  recvBytes = 0;
390
+ // running SHA-256 of the received file content, for the whole-file integrity check
391
+ recvHash;
195
392
  closed = false;
196
393
  sentAllData = false;
197
394
  completed = false;
@@ -404,8 +601,13 @@ var TransferManager = class {
404
601
  try {
405
602
  const mod = await this.loadCrypto();
406
603
  this.pake = await createPake(mod, this.secret, this.channel);
604
+ this.localFp = parseDtlsFingerprint(this.pc?.localDescription?.sdp);
605
+ this.remoteFp = parseDtlsFingerprint(this.pc?.remoteDescription?.sdp);
606
+ this.bindLocal = this.localFp !== null && this.remoteFp !== null;
407
607
  if (this.dc?.readyState === "open") {
408
- this.dc.send(JSON.stringify({ kind: "pake", data: toB64(this.pake.outbound) }));
608
+ this.dc.send(
609
+ JSON.stringify({ kind: "pake", data: toB64(this.pake.outbound), bind: this.bindLocal })
610
+ );
409
611
  }
410
612
  await this.maybeCompleteHandshake();
411
613
  } catch {
@@ -417,8 +619,15 @@ var TransferManager = class {
417
619
  const master = this.pake.finish(this.peerPake);
418
620
  const senderMsg = this.role === "sender" ? this.pake.outbound : this.peerPake;
419
621
  const receiverMsg = this.role === "sender" ? this.peerPake : this.pake.outbound;
420
- const salt = await sha256(TEXT_ENC.encode(`DSTP/1:${this.channel}`), senderMsg, receiverMsg);
622
+ const saltParts = [TEXT_ENC.encode(`DSTP/1:${this.channel}`), senderMsg, receiverMsg];
623
+ if (this.bindLocal && this.peerBind && this.localFp && this.remoteFp) {
624
+ const senderFp = this.role === "sender" ? this.localFp : this.remoteFp;
625
+ const receiverFp = this.role === "sender" ? this.remoteFp : this.localFp;
626
+ saltParts.push(TEXT_ENC.encode(`DSTP/1 fp:${senderFp}:${receiverFp}`));
627
+ }
628
+ const salt = await sha256(...saltParts);
421
629
  this.fileKey = await deriveFileKey(master, salt);
630
+ this.patch({ sas: await deriveSas(master, salt) });
422
631
  this.keyReadyResolve();
423
632
  if (this.role === "sender" && this.source && this.dc) void this.sendEncrypted(this.source, this.dc);
424
633
  }
@@ -427,6 +636,7 @@ var TransferManager = class {
427
636
  const msg = JSON.parse(data);
428
637
  if (msg.kind === "pake" && msg.data) {
429
638
  this.peerPake = fromB64(msg.data);
639
+ this.peerBind = msg.bind === true;
430
640
  await this.maybeCompleteHandshake();
431
641
  } else if (msg.kind === "ready") {
432
642
  this.peerReadyResolve();
@@ -450,6 +660,7 @@ var TransferManager = class {
450
660
  const meta = JSON.parse(TEXT_DEC.decode(payload));
451
661
  this.recvMeta = meta;
452
662
  this.recvBytes = 0;
663
+ this.recvHash = new Sha256();
453
664
  if (meta.protected && meta.salt && meta.verifier) {
454
665
  this.vaultSalt = fromB64(meta.salt);
455
666
  this.vaultVerifier = fromB64(meta.verifier);
@@ -472,6 +683,7 @@ var TransferManager = class {
472
683
  return;
473
684
  }
474
685
  }
686
+ this.recvHash?.update(chunk);
475
687
  this.recvBytes += chunk.length;
476
688
  await this.sink?.write(chunk);
477
689
  this.emitProgress(this.recvBytes, this.recvMeta?.size ?? 0);
@@ -479,11 +691,16 @@ var TransferManager = class {
479
691
  if (this.recvMeta && this.recvBytes !== this.recvMeta.size) {
480
692
  await this.sink?.abort();
481
693
  this.fail("Transfer ended early \u2014 the file looks incomplete.");
482
- } else {
483
- await this.sink?.close();
484
- if (this.dc?.readyState === "open") this.dc.send(JSON.stringify({ kind: "done-ack" }));
485
- this.finishOk(this.recvBytes);
694
+ return;
695
+ }
696
+ if (payload.length === 32 && this.recvHash && !digestsEqual(this.recvHash.digest(), payload)) {
697
+ await this.sink?.abort();
698
+ this.fail("Integrity check failed \u2014 the file was corrupted in transit. Nothing was saved.");
699
+ return;
486
700
  }
701
+ await this.sink?.close();
702
+ if (this.dc?.readyState === "open") this.dc.send(JSON.stringify({ kind: "done-ack" }));
703
+ this.finishOk(this.recvBytes);
487
704
  }
488
705
  }
489
706
  /** Receiver: ask the host how to save, then act on its decision. */
@@ -536,19 +753,22 @@ var TransferManager = class {
536
753
  dc.bufferedAmountLowThreshold = LOW_WATER;
537
754
  this.startProgress();
538
755
  this.patch({ phase: "transferring", transferred: 0, progress: 0 });
756
+ const fileHash = new Sha256();
539
757
  let offset = 0;
540
758
  while (offset < source.size && dc.readyState === "open") {
541
759
  if (dc.bufferedAmount > HIGH_WATER) await this.waitDrain(dc);
542
760
  const end = Math.min(offset + chunkSize, source.size);
543
- let chunk = await source.slice(offset, end);
761
+ const plain = await source.slice(offset, end);
762
+ fileHash.update(plain);
763
+ let chunk = plain;
544
764
  if (this.vaultKey) {
545
- chunk = new Uint8Array(await encryptFrame(this.vaultKey, this.innerSendCounter++, chunk));
765
+ chunk = new Uint8Array(await encryptFrame(this.vaultKey, this.innerSendCounter++, plain));
546
766
  }
547
767
  await this.sendFrame(dc, TYPE_CHUNK, chunk);
548
768
  offset = end;
549
769
  this.emitProgress(Math.max(0, offset - dc.bufferedAmount), source.size);
550
770
  }
551
- if (dc.readyState === "open") await this.sendFrame(dc, TYPE_DONE, new Uint8Array(0));
771
+ if (dc.readyState === "open") await this.sendFrame(dc, TYPE_DONE, fileHash.digest());
552
772
  this.sentAllData = true;
553
773
  while (dc.readyState === "open" && dc.bufferedAmount > 0) {
554
774
  this.emitProgress(Math.max(0, source.size - dc.bufferedAmount), source.size);
@@ -1063,7 +1283,7 @@ function exit(code) {
1063
1283
  setTimeout(() => process.exit(code), 150);
1064
1284
  return void 0;
1065
1285
  }
1066
- var VERSION = true ? "0.1.0" : "dev";
1286
+ var VERSION = true ? "0.2.0" : "dev";
1067
1287
  var USAGE = [
1068
1288
  "drop2p \u2014 zero-storage, end-to-end-encrypted file transfer in your terminal.",
1069
1289
  "",
@@ -1127,9 +1347,16 @@ async function send(filePath, flags, emit) {
1127
1347
  iceTransportPolicy: process.env.DROP2P_FORCE_RELAY ? "relay" : void 0
1128
1348
  });
1129
1349
  let printedCode = false;
1350
+ let printedSas = false;
1130
1351
  let finished = false;
1131
1352
  let lastLine = 0;
1132
1353
  const mgr = new TransferManager(env, (s) => {
1354
+ if (s.sas && !printedSas) {
1355
+ printedSas = true;
1356
+ emit("verify", { sas: s.sas });
1357
+ if (!flags.json)
1358
+ err2(` \u{1F512} Verification code: ${s.sas.slice(0, 3)} ${s.sas.slice(3)} \u2014 confirm it matches on the other device.`);
1359
+ }
1133
1360
  if (s.code && !printedCode) {
1134
1361
  printedCode = true;
1135
1362
  emit("code", { code: s.code, expiresAt: s.expiresAt });
@@ -1194,9 +1421,16 @@ async function receive(code, flags, emit) {
1194
1421
  let name = "";
1195
1422
  let lastLine = 0;
1196
1423
  let askedPassphrase = false;
1424
+ let printedSas = false;
1197
1425
  const mgr = new TransferManager(env, (s) => {
1198
1426
  if (s.fileSize != null) total = s.fileSize;
1199
1427
  if (s.fileName) name = s.fileName;
1428
+ if (s.sas && !printedSas) {
1429
+ printedSas = true;
1430
+ emit("verify", { sas: s.sas });
1431
+ if (!flags.json)
1432
+ err2(` \u{1F512} Verification code: ${s.sas.slice(0, 3)} ${s.sas.slice(3)} \u2014 confirm it matches on the other device.`);
1433
+ }
1200
1434
  if (s.connectionType && s.connectionType !== "unknown") {
1201
1435
  emit("connected", { via: s.connectionType });
1202
1436
  if (!flags.json) err2(` Connected (${s.connectionType === "relay" ? "\u{1F6F0} relay" : "\u26A1 direct"}). Receiving\u2026`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drop2p/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Drop2p CLI — a zero-storage, end-to-end-encrypted file transfer peer for the terminal.",
6
6
  "bin": {
@@ -14,7 +14,7 @@
14
14
  "node": ">=20"
15
15
  },
16
16
  "scripts": {
17
- "dev": "tsx src/cli.ts",
17
+ "start": "tsx src/cli.ts",
18
18
  "build": "node build.mjs",
19
19
  "prepack": "node build.mjs",
20
20
  "typecheck": "tsc --noEmit"