ghostbit-cli 1.0.0__tar.gz
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.
- ghostbit_cli-1.0.0/PKG-INFO +97 -0
- ghostbit_cli-1.0.0/README.md +64 -0
- ghostbit_cli-1.0.0/cli.py +479 -0
- ghostbit_cli-1.0.0/ghostbit_cli.egg-info/PKG-INFO +97 -0
- ghostbit_cli-1.0.0/ghostbit_cli.egg-info/SOURCES.txt +10 -0
- ghostbit_cli-1.0.0/ghostbit_cli.egg-info/dependency_links.txt +1 -0
- ghostbit_cli-1.0.0/ghostbit_cli.egg-info/entry_points.txt +3 -0
- ghostbit_cli-1.0.0/ghostbit_cli.egg-info/requires.txt +11 -0
- ghostbit_cli-1.0.0/ghostbit_cli.egg-info/top_level.txt +1 -0
- ghostbit_cli-1.0.0/pyproject.toml +47 -0
- ghostbit_cli-1.0.0/setup.cfg +4 -0
- ghostbit_cli-1.0.0/setup.py +3 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ghostbit-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Ghostbit CLI — create end-to-end encrypted pastes from the terminal
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/stackopshq/ghostbit
|
|
7
|
+
Project-URL: Repository, https://github.com/stackopshq/ghostbit
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/stackopshq/ghostbit/issues
|
|
9
|
+
Keywords: paste,cli,encryption,privacy,ghostbit
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Internet
|
|
21
|
+
Classifier: Topic :: Security :: Cryptography
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
Requires-Dist: cryptography>=42.0.0
|
|
26
|
+
Provides-Extra: color
|
|
27
|
+
Requires-Dist: pygments>=2.17.0; extra == "color"
|
|
28
|
+
Provides-Extra: markdown
|
|
29
|
+
Requires-Dist: rich>=13.0.0; extra == "markdown"
|
|
30
|
+
Provides-Extra: all
|
|
31
|
+
Requires-Dist: pygments>=2.17.0; extra == "all"
|
|
32
|
+
Requires-Dist: rich>=13.0.0; extra == "all"
|
|
33
|
+
|
|
34
|
+
# Ghostbit CLI
|
|
35
|
+
|
|
36
|
+
Command-line tool for [Ghostbit](https://github.com/stackopshq/ghostbit) — a self-hosted, end-to-end encrypted paste service.
|
|
37
|
+
|
|
38
|
+
All content is encrypted **in the client** before being sent to the server. The server never sees your plaintext.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install ghostbit-cli
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# Paste from stdin
|
|
50
|
+
cat file.py | gb
|
|
51
|
+
|
|
52
|
+
# Paste a file (language auto-detected from extension)
|
|
53
|
+
gb file.py
|
|
54
|
+
|
|
55
|
+
# With options
|
|
56
|
+
gb file.py --lang python --burn --expires 3600
|
|
57
|
+
|
|
58
|
+
# Password-protected paste
|
|
59
|
+
echo "secret" | gb --password mysecret
|
|
60
|
+
|
|
61
|
+
# Output JSON (includes full URL with decryption key)
|
|
62
|
+
cat data.json | gb --json
|
|
63
|
+
|
|
64
|
+
# Point to your self-hosted instance
|
|
65
|
+
gb config set server https://paste.example.com
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Options
|
|
69
|
+
|
|
70
|
+
| Flag | Short | Description |
|
|
71
|
+
|------|-------|-------------|
|
|
72
|
+
| `--lang` | `-l` | Language hint (python, go, rust, …) |
|
|
73
|
+
| `--expires` | `-e` | TTL in seconds (3600 = 1h, 86400 = 1d) |
|
|
74
|
+
| `--burn` | `-b` | Delete after the first view |
|
|
75
|
+
| `--max-views` | `-m` | Delete after N views |
|
|
76
|
+
| `--password` | `-p` | Encrypt with a password |
|
|
77
|
+
| `--server` | `-s` | Override server URL for this call |
|
|
78
|
+
| `--quiet` | `-q` | Print URL only |
|
|
79
|
+
| `--json` | | Print full JSON response |
|
|
80
|
+
|
|
81
|
+
## Configuration
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
gb config set server https://paste.example.com
|
|
85
|
+
gb config show
|
|
86
|
+
gb config unset server
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Config is stored at `~/.config/ghostbit.toml`.
|
|
90
|
+
|
|
91
|
+
## Self-hosting
|
|
92
|
+
|
|
93
|
+
See the [Ghostbit server repository](https://github.com/stackopshq/ghostbit) for Docker and manual setup instructions.
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Ghostbit CLI
|
|
2
|
+
|
|
3
|
+
Command-line tool for [Ghostbit](https://github.com/stackopshq/ghostbit) — a self-hosted, end-to-end encrypted paste service.
|
|
4
|
+
|
|
5
|
+
All content is encrypted **in the client** before being sent to the server. The server never sees your plaintext.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install ghostbit-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Paste from stdin
|
|
17
|
+
cat file.py | gb
|
|
18
|
+
|
|
19
|
+
# Paste a file (language auto-detected from extension)
|
|
20
|
+
gb file.py
|
|
21
|
+
|
|
22
|
+
# With options
|
|
23
|
+
gb file.py --lang python --burn --expires 3600
|
|
24
|
+
|
|
25
|
+
# Password-protected paste
|
|
26
|
+
echo "secret" | gb --password mysecret
|
|
27
|
+
|
|
28
|
+
# Output JSON (includes full URL with decryption key)
|
|
29
|
+
cat data.json | gb --json
|
|
30
|
+
|
|
31
|
+
# Point to your self-hosted instance
|
|
32
|
+
gb config set server https://paste.example.com
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Options
|
|
36
|
+
|
|
37
|
+
| Flag | Short | Description |
|
|
38
|
+
|------|-------|-------------|
|
|
39
|
+
| `--lang` | `-l` | Language hint (python, go, rust, …) |
|
|
40
|
+
| `--expires` | `-e` | TTL in seconds (3600 = 1h, 86400 = 1d) |
|
|
41
|
+
| `--burn` | `-b` | Delete after the first view |
|
|
42
|
+
| `--max-views` | `-m` | Delete after N views |
|
|
43
|
+
| `--password` | `-p` | Encrypt with a password |
|
|
44
|
+
| `--server` | `-s` | Override server URL for this call |
|
|
45
|
+
| `--quiet` | `-q` | Print URL only |
|
|
46
|
+
| `--json` | | Print full JSON response |
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
gb config set server https://paste.example.com
|
|
52
|
+
gb config show
|
|
53
|
+
gb config unset server
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Config is stored at `~/.config/ghostbit.toml`.
|
|
57
|
+
|
|
58
|
+
## Self-hosting
|
|
59
|
+
|
|
60
|
+
See the [Ghostbit server repository](https://github.com/stackopshq/ghostbit) for Docker and manual setup instructions.
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
MIT
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Ghostbit CLI — create and view pastes from the terminal.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
cat file.py | gb
|
|
7
|
+
gb file.py
|
|
8
|
+
gb file.py --lang python --burn
|
|
9
|
+
gb view https://paste.example.com/abc123#KEY~TOKEN
|
|
10
|
+
gb config set server https://paste.example.com
|
|
11
|
+
gb config show
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import base64
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.request
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
__version__ = "1.0.0"
|
|
25
|
+
_USER_AGENT = f"Ghostbit-CLI/{__version__}"
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
29
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
30
|
+
from cryptography.hazmat.primitives import hashes as _hashes
|
|
31
|
+
_CRYPTO_OK = True
|
|
32
|
+
except ImportError:
|
|
33
|
+
_CRYPTO_OK = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ── Crypto (mirrors e2e.js) ───────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
def _require_crypto():
|
|
39
|
+
if not _CRYPTO_OK:
|
|
40
|
+
print("Error: 'cryptography' package required. Run: pip install cryptography", file=sys.stderr)
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
|
|
43
|
+
def _gen_key() -> bytes:
|
|
44
|
+
return os.urandom(32)
|
|
45
|
+
|
|
46
|
+
def _gen_salt() -> str:
|
|
47
|
+
return base64.b64encode(os.urandom(16)).decode()
|
|
48
|
+
|
|
49
|
+
def _encrypt(plaintext: str, key: bytes) -> tuple[str, str]:
|
|
50
|
+
nonce = os.urandom(12)
|
|
51
|
+
ct = AESGCM(key).encrypt(nonce, plaintext.encode(), None)
|
|
52
|
+
return base64.b64encode(ct).decode(), base64.b64encode(nonce).decode()
|
|
53
|
+
|
|
54
|
+
def _decrypt(ciphertext_b64: str, nonce_b64: str, key: bytes) -> str:
|
|
55
|
+
ct = base64.b64decode(ciphertext_b64)
|
|
56
|
+
nonce = base64.b64decode(nonce_b64)
|
|
57
|
+
pt = AESGCM(key).decrypt(nonce, ct, None)
|
|
58
|
+
return pt.decode()
|
|
59
|
+
|
|
60
|
+
def _derive_key(password: str, salt_b64: str) -> bytes:
|
|
61
|
+
salt = base64.b64decode(salt_b64)
|
|
62
|
+
kdf = PBKDF2HMAC(algorithm=_hashes.SHA256(), length=32, salt=salt, iterations=600_000)
|
|
63
|
+
return kdf.derive(password.encode())
|
|
64
|
+
|
|
65
|
+
def _key_to_fragment(key: bytes) -> str:
|
|
66
|
+
return base64.urlsafe_b64encode(key).rstrip(b'=').decode()
|
|
67
|
+
|
|
68
|
+
# ── Config ────────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
CONFIG_PATH = Path.home() / ".config" / "ghostbit.toml"
|
|
71
|
+
DEFAULT_SERVER = "http://localhost:8000"
|
|
72
|
+
|
|
73
|
+
VALID_KEYS = {"server"}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _load_config() -> dict:
|
|
77
|
+
if not CONFIG_PATH.exists():
|
|
78
|
+
return {}
|
|
79
|
+
cfg = {}
|
|
80
|
+
for line in CONFIG_PATH.read_text().splitlines():
|
|
81
|
+
line = line.strip()
|
|
82
|
+
if not line or line.startswith("#"):
|
|
83
|
+
continue
|
|
84
|
+
if "=" in line:
|
|
85
|
+
key, _, val = line.partition("=")
|
|
86
|
+
cfg[key.strip()] = val.strip().strip('"').strip("'")
|
|
87
|
+
return cfg
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _write_config(cfg: dict) -> None:
|
|
91
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
lines = [f'{k} = "{v}"' for k, v in cfg.items()]
|
|
93
|
+
CONFIG_PATH.write_text("\n".join(lines) + "\n")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ── Subcommands ───────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
def _run_config(argv):
|
|
99
|
+
parser = argparse.ArgumentParser(prog="gb config", description="Manage Ghostbit CLI configuration.")
|
|
100
|
+
sub = parser.add_subparsers(dest="action")
|
|
101
|
+
|
|
102
|
+
sub.add_parser("show", help="Show current configuration.")
|
|
103
|
+
|
|
104
|
+
p_set = sub.add_parser("set", help="Set a config value.")
|
|
105
|
+
p_set.add_argument("key", help="Config key (e.g. server)")
|
|
106
|
+
p_set.add_argument("value", help="Value to set")
|
|
107
|
+
|
|
108
|
+
p_unset = sub.add_parser("unset", help="Remove a config key.")
|
|
109
|
+
p_unset.add_argument("key", help="Config key to remove")
|
|
110
|
+
|
|
111
|
+
args = parser.parse_args(argv)
|
|
112
|
+
if not args.action:
|
|
113
|
+
parser.print_help()
|
|
114
|
+
sys.exit(0)
|
|
115
|
+
cmd_config(args.action, getattr(args, "key", None), getattr(args, "value", None))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def cmd_config(action, key=None, value=None):
|
|
119
|
+
cfg = _load_config()
|
|
120
|
+
|
|
121
|
+
if action == "show":
|
|
122
|
+
if not cfg:
|
|
123
|
+
print(f"No config yet. File would be: {CONFIG_PATH}", file=sys.stderr)
|
|
124
|
+
print(f"server = {DEFAULT_SERVER!r} (default)")
|
|
125
|
+
else:
|
|
126
|
+
print(f"# {CONFIG_PATH}")
|
|
127
|
+
for k, v in cfg.items():
|
|
128
|
+
print(f"{k} = {v!r}")
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
if action == "set":
|
|
132
|
+
k = key.lower()
|
|
133
|
+
if k not in VALID_KEYS:
|
|
134
|
+
print(f"Unknown config key {k!r}. Valid keys: {', '.join(VALID_KEYS)}", file=sys.stderr)
|
|
135
|
+
sys.exit(1)
|
|
136
|
+
cfg[k] = value
|
|
137
|
+
_write_config(cfg)
|
|
138
|
+
print(f"Set {k} = {value!r}")
|
|
139
|
+
print(f"Config saved to {CONFIG_PATH}")
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
if action == "unset":
|
|
143
|
+
k = key.lower()
|
|
144
|
+
if k in cfg:
|
|
145
|
+
del cfg[k]
|
|
146
|
+
_write_config(cfg)
|
|
147
|
+
print(f"Removed {k!r} from config.")
|
|
148
|
+
else:
|
|
149
|
+
print(f"{k!r} is not set.", file=sys.stderr)
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def cmd_paste(args):
|
|
154
|
+
cfg = _load_config()
|
|
155
|
+
server = args.server or cfg.get("server", DEFAULT_SERVER)
|
|
156
|
+
|
|
157
|
+
# Read content
|
|
158
|
+
if args.file:
|
|
159
|
+
path = Path(args.file)
|
|
160
|
+
if not path.exists():
|
|
161
|
+
print(f"File not found: {args.file}", file=sys.stderr)
|
|
162
|
+
sys.exit(1)
|
|
163
|
+
content = path.read_text(errors="replace")
|
|
164
|
+
# Infer language from extension if not specified
|
|
165
|
+
if args.lang is None:
|
|
166
|
+
ext_map = {
|
|
167
|
+
".py": "python", ".js": "javascript", ".ts": "typescript",
|
|
168
|
+
".go": "go", ".rs": "rust", ".rb": "ruby", ".php": "php",
|
|
169
|
+
".java": "java", ".c": "c", ".cpp": "cpp", ".cs": "csharp",
|
|
170
|
+
".sh": "bash", ".bash": "bash", ".zsh": "bash",
|
|
171
|
+
".html": "html", ".css": "css", ".sql": "sql",
|
|
172
|
+
".json": "json", ".yaml": "yaml", ".yml": "yaml",
|
|
173
|
+
".toml": "toml", ".xml": "xml", ".md": "markdown",
|
|
174
|
+
".dockerfile": "dockerfile", ".kt": "kotlin", ".swift": "swift",
|
|
175
|
+
".lua": "lua", ".r": "r", ".diff": "diff", ".patch": "diff",
|
|
176
|
+
}
|
|
177
|
+
args.lang = ext_map.get(path.suffix.lower())
|
|
178
|
+
else:
|
|
179
|
+
if sys.stdin.isatty():
|
|
180
|
+
print("Reading from stdin… (Ctrl+D to finish)", file=sys.stderr)
|
|
181
|
+
content = sys.stdin.read()
|
|
182
|
+
|
|
183
|
+
if not content.strip():
|
|
184
|
+
print("Error: content is empty.", file=sys.stderr)
|
|
185
|
+
sys.exit(1)
|
|
186
|
+
|
|
187
|
+
_require_crypto()
|
|
188
|
+
|
|
189
|
+
# ── Encrypt client-side (mirrors browser e2e.js) ──
|
|
190
|
+
if args.password:
|
|
191
|
+
kdf_salt = _gen_salt()
|
|
192
|
+
key = _derive_key(args.password, kdf_salt)
|
|
193
|
+
else:
|
|
194
|
+
key = _gen_key()
|
|
195
|
+
kdf_salt = None
|
|
196
|
+
|
|
197
|
+
ciphertext, nonce = _encrypt(content, key)
|
|
198
|
+
|
|
199
|
+
payload = {
|
|
200
|
+
"content": ciphertext,
|
|
201
|
+
"nonce": nonce,
|
|
202
|
+
"kdf_salt": kdf_salt,
|
|
203
|
+
"language": args.lang,
|
|
204
|
+
"expires_in": args.expires,
|
|
205
|
+
"burn": args.burn,
|
|
206
|
+
"max_views": args.max_views,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
result = _api_create(server, payload)
|
|
210
|
+
|
|
211
|
+
# Build full URL with fragment (key + delete token)
|
|
212
|
+
if args.password:
|
|
213
|
+
fragment = f"~{result['delete_token']}"
|
|
214
|
+
else:
|
|
215
|
+
fragment = f"{_key_to_fragment(key)}~{result['delete_token']}"
|
|
216
|
+
|
|
217
|
+
full_url = f"{result['url']}#{fragment}"
|
|
218
|
+
|
|
219
|
+
if args.json:
|
|
220
|
+
result["full_url"] = full_url
|
|
221
|
+
print(json.dumps(result, indent=2))
|
|
222
|
+
elif args.quiet:
|
|
223
|
+
print(full_url)
|
|
224
|
+
else:
|
|
225
|
+
print(full_url)
|
|
226
|
+
if sys.stdout.isatty():
|
|
227
|
+
parts = []
|
|
228
|
+
if result.get("expires_at"):
|
|
229
|
+
delta = result["expires_at"] - int(time.time())
|
|
230
|
+
if delta < 3600:
|
|
231
|
+
parts.append(f"expires in {delta // 60}m")
|
|
232
|
+
elif delta < 86400:
|
|
233
|
+
parts.append(f"expires in {delta // 3600}h")
|
|
234
|
+
else:
|
|
235
|
+
parts.append(f"expires in {delta // 86400}d")
|
|
236
|
+
if result.get("burn"):
|
|
237
|
+
parts.append("burn after read")
|
|
238
|
+
if result.get("max_views"):
|
|
239
|
+
parts.append(f"max {result['max_views']} views")
|
|
240
|
+
if args.password:
|
|
241
|
+
parts.append("password protected")
|
|
242
|
+
if parts:
|
|
243
|
+
print(" " + " · ".join(parts), file=sys.stderr)
|
|
244
|
+
if not args.password:
|
|
245
|
+
print(" Share the full URL — the decryption key is in the #fragment.", file=sys.stderr)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _print_highlighted(content: str, language: str | None) -> None:
|
|
249
|
+
"""Print content with terminal syntax highlighting.
|
|
250
|
+
|
|
251
|
+
Markdown: rendered via rich (titles, bold, lists, code blocks…) if available.
|
|
252
|
+
Other languages: syntax-highlighted via Pygments if available.
|
|
253
|
+
Fallback: plain text.
|
|
254
|
+
"""
|
|
255
|
+
if language == "markdown":
|
|
256
|
+
try:
|
|
257
|
+
from rich.console import Console
|
|
258
|
+
from rich.markdown import Markdown
|
|
259
|
+
Console().print(Markdown(content))
|
|
260
|
+
return
|
|
261
|
+
except ImportError:
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
from pygments import highlight
|
|
266
|
+
from pygments.formatters import TerminalTrueColorFormatter
|
|
267
|
+
from pygments.lexers import TextLexer, get_lexer_by_name
|
|
268
|
+
from pygments.util import ClassNotFound
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
lexer = get_lexer_by_name(language) if language else TextLexer()
|
|
272
|
+
except ClassNotFound:
|
|
273
|
+
lexer = TextLexer()
|
|
274
|
+
|
|
275
|
+
print(highlight(content, lexer, TerminalTrueColorFormatter(style="monokai")), end="")
|
|
276
|
+
return
|
|
277
|
+
except ImportError:
|
|
278
|
+
pass
|
|
279
|
+
|
|
280
|
+
print(content)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def cmd_view(args):
|
|
284
|
+
import getpass
|
|
285
|
+
from urllib.parse import urldefrag, urlparse
|
|
286
|
+
|
|
287
|
+
url_no_frag, fragment = urldefrag(args.url)
|
|
288
|
+
parsed = urlparse(url_no_frag)
|
|
289
|
+
server = f"{parsed.scheme}://{parsed.netloc}"
|
|
290
|
+
paste_id = parsed.path.strip("/")
|
|
291
|
+
|
|
292
|
+
if not paste_id or not parsed.scheme:
|
|
293
|
+
print("Error: invalid URL.", file=sys.stderr)
|
|
294
|
+
sys.exit(1)
|
|
295
|
+
|
|
296
|
+
# Fragment format: KEY_B64URL~DELETE_TOKEN (no password)
|
|
297
|
+
# ~DELETE_TOKEN (password-protected)
|
|
298
|
+
key_b64url = fragment.partition("~")[0]
|
|
299
|
+
is_password = not key_b64url
|
|
300
|
+
|
|
301
|
+
# Fetch paste metadata + ciphertext
|
|
302
|
+
api_url = f"{server}/api/v1/pastes/{paste_id}"
|
|
303
|
+
req = urllib.request.Request(api_url, headers={"User-Agent": _USER_AGENT})
|
|
304
|
+
try:
|
|
305
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
306
|
+
data = json.loads(resp.read())
|
|
307
|
+
except urllib.error.HTTPError as e:
|
|
308
|
+
body = e.read().decode(errors="replace")
|
|
309
|
+
try:
|
|
310
|
+
detail = json.loads(body).get("detail", body)
|
|
311
|
+
except Exception:
|
|
312
|
+
detail = body
|
|
313
|
+
print(f"Error {e.code}: {detail}", file=sys.stderr)
|
|
314
|
+
sys.exit(1)
|
|
315
|
+
except urllib.error.URLError as e:
|
|
316
|
+
print(f"Could not connect to {server}: {e.reason}", file=sys.stderr)
|
|
317
|
+
sys.exit(1)
|
|
318
|
+
|
|
319
|
+
_require_crypto()
|
|
320
|
+
|
|
321
|
+
# Derive or import decryption key
|
|
322
|
+
if is_password:
|
|
323
|
+
password = getpass.getpass("Password: ")
|
|
324
|
+
kdf_salt = data.get("kdf_salt")
|
|
325
|
+
if not kdf_salt:
|
|
326
|
+
print("Error: no KDF salt — paste is not password-protected.", file=sys.stderr)
|
|
327
|
+
sys.exit(1)
|
|
328
|
+
key = _derive_key(password, kdf_salt)
|
|
329
|
+
else:
|
|
330
|
+
# Restore base64 padding stripped for URL safety
|
|
331
|
+
padded = key_b64url + "=" * (-len(key_b64url) % 4)
|
|
332
|
+
key = base64.urlsafe_b64decode(padded)
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
plaintext = _decrypt(data["content"], data["nonce"], key)
|
|
336
|
+
except Exception:
|
|
337
|
+
print("Error: decryption failed — wrong key or corrupted paste.", file=sys.stderr)
|
|
338
|
+
sys.exit(1)
|
|
339
|
+
|
|
340
|
+
# Warn if this view just burned the paste
|
|
341
|
+
burned = data.get("burn") or (
|
|
342
|
+
data.get("max_views") and data.get("view_count", 0) >= data["max_views"]
|
|
343
|
+
)
|
|
344
|
+
if burned and sys.stderr.isatty():
|
|
345
|
+
print("⚠️ This paste has been burned and is no longer available on the server.", file=sys.stderr)
|
|
346
|
+
|
|
347
|
+
language = data.get("language")
|
|
348
|
+
if sys.stdout.isatty():
|
|
349
|
+
_print_highlighted(plaintext, language)
|
|
350
|
+
else:
|
|
351
|
+
print(plaintext, end="")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# ── API ───────────────────────────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
def _api_create(server: str, payload: dict) -> dict:
|
|
357
|
+
url = server.rstrip("/") + "/api/v1/pastes"
|
|
358
|
+
data = json.dumps(payload).encode()
|
|
359
|
+
req = urllib.request.Request(
|
|
360
|
+
url,
|
|
361
|
+
data=data,
|
|
362
|
+
headers={"Content-Type": "application/json", "User-Agent": _USER_AGENT},
|
|
363
|
+
method="POST",
|
|
364
|
+
)
|
|
365
|
+
try:
|
|
366
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
367
|
+
return json.loads(resp.read())
|
|
368
|
+
except urllib.error.HTTPError as e:
|
|
369
|
+
body = e.read().decode(errors="replace")
|
|
370
|
+
try:
|
|
371
|
+
detail = json.loads(body).get("detail", body)
|
|
372
|
+
except Exception:
|
|
373
|
+
detail = body
|
|
374
|
+
print(f"Error {e.code}: {detail}", file=sys.stderr)
|
|
375
|
+
sys.exit(1)
|
|
376
|
+
except urllib.error.URLError as e:
|
|
377
|
+
print(f"Could not connect to {server}: {e.reason}", file=sys.stderr)
|
|
378
|
+
print(f" Tip: run `gb config set server <URL>` to set your server.", file=sys.stderr)
|
|
379
|
+
sys.exit(1)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# ── Entry point ───────────────────────────────────────────────────────────────
|
|
383
|
+
|
|
384
|
+
def main():
|
|
385
|
+
# Route to config subcommand early so file paths aren't mistaken for subcommands
|
|
386
|
+
if len(sys.argv) > 1 and sys.argv[1] == "config":
|
|
387
|
+
_run_config(sys.argv[2:])
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
# Route to view subcommand
|
|
391
|
+
if len(sys.argv) > 1 and sys.argv[1] == "view":
|
|
392
|
+
if len(sys.argv) < 3:
|
|
393
|
+
print("Usage: gb view <url>", file=sys.stderr)
|
|
394
|
+
sys.exit(1)
|
|
395
|
+
view_parser = argparse.ArgumentParser(prog="gb view")
|
|
396
|
+
view_parser.add_argument("url", help="Full paste URL (including #fragment).")
|
|
397
|
+
cmd_view(view_parser.parse_args(sys.argv[2:]))
|
|
398
|
+
return
|
|
399
|
+
|
|
400
|
+
cfg = _load_config()
|
|
401
|
+
current_server = cfg.get("server", DEFAULT_SERVER)
|
|
402
|
+
|
|
403
|
+
parser = argparse.ArgumentParser(
|
|
404
|
+
prog="gb",
|
|
405
|
+
description="Ghostbit CLI — create encrypted pastes from the terminal.",
|
|
406
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
407
|
+
epilog=f"""
|
|
408
|
+
examples:
|
|
409
|
+
cat main.py | gb
|
|
410
|
+
gb main.py
|
|
411
|
+
gb main.py --lang python --burn
|
|
412
|
+
gb main.py --expires 3600 --password secret
|
|
413
|
+
gb view https://paste.example.com/abc123#KEY~TOKEN
|
|
414
|
+
gb config set server https://paste.example.com
|
|
415
|
+
gb config show
|
|
416
|
+
|
|
417
|
+
current server: {current_server}
|
|
418
|
+
""",
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
# ── gb [file] ──
|
|
422
|
+
parser.add_argument(
|
|
423
|
+
"file",
|
|
424
|
+
nargs="?",
|
|
425
|
+
help="File to paste. Reads from stdin if omitted.",
|
|
426
|
+
)
|
|
427
|
+
parser.add_argument(
|
|
428
|
+
"--server", "-s",
|
|
429
|
+
default=None,
|
|
430
|
+
metavar="URL",
|
|
431
|
+
help=f"Server URL for this invocation only (current: {current_server})",
|
|
432
|
+
)
|
|
433
|
+
parser.add_argument(
|
|
434
|
+
"--lang", "-l",
|
|
435
|
+
default=None,
|
|
436
|
+
help="Language (e.g. python, javascript, go). Auto-detected if omitted.",
|
|
437
|
+
)
|
|
438
|
+
parser.add_argument(
|
|
439
|
+
"--expires", "-e",
|
|
440
|
+
type=int,
|
|
441
|
+
default=None,
|
|
442
|
+
metavar="SECONDS",
|
|
443
|
+
help="Expiry TTL in seconds (3600 = 1 h, 86400 = 1 d). Default: never.",
|
|
444
|
+
)
|
|
445
|
+
parser.add_argument(
|
|
446
|
+
"--burn", "-b",
|
|
447
|
+
action="store_true",
|
|
448
|
+
help="Delete after the first view.",
|
|
449
|
+
)
|
|
450
|
+
parser.add_argument(
|
|
451
|
+
"--max-views", "-m",
|
|
452
|
+
type=int,
|
|
453
|
+
default=None,
|
|
454
|
+
metavar="N",
|
|
455
|
+
help="Delete after N views.",
|
|
456
|
+
)
|
|
457
|
+
parser.add_argument(
|
|
458
|
+
"--password", "-p",
|
|
459
|
+
default=None,
|
|
460
|
+
metavar="PASS",
|
|
461
|
+
help="Encrypt with a password.",
|
|
462
|
+
)
|
|
463
|
+
parser.add_argument(
|
|
464
|
+
"--quiet", "-q",
|
|
465
|
+
action="store_true",
|
|
466
|
+
help="Print only the URL.",
|
|
467
|
+
)
|
|
468
|
+
parser.add_argument(
|
|
469
|
+
"--json",
|
|
470
|
+
action="store_true",
|
|
471
|
+
help="Print the full JSON response.",
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
args = parser.parse_args()
|
|
475
|
+
cmd_paste(args)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
if __name__ == "__main__":
|
|
479
|
+
main()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ghostbit-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Ghostbit CLI — create end-to-end encrypted pastes from the terminal
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/stackopshq/ghostbit
|
|
7
|
+
Project-URL: Repository, https://github.com/stackopshq/ghostbit
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/stackopshq/ghostbit/issues
|
|
9
|
+
Keywords: paste,cli,encryption,privacy,ghostbit
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Internet
|
|
21
|
+
Classifier: Topic :: Security :: Cryptography
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
Requires-Dist: cryptography>=42.0.0
|
|
26
|
+
Provides-Extra: color
|
|
27
|
+
Requires-Dist: pygments>=2.17.0; extra == "color"
|
|
28
|
+
Provides-Extra: markdown
|
|
29
|
+
Requires-Dist: rich>=13.0.0; extra == "markdown"
|
|
30
|
+
Provides-Extra: all
|
|
31
|
+
Requires-Dist: pygments>=2.17.0; extra == "all"
|
|
32
|
+
Requires-Dist: rich>=13.0.0; extra == "all"
|
|
33
|
+
|
|
34
|
+
# Ghostbit CLI
|
|
35
|
+
|
|
36
|
+
Command-line tool for [Ghostbit](https://github.com/stackopshq/ghostbit) — a self-hosted, end-to-end encrypted paste service.
|
|
37
|
+
|
|
38
|
+
All content is encrypted **in the client** before being sent to the server. The server never sees your plaintext.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install ghostbit-cli
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# Paste from stdin
|
|
50
|
+
cat file.py | gb
|
|
51
|
+
|
|
52
|
+
# Paste a file (language auto-detected from extension)
|
|
53
|
+
gb file.py
|
|
54
|
+
|
|
55
|
+
# With options
|
|
56
|
+
gb file.py --lang python --burn --expires 3600
|
|
57
|
+
|
|
58
|
+
# Password-protected paste
|
|
59
|
+
echo "secret" | gb --password mysecret
|
|
60
|
+
|
|
61
|
+
# Output JSON (includes full URL with decryption key)
|
|
62
|
+
cat data.json | gb --json
|
|
63
|
+
|
|
64
|
+
# Point to your self-hosted instance
|
|
65
|
+
gb config set server https://paste.example.com
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Options
|
|
69
|
+
|
|
70
|
+
| Flag | Short | Description |
|
|
71
|
+
|------|-------|-------------|
|
|
72
|
+
| `--lang` | `-l` | Language hint (python, go, rust, …) |
|
|
73
|
+
| `--expires` | `-e` | TTL in seconds (3600 = 1h, 86400 = 1d) |
|
|
74
|
+
| `--burn` | `-b` | Delete after the first view |
|
|
75
|
+
| `--max-views` | `-m` | Delete after N views |
|
|
76
|
+
| `--password` | `-p` | Encrypt with a password |
|
|
77
|
+
| `--server` | `-s` | Override server URL for this call |
|
|
78
|
+
| `--quiet` | `-q` | Print URL only |
|
|
79
|
+
| `--json` | | Print full JSON response |
|
|
80
|
+
|
|
81
|
+
## Configuration
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
gb config set server https://paste.example.com
|
|
85
|
+
gb config show
|
|
86
|
+
gb config unset server
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Config is stored at `~/.config/ghostbit.toml`.
|
|
90
|
+
|
|
91
|
+
## Self-hosting
|
|
92
|
+
|
|
93
|
+
See the [Ghostbit server repository](https://github.com/stackopshq/ghostbit) for Docker and manual setup instructions.
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
cli.py
|
|
3
|
+
pyproject.toml
|
|
4
|
+
setup.py
|
|
5
|
+
ghostbit_cli.egg-info/PKG-INFO
|
|
6
|
+
ghostbit_cli.egg-info/SOURCES.txt
|
|
7
|
+
ghostbit_cli.egg-info/dependency_links.txt
|
|
8
|
+
ghostbit_cli.egg-info/entry_points.txt
|
|
9
|
+
ghostbit_cli.egg-info/requires.txt
|
|
10
|
+
ghostbit_cli.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cli
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ghostbit-cli"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Ghostbit CLI — create end-to-end encrypted pastes from the terminal"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
keywords = ["paste", "cli", "encryption", "privacy", "ghostbit"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Intended Audience :: System Administrators",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Topic :: Internet",
|
|
25
|
+
"Topic :: Security :: Cryptography",
|
|
26
|
+
"Topic :: Utilities",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"cryptography>=42.0.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
color = ["pygments>=2.17.0"]
|
|
34
|
+
markdown = ["rich>=13.0.0"]
|
|
35
|
+
all = ["pygments>=2.17.0", "rich>=13.0.0"]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/stackopshq/ghostbit"
|
|
39
|
+
Repository = "https://github.com/stackopshq/ghostbit"
|
|
40
|
+
"Bug Tracker" = "https://github.com/stackopshq/ghostbit/issues"
|
|
41
|
+
|
|
42
|
+
[project.scripts]
|
|
43
|
+
gb = "cli:main"
|
|
44
|
+
ghostbit = "cli:main"
|
|
45
|
+
|
|
46
|
+
[tool.setuptools]
|
|
47
|
+
py-modules = ["cli"]
|