Coverage for app/venv/lib/python3.14/site-packages/weblate/vcs/ssh.py: 50%

181 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 07:15 +0000

1# Copyright © Michal Čihař <michal@weblate.org> 

2# 

3# SPDX-License-Identifier: GPL-3.0-or-later 

4from __future__ import annotations 

5 

6import hashlib 

7import os 

8import stat 

9import subprocess 

10from base64 import b64decode, b64encode 

11from typing import TYPE_CHECKING, Literal, TypedDict 

12 

13from django.conf import settings 

14from django.core.management.utils import find_command 

15from django.utils.functional import cached_property 

16from django.utils.translation import gettext, pgettext_lazy 

17 

18from weblate.trans.util import get_clean_env 

19from weblate.utils import messages 

20from weblate.utils.data import data_path 

21from weblate.utils.files import cleanup_error_message 

22from weblate.utils.hash import calculate_checksum 

23 

24if TYPE_CHECKING: 24 ↛ 25line 24 didn't jump to line 25 because the condition on line 24 was never true

25 from pathlib import Path 

26 

27 from django_stubs_ext import StrOrPromise 

28 

29 from weblate.auth.models import AuthenticatedHttpRequest 

30 

31# SSH key files 

32KNOWN_HOSTS = "known_hosts" 

33CONFIG = "config" 

34 

35 

36class KeyInfo(TypedDict): 

37 private: str 

38 public: str 

39 name: StrOrPromise 

40 keygen: list[str] 

41 

42 

43KeyType = Literal["rsa", "ed25519"] 

44 

45KEYS: dict[KeyType, KeyInfo] = { 

46 "rsa": { 

47 "private": "id_rsa", 

48 "public": "id_rsa.pub", 

49 "name": pgettext_lazy("SSH key type", "RSA"), 

50 "keygen": ["-b", "4096", "-t", "rsa"], 

51 }, 

52 "ed25519": { 

53 "private": "id_ed25519", 

54 "public": "id_ed25519.pub", 

55 "name": pgettext_lazy("SSH key type", "Ed25519"), 

56 "keygen": ["-t", "ed25519"], 

57 }, 

58} 

59 

60 

61def ssh_file(filename: str) -> Path: 

62 """Generate full path to SSH configuration file.""" 

63 return data_path("ssh") / filename 

64 

65 

66def is_key_line(key: str) -> bool: 

67 """Check whether this line looks like a valid known_hosts line.""" 

68 if not key: 

69 return False 

70 # Comment 

71 if key[0] == "#": 

72 return False 

73 # Special entry like @cert-authority 

74 if key[0] == "@": 

75 return False 

76 return ( 

77 " ssh-rsa " in key or " ecdsa-sha2-nistp256 " in key or " ssh-ed25519 " in key 

78 ) 

79 

80 

81def parse_hosts_line(line: str) -> tuple[str, str, str]: 

82 """Parse single hosts line into tuple host, key fingerprint.""" 

83 host, keytype, key = line.strip().split(None, 3)[:3] 

84 digest = hashlib.sha256(b64decode(key)).digest() 

85 fingerprint = b64encode(digest).rstrip(b"=").decode() 

86 if host.startswith("|1|"): 

87 # Translators: placeholder SSH hashed hostname 

88 host = gettext("[hostname hashed]") 

89 return host, keytype, fingerprint 

90 

91 

92def get_host_keys() -> list[tuple[str, str, str]]: 

93 """Return list of host keys.""" 

94 try: 

95 result = [] 

96 with open(ssh_file(KNOWN_HOSTS)) as handle: 

97 for line in handle: 

98 line = line.strip() 

99 if is_key_line(line): 

100 result.append(parse_hosts_line(line)) 

101 except OSError: 

102 return [] 

103 

104 return result 

105 

106 

107def get_key_data_raw( 

108 key_type: KeyType = "rsa", kind: Literal["public", "private"] = "public" 

109) -> tuple[str, str | None]: 

110 """Return raw public key data.""" 

111 # Read key data if it exists 

112 filename = KEYS[key_type][kind] 

113 key_file = ssh_file(filename) 

114 if os.path.exists(key_file): 

115 with open(key_file) as handle: 

116 return filename, handle.read() 

117 return filename, None 

118 

119 

120def get_key_data(key_type: KeyType = "rsa") -> dict[str, StrOrPromise | None]: 

121 """Parse host key and returns it.""" 

122 filename, key_data = get_key_data_raw(key_type) 

123 if key_data is not None: 

124 _key_type_parsed, key_fingerprint, key_id = key_data.strip().split(None, 2) 

