safedrop-cli 1.0.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/API.md +197 -0
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/SECURITY.md +144 -0
- package/package.json +45 -0
- package/src/api.js +170 -0
- package/src/cli.js +114 -0
- package/src/code.js +104 -0
- package/src/crypto.js +82 -0
- package/src/index.js +25 -0
- package/src/paths.js +95 -0
- package/src/receive.js +190 -0
- package/src/sas.js +59 -0
- package/src/send.js +182 -0
- package/src/ui.js +94 -0
package/src/receive.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// receive.js — the receiver workflow.
|
|
2
|
+
//
|
|
3
|
+
// 1. parse code/link 7. download encrypted bytes
|
|
4
|
+
// 2. extract uploadCode + key 8. decrypt filename
|
|
5
|
+
// 3. initiate handshake 9. decrypt file
|
|
6
|
+
// 4. print handshake code 10. save safely to disk
|
|
7
|
+
// 5. (optional) verify SAS 11. confirm; server copy is deleted
|
|
8
|
+
// 6. poll until sender authorizes
|
|
9
|
+
|
|
10
|
+
import { promises as fs, existsSync, statSync } from 'node:fs';
|
|
11
|
+
|
|
12
|
+
import { decryptBuffer, decryptString } from './crypto.js';
|
|
13
|
+
import { decodeCombinedCode } from './code.js';
|
|
14
|
+
import { generateSAS } from './sas.js';
|
|
15
|
+
import { SafeDropApi, SafeDropApiError } from './api.js';
|
|
16
|
+
import { resolveOutputPath, dedupePath } from './paths.js';
|
|
17
|
+
import { log, style, ask, confirm, spinner, formatBytes } from './ui.js';
|
|
18
|
+
|
|
19
|
+
const HANDSHAKE_TTL_SECONDS = 5 * 60; // mirrors backend HANDSHAKE_TTL
|
|
20
|
+
const POLL_INTERVAL_MS = 2000;
|
|
21
|
+
|
|
22
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
23
|
+
|
|
24
|
+
export async function runReceive(codeOrLink, opts = {}) {
|
|
25
|
+
const api = new SafeDropApi(opts.api);
|
|
26
|
+
|
|
27
|
+
// --- 1-2. Parse the code/link -----------------------------------------
|
|
28
|
+
let uploadCode, key, fullSecurity;
|
|
29
|
+
try {
|
|
30
|
+
({ uploadCode, key, fullSecurity } = decodeCombinedCode(codeOrLink));
|
|
31
|
+
} catch (err) {
|
|
32
|
+
log.error(err.message);
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
log.ok('Code parsed. The encryption key stays on this machine.');
|
|
37
|
+
|
|
38
|
+
// --- (optional) SAS verification ---------------------------------------
|
|
39
|
+
if (fullSecurity) {
|
|
40
|
+
const sas = generateSAS(key);
|
|
41
|
+
log.plain();
|
|
42
|
+
log.plain(` ${style.yellow('Safety code')}: ${style.bold(sas)}`);
|
|
43
|
+
log.plain(style.dim(' This must match the code the sender reads to you. If it differs, STOP.'));
|
|
44
|
+
log.plain();
|
|
45
|
+
const matches = await confirm('Does the safety code match the sender\'s?', false);
|
|
46
|
+
if (!matches) {
|
|
47
|
+
log.error('Safety code mismatch — aborting to avoid a possible interception.');
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// --- 3-4. Initiate handshake -------------------------------------------
|
|
54
|
+
let handshakeCode, recipientToken;
|
|
55
|
+
try {
|
|
56
|
+
const spin = spinner('Initiating handshake…');
|
|
57
|
+
const hs = await api.initiateHandshake(uploadCode);
|
|
58
|
+
({ handshakeCode, recipientToken } = hs);
|
|
59
|
+
spin.stop(`${style.green('✓')} Handshake ready.`);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (err instanceof SafeDropApiError && err.statusCode === 404) {
|
|
62
|
+
log.error('This transfer was not found. It may have expired, been downloaded, or been cancelled.');
|
|
63
|
+
} else {
|
|
64
|
+
log.error(err instanceof SafeDropApiError ? err.message : `Handshake failed: ${err.message}`);
|
|
65
|
+
}
|
|
66
|
+
process.exitCode = 1;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
log.plain();
|
|
71
|
+
log.plain(style.bold(' Give this handshake code to the sender:'));
|
|
72
|
+
log.plain();
|
|
73
|
+
log.plain(` ${style.cyan(style.bold(handshakeCode))}`);
|
|
74
|
+
log.plain();
|
|
75
|
+
|
|
76
|
+
// Ctrl-C cancels the recipient session cleanly.
|
|
77
|
+
let stopped = false;
|
|
78
|
+
const cancelRemote = async () => {
|
|
79
|
+
try { await api.cancelHandshake(uploadCode, recipientToken); } catch { /* best effort */ }
|
|
80
|
+
};
|
|
81
|
+
const onSigint = () => { stopped = true; cancelRemote().then(() => process.exit(130)); };
|
|
82
|
+
process.on('SIGINT', onSigint);
|
|
83
|
+
|
|
84
|
+
// --- 6. Poll until the sender authorizes -------------------------------
|
|
85
|
+
let downloadToken;
|
|
86
|
+
const spin = spinner('Waiting for the sender to authorize…');
|
|
87
|
+
const deadline = Date.now() + HANDSHAKE_TTL_SECONDS * 1000;
|
|
88
|
+
try {
|
|
89
|
+
while (!stopped) {
|
|
90
|
+
if (Date.now() > deadline) {
|
|
91
|
+
spin.stop();
|
|
92
|
+
log.error('The handshake expired before the sender authorized. Ask them to restart and try again.');
|
|
93
|
+
process.exitCode = 1;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const { downloadToken: token } = await api.getDownloadToken(uploadCode, recipientToken);
|
|
98
|
+
if (token) { downloadToken = token; break; }
|
|
99
|
+
} catch (err) {
|
|
100
|
+
if (err instanceof SafeDropApiError && err.statusCode === 404) {
|
|
101
|
+
// Still pending — keep polling.
|
|
102
|
+
} else if (err instanceof SafeDropApiError && err.statusCode === 403) {
|
|
103
|
+
spin.stop();
|
|
104
|
+
log.error('The session is no longer available (it may have been cancelled).');
|
|
105
|
+
process.exitCode = 1;
|
|
106
|
+
return;
|
|
107
|
+
} else {
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const left = Math.max(0, Math.round((deadline - Date.now()) / 1000));
|
|
112
|
+
spin.setLabel(`Waiting for the sender to authorize… ${style.dim(`(${left}s left)`)}`);
|
|
113
|
+
await sleep(POLL_INTERVAL_MS);
|
|
114
|
+
}
|
|
115
|
+
} catch (err) {
|
|
116
|
+
spin.stop();
|
|
117
|
+
log.error(err instanceof SafeDropApiError ? err.message : `Error while waiting: ${err.message}`);
|
|
118
|
+
process.exitCode = 1;
|
|
119
|
+
return;
|
|
120
|
+
} finally {
|
|
121
|
+
process.off('SIGINT', onSigint);
|
|
122
|
+
}
|
|
123
|
+
if (!downloadToken) return; // stopped via SIGINT
|
|
124
|
+
spin.stop(`${style.green('✓')} Authorized by the sender.`);
|
|
125
|
+
|
|
126
|
+
// --- 7. Download --------------------------------------------------------
|
|
127
|
+
let bytes, encryptedFilename;
|
|
128
|
+
try {
|
|
129
|
+
const dl = spinner('Downloading encrypted file…');
|
|
130
|
+
({ bytes, encryptedFilename } = await api.downloadFile(uploadCode, downloadToken));
|
|
131
|
+
dl.stop(`${style.green('✓')} Downloaded ${formatBytes(bytes.length)} of ciphertext.`);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
log.error(err instanceof SafeDropApiError ? err.message : `Download failed: ${err.message}`);
|
|
134
|
+
process.exitCode = 1;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- 8-9. Decrypt filename and contents --------------------------------
|
|
139
|
+
let senderFilename = 'safedrop-file';
|
|
140
|
+
if (encryptedFilename) {
|
|
141
|
+
try {
|
|
142
|
+
senderFilename = decryptString(encryptedFilename, key);
|
|
143
|
+
} catch {
|
|
144
|
+
log.warn('Could not decrypt the original filename; using a default name.');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let plain;
|
|
149
|
+
try {
|
|
150
|
+
plain = decryptBuffer(bytes, key);
|
|
151
|
+
} catch {
|
|
152
|
+
log.error('Decryption failed. The key may be wrong, or the data was corrupted in transit.');
|
|
153
|
+
process.exitCode = 1;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- 10. Save safely ----------------------------------------------------
|
|
158
|
+
let target;
|
|
159
|
+
try {
|
|
160
|
+
target = resolveOutputPath(senderFilename, opts.output, {
|
|
161
|
+
isDirectory: (p) => existsSync(p) && statSync(p).isDirectory(),
|
|
162
|
+
});
|
|
163
|
+
} catch (err) {
|
|
164
|
+
log.error(err.message);
|
|
165
|
+
process.exitCode = 1;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (existsSync(target)) {
|
|
170
|
+
const overwrite = await confirm(`File ${style.bold(target)} already exists. Overwrite?`, false);
|
|
171
|
+
if (!overwrite) {
|
|
172
|
+
target = dedupePath(target, (p) => existsSync(p));
|
|
173
|
+
log.info(`Saving as ${style.bold(target)} instead.`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
await fs.writeFile(target, plain);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
log.error(`Could not write file: ${err.message}`);
|
|
181
|
+
process.exitCode = 1;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// --- 11. Confirm --------------------------------------------------------
|
|
186
|
+
log.plain();
|
|
187
|
+
log.ok(`Saved ${style.bold(target)} ${style.dim(`(${formatBytes(plain.length)})`)}`);
|
|
188
|
+
log.info('Decryption happened entirely on this machine.');
|
|
189
|
+
log.warn('The server has now deleted its encrypted copy — this code cannot be used again.');
|
|
190
|
+
}
|
package/src/sas.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// sas.js
|
|
2
|
+
//
|
|
3
|
+
// Short Authentication String (SAS) generation — identical to the browser
|
|
4
|
+
// client (frontend/utils/sas.ts). When "full security" is enabled, both sides
|
|
5
|
+
// derive a 3-word code from SHA-256 of the raw key and compare them out-of-band
|
|
6
|
+
// to detect a man-in-the-middle. The wordlist and indexing must match the
|
|
7
|
+
// browser byte-for-byte.
|
|
8
|
+
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
|
|
11
|
+
// BIP39-inspired wordlist (256 words). Mirrors frontend/utils/sas.ts.
|
|
12
|
+
const wordList = [
|
|
13
|
+
'abandon', 'ability', 'able', 'about', 'above', 'absent', 'absorb', 'abstract',
|
|
14
|
+
'absurd', 'abuse', 'access', 'accident', 'account', 'accuse', 'achieve', 'acid',
|
|
15
|
+
'acoustic', 'acquire', 'across', 'act', 'action', 'actor', 'actress', 'actual',
|
|
16
|
+
'adapt', 'add', 'addict', 'address', 'adjust', 'admit', 'adult', 'advance',
|
|
17
|
+
'advice', 'aerobic', 'affair', 'afford', 'afraid', 'again', 'age', 'agent',
|
|
18
|
+
'agree', 'ahead', 'aim', 'air', 'airport', 'aisle', 'alarm', 'album',
|
|
19
|
+
'alcohol', 'alert', 'alien', 'all', 'alley', 'allow', 'almost', 'alone',
|
|
20
|
+
'alpha', 'already', 'also', 'alter', 'always', 'amateur', 'amazing', 'among',
|
|
21
|
+
'amount', 'amused', 'analyst', 'anchor', 'ancient', 'anger', 'angle', 'angry',
|
|
22
|
+
'animal', 'ankle', 'announce', 'annual', 'another', 'answer', 'antenna', 'antique',
|
|
23
|
+
'anxiety', 'any', 'apart', 'apology', 'appear', 'apple', 'approve', 'april',
|
|
24
|
+
'arch', 'arctic', 'area', 'arena', 'argue', 'arm', 'armed', 'armor',
|
|
25
|
+
'army', 'around', 'arrange', 'arrest', 'arrive', 'arrow', 'art', 'artefact',
|
|
26
|
+
'artist', 'artwork', 'ask', 'aspect', 'assault', 'asset', 'assist', 'assume',
|
|
27
|
+
'asthma', 'athlete', 'atom', 'attack', 'attend', 'attitude', 'attract', 'auction',
|
|
28
|
+
'audit', 'august', 'aunt', 'author', 'auto', 'autumn', 'average', 'avocado',
|
|
29
|
+
'avoid', 'awake', 'aware', 'away', 'awesome', 'awful', 'awkward', 'axis',
|
|
30
|
+
'baby', 'bachelor', 'bacon', 'badge', 'bag', 'balance', 'balcony', 'ball',
|
|
31
|
+
'bamboo', 'banana', 'banner', 'bar', 'barely', 'bargain', 'barrel', 'base',
|
|
32
|
+
'basic', 'basket', 'battle', 'beach', 'bean', 'beauty', 'because', 'become',
|
|
33
|
+
'beef', 'before', 'begin', 'behave', 'behind', 'believe', 'below', 'belt',
|
|
34
|
+
'bench', 'benefit', 'best', 'betray', 'better', 'between', 'beyond', 'bicycle',
|
|
35
|
+
'bid', 'bike', 'bind', 'biology', 'bird', 'birth', 'bitter', 'black',
|
|
36
|
+
'blade', 'blame', 'blanket', 'blast', 'bleak', 'bless', 'blind', 'blood',
|
|
37
|
+
'blossom', 'blouse', 'blue', 'blur', 'blush', 'board', 'boat', 'body',
|
|
38
|
+
'boil', 'bomb', 'bone', 'bonus', 'book', 'boost', 'border', 'boring',
|
|
39
|
+
'borrow', 'boss', 'bottom', 'bounce', 'box', 'boy', 'bracket', 'brain',
|
|
40
|
+
'brand', 'brass', 'brave', 'bread', 'breeze', 'brick', 'bridge', 'brief',
|
|
41
|
+
'bright', 'bring', 'brisk', 'broccoli', 'broken', 'bronze', 'broom', 'brother',
|
|
42
|
+
'brown', 'brush', 'bubble', 'buddy', 'budget', 'buffalo', 'build', 'bulb',
|
|
43
|
+
'bulk', 'bullet', 'bundle', 'bunker', 'burden', 'burger', 'burst', 'bus',
|
|
44
|
+
'business', 'busy', 'butter', 'buyer', 'buzz', 'cabbage', 'cabin', 'cable',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Derive the 3-word safety code from a hex key.
|
|
49
|
+
* @param {string} keyHex 64-char hex key
|
|
50
|
+
* @returns {string} e.g. "apple-bridge-cabin"
|
|
51
|
+
*/
|
|
52
|
+
export function generateSAS(keyHex) {
|
|
53
|
+
const keyBuffer = Buffer.from(keyHex, 'hex');
|
|
54
|
+
const hash = createHash('sha256').update(keyBuffer).digest();
|
|
55
|
+
const w1 = wordList[hash[0] % wordList.length];
|
|
56
|
+
const w2 = wordList[hash[1] % wordList.length];
|
|
57
|
+
const w3 = wordList[hash[2] % wordList.length];
|
|
58
|
+
return `${w1}-${w2}-${w3}`;
|
|
59
|
+
}
|
package/src/send.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// send.js — the sender workflow.
|
|
2
|
+
//
|
|
3
|
+
// 1. read file 5. initiate upload
|
|
4
|
+
// 2. generate AES-256 key 6. PUT encrypted bytes to presigned URL
|
|
5
|
+
// 3. encrypt file locally 7. finalize with encrypted filename + senderToken
|
|
6
|
+
// 4. encrypt filename 8. print share code/link, wait for handshake,
|
|
7
|
+
// authorize, report status.
|
|
8
|
+
|
|
9
|
+
import { promises as fs } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { encryptBuffer, encryptString } from './crypto.js';
|
|
13
|
+
import { encodeCombinedCode, encodeShareLink } from './code.js';
|
|
14
|
+
import { generateSAS } from './sas.js';
|
|
15
|
+
import { SafeDropApi, SafeDropApiError } from './api.js';
|
|
16
|
+
import { log, style, output, ask, spinner, formatBytes, formatDuration } from './ui.js';
|
|
17
|
+
|
|
18
|
+
const MAX_FILE_SIZE_MB = 1024; // mirrors backend store.ts MAX_FILE_SIZE_MB
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Derive a browser origin from the API base so links open the web app.
|
|
22
|
+
* "https://safedrop.ma/api" -> "https://safedrop.ma".
|
|
23
|
+
* Local dev backends have no web origin, so we omit the link there.
|
|
24
|
+
*/
|
|
25
|
+
function webOriginFromApi(apiBase) {
|
|
26
|
+
try {
|
|
27
|
+
const u = new URL(apiBase);
|
|
28
|
+
if (u.hostname === 'localhost' || u.hostname === '127.0.0.1') return null;
|
|
29
|
+
return `${u.protocol}//${u.host}`;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function runSend(filePath, opts = {}) {
|
|
36
|
+
const api = new SafeDropApi(opts.api);
|
|
37
|
+
|
|
38
|
+
// --- 1. Read and validate the file -------------------------------------
|
|
39
|
+
let stat;
|
|
40
|
+
try {
|
|
41
|
+
stat = await fs.stat(filePath);
|
|
42
|
+
} catch {
|
|
43
|
+
log.error(`File not found: ${filePath}`);
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!stat.isFile()) {
|
|
48
|
+
log.error(`Not a regular file: ${filePath}`);
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const sizeMb = stat.size / 1024 / 1024;
|
|
53
|
+
if (sizeMb > MAX_FILE_SIZE_MB) {
|
|
54
|
+
log.error(`File is ${formatBytes(stat.size)} — exceeds the ${MAX_FILE_SIZE_MB} MB limit.`);
|
|
55
|
+
process.exitCode = 1;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const filename = path.basename(filePath);
|
|
60
|
+
log.info(`Sending ${style.bold(filename)} ${style.dim(`(${formatBytes(stat.size)})`)}`);
|
|
61
|
+
|
|
62
|
+
const fullSecurity = !!opts.fullSecurity;
|
|
63
|
+
|
|
64
|
+
// --- 2-4. Encrypt locally ----------------------------------------------
|
|
65
|
+
const spin = spinner('Encrypting locally…');
|
|
66
|
+
let payload, keyHex, encryptedFilename;
|
|
67
|
+
try {
|
|
68
|
+
const plain = await fs.readFile(filePath);
|
|
69
|
+
({ payload, keyHex } = encryptBuffer(plain));
|
|
70
|
+
encryptedFilename = encryptString(filename, keyHex);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
spin.stop();
|
|
73
|
+
log.error(`Encryption failed: ${err.message}`);
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- 5-7. Initiate, upload, finalize -----------------------------------
|
|
79
|
+
let uploadCode, presignedUrl, senderToken, uploadTTLSeconds;
|
|
80
|
+
try {
|
|
81
|
+
spin.setLabel('Requesting upload slot…');
|
|
82
|
+
const customExpirationSeconds = opts.ttlMinutes ? Math.floor(opts.ttlMinutes * 60) : undefined;
|
|
83
|
+
const init = await api.initiateUpload({ customExpirationSeconds });
|
|
84
|
+
({ uploadCode, url: presignedUrl, senderToken, uploadTTLSeconds } = init);
|
|
85
|
+
|
|
86
|
+
spin.setLabel('Uploading encrypted bytes…');
|
|
87
|
+
await api.uploadBytes(presignedUrl, payload);
|
|
88
|
+
|
|
89
|
+
spin.setLabel('Finalizing…');
|
|
90
|
+
await api.finalizeUpload(uploadCode, encryptedFilename, senderToken);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
spin.stop();
|
|
93
|
+
log.error(err instanceof SafeDropApiError ? err.message : `Upload failed: ${err.message}`);
|
|
94
|
+
process.exitCode = 1;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
spin.stop(`${style.green('✓')} Encrypted and uploaded. The server holds only ciphertext.`);
|
|
98
|
+
|
|
99
|
+
// --- 8. Share details ---------------------------------------------------
|
|
100
|
+
const combinedCode = encodeCombinedCode({ uploadCode, key: keyHex, fullSecurity });
|
|
101
|
+
const origin = webOriginFromApi(opts.api);
|
|
102
|
+
const ttl = uploadTTLSeconds || 15 * 60;
|
|
103
|
+
|
|
104
|
+
log.plain();
|
|
105
|
+
log.plain(style.bold(' Share this code with the receiver:'));
|
|
106
|
+
log.plain();
|
|
107
|
+
output(combinedCode); // the only secret printed — it is the whole point
|
|
108
|
+
log.plain();
|
|
109
|
+
if (origin) {
|
|
110
|
+
log.plain(style.dim(' Or this link (key stays in the URL fragment, never sent to the server):'));
|
|
111
|
+
log.plain(` ${style.cyan(encodeShareLink(combinedCode, origin))}`);
|
|
112
|
+
log.plain();
|
|
113
|
+
}
|
|
114
|
+
if (fullSecurity) {
|
|
115
|
+
const sas = generateSAS(keyHex);
|
|
116
|
+
log.plain(` ${style.yellow('Safety code')} (read aloud to verify, must match the receiver's): ${style.bold(sas)}`);
|
|
117
|
+
log.plain();
|
|
118
|
+
}
|
|
119
|
+
log.info(`This transfer expires in ${style.bold(formatDuration(ttl))}.`);
|
|
120
|
+
|
|
121
|
+
// --- Wait for the receiver handshake, then authorize -------------------
|
|
122
|
+
const expiresAt = Date.now() + ttl * 1000;
|
|
123
|
+
let cancelled = false;
|
|
124
|
+
|
|
125
|
+
const cleanup = async (reason) => {
|
|
126
|
+
if (cancelled) return;
|
|
127
|
+
cancelled = true;
|
|
128
|
+
try {
|
|
129
|
+
await api.cancelUpload(uploadCode, senderToken);
|
|
130
|
+
log.ok(`Transfer cancelled — the encrypted server copy was deleted (${reason}).`);
|
|
131
|
+
} catch {
|
|
132
|
+
log.warn('Transfer cancelled locally (server copy may already be gone).');
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Ctrl-C cancels the transfer cleanly from the sender side.
|
|
137
|
+
const onSigint = () => { cleanup('you pressed Ctrl-C').then(() => process.exit(130)); };
|
|
138
|
+
process.on('SIGINT', onSigint);
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
log.plain();
|
|
142
|
+
log.info('Waiting for the receiver. They will give you a handshake code.');
|
|
143
|
+
if (Date.now() > expiresAt) { log.warn('Transfer already expired.'); return; }
|
|
144
|
+
|
|
145
|
+
const remainingMin = Math.max(1, Math.round((expiresAt - Date.now()) / 60000));
|
|
146
|
+
const handshakeCode = await ask(
|
|
147
|
+
`${style.bold('Paste the receiver handshake code')} ${style.dim(`(≤ ${remainingMin}m left, or press Enter to cancel)`)}: `,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
if (!handshakeCode) {
|
|
151
|
+
await cleanup('no code entered');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (Date.now() > expiresAt) {
|
|
155
|
+
log.warn('Transfer expired before authorization. The server copy is gone.');
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const spin2 = spinner('Authorizing the receiver…');
|
|
160
|
+
try {
|
|
161
|
+
await api.authorizeHandshake(uploadCode, handshakeCode);
|
|
162
|
+
spin2.stop(`${style.green('✓')} Receiver authorized.`);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
spin2.stop();
|
|
165
|
+
if (err instanceof SafeDropApiError && err.statusCode === 403) {
|
|
166
|
+
log.error('That handshake code did not match. Ask the receiver to re-check it and try again.');
|
|
167
|
+
} else if (err instanceof SafeDropApiError && err.statusCode === 429) {
|
|
168
|
+
log.error('Too many failed attempts — the session was terminated for safety.');
|
|
169
|
+
} else {
|
|
170
|
+
log.error(err.message);
|
|
171
|
+
}
|
|
172
|
+
process.exitCode = 1;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
log.plain();
|
|
177
|
+
log.ok('The receiver can now download and decrypt the file.');
|
|
178
|
+
log.info('The server deletes its encrypted copy the moment the download completes.');
|
|
179
|
+
} finally {
|
|
180
|
+
process.off('SIGINT', onSigint);
|
|
181
|
+
}
|
|
182
|
+
}
|
package/src/ui.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// ui.js
|
|
2
|
+
//
|
|
3
|
+
// Minimal terminal helpers: colored status lines, an interactive prompt, and a
|
|
4
|
+
// lightweight spinner. No dependencies. Colors auto-disable when stdout is not
|
|
5
|
+
// a TTY or NO_COLOR is set, so piped/CI output stays clean.
|
|
6
|
+
|
|
7
|
+
import readline from 'node:readline';
|
|
8
|
+
|
|
9
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
10
|
+
const c = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
11
|
+
|
|
12
|
+
export const style = {
|
|
13
|
+
bold: (s) => c('1', s),
|
|
14
|
+
dim: (s) => c('2', s),
|
|
15
|
+
green: (s) => c('32', s),
|
|
16
|
+
red: (s) => c('31', s),
|
|
17
|
+
yellow: (s) => c('33', s),
|
|
18
|
+
cyan: (s) => c('36', s),
|
|
19
|
+
gray: (s) => c('90', s),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const log = {
|
|
23
|
+
info: (msg) => console.error(`${style.cyan('•')} ${msg}`),
|
|
24
|
+
ok: (msg) => console.error(`${style.green('✓')} ${msg}`),
|
|
25
|
+
warn: (msg) => console.error(`${style.yellow('!')} ${msg}`),
|
|
26
|
+
error: (msg) => console.error(`${style.red('✗')} ${msg}`),
|
|
27
|
+
plain: (msg = '') => console.error(msg),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Print a value to stdout (the "result" channel; logs go to stderr). */
|
|
31
|
+
export function output(value) {
|
|
32
|
+
process.stdout.write(`${value}\n`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Ask a free-text question. Returns the trimmed answer. */
|
|
36
|
+
export function ask(question) {
|
|
37
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
rl.question(question, (answer) => {
|
|
40
|
+
rl.close();
|
|
41
|
+
resolve(answer.trim());
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Ask a yes/no question. Returns boolean. */
|
|
47
|
+
export async function confirm(question, defaultYes = false) {
|
|
48
|
+
const hint = defaultYes ? '[Y/n]' : '[y/N]';
|
|
49
|
+
const answer = (await ask(`${question} ${hint} `)).toLowerCase();
|
|
50
|
+
if (!answer) return defaultYes;
|
|
51
|
+
return answer === 'y' || answer === 'yes';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Simple braille spinner. Returns a handle with .stop(). */
|
|
55
|
+
export function spinner(label) {
|
|
56
|
+
if (!process.stderr.isTTY) {
|
|
57
|
+
console.error(`${style.dim('…')} ${label}`);
|
|
58
|
+
return { stop: () => {}, setLabel: () => {} };
|
|
59
|
+
}
|
|
60
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
61
|
+
let i = 0;
|
|
62
|
+
let current = label;
|
|
63
|
+
const timer = setInterval(() => {
|
|
64
|
+
process.stderr.write(`\r${style.cyan(frames[i++ % frames.length])} ${current} `);
|
|
65
|
+
}, 80);
|
|
66
|
+
return {
|
|
67
|
+
setLabel: (l) => { current = l; },
|
|
68
|
+
stop: (finalLine) => {
|
|
69
|
+
clearInterval(timer);
|
|
70
|
+
process.stderr.write('\r\x1b[K');
|
|
71
|
+
if (finalLine) console.error(finalLine);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Format a byte count as a human-readable size. */
|
|
77
|
+
export function formatBytes(bytes) {
|
|
78
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
79
|
+
let n = bytes;
|
|
80
|
+
let u = 0;
|
|
81
|
+
while (n >= 1024 && u < units.length - 1) { n /= 1024; u++; }
|
|
82
|
+
return `${n.toFixed(n < 10 && u > 0 ? 2 : 0)} ${units[u]}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Format seconds as "Xm Ys". */
|
|
86
|
+
export function formatDuration(totalSeconds) {
|
|
87
|
+
const s = Math.max(0, Math.floor(totalSeconds));
|
|
88
|
+
const h = Math.floor(s / 3600);
|
|
89
|
+
const m = Math.floor((s % 3600) / 60);
|
|
90
|
+
const sec = s % 60;
|
|
91
|
+
if (h > 0) return `${h}h ${m}m`;
|
|
92
|
+
if (m > 0) return `${m}m ${sec}s`;
|
|
93
|
+
return `${sec}s`;
|
|
94
|
+
}
|