keywarden-ssh 0.3.0__py3-none-any.whl

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.
keywarden/parse.py ADDED
@@ -0,0 +1,443 @@
1
+ """Turn public-key text into fingerprints. The key blob is not kept."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import re
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime, timezone
10
+
11
+ KEY_TYPES = (
12
+ "sk-ssh-ed25519-cert-v01@openssh.com",
13
+ "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com",
14
+ "sk-ssh-ed25519@openssh.com",
15
+ "sk-ecdsa-sha2-nistp256@openssh.com",
16
+ "ssh-ed25519-cert-v01@openssh.com",
17
+ "ssh-rsa-cert-v01@openssh.com",
18
+ "ssh-dss-cert-v01@openssh.com",
19
+ "ecdsa-sha2-nistp256-cert-v01@openssh.com",
20
+ "ecdsa-sha2-nistp384-cert-v01@openssh.com",
21
+ "ecdsa-sha2-nistp521-cert-v01@openssh.com",
22
+ "ecdsa-sha2-nistp256",
23
+ "ecdsa-sha2-nistp384",
24
+ "ecdsa-sha2-nistp521",
25
+ "ssh-ed25519",
26
+ "ssh-rsa",
27
+ "ssh-dss",
28
+ )
29
+
30
+
31
+ class PrivateKeyError(Exception):
32
+ """The input contained a private key. Nothing from it was retained."""
33
+
34
+
35
+ def contains_private_key(text: str) -> bool:
36
+ return "PRIVATE KEY" in text or "-----BEGIN" in text or "PuTTY-User-Key-File" in text
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class CertInfo:
41
+ cert_type: str
42
+ valid_after: int
43
+ valid_before: int | None
44
+ key_id: str
45
+ ca_fingerprint: str
46
+ unreadable: bool = False
47
+
48
+ def public(self) -> dict:
49
+ return {
50
+ "certType": self.cert_type,
51
+ "validAfter": self.valid_after,
52
+ "validBefore": self.valid_before,
53
+ "keyId": self.key_id,
54
+ "caFingerprint": self.ca_fingerprint,
55
+ }
56
+
57
+
58
+ # Options that turn a capability back on after "restrict".
59
+ _REENABLE = {
60
+ "port-forwarding": "port",
61
+ "agent-forwarding": "agent",
62
+ "x11-forwarding": "x11",
63
+ "pty": "pty",
64
+ "user-rc": "user-rc",
65
+ }
66
+ _DISABLE = {
67
+ "no-port-forwarding": "port",
68
+ "no-agent-forwarding": "agent",
69
+ "no-x11-forwarding": "x11",
70
+ "no-pty": "pty",
71
+ "no-user-rc": "user-rc",
72
+ }
73
+
74
+
75
+ def parse_options(text: str) -> tuple[tuple[str, str | None], ...]:
76
+ """Split an authorized_keys options field. Values may be quoted and contain commas."""
77
+ options: list[tuple[str, str | None]] = []
78
+ index = 0
79
+ length = len(text)
80
+ while index < length:
81
+ while index < length and text[index] in ", \t":
82
+ index += 1
83
+ start = index
84
+ while index < length and text[index] not in ",=":
85
+ index += 1
86
+ name = text[start:index].strip().lower()
87
+ value: str | None = None
88
+ if index < length and text[index] == "=":
89
+ index += 1
90
+ if index < length and text[index] == '"':
91
+ index += 1
92
+ chars: list[str] = []
93
+ while index < length and text[index] != '"':
94
+ if text[index] == "\\" and index + 1 < length and text[index + 1] == '"':
95
+ index += 1
96
+ chars.append(text[index])
97
+ index += 1
98
+ index += 1
99
+ value = "".join(chars)
100
+ else:
101
+ start = index
102
+ while index < length and text[index] != ",":
103
+ index += 1
104
+ value = text[start:index]
105
+ if name:
106
+ options.append((name, value))
107
+ return tuple(options)
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class KeyOptions:
112
+ """What an authorized_keys line allows, before sshd's own settings apply."""
113
+
114
+ source: tuple[str, ...] = ()
115
+ forced_command: bool = False
116
+ restricted: bool = False
117
+ allows: frozenset[str] = frozenset({"port", "agent", "x11", "pty", "user-rc"})
118
+ permit_open: bool = False
119
+ permit_listen: bool = False
120
+ environment: bool = False
121
+ expires: int | None = None
122
+ expiry_unreadable: bool = False
123
+ principals: bool = False
124
+ from_unparsed: bool = False
125
+ names: tuple[str, ...] = field(default_factory=tuple)
126
+
127
+ def public(self) -> dict:
128
+ data: dict = {"restrict": self.restricted, "allows": sorted(self.allows)}
129
+ if self.source:
130
+ data["from"] = list(self.source)
131
+ if self.forced_command:
132
+ data["command"] = True
133
+ if self.permit_open:
134
+ data["permitopen"] = True
135
+ if self.permit_listen:
136
+ data["permitlisten"] = True
137
+ if self.environment:
138
+ data["environment"] = True
139
+ if self.expires is not None:
140
+ data["expiryTime"] = self.expires
141
+ if self.principals:
142
+ data["principals"] = True
143
+ return data
144
+
145
+
146
+ def _expiry(value: str) -> int | None:
147
+ value = value.strip()
148
+ zone = timezone.utc if value.endswith("Z") else None
149
+ digits = value.rstrip("Z")
150
+ for pattern in ("%Y%m%d%H%M%S", "%Y%m%d%H%M", "%Y%m%d"):
151
+ try:
152
+ moment = datetime.strptime(digits, pattern)
153
+ except ValueError:
154
+ continue
155
+ if zone is None:
156
+ # sshd reads an expiry without Z in the server's local time.
157
+ return int(moment.timestamp())
158
+ return int(moment.replace(tzinfo=zone).timestamp())
159
+ return None
160
+
161
+
162
+ def summarize_options(options: tuple[tuple[str, str | None], ...]) -> KeyOptions:
163
+ names = tuple(name for name, _value in options)
164
+ restricted = "restrict" in names
165
+ allows = set() if restricted else {"port", "agent", "x11", "pty", "user-rc"}
166
+ source: list[str] = []
167
+ expires: int | None = None
168
+ expiry_unreadable = False
169
+ for name, value in options:
170
+ if name in _REENABLE:
171
+ allows.add(_REENABLE[name])
172
+ elif name in _DISABLE:
173
+ allows.discard(_DISABLE[name])
174
+ elif name == "from" and value:
175
+ source.extend(part.strip() for part in value.split(",") if part.strip())
176
+ elif name == "expiry-time" and value:
177
+ expires = _expiry(value)
178
+ expiry_unreadable = expires is None
179
+ return KeyOptions(
180
+ source=tuple(source),
181
+ forced_command="command" in names,
182
+ restricted=restricted,
183
+ allows=frozenset(allows),
184
+ permit_open="permitopen" in names,
185
+ permit_listen="permitlisten" in names,
186
+ environment="environment" in names,
187
+ expires=expires,
188
+ expiry_unreadable=expiry_unreadable,
189
+ principals="principals" in names,
190
+ from_unparsed="from" in names and not source,
191
+ names=names,
192
+ )
193
+
194
+
195
+ @dataclass(frozen=True)
196
+ class KeyRef:
197
+ fingerprint: str
198
+ type: str
199
+ kind: str
200
+ bits: int | None
201
+ comment: str
202
+ user: str | None = None
203
+ cert_authority: bool = False
204
+ cert: CertInfo | None = None
205
+ options: KeyOptions = field(default_factory=KeyOptions)
206
+
207
+ def public(self) -> dict:
208
+ data = {
209
+ "fingerprint": self.fingerprint,
210
+ "type": self.type,
211
+ "bits": self.bits,
212
+ "comment": self.comment,
213
+ "certAuthority": self.cert_authority,
214
+ }
215
+ if self.cert and not self.cert.unreadable:
216
+ data["cert"] = self.cert.public()
217
+ return data
218
+
219
+
220
+ @dataclass(frozen=True)
221
+ class ParseIssue:
222
+ line: int
223
+ text: str
224
+
225
+
226
+ def fingerprint_bytes(raw: bytes) -> str:
227
+ digest = hashlib.sha256(raw).digest()
228
+ return "SHA256:" + base64.b64encode(digest).decode("ascii").rstrip("=")
229
+
230
+
231
+ def _b64_to_bytes(blob: str) -> bytes | None:
232
+ padded = blob + "=" * ((4 - len(blob) % 4) % 4)
233
+ try:
234
+ return base64.b64decode(padded, validate=True)
235
+ except Exception:
236
+ return None
237
+
238
+
239
+ def _read_string(buf: bytes, offset: int) -> tuple[bytes, int] | None:
240
+ if offset + 4 > len(buf):
241
+ return None
242
+ length = int.from_bytes(buf[offset : offset + 4], "big")
243
+ start = offset + 4
244
+ if start + length > len(buf):
245
+ return None
246
+ return buf[start : start + length], start + length
247
+
248
+
249
+ def _mpint_bits(number: bytes) -> int:
250
+ index = 0
251
+ while index < len(number) - 1 and number[index] == 0:
252
+ index += 1
253
+ top = 0
254
+ for bit in range(7, -1, -1):
255
+ if (number[index] >> bit) & 1:
256
+ top = bit + 1
257
+ break
258
+ return (len(number) - index - 1) * 8 + top
259
+
260
+
261
+ def rsa_bits(raw: bytes) -> int | None:
262
+ type_part = _read_string(raw, 0)
263
+ if not type_part:
264
+ return None
265
+ exponent = _read_string(raw, type_part[1])
266
+ if not exponent:
267
+ return None
268
+ modulus = _read_string(raw, exponent[1])
269
+ if not modulus:
270
+ return None
271
+ return _mpint_bits(modulus[0])
272
+
273
+
274
+ def kind_of(key_type: str) -> str:
275
+ base = key_type.replace("-cert-v01@openssh.com", "")
276
+ if base == "ssh-ed25519":
277
+ return "ed25519"
278
+ if base == "ssh-rsa":
279
+ return "rsa"
280
+ if base == "ssh-dss":
281
+ return "dsa"
282
+ if base.startswith("ecdsa-"):
283
+ return "ecdsa"
284
+ if base.startswith("sk-"):
285
+ return "security-key"
286
+ return "unknown"
287
+
288
+
289
+ def _read_u32(buf: bytes, offset: int) -> tuple[int, int] | None:
290
+ if offset + 4 > len(buf):
291
+ return None
292
+ return int.from_bytes(buf[offset : offset + 4], "big"), offset + 4
293
+
294
+
295
+ def _read_u64(buf: bytes, offset: int) -> tuple[int | None, int] | None:
296
+ hi = _read_u32(buf, offset)
297
+ lo = _read_u32(buf, offset + 4)
298
+ if not hi or not lo:
299
+ return None
300
+ if hi[0] == 0xFFFFFFFF:
301
+ return None, offset + 8
302
+ return hi[0] * 4294967296 + lo[0], offset + 8
303
+
304
+
305
+ def _cert_lead(key_type: str) -> int | None:
306
+ if key_type == "ssh-ed25519-cert-v01@openssh.com":
307
+ return 3
308
+ if key_type == "ssh-rsa-cert-v01@openssh.com":
309
+ return 4
310
+ if key_type == "ssh-dss-cert-v01@openssh.com":
311
+ return 6
312
+ if key_type.startswith("ecdsa-") and "-cert-" in key_type:
313
+ return 4
314
+ if key_type == "sk-ssh-ed25519-cert-v01@openssh.com":
315
+ return 4
316
+ if key_type.startswith("sk-ecdsa-") and "-cert-" in key_type:
317
+ return 5
318
+ return None
319
+
320
+
321
+ def read_certificate(key_type: str, raw: bytes) -> CertInfo | None:
322
+ lead = _cert_lead(key_type)
323
+ if lead is None:
324
+ return None
325
+ offset = 0
326
+ for _ in range(lead):
327
+ part = _read_string(raw, offset)
328
+ if not part:
329
+ return None
330
+ offset = part[1]
331
+ serial = _read_u64(raw, offset)
332
+ if not serial:
333
+ return None
334
+ offset = serial[1]
335
+ cert_type = _read_u32(raw, offset)
336
+ if not cert_type:
337
+ return None
338
+ offset = cert_type[1]
339
+ key_id = _read_string(raw, offset)
340
+ if not key_id:
341
+ return None
342
+ offset = key_id[1]
343
+ principals = _read_string(raw, offset)
344
+ if not principals:
345
+ return None
346
+ offset = principals[1]
347
+ valid_after = _read_u64(raw, offset)
348
+ if not valid_after or valid_after[0] is None:
349
+ return None
350
+ offset = valid_after[1]
351
+ valid_before = _read_u64(raw, offset)
352
+ if not valid_before:
353
+ return None
354
+ offset = valid_before[1]
355
+ for _ in range(3):
356
+ skipped = _read_string(raw, offset)
357
+ if not skipped:
358
+ return None
359
+ offset = skipped[1]
360
+ ca = _read_string(raw, offset)
361
+ if not ca:
362
+ return None
363
+ label = {1: "user", 2: "host"}.get(cert_type[0], "unknown")
364
+ ident = key_id[0].decode("utf-8", errors="replace").replace("\x00", "").strip()[:80]
365
+ return CertInfo(label, valid_after[0], valid_before[0], ident, fingerprint_bytes(ca[0]), False)
366
+
367
+
368
+ def _cert_authority(options: tuple[tuple[str, str | None], ...]) -> bool:
369
+ return any(name == "cert-authority" for name, _value in options)
370
+
371
+
372
+ def _split_line(line: str) -> tuple[str, str, str, str] | None:
373
+ for key_type in KEY_TYPES:
374
+ token = f"{key_type} "
375
+ start = 0
376
+ while start < len(line):
377
+ index = line.find(token, start)
378
+ if index == -1:
379
+ break
380
+ if index == 0 or line[index - 1].isspace():
381
+ rest = line[index + len(token) :].strip()
382
+ space = re.search(r"\s", rest)
383
+ blob = rest if space is None else rest[: space.start()]
384
+ comment = "" if space is None else rest[space.end() :].strip()
385
+ if re.fullmatch(r"[A-Za-z0-9+/]+=*", blob) and len(blob) >= 16:
386
+ return key_type, blob, comment, line[:index].strip()
387
+ start = index + 1
388
+ return None
389
+
390
+
391
+ def parse_public_keys(text: str, user: str | None = None) -> tuple[list[KeyRef], list[ParseIssue]]:
392
+ if contains_private_key(text):
393
+ raise PrivateKeyError("private key material was present and was not recorded")
394
+ keys: list[KeyRef] = []
395
+ issues: list[ParseIssue] = []
396
+ seen: set[str] = set()
397
+ for index, raw_line in enumerate(text.splitlines()):
398
+ line = raw_line.strip()
399
+ if not line or line.startswith("#"):
400
+ continue
401
+ parts = _split_line(line)
402
+ if not parts:
403
+ issues.append(ParseIssue(index + 1, "Not a recognized public key"))
404
+ continue
405
+ raw = _b64_to_bytes(parts[1])
406
+ if raw is None:
407
+ issues.append(ParseIssue(index + 1, "Key blob is not valid base64"))
408
+ continue
409
+ fingerprint = fingerprint_bytes(raw)
410
+ if fingerprint in seen:
411
+ continue
412
+ seen.add(fingerprint)
413
+ is_cert = "-cert-" in parts[0]
414
+ cert = read_certificate(parts[0], raw) if is_cert else None
415
+ if parts[0] == "ssh-rsa":
416
+ bits: int | None = rsa_bits(raw)
417
+ elif parts[0] == "ssh-dss" or parts[0].startswith("ssh-dss-cert-"):
418
+ bits = 1024
419
+ elif parts[0].startswith("ssh-rsa-cert-"):
420
+ lead = _read_string(raw, 0)
421
+ nonce = _read_string(raw, lead[1]) if lead else None
422
+ exponent = _read_string(raw, nonce[1]) if nonce else None
423
+ modulus = _read_string(raw, exponent[1]) if exponent else None
424
+ bits = _mpint_bits(modulus[0]) if modulus else None
425
+ else:
426
+ bits = None
427
+ if is_cert and cert is None:
428
+ cert = CertInfo("unknown", 0, 0, "", "", True)
429
+ options = parse_options(parts[3])
430
+ keys.append(
431
+ KeyRef(
432
+ fingerprint,
433
+ parts[0],
434
+ kind_of(parts[0]),
435
+ bits,
436
+ parts[2],
437
+ user,
438
+ _cert_authority(options),
439
+ cert,
440
+ summarize_options(options),
441
+ )
442
+ )
443
+ return keys, issues
keywarden/term.py ADDED
@@ -0,0 +1,67 @@
1
+ """Terminal colour. Off when output is not a terminal, when NO_COLOR is set, or when asked."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+
8
+ _CODES = {
9
+ "fail": "31;1",
10
+ "warn": "33;1",
11
+ "pass": "32",
12
+ "accepted": "34;1",
13
+ "dim": "2",
14
+ "bold": "1",
15
+ "fp": "36",
16
+ }
17
+
18
+
19
+ def _enable_windows_vt() -> bool:
20
+ """Windows 10 and later understand ANSI once virtual terminal processing is switched on."""
21
+ try:
22
+ import ctypes
23
+
24
+ kernel = ctypes.windll.kernel32 # type: ignore[attr-defined]
25
+ handle = kernel.GetStdHandle(-11)
26
+ mode = ctypes.c_uint32()
27
+ if not kernel.GetConsoleMode(handle, ctypes.byref(mode)):
28
+ return False
29
+ return bool(kernel.SetConsoleMode(handle, mode.value | 0x0004))
30
+ except Exception:
31
+ return False
32
+
33
+
34
+ def use_colour(choice: str = "auto", stream=None) -> bool:
35
+ stream = stream or sys.stdout
36
+ if choice == "always":
37
+ if os.name == "nt":
38
+ _enable_windows_vt()
39
+ return True
40
+ if choice == "never" or os.environ.get("NO_COLOR") or os.environ.get("TERM") == "dumb":
41
+ return False
42
+ if not hasattr(stream, "isatty") or not stream.isatty():
43
+ return False
44
+ if os.name == "nt":
45
+ return _enable_windows_vt()
46
+ return True
47
+
48
+
49
+ class Paint:
50
+ def __init__(self, enabled: bool):
51
+ self.enabled = enabled
52
+
53
+ def __call__(self, style: str, text: str) -> str:
54
+ if not self.enabled or style not in _CODES:
55
+ return text
56
+ return f"\033[{_CODES[style]}m{text}\033[0m"
57
+
58
+ def severity(self, severity: str) -> str:
59
+ label = {"fail": "FAIL", "warn": "WARN", "pass": "PASS", "accepted": "ACPT"}.get(severity, severity.upper())
60
+ return self(severity, f"{label:4}")
61
+
62
+ def fingerprints(self, text: str) -> str:
63
+ if not self.enabled:
64
+ return text
65
+ from keywarden.check import FINGERPRINT
66
+
67
+ return FINGERPRINT.sub(lambda match: self("fp", match.group(0)), text)