125 return { 

126 "key": key_data, 

127 "fingerprint": key_fingerprint, 

128 "id": key_id, 

129 "filename": filename, 

130 "type": key_type, 

131 "name": KEYS[key_type]["name"], 

132 } 

133 return { 

134 "key": None, 

135 "type": key_type, 

136 "name": KEYS[key_type]["name"], 

137 } 

138 

139 

140def get_all_key_data() -> dict[str, dict[str, StrOrPromise | None]]: 

141 """Return all supported SSH keys.""" 

142 return {key_type: get_key_data(key_type) for key_type in KEYS} 

143 

144 

145def ensure_ssh_key(): 

146 """Ensure SSH key is existing.""" 

147 result = None 

148 for key_type in KEYS: 

149 ssh_key = get_key_data(key_type) 

150 if not ssh_key["key"]: 150 ↛ 153line 150 didn't jump to line 153 because the condition on line 150 was always true

151 generate_ssh_key(None, key_type) 

152 ssh_key = get_key_data() 

153 if key_type == "rsa": 

154 result = ssh_key 

155 return result 

156 

157 

158def generate_ssh_key( 

159 request: AuthenticatedHttpRequest | None, key_type: KeyType = "rsa" 

160) -> None: 

161 """Generate SSH key.""" 

162 key_info = KEYS[key_type] 

163 keyfile = ssh_file(key_info["private"]) 

164 pubkeyfile = ssh_file(key_info["public"]) 

165 try: 

166 # Actually generate the key 

167 subprocess.run( 

168 [ 

169 "ssh-keygen", 

170 "-q", 

171 *key_info["keygen"], 

172 "-N", 

173 "", 

174 "-C", 

175 settings.SITE_TITLE, 

176 "-f", 

177 keyfile, 

178 ], 

179 text=True, 

180 check=True, 

181 capture_output=True, 

182 env=get_clean_env(), 

183 ) 

184 except (subprocess.CalledProcessError, OSError) as exc: 

185 error = getattr(exc, "output", "").strip() 

186 if not error: 

187 error = str(exc) 

188 messages.error( 

189 request, 

190 gettext("Could not generate key: %s") % cleanup_error_message(error), 

191 ) 

192 return 

193 

194 # Fix key permissions 

195 os.chmod(keyfile, stat.S_IWUSR | stat.S_IRUSR) 

196 os.chmod(pubkeyfile, stat.S_IWUSR | stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH) 

197 

198 messages.success(request, gettext("Created new SSH key.")) 

199 

200 

201def add_host_key(request: AuthenticatedHttpRequest | None, host, port="") -> None: 

202 """Add host key for a host.""" 

203 if not host: 

204 messages.error(request, gettext("Invalid host name given!")) 

205 else: 

206 cmdline = ["ssh-keyscan"] 

207 if port: 

208 cmdline.extend(["-p", str(port)]) 

209 cmdline.append(host) 

210 try: 

211 result = subprocess.run( 

212 cmdline, 

213 env=get_clean_env(), 

214 check=True, 

215 text=True, 

216 capture_output=True, 

217 ) 

218 keys = set() 

219 for key in result.stdout.splitlines(): 

220 key = key.strip() 

221 if not is_key_line(key): 

222 continue 

223 keys.add(key) 

224 host, keytype, fingerprint = parse_hosts_line(key) 

225 messages.warning( 

226 request, 

227 gettext( 

228 "Added host key for %(host)s with fingerprint " 

229 "%(fingerprint)s (%(keytype)s), " 

230 "please verify that it is correct." 

231 ) 

232 % {"host": host, "fingerprint": fingerprint, "keytype": keytype}, 

233 ) 

234 if keys: 

235 known_hosts_file = ssh_file(KNOWN_HOSTS) 

236 # Remove existing key entries 

237 if known_hosts_file.exists(): 

238 with known_hosts_file.open() as handle: 

239 keys.difference_update(line.strip() for line in handle) 

240 # Write any new keys 

241 if keys: 

242 with known_hosts_file.open(mode="a") as handle: 

243 for key in keys: 

244 handle.write(key) 

245 handle.write("\n") 

246 else: 

247 messages.error( 

248 request, 

249 gettext("Could not fetch public key for a host: %s") % result.stderr 

250 or result.stdout, 

251 ) 

252 except subprocess.CalledProcessError as exc: 

253 messages.error( 

254 request, 

255 gettext("Could not fetch public key for a host: %s") 

256 % cleanup_error_message(exc.stderr or exc.stdout), 

257 ) 

