@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.
- package/dist/cli.js +244 -10
- 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(
|
|
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
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
this.
|
|
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
|
-
|
|
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++,
|
|
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,
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
17
|
+
"start": "tsx src/cli.ts",
|
|
18
18
|
"build": "node build.mjs",
|
|
19
19
|
"prepack": "node build.mjs",
|
|
20
20
|
"typecheck": "tsc --noEmit"
|