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/API.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# SafeDrop public API contract
|
|
2
|
+
|
|
3
|
+
This document describes **only the public HTTP endpoints the CLI consumes**. It
|
|
4
|
+
is the contract between any SafeDrop client (browser or CLI) and the SafeDrop
|
|
5
|
+
backend. It deliberately documents nothing about the server's internals
|
|
6
|
+
(storage, Redis keys, token signing, rate-limit internals).
|
|
7
|
+
|
|
8
|
+
All request/response bodies are JSON unless noted. The `--api` base URL is
|
|
9
|
+
prefixed to every path below. For the hosted app that base is
|
|
10
|
+
`https://safedrop.ma/api`; for local development it is `http://localhost:4000`.
|
|
11
|
+
|
|
12
|
+
Throughout, **`uploadCode`** is a 16-character identifier and **`handshakeCode`**
|
|
13
|
+
is a 16-character identifier.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Sender endpoints
|
|
18
|
+
|
|
19
|
+
### `POST /initiate-upload`
|
|
20
|
+
|
|
21
|
+
Reserve an upload slot and obtain a presigned storage URL.
|
|
22
|
+
|
|
23
|
+
**Request body** (all optional):
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{ "customExpirationSeconds": 3600 }
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`customExpirationSeconds` clamps to the server's allowed range (max 24h); omit it
|
|
30
|
+
for the default TTL (15 minutes).
|
|
31
|
+
|
|
32
|
+
**Response `200`:**
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"uploadCode": "abcdef0123456789",
|
|
37
|
+
"url": "https://.../presigned-put-url",
|
|
38
|
+
"sseToken": "…",
|
|
39
|
+
"senderToken": "…",
|
|
40
|
+
"maxFileSizeMB": 1024,
|
|
41
|
+
"uploadTTLSeconds": 900
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- `url` — presigned `PUT` URL for the encrypted bytes.
|
|
46
|
+
- `senderToken` — secret proving you are the sender; required to finalize,
|
|
47
|
+
authorize, and cancel. Never share it.
|
|
48
|
+
|
|
49
|
+
### `PUT <presigned url>`
|
|
50
|
+
|
|
51
|
+
Upload the **encrypted** file bytes directly to storage.
|
|
52
|
+
|
|
53
|
+
- Header: `Content-Type: application/octet-stream`
|
|
54
|
+
- Body: the raw ciphertext payload (`IV || ciphertext || GCM tag`).
|
|
55
|
+
- Success: HTTP `2xx`.
|
|
56
|
+
|
|
57
|
+
### `POST /upload/:uploadCode/finalize`
|
|
58
|
+
|
|
59
|
+
Attach the encrypted filename and commit the upload.
|
|
60
|
+
|
|
61
|
+
**Request body:**
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{ "encryptedFilename": "<base64>", "senderToken": "…" }
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**Response `200`:** `{ "message": "Upload finalized successfully." }`
|
|
68
|
+
|
|
69
|
+
Errors: `400` missing filename, `401` missing sender token, `403` bad sender
|
|
70
|
+
token, `404` bytes not uploaded, `413` file exceeds size limit.
|
|
71
|
+
|
|
72
|
+
### `DELETE /upload/:uploadCode` (sender cancellation)
|
|
73
|
+
|
|
74
|
+
**Request body:** `{ "senderToken": "…" }`
|
|
75
|
+
|
|
76
|
+
Terminates the session and deletes the encrypted copy. `403` on bad token.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Receiver endpoints
|
|
81
|
+
|
|
82
|
+
### `POST /handshake/initiate`
|
|
83
|
+
|
|
84
|
+
Begin a receive handshake for an upload.
|
|
85
|
+
|
|
86
|
+
**Request body:** `{ "uploadCode": "abcdef0123456789" }`
|
|
87
|
+
|
|
88
|
+
**Response `200`:**
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{ "handshakeCode": "…", "recipientToken": "…", "sseToken": "…" }
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
- `handshakeCode` — read this to the sender so they can authorize you.
|
|
95
|
+
- `recipientToken` — secret used to poll for the download token and to cancel.
|
|
96
|
+
|
|
97
|
+
Errors: `404` invalid or expired upload code.
|
|
98
|
+
|
|
99
|
+
### `GET /handshake/token/:uploadCode`
|
|
100
|
+
|
|
101
|
+
Poll for the download token. Returns it once the sender has authorized.
|
|
102
|
+
|
|
103
|
+
- Header: `Authorization: Bearer <recipientToken>`
|
|
104
|
+
|
|
105
|
+
**Response `200`:** `{ "downloadToken": "<jwt>" }`
|
|
106
|
+
|
|
107
|
+
**Response `404`:** `{ "error": "Token not found or expired." }` — not yet
|
|
108
|
+
authorized; keep polling.
|
|
109
|
+
|
|
110
|
+
**Response `403`:** the recipient token is invalid or the session was cancelled.
|
|
111
|
+
|
|
112
|
+
> The download token is single-issue: once returned, the recipient token is
|
|
113
|
+
> consumed.
|
|
114
|
+
|
|
115
|
+
### `GET /upload/:uploadCode` (download)
|
|
116
|
+
|
|
117
|
+
Download the encrypted file.
|
|
118
|
+
|
|
119
|
+
- Header: `Authorization: Bearer <downloadToken>`
|
|
120
|
+
|
|
121
|
+
**Response `200`:**
|
|
122
|
+
|
|
123
|
+
- Body: the raw ciphertext payload.
|
|
124
|
+
- Header `X-Encrypted-Filename`: the base64 encrypted filename to decrypt
|
|
125
|
+
locally.
|
|
126
|
+
|
|
127
|
+
The server **deletes its stored copy** as soon as the response finishes
|
|
128
|
+
streaming. Errors: `401`/`403` bad token, `404` metadata missing/expired.
|
|
129
|
+
|
|
130
|
+
### `DELETE /handshake/:uploadCode` (recipient cancellation)
|
|
131
|
+
|
|
132
|
+
- Header: `Authorization: Bearer <recipientToken>` (a valid download token is
|
|
133
|
+
also accepted).
|
|
134
|
+
|
|
135
|
+
Terminates the session.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Authorization (sender side)
|
|
140
|
+
|
|
141
|
+
### `POST /handshake/authorize`
|
|
142
|
+
|
|
143
|
+
The sender calls this with the handshake code the receiver gave them.
|
|
144
|
+
|
|
145
|
+
**Request body:**
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
{ "uploadCode": "abcdef0123456789", "handshakeCode": "…" }
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**Response `200`:** `{ "message": "Download authorized" }`
|
|
152
|
+
|
|
153
|
+
Errors: `403` wrong handshake code, `404` session terminated/expired, `429` too
|
|
154
|
+
many failed attempts (the session is terminated for safety).
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Real-time events (optional)
|
|
159
|
+
|
|
160
|
+
The server exposes Server-Sent Events at `GET /session-events/:sseToken`
|
|
161
|
+
carrying `status-authorized` and `session-terminated` messages. The CLI does
|
|
162
|
+
**not** require SSE — it polls `GET /handshake/token/:uploadCode` instead, which
|
|
163
|
+
the browser also does as a fallback. SSE is documented here only for
|
|
164
|
+
completeness.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Client-side payload formats
|
|
169
|
+
|
|
170
|
+
These are computed entirely on the client; the server treats them as opaque.
|
|
171
|
+
|
|
172
|
+
### Encryption payload
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
payload = IV (12 bytes) || ciphertext || GCM auth tag (16 bytes)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
- Algorithm: **AES-256-GCM**.
|
|
179
|
+
- Key: 256-bit, shared as 64 lowercase hex characters.
|
|
180
|
+
- The filename is encrypted the same way, but its plaintext is encoded as
|
|
181
|
+
**UTF-16LE** before encryption, then base64-encoded for the
|
|
182
|
+
`X-Encrypted-Filename` header / `encryptedFilename` field.
|
|
183
|
+
|
|
184
|
+
### Combined share code
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
combinedCode = base64( JSON.stringify({ uploadCode, key, fullSecurity }) )
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Share link
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
https://<host>/#code=<combinedCode>
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The code lives in the **URL fragment** (`#`), which browsers never transmit to a
|
|
197
|
+
server.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SafeDrop
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# safedrop-cli
|
|
2
|
+
|
|
3
|
+
Zero-knowledge, end-to-end encrypted file transfer from your terminal — fully
|
|
4
|
+
compatible with the [SafeDrop](https://safedrop.ma) web app.
|
|
5
|
+
|
|
6
|
+
Files are **encrypted on your machine before upload** and **decrypted on the
|
|
7
|
+
receiver's machine after download**. The SafeDrop server only ever sees
|
|
8
|
+
ciphertext: it never receives your plaintext files, your real filenames, or
|
|
9
|
+
your encryption keys.
|
|
10
|
+
|
|
11
|
+
A file sent from the CLI can be received in the browser, and a file sent from
|
|
12
|
+
the browser can be received by the CLI — they use the identical encryption and
|
|
13
|
+
code format.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install -g safedrop-cli
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or run without installing:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx safedrop-cli send ./report.pdf
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Requires **Node.js 18+** (uses the built-in `fetch` and `crypto`). The package
|
|
30
|
+
has **zero runtime dependencies**, so it is small and easy to audit.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
### Send a file
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
safedrop send ./report.pdf
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The CLI encrypts the file locally, uploads the ciphertext, and prints a **share
|
|
43
|
+
code** (and a share link if you point it at a hosted SafeDrop). Give that code
|
|
44
|
+
to your receiver. Then the receiver runs `receive`, reads you back a short
|
|
45
|
+
**handshake code**, you paste it in, and the transfer is authorized.
|
|
46
|
+
|
|
47
|
+
### Receive a file
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
safedrop receive eyJ1cGxvYWRDb2RlIjoi...
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The CLI parses the code, shows you a handshake code to read to the sender, waits
|
|
54
|
+
for them to authorize, then downloads and decrypts the file to your current
|
|
55
|
+
directory.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Commands
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
safedrop send <file> [options]
|
|
63
|
+
safedrop receive <code-or-link> [options]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### `send` options
|
|
67
|
+
|
|
68
|
+
| Option | Description |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `--ttl <minutes>` | How long the transfer stays available before it expires. Default `15`, max `1440` (24h). |
|
|
71
|
+
| `--secure` | Enable full-security mode: both sides compare an out-of-band 3-word safety code to detect interception. |
|
|
72
|
+
| `--api <base-url>` | SafeDrop API base URL. Defaults to `http://localhost:4000`, or the `SAFEDROP_API` env var. |
|
|
73
|
+
|
|
74
|
+
### `receive` options
|
|
75
|
+
|
|
76
|
+
| Option | Description |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `--output`, `-o <path>` | Where to save the file. A directory (saves under the sender's filename) or a full file path. |
|
|
79
|
+
| `--api <base-url>` | SafeDrop API base URL. Defaults to `http://localhost:4000`, or the `SAFEDROP_API` env var. |
|
|
80
|
+
|
|
81
|
+
### Pointing at a hosted SafeDrop
|
|
82
|
+
|
|
83
|
+
The production web app serves its API under `/api`:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
safedrop send ./report.pdf --api https://safedrop.ma/api
|
|
87
|
+
# or set it once:
|
|
88
|
+
export SAFEDROP_API=https://safedrop.ma/api
|
|
89
|
+
safedrop send ./report.pdf
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Examples
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
# Send with a 1-hour expiry and full-security verification
|
|
98
|
+
safedrop send ./contract.pdf --ttl 60 --secure
|
|
99
|
+
|
|
100
|
+
# Receive from a full browser link into your Downloads folder
|
|
101
|
+
safedrop receive "https://safedrop.ma/#code=eyJ1cGxv..." -o ~/Downloads/
|
|
102
|
+
|
|
103
|
+
# Receive and save under a specific name
|
|
104
|
+
safedrop receive eyJ1cGxv... -o ./received-contract.pdf
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
See [`examples/transcript-sender.txt`](examples/transcript-sender.txt) and
|
|
108
|
+
[`examples/transcript-receiver.txt`](examples/transcript-receiver.txt) for full
|
|
109
|
+
end-to-end terminal walkthroughs.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## How a transfer works
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
SENDER SAFEDROP SERVER RECEIVER
|
|
117
|
+
------ --------------- --------
|
|
118
|
+
encrypt file + name locally
|
|
119
|
+
generate AES-256 key
|
|
120
|
+
│ POST /initiate-upload ───────────▶ uploadCode, presigned URL,
|
|
121
|
+
│ senderToken
|
|
122
|
+
│ PUT ciphertext ──────────────────▶ (stores ciphertext only)
|
|
123
|
+
│ POST /upload/:code/finalize ─────▶ (stores encrypted filename)
|
|
124
|
+
print share code ◀──────────────────────────────────────────── paste code
|
|
125
|
+
POST /handshake/initiate
|
|
126
|
+
handshakeCode ◀────────────── (gets handshake code)
|
|
127
|
+
read handshake code ◀────────────────────────────────────────── read it aloud
|
|
128
|
+
│ POST /handshake/authorize ───────▶ issues download token
|
|
129
|
+
poll /handshake/token/:code ◀──
|
|
130
|
+
downloadToken ─────────────────────────────▶
|
|
131
|
+
GET /upload/:code ◀───────────
|
|
132
|
+
ciphertext ────────────────────────────────▶
|
|
133
|
+
(server deletes its copy) decrypt locally,
|
|
134
|
+
save to disk
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The **key never leaves the client**. The server stores only the encrypted bytes
|
|
138
|
+
and the encrypted filename, and deletes both the moment the download completes.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Using it as a library
|
|
143
|
+
|
|
144
|
+
The encryption and code helpers are exported so you can build your own tools:
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
import {
|
|
148
|
+
encryptBuffer, decryptBuffer,
|
|
149
|
+
encodeCombinedCode, decodeCombinedCode,
|
|
150
|
+
SafeDropApi,
|
|
151
|
+
} from 'safedrop-cli';
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
These are the same building blocks the CLI uses. See [`API.md`](API.md) for the
|
|
155
|
+
HTTP contract and [`SECURITY.md`](SECURITY.md) for the threat model.
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Security at a glance
|
|
160
|
+
|
|
161
|
+
- **AES-256-GCM** authenticated encryption, done entirely client-side.
|
|
162
|
+
- The server is **zero-knowledge**: no keys, no plaintext, no real filenames.
|
|
163
|
+
- Share **codes/links carry the key in the URL fragment** (`#code=...`), which
|
|
164
|
+
browsers never send to servers.
|
|
165
|
+
- The CLI **never logs** file contents or keys; the share code is the only
|
|
166
|
+
secret it prints, and only because sharing it is the entire purpose.
|
|
167
|
+
- Downloaded filenames are **sanitized** — path traversal is refused and local
|
|
168
|
+
files are **never overwritten without confirmation**.
|
|
169
|
+
|
|
170
|
+
Full details in [`SECURITY.md`](SECURITY.md).
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Development
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
npm test # runs the cross-compatibility, code-parsing, and path tests
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The crypto tests encrypt with the CLI and decrypt using the **actual Web Crypto
|
|
181
|
+
API** (and vice versa) to guarantee browser ⇄ CLI compatibility.
|
|
182
|
+
|
|
183
|
+
## License
|
|
184
|
+
|
|
185
|
+
MIT
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# SafeDrop CLI security model
|
|
2
|
+
|
|
3
|
+
SafeDrop is **zero-knowledge**: the server stores and relays data it cannot
|
|
4
|
+
read. This document explains the guarantees the CLI provides, how it provides
|
|
5
|
+
them, and what is explicitly out of scope.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## The core guarantee
|
|
10
|
+
|
|
11
|
+
> The SafeDrop server never receives plaintext file contents, plaintext
|
|
12
|
+
> filenames, or encryption keys.
|
|
13
|
+
|
|
14
|
+
Everything sensitive is encrypted and decrypted **on the client**. The CLI is
|
|
15
|
+
just another zero-knowledge client, identical in behavior to the browser app.
|
|
16
|
+
|
|
17
|
+
| Data | Where it is in plaintext | What the server sees |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| File contents | Sender's & receiver's machines only | AES-256-GCM ciphertext |
|
|
20
|
+
| Real filename | Sender's & receiver's machines only | Encrypted, base64 blob |
|
|
21
|
+
| Encryption key | Sender's machine, then the share code | Never |
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Cryptography
|
|
26
|
+
|
|
27
|
+
- **AES-256-GCM** authenticated encryption (confidentiality + integrity). A
|
|
28
|
+
tampered or truncated payload fails decryption rather than producing garbage.
|
|
29
|
+
- Keys are **256-bit**, generated with the OS CSPRNG (`crypto.randomBytes`).
|
|
30
|
+
- A fresh **96-bit IV** is generated per encryption (`crypto.randomBytes(12)`)
|
|
31
|
+
and prepended to the payload.
|
|
32
|
+
- Wire format: `IV (12B) || ciphertext || GCM tag (16B)` — byte-for-byte
|
|
33
|
+
identical to the browser's Web Crypto output, so files cross between the two
|
|
34
|
+
clients transparently. This is enforced by tests that decrypt CLI output with
|
|
35
|
+
the real Web Crypto API and vice versa.
|
|
36
|
+
- Filenames are encrypted with the same scheme; their plaintext is UTF-16LE so
|
|
37
|
+
the bytes match the browser exactly.
|
|
38
|
+
|
|
39
|
+
The CLI does **not** invent its own protocol — it reuses the established
|
|
40
|
+
SafeDrop client format. No backend logic is reimplemented; the CLI only calls
|
|
41
|
+
the documented HTTP API ([`API.md`](API.md)).
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Key distribution and links
|
|
46
|
+
|
|
47
|
+
- The encryption key travels **only inside the share code**, which the sender
|
|
48
|
+
hands to the receiver out-of-band (chat, in person, etc.).
|
|
49
|
+
- The combined code is `base64(JSON{ uploadCode, key, fullSecurity })`.
|
|
50
|
+
- The browser share link places the code in the **URL fragment**
|
|
51
|
+
(`https://host/#code=...`). Fragments are never sent to the server in an HTTP
|
|
52
|
+
request, so navigating to a link does not leak the key to the host.
|
|
53
|
+
- The CLI prints the share code to **stdout** and all status/log output to
|
|
54
|
+
**stderr**, so you can capture the code cleanly (e.g. `safedrop send f >
|
|
55
|
+
code.txt`) without log noise, and logs never carry the secret by accident.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Optional MITM protection: full-security mode (`--secure`)
|
|
60
|
+
|
|
61
|
+
When enabled, both sides derive a **3-word Short Authentication String (SAS)**
|
|
62
|
+
from `SHA-256(key)` and compare it over a trusted channel (e.g. read it aloud on
|
|
63
|
+
a call). If the words differ, the key was tampered with in transit and the
|
|
64
|
+
transfer is aborted. The wordlist and derivation match the browser exactly, so a
|
|
65
|
+
browser sender and CLI receiver (or vice versa) compute the same words.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## What the CLI does and does not log
|
|
70
|
+
|
|
71
|
+
- **Never logged:** file contents, encryption keys, sender/recipient/download
|
|
72
|
+
tokens.
|
|
73
|
+
- **Printed once, intentionally:** the share code (sender) and the handshake
|
|
74
|
+
code (receiver). These are the secrets you are meant to share — that is the
|
|
75
|
+
entire purpose of the command. The share code is written to stdout; with
|
|
76
|
+
`--secure` the safety code is shown so you can verify it.
|
|
77
|
+
- Secrets are not written to any cache, history, or temp file by the CLI.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Safe handling of downloaded files
|
|
82
|
+
|
|
83
|
+
The decrypted filename comes from the sender and is therefore **untrusted
|
|
84
|
+
input** after decryption. The CLI:
|
|
85
|
+
|
|
86
|
+
- **Strips directory components** from the sender's filename (`../`, absolute
|
|
87
|
+
paths, drive letters, Windows separators) — a malicious name can only ever
|
|
88
|
+
resolve to a basename inside the chosen output directory.
|
|
89
|
+
- **Refuses path traversal**: the resolved path is verified to stay within its
|
|
90
|
+
parent directory, otherwise the write is rejected.
|
|
91
|
+
- **Neutralizes dangerous names**: control characters and Windows-illegal
|
|
92
|
+
characters are removed; reserved device names (`CON`, `NUL`, `COM1`, …) are
|
|
93
|
+
prefixed.
|
|
94
|
+
- **Never overwrites without confirmation**: if the target exists you are asked;
|
|
95
|
+
declining writes to `name (1).ext`, `name (2).ext`, … instead.
|
|
96
|
+
|
|
97
|
+
See [`test/paths.test.js`](test/paths.test.js) for the enforced behaviors.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Availability, expiry, and cancellation
|
|
102
|
+
|
|
103
|
+
- Transfers **expire** server-side after their TTL; the CLI reports expiry
|
|
104
|
+
cleanly on both sides rather than hanging.
|
|
105
|
+
- **Either side can cancel.** The sender's `Ctrl-C` (or empty handshake input)
|
|
106
|
+
calls `DELETE /upload/:code`; the receiver's `Ctrl-C` calls
|
|
107
|
+
`DELETE /handshake/:code`. Both delete the encrypted server copy.
|
|
108
|
+
- The server deletes the stored ciphertext the instant the download completes,
|
|
109
|
+
so each code is **single-use**.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Validation and limits
|
|
114
|
+
|
|
115
|
+
- File size is checked against the server limit (1024 MB) **before** upload, and
|
|
116
|
+
the server re-checks on finalize.
|
|
117
|
+
- Network errors are translated into actionable messages (connection refused,
|
|
118
|
+
DNS failure, TLS problems, timeouts) instead of raw stack traces.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Threat model — out of scope
|
|
123
|
+
|
|
124
|
+
SafeDrop does **not** defend against:
|
|
125
|
+
|
|
126
|
+
- A **compromised endpoint** (malware on the sender's or receiver's machine).
|
|
127
|
+
Plaintext exists there by necessity.
|
|
128
|
+
- The sender **sending the code to the wrong person**, or an attacker who
|
|
129
|
+
obtains the code before the legitimate receiver (use `--secure` to detect a
|
|
130
|
+
key swap; protect the channel you share the code over).
|
|
131
|
+
- **Traffic analysis** (the server learns file size and timing).
|
|
132
|
+
- **Denial of service** against the server.
|
|
133
|
+
|
|
134
|
+
Because the share code contains the key, **anyone who obtains the code can
|
|
135
|
+
decrypt the file** until the transfer is downloaded, cancelled, or expires.
|
|
136
|
+
Treat the code like a password and prefer ephemeral, trusted channels.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Reporting
|
|
141
|
+
|
|
142
|
+
This CLI is open source and dependency-free for auditability. Please report
|
|
143
|
+
suspected vulnerabilities privately to the SafeDrop maintainers rather than
|
|
144
|
+
opening a public issue.
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "safedrop-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Zero-knowledge, end-to-end encrypted file transfer from your terminal. Fully compatible with the SafeDrop web app.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"safedrop": "src/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "src/index.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.js",
|
|
12
|
+
"./crypto": "./src/crypto.js",
|
|
13
|
+
"./code": "./src/code.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"README.md",
|
|
18
|
+
"API.md",
|
|
19
|
+
"SECURITY.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node --test test/",
|
|
24
|
+
"start": "node src/cli.js"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"safedrop",
|
|
31
|
+
"encryption",
|
|
32
|
+
"aes-256-gcm",
|
|
33
|
+
"zero-knowledge",
|
|
34
|
+
"file-transfer",
|
|
35
|
+
"e2ee",
|
|
36
|
+
"cli",
|
|
37
|
+
"secure"
|
|
38
|
+
],
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "https://github.com/safedrop/safedrop-cli.git"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {}
|
|
45
|
+
}
|