258 except OSError as exc: 

259 messages.error(request, gettext("Could not get host key: %s") % str(exc)) 

260 

261 

262GITHUB_RSA_KEY = ( 

263 "AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7" 

264 "PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQq" 

265 "ZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG" 

266 "6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3J" 

267 "EAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==" 

268) 

269 

270 

271def cleanup_host_keys(*args, **kwargs) -> None: 

272 known_hosts_file = ssh_file(KNOWN_HOSTS) 

273 if not known_hosts_file.exists(): 273 ↛ 275line 273 didn't jump to line 275 because the condition on line 273 was always true

274 return 

275 logger = kwargs.get("logger", print) 

276 keys = [] 

277 with known_hosts_file.open() as handle: 

278 for line in handle: 

279 # Ignore IP address based RSA keys for GitHub, these 

280 # are duplicate to hostname based and cause problems on 

281 # migration to ECDSA. 

282 # See https://github.com/WeblateOrg/weblate/issues/6830 

283 if line[0].isdigit() and GITHUB_RSA_KEY in line: 

284 logger(f"Removing deprecated RSA key for GitHub: {line.strip()}") 

285 continue 

286 

287 # Avoid duplicates 

288 if line in keys: 

289 logger(f"Skipping duplicate key: {line.strip()}") 

290 continue 

291 

292 keys.append(line) 

293 

294 with known_hosts_file.open(mode="w") as handle: 

295 handle.writelines(keys) 

296 

297 

298def can_generate_key(): 

299 """Check whether we can generate key.""" 

300 return find_command("ssh-keygen") is not None 

301 

302 

303SSH_WRAPPER_TEMPLATE = r"""#!/bin/sh 

304exec {command} \ 

305 -o "UserKnownHostsFile={known_hosts}" \ 

306 -o "IdentityFile={identity_rsa}" \ 

307 -o "IdentityFile={identity_ed25519}" \ 

308 -o StrictHostKeyChecking=yes \ 

309 -o HashKnownHosts=no \ 

310 -o UpdateHostKeys=yes \ 

311 -o ConnectTimeout=20 \ 

312 -o BatchMode=yes \ 

313 -F {config_file} \ 

314 {extra_args} \ 

315 "$@" 

316""" 

317 

318 

319class SSHWrapper: 

320 # Custom ssh wrapper 

321 # - use custom location for known hosts and key 

322 # - do not hash it 

323 # - strict hosk key checking 

324 # - force not using system configuration (to avoid evil things as SendEnv) 

325 

326 @cached_property 

327 def digest(self) -> str: 

328 return calculate_checksum(self.get_content()) 

329 

330 @property 

331 def path(self) -> Path: 

332 """ 

333 Calculates unique wrapper path. 

334 

335 It is based on template and DATA_DIR settings. 

336 """ 

337 return ssh_file(f"bin-{self.digest}") 

338 

339 def get_content(self, command: str = "ssh") -> str: 

340 return SSH_WRAPPER_TEMPLATE.format( 

341 command=command, 

342 known_hosts=ssh_file(KNOWN_HOSTS).as_posix(), 

343 config_file=ssh_file(CONFIG).as_posix(), 

344 identity_rsa=ssh_file(KEYS["rsa"]["private"]).as_posix(), 

345 identity_ed25519=ssh_file(KEYS["ed25519"]["private"]).as_posix(), 

346 extra_args=settings.SSH_EXTRA_ARGS, 

347 ) 

348 

349 @property 

350 def filename(self) -> Path: 

351 """Calculate unique wrapper filename.""" 

352 return self.path / "ssh" 

353 

354 def create(self) -> None: 

355 """Create wrapper for SSH to pass custom known hosts and key.""" 

356 self.path.mkdir(parents=True, exist_ok=True) 

357 

358 ssh_config = ssh_file(CONFIG) 

359 if not ssh_config.exists(): 

360 try: 

361 with ssh_config.open(mode="x") as handle: 

362 handle.write( 

363 "# SSH configuration for customising SSH client in Weblate\n" 

364 ) 

365 except OSError: 

366 pass 

367 

368 for command in ("ssh", "scp"): 

369 path = find_command(command) 

370 if path is None: 370 ↛ 371line 370 didn't jump to line 371 because the condition on line 370 was never true

371 continue 

372 filename = self.path / command 

373 

374 if not filename.exists(): 

375 filename.write_text(self.get_content(path)) 

376 

377 if not os.access(filename, os.X_OK): 

378 filename.chmod(0o755) 

379 

380 

381SSH_WRAPPER = SSHWrapper()