-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpgp_verify.py
More file actions
316 lines (248 loc) · 9.96 KB
/
Copy pathpgp_verify.py
File metadata and controls
316 lines (248 loc) · 9.96 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
#!/usr/bin/env python3
'''
Dark web PGP signature verifier
Verifies a signed message against a site's published PGP public key.
Useful for checking canary statements, site authenticity, or any
signed message a dark web operator publishes.
Usage:
python pgp_verify.py [--debug]
Dependencies:
pip install python-gnupg
gpg must be installed on the system (gpg or gpg2)!!!
'''
from __future__ import annotations
import os
import sys
import tempfile
import textwrap
from datetime import datetime, timezone
from typing import Any, Literal
import gnupg # type: ignore[import-untyped] no stub types
# Constants
BANNER = r'''
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
██ ▄█▀ ▄█▀ PGP Verifier ▀█▄ ▀█▄ ██
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
PGP Authenticity Checker
'''
DIVIDER: str = "─" * 60
PGP_END_MARKERS: tuple[str, ...] = (
"-----END PGP PUBLIC KEY BLOCK-----",
"-----END PGP SIGNED MESSAGE-----",
"-----END PGP SIGNATURE-----",
)
SignatureMode = Literal["clearsigned", "detached"]
# Helpers
def print_divider() -> None:
print(DIVIDER)
def collect_pgp_block(prompt: str) -> str:
print(prompt)
print(" (Input stops automatically after the closing marker)\n")
lines: list[str] = []
seen_end_marker = False
while True:
try:
line = input()
except EOFError:
break
lines.append(line)
if any(marker in line for marker in PGP_END_MARKERS):
seen_end_marker = True
continue
if seen_end_marker and line.strip() == "":
break
return "\n".join(lines).strip()
def collect_plain_text(prompt: str) -> str:
print(prompt)
print(" (Press Enter twice on a blank line when done)\n")
lines: list[str] = []
consecutive_blanks = 0
while True:
try:
line = input()
except EOFError:
break
if line.strip() == "":
consecutive_blanks += 1
if consecutive_blanks >= 2:
break
else:
consecutive_blanks = 0
lines.append(line)
return "\n".join(lines).strip()
def detect_mode(text: str) -> SignatureMode | None:
'''Return the signature mode implied by *text*, or None if unrecognised.'''
if "-----BEGIN PGP SIGNED MESSAGE-----" in text:
return "clearsigned"
if "-----BEGIN PGP SIGNATURE-----" in text:
return "detached"
return None
def format_fingerprint(fp: str) -> str:
'''Format a 40-char fingerprint into space-separated 4-char groups.'''
clean = fp.upper().replace(" ", "")
return " ".join(clean[i : i + 4] for i in range(0, len(clean), 4))
def format_timestamp(raw: str | int | None) -> str:
'''Convert a Unix timestamp (string or int) to a human-readable UTC string.'''
if raw is None:
return "unknown"
try:
dt = datetime.fromtimestamp(int(raw), tz=timezone.utc)
return dt.strftime("%Y-%m-%d %H:%M:%S UTC")
except (ValueError, OSError, OverflowError):
return str(raw)
def attr(obj: Any, name: str, default: str = "") -> str:
'''Safely retrieve a string attribute from an untyped gnupg result object.'''
value = getattr(obj, name, None)
return str(value) if value is not None else default
# Collect inputs
def collect_pubkey() -> str:
print("\n[1/3] PUBLIC KEY")
pubkey = collect_pgp_block("Paste the site's PGP public key block:")
if "-----BEGIN PGP PUBLIC KEY BLOCK-----" not in pubkey:
sys.exit(
"\n[ERROR] Input does not look like a valid PGP public key block.\n"
" Expected to find: -----BEGIN PGP PUBLIC KEY BLOCK-----"
)
return pubkey
def collect_signed_content() -> tuple[SignatureMode, str, str | None]:
'''Return (mode, primary_text, optional_plain_message).
For clearsigned mode the primary text IS the full signed block.
For detached mode the primary text is the signature block and
plain message is returned separately.
'''
print_divider()
print("\n[2/3] SIGNED MESSAGE / SIGNATURE")
print("What do you have?")
print(" [1] A clearsigned message (message AND signature together)")
print(" [2] A detached signature (separate from the message)\n")
while True:
choice = input("Enter 1 or 2: ").strip()
if choice in ("1", "2"):
break
print("Please enter 1 or 2.")
if choice == "1":
signed_block = collect_pgp_block("\nPaste the full clearsigned message block:")
if detect_mode(signed_block) != "clearsigned":
sys.exit(
"\n[ERROR] This doesn't appear to be a clearsigned PGP block.\n"
" Expected: -----BEGIN PGP SIGNED MESSAGE-----"
)
return "clearsigned", signed_block, None
plain = collect_plain_text("\nPaste the plain message text (without any PGP headers):")
sig_block = collect_pgp_block("\nPaste the detached PGP signature block:")
if "-----BEGIN PGP SIGNATURE-----" not in sig_block:
sys.exit(
"\n[ERROR] This doesn't appear to be a valid PGP signature block.\n"
" Expected: -----BEGIN PGP SIGNATURE-----"
)
return "detached", sig_block, plain
# Verification
def verify(
pubkey: str,
mode: SignatureMode,
primary: str,
plain_message: str | None,
*,
debug: bool,
) -> int:
'''Import *pubkey*, run GPG verification, print results. Returns exit code.'''
print_divider()
print("\n[3/3] VERIFYING...\n")
with tempfile.TemporaryDirectory() as tmpdir:
os.chmod(tmpdir, 0o700)
gpg: Any = gnupg.GPG(gnupghome=tmpdir)
gpg.encoding = "utf-8"
import_result: Any = gpg.import_keys(pubkey)
if not import_result.count:
sys.exit(
"[ERROR] Failed to import the public key.\n"
" Check that the key block is complete and unmodified."
)
fingerprints: list[str] = import_result.fingerprints or []
imported_fp: str = fingerprints[0] if fingerprints else "unknown"
result: Any
if mode == "clearsigned":
asc_path = os.path.join(tmpdir, "message.asc")
with open(asc_path, "w", encoding="utf-8") as fh:
fh.write(primary)
with open(asc_path, "rb") as fh:
result = gpg.verify_file(fh)
else:
sig_path = os.path.join(tmpdir, "message.sig")
msg_path = os.path.join(tmpdir, "message.txt")
with open(sig_path, "w", encoding="utf-8") as fh:
fh.write(primary)
with open(msg_path, "w", encoding="utf-8") as fh:
fh.write(plain_message or "")
with open(sig_path, "rb") as fh:
result = gpg.verify_file(fh, msg_path)
valid: bool = bool(result)
# Print results
print_divider()
print()
print(
" ✓ SIGNATURE VALID — message is authentic"
if valid
else " ✗ SIGNATURE INVALID — possible compromise or tampering"
)
print()
print_divider()
print()
sig_fp: str = attr(result, "fingerprint")
sig_keyid: str = attr(result, "key_id")
timestamp: str = attr(result, "timestamp") or attr(result, "sig_timestamp")
username: str = attr(result, "username")
print(f" Imported key fingerprint : {format_fingerprint(imported_fp)}")
if sig_fp:
print(f" Signing key fingerprint : {format_fingerprint(sig_fp)}")
if sig_fp.upper() != imported_fp.upper():
print()
print(" [!] WARNING: The signing key does NOT match the imported public key.")
print(" The message was signed by a different key than the one you provided.")
print(" This is a strong indicator of compromise or key substitution.")
elif sig_keyid:
print(f" Signing key ID : {sig_keyid}")
if username:
print(f" Key identity (UID) : {username}")
if timestamp:
print(f" Signature timestamp : {format_timestamp(timestamp)}")
if not valid:
status: str = attr(result, "status")
stderr: str = attr(result, "stderr")
if status:
print(f"\n GPG status : {status}")
if debug and stderr:
print(f"\n GPG stderr:\n{textwrap.indent(stderr, ' ')}")
print()
print(" Possible reasons for failure:")
print(" • The message was altered after signing")
print(" • The signature belongs to a different key (key substitution attack)")
print(" • The public key provided is not the real site key")
print(" • The signature block was truncated or corrupted")
print()
print(" RECOMMENDATION: Do not trust this site or message.")
else:
print()
print(" The message was signed with the private key corresponding to")
print(" the public key you provided. The content has not been altered.")
print()
print_divider()
print()
return 0 if valid else 1
# Entry point
def main() -> int:
debug = "--debug" in sys.argv
print(BANNER)
print("This tool verifies that a message was signed by a site's known PGP key.")
print("If verification fails, the site may be compromised, spoofed, or the")
print("message may have been tampered with.\n")
print_divider()
pubkey = collect_pubkey()
mode, primary, plain_message = collect_signed_content()
return verify(pubkey, mode, primary, plain_message, debug=debug)
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\n\n[Aborted]")
sys.exit(130)