ghostbit-cli 1.3.0__tar.gz → 1.5.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.3.0 → ghostbit_cli-1.5.0}/PKG-INFO +1 -1
- {ghostbit_cli-1.3.0 → ghostbit_cli-1.5.0}/__init__.py +32 -4
- ghostbit_cli-1.5.0/_crypto.py +120 -0
- {ghostbit_cli-1.3.0 → ghostbit_cli-1.5.0}/ghostbit_cli.egg-info/PKG-INFO +1 -1
- {ghostbit_cli-1.3.0 → ghostbit_cli-1.5.0}/pyproject.toml +1 -1
- ghostbit_cli-1.3.0/_crypto.py +0 -67
- {ghostbit_cli-1.3.0 → ghostbit_cli-1.5.0}/README.md +0 -0
- {ghostbit_cli-1.3.0 → ghostbit_cli-1.5.0}/_api.py +0 -0
- {ghostbit_cli-1.3.0 → ghostbit_cli-1.5.0}/_completion.py +0 -0
- {ghostbit_cli-1.3.0 → ghostbit_cli-1.5.0}/_config.py +0 -0
- {ghostbit_cli-1.3.0 → ghostbit_cli-1.5.0}/_history.py +0 -0
- {ghostbit_cli-1.3.0 → ghostbit_cli-1.5.0}/ghostbit_cli.egg-info/SOURCES.txt +0 -0
- {ghostbit_cli-1.3.0 → ghostbit_cli-1.5.0}/ghostbit_cli.egg-info/dependency_links.txt +0 -0
- {ghostbit_cli-1.3.0 → ghostbit_cli-1.5.0}/ghostbit_cli.egg-info/entry_points.txt +0 -0
- {ghostbit_cli-1.3.0 → ghostbit_cli-1.5.0}/ghostbit_cli.egg-info/requires.txt +0 -0
- {ghostbit_cli-1.3.0 → ghostbit_cli-1.5.0}/ghostbit_cli.egg-info/top_level.txt +0 -0
- {ghostbit_cli-1.3.0 → ghostbit_cli-1.5.0}/setup.cfg +0 -0
- {ghostbit_cli-1.3.0 → ghostbit_cli-1.5.0}/setup.py +0 -0
|
@@ -27,7 +27,9 @@ from ._completion import cmd_completion as _cmd_completion_raw
|
|
|
27
27
|
from ._config import DEFAULT_SERVER, cmd_config, load_config
|
|
28
28
|
from ._crypto import (
|
|
29
29
|
decrypt,
|
|
30
|
+
decrypt_bytes,
|
|
30
31
|
derive_key,
|
|
32
|
+
derive_key_for,
|
|
31
33
|
encrypt,
|
|
32
34
|
gen_key,
|
|
33
35
|
gen_salt,
|
|
@@ -109,12 +111,20 @@ def cmd_paste(args) -> None:
|
|
|
109
111
|
|
|
110
112
|
if password:
|
|
111
113
|
kdf_salt = gen_salt()
|
|
112
|
-
|
|
114
|
+
kdf_choice = args.kdf
|
|
115
|
+
key = derive_key_for(kdf_choice, password, kdf_salt)
|
|
113
116
|
else:
|
|
114
117
|
key = gen_key()
|
|
115
118
|
kdf_salt = None
|
|
119
|
+
kdf_choice = "pbkdf2-sha256" # ignored when has_password is false
|
|
116
120
|
|
|
117
|
-
|
|
121
|
+
if args.compress:
|
|
122
|
+
import gzip
|
|
123
|
+
|
|
124
|
+
payload_input: str | bytes = gzip.compress(content.encode())
|
|
125
|
+
else:
|
|
126
|
+
payload_input = content
|
|
127
|
+
ciphertext, nonce = encrypt(payload_input, key)
|
|
118
128
|
|
|
119
129
|
payload = {
|
|
120
130
|
"content": ciphertext,
|
|
@@ -124,6 +134,8 @@ def cmd_paste(args) -> None:
|
|
|
124
134
|
"expires_in": args.expires,
|
|
125
135
|
"burn": args.burn,
|
|
126
136
|
"max_views": args.max_views,
|
|
137
|
+
"compressed": args.compress,
|
|
138
|
+
"kdf": kdf_choice,
|
|
127
139
|
}
|
|
128
140
|
|
|
129
141
|
result = api_create(server, payload)
|
|
@@ -262,14 +274,22 @@ def cmd_view(args) -> None:
|
|
|
262
274
|
if not kdf_salt:
|
|
263
275
|
print("Error: no KDF salt — paste is not password-protected.", file=sys.stderr)
|
|
264
276
|
sys.exit(1)
|
|
265
|
-
|
|
277
|
+
# Server tells us which KDF was used; default to legacy PBKDF2 for
|
|
278
|
+
# pastes written before the field existed.
|
|
279
|
+
kdf_choice = data.get("kdf") or "pbkdf2-sha256"
|
|
280
|
+
key = derive_key_for(kdf_choice, password, kdf_salt)
|
|
266
281
|
else:
|
|
267
282
|
# Restore base64 padding stripped for URL safety.
|
|
268
283
|
padded = key_b64url + "=" * (-len(key_b64url) % 4)
|
|
269
284
|
key = base64.urlsafe_b64decode(padded)
|
|
270
285
|
|
|
271
286
|
try:
|
|
272
|
-
|
|
287
|
+
if data.get("compressed"):
|
|
288
|
+
import gzip
|
|
289
|
+
|
|
290
|
+
plaintext = gzip.decompress(decrypt_bytes(data["content"], data["nonce"], key)).decode()
|
|
291
|
+
else:
|
|
292
|
+
plaintext = decrypt(data["content"], data["nonce"], key)
|
|
273
293
|
except Exception: # noqa: BLE001
|
|
274
294
|
print("Error: decryption failed — wrong key or corrupted paste.", file=sys.stderr)
|
|
275
295
|
sys.exit(1)
|
|
@@ -501,6 +521,14 @@ current server: {current_server}
|
|
|
501
521
|
"--burn", "-b", action="store_true",
|
|
502
522
|
help="Delete after the first view.",
|
|
503
523
|
) # fmt: skip
|
|
524
|
+
parser.add_argument(
|
|
525
|
+
"--compress", "-z", action="store_true",
|
|
526
|
+
help="Gzip the plaintext locally BEFORE encryption (60–80%% smaller for text).",
|
|
527
|
+
) # fmt: skip
|
|
528
|
+
parser.add_argument(
|
|
529
|
+
"--kdf", choices=("pbkdf2-sha256", "argon2id"), default="pbkdf2-sha256",
|
|
530
|
+
help="Key derivation function (used only with --password). Default: pbkdf2-sha256.",
|
|
531
|
+
) # fmt: skip
|
|
504
532
|
parser.add_argument(
|
|
505
533
|
"--max-views", "-m", type=int, default=None, metavar="N",
|
|
506
534
|
help="Delete after N views.",
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Client-side crypto — mirrors the browser's static/e2e.js behaviour.
|
|
2
|
+
|
|
3
|
+
Any parameter change here (PBKDF2 iterations, AES mode, nonce length,
|
|
4
|
+
salt length) must be reflected in static/e2e.js and in the app/api.py
|
|
5
|
+
validators, or pastes created by one side will be unreadable by the
|
|
6
|
+
other. See tests/test_cli_crypto.py for the contract.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from cryptography.hazmat.primitives import hashes as _hashes
|
|
17
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
18
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
19
|
+
|
|
20
|
+
_CRYPTO_OK = True
|
|
21
|
+
except ImportError:
|
|
22
|
+
_CRYPTO_OK = False
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from argon2.low_level import Type as _Argon2Type
|
|
26
|
+
from argon2.low_level import hash_secret_raw as _argon2_hash
|
|
27
|
+
|
|
28
|
+
_ARGON2_OK = True
|
|
29
|
+
except ImportError:
|
|
30
|
+
_ARGON2_OK = False
|
|
31
|
+
|
|
32
|
+
_PBKDF2_ITERATIONS = 600_000
|
|
33
|
+
|
|
34
|
+
# OWASP 2023+ minimum for Argon2id in interactive contexts (browser/CLI).
|
|
35
|
+
# m = memory in KiB; t = passes; p = parallelism; hash length in bytes.
|
|
36
|
+
# Anything stronger meaningfully impacts page-load time on a phone — bump
|
|
37
|
+
# these together with the browser WASM lib's defaults when it lands.
|
|
38
|
+
_ARGON2_MEMORY_KIB = 19_456 # 19 MiB
|
|
39
|
+
_ARGON2_TIME_COST = 2
|
|
40
|
+
_ARGON2_PARALLELISM = 1
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def require_crypto() -> None:
|
|
44
|
+
"""Abort with an actionable message if `cryptography` is missing."""
|
|
45
|
+
if not _CRYPTO_OK:
|
|
46
|
+
print(
|
|
47
|
+
"Error: 'cryptography' package required. Run: pip install cryptography",
|
|
48
|
+
file=sys.stderr,
|
|
49
|
+
)
|
|
50
|
+
sys.exit(1)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def gen_key() -> bytes:
|
|
54
|
+
return os.urandom(32)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def gen_salt() -> str:
|
|
58
|
+
return base64.b64encode(os.urandom(16)).decode()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def encrypt(plaintext: str | bytes, key: bytes) -> tuple[str, str]:
|
|
62
|
+
"""Encrypt a string or raw bytes with AES-256-GCM. Returns (ct_b64, nonce_b64)."""
|
|
63
|
+
nonce = os.urandom(12)
|
|
64
|
+
data = plaintext.encode() if isinstance(plaintext, str) else plaintext
|
|
65
|
+
ct = AESGCM(key).encrypt(nonce, data, None)
|
|
66
|
+
return base64.b64encode(ct).decode(), base64.b64encode(nonce).decode()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def decrypt_bytes(ciphertext_b64: str, nonce_b64: str, key: bytes) -> bytes:
|
|
70
|
+
"""Decrypt returning raw bytes — used by the compressed-paste path."""
|
|
71
|
+
ct = base64.b64decode(ciphertext_b64)
|
|
72
|
+
nonce = base64.b64decode(nonce_b64)
|
|
73
|
+
return AESGCM(key).decrypt(nonce, ct, None)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def decrypt(ciphertext_b64: str, nonce_b64: str, key: bytes) -> str:
|
|
77
|
+
return decrypt_bytes(ciphertext_b64, nonce_b64, key).decode()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def derive_key(password: str, salt_b64: str) -> bytes:
|
|
81
|
+
"""PBKDF2-SHA256 at 600k iterations — the historical default."""
|
|
82
|
+
salt = base64.b64decode(salt_b64)
|
|
83
|
+
kdf = PBKDF2HMAC(
|
|
84
|
+
algorithm=_hashes.SHA256(), length=32, salt=salt, iterations=_PBKDF2_ITERATIONS
|
|
85
|
+
)
|
|
86
|
+
return kdf.derive(password.encode())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def derive_key_argon2id(password: str, salt_b64: str) -> bytes:
|
|
90
|
+
"""Argon2id with OWASP minimum params (m=19 MiB, t=2, p=1)."""
|
|
91
|
+
if not _ARGON2_OK:
|
|
92
|
+
print(
|
|
93
|
+
"Error: 'argon2-cffi' package required for --kdf argon2id. "
|
|
94
|
+
"Run: pip install argon2-cffi",
|
|
95
|
+
file=sys.stderr,
|
|
96
|
+
)
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
salt = base64.b64decode(salt_b64)
|
|
99
|
+
return _argon2_hash(
|
|
100
|
+
secret=password.encode(),
|
|
101
|
+
salt=salt,
|
|
102
|
+
time_cost=_ARGON2_TIME_COST,
|
|
103
|
+
memory_cost=_ARGON2_MEMORY_KIB,
|
|
104
|
+
parallelism=_ARGON2_PARALLELISM,
|
|
105
|
+
hash_len=32,
|
|
106
|
+
type=_Argon2Type.ID,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def derive_key_for(kdf: str, password: str, salt_b64: str) -> bytes:
|
|
111
|
+
"""Dispatch to the right KDF based on the protocol's kdf field."""
|
|
112
|
+
if kdf == "pbkdf2-sha256":
|
|
113
|
+
return derive_key(password, salt_b64)
|
|
114
|
+
if kdf == "argon2id":
|
|
115
|
+
return derive_key_argon2id(password, salt_b64)
|
|
116
|
+
raise ValueError(f"unsupported kdf {kdf!r}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def key_to_fragment(key: bytes) -> str:
|
|
120
|
+
return base64.urlsafe_b64encode(key).rstrip(b"=").decode()
|
ghostbit_cli-1.3.0/_crypto.py
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
"""Client-side crypto — mirrors the browser's static/e2e.js behaviour.
|
|
2
|
-
|
|
3
|
-
Any parameter change here (PBKDF2 iterations, AES mode, nonce length,
|
|
4
|
-
salt length) must be reflected in static/e2e.js and in the app/api.py
|
|
5
|
-
validators, or pastes created by one side will be unreadable by the
|
|
6
|
-
other. See tests/test_cli_crypto.py for the contract.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
import base64
|
|
12
|
-
import os
|
|
13
|
-
import sys
|
|
14
|
-
|
|
15
|
-
try:
|
|
16
|
-
from cryptography.hazmat.primitives import hashes as _hashes
|
|
17
|
-
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
18
|
-
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
19
|
-
|
|
20
|
-
_CRYPTO_OK = True
|
|
21
|
-
except ImportError:
|
|
22
|
-
_CRYPTO_OK = False
|
|
23
|
-
|
|
24
|
-
_PBKDF2_ITERATIONS = 600_000
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def require_crypto() -> None:
|
|
28
|
-
"""Abort with an actionable message if `cryptography` is missing."""
|
|
29
|
-
if not _CRYPTO_OK:
|
|
30
|
-
print(
|
|
31
|
-
"Error: 'cryptography' package required. Run: pip install cryptography",
|
|
32
|
-
file=sys.stderr,
|
|
33
|
-
)
|
|
34
|
-
sys.exit(1)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def gen_key() -> bytes:
|
|
38
|
-
return os.urandom(32)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def gen_salt() -> str:
|
|
42
|
-
return base64.b64encode(os.urandom(16)).decode()
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def encrypt(plaintext: str, key: bytes) -> tuple[str, str]:
|
|
46
|
-
nonce = os.urandom(12)
|
|
47
|
-
ct = AESGCM(key).encrypt(nonce, plaintext.encode(), None)
|
|
48
|
-
return base64.b64encode(ct).decode(), base64.b64encode(nonce).decode()
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def decrypt(ciphertext_b64: str, nonce_b64: str, key: bytes) -> str:
|
|
52
|
-
ct = base64.b64decode(ciphertext_b64)
|
|
53
|
-
nonce = base64.b64decode(nonce_b64)
|
|
54
|
-
pt = AESGCM(key).decrypt(nonce, ct, None)
|
|
55
|
-
return pt.decode()
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def derive_key(password: str, salt_b64: str) -> bytes:
|
|
59
|
-
salt = base64.b64decode(salt_b64)
|
|
60
|
-
kdf = PBKDF2HMAC(
|
|
61
|
-
algorithm=_hashes.SHA256(), length=32, salt=salt, iterations=_PBKDF2_ITERATIONS
|
|
62
|
-
)
|
|
63
|
-
return kdf.derive(password.encode())
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def key_to_fragment(key: bytes) -> str:
|
|
67
|
-
return base64.urlsafe_b64encode(key).rstrip(b"=").decode()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|