#!/usr/bin/env python3 """Copy one explicitly selected Git credential-store entry to a new Windows GCM namespace. The secret travels only over subprocess pipes. This program never removes the source entry or changes Git configuration. A successful copy is not proof that the server accepts the token; validate that separately without erasing it. """ import argparse import ctypes import hmac import os from pathlib import Path import secrets import subprocess import sys import tempfile from urllib.parse import unquote, urlsplit class MigrationError(Exception): """An error whose message contains no credential data.""" def safe_environment(): env = os.environ.copy() for key in list(env): if key.startswith("GIT_TRACE") or key.startswith("GCM_TRACE") or key == "GIT_CURL_VERBOSE": env.pop(key, None) no_askpass = os.path.join(tempfile.gettempdir(), "gitea-no-askpass-" + secrets.token_hex(16)) env.update({ "GIT_TRACE": "0", "GIT_TRACE2": "0", "GIT_TRACE2_EVENT": "0", "GIT_TRACE2_PERF": "0", "GIT_TRACE_CURL": "0", "GIT_TRACE_REDACT": "1", "GCM_TRACE": "0", "GCM_TRACE_SECRETS": "0", "GCM_TRACE_MSAUTH": "0", "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": no_askpass, "SSH_ASKPASS": no_askpass, "GCM_INTERACTIVE": "never", "GCM_PROVIDER": "generic", }) return env def checked_request(site, account): try: if not site or any(ord(c) < 33 or ord(c) == 127 for c in site): raise ValueError() parsed = urlsplit(site) if (parsed.scheme.lower() != "https" or not parsed.hostname or parsed.username is not None or parsed.password is not None or parsed.path not in ("", "/") or parsed.query or parsed.fragment): raise ValueError() hostname = parsed.hostname.encode("idna").decode("ascii").lower() if ":" in hostname: hostname = "[" + hostname + "]" port = parsed.port host = hostname + ((":" + str(port)) if port is not None else "") if not account or len(account) > 1024 or any(c in account for c in "\r\n\x00"): raise ValueError() return {"protocol": b"https", "host": host.encode("ascii"), "username": account.encode("utf-8")} except (ValueError, UnicodeError): raise MigrationError("Use a clean HTTPS site origin and a valid account name.") from None def inspect_source_file(site, account, source_file): """Refuse ambiguous and path-scoped entries before asking credential-store.""" expected = checked_request(site, account)["host"] matches = 0 try: for line in Path(source_file).read_text(encoding="utf-8").splitlines(): parts = urlsplit(line) if parts.scheme.lower() != "https" or not parts.hostname or parts.username is None or parts.password is None: continue hostname = parts.hostname.encode("idna").decode("ascii").lower() if ":" in hostname: hostname = "[" + hostname + "]" host = (hostname + ((":" + str(parts.port)) if parts.port is not None else "")).encode("ascii") if host != expected or unquote(parts.username, errors="strict") != account: continue if parts.path not in ("", "/") or parts.query or parts.fragment: raise MigrationError("The source contains a path-scoped entry; automatic migration is unsafe.") matches += 1 except MigrationError: raise except (OSError, ValueError, UnicodeError): raise MigrationError("The selected credential-store file cannot be inspected safely.") from None if matches != 1: raise MigrationError("The selected file does not contain exactly one unambiguous entry for this site and account.") def _encode(fields): lines = [] for key, value in fields.items(): if key not in {"protocol", "host", "username", "password"}: raise MigrationError("Unsupported credential field.") if not isinstance(value, bytes) or any(c in value for c in (b"\r", b"\n", b"\x00")): raise MigrationError("Invalid credential field.") lines.append(key.encode("ascii") + b"=" + value) return b"\n".join(lines) + b"\n\n" def _decode(output): result = {} for line in output.splitlines(): if not line: break if b"=" not in line: raise MigrationError("Credential helper returned an invalid response.") raw_key, value = line.split(b"=", 1) try: key = raw_key.decode("ascii") except UnicodeError: raise MigrationError("Credential helper returned an invalid response.") from None if key in result: raise MigrationError("Credential helper returned duplicate fields.") if key in {"protocol", "host", "username", "password"}: result[key] = value return result or None class GitHelper: """Invoke exactly one helper, without Git's multi-helper fill/approve chain.""" def __init__(self, command, env, cwd): self.command, self.env, self.cwd = command, env, cwd def _run(self, operation, fields): try: proc = subprocess.run(self.command + [operation], input=_encode(fields), stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=self.env, cwd=self.cwd, timeout=30, check=False) except (OSError, subprocess.SubprocessError): raise MigrationError("Credential helper could not complete the operation.") from None if proc.returncode != 0: raise MigrationError("Credential helper failed; inspect its configuration without exposing credentials.") return proc.stdout def get(self, request): return _decode(self._run("get", request)) def store(self, credential): self._run("store", credential) def _assert_windows_namespace_empty(namespace): """Check a fresh namespace without reading any Windows credential values.""" if sys.platform != "win32": raise MigrationError("Automatic migration currently requires native Windows.") from ctypes import wintypes api = ctypes.WinDLL("Advapi32.dll", use_last_error=True) api.CredEnumerateW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), ctypes.POINTER(ctypes.c_void_p)] api.CredEnumerateW.restype = wintypes.BOOL api.CredFree.argtypes = [ctypes.c_void_p] count, entries = wintypes.DWORD(), ctypes.c_void_p() if api.CredEnumerateW(namespace + ":*", 0, ctypes.byref(count), ctypes.byref(entries)): try: if count.value: raise MigrationError("The new credential namespace is already in use.") finally: api.CredFree(entries) elif ctypes.get_last_error() != 1168: # ERROR_NOT_FOUND means no entries match. raise MigrationError("Cannot check whether the Windows credential namespace is empty.") class NewWindowsNamespace(GitHelper): def __init__(self, command, env, cwd, namespace): super().__init__(command, env, cwd) self.namespace, self.first_lookup = namespace, True def get(self, request): if self.first_lookup: self.first_lookup = False _assert_windows_namespace_empty(self.namespace) return None return super().get(request) def migrate(site, account, source, target): request = checked_request(site, account) try: found = source.get(request) if not found or not found.get("username") or not found.get("password"): raise MigrationError("No complete credential was returned by the selected source.") if found["username"] != request["username"]: raise MigrationError("The source credential account does not match.") for field in ("protocol", "host"): if field in found and found[field] != request[field]: raise MigrationError("The source credential site does not match.") credential = dict(request) credential["password"] = found["password"] _encode(credential) target_request = {"protocol": request["protocol"], "host": request["host"]} if target.get(target_request): raise MigrationError("The target already contains a credential; nothing was overwritten.") target.store(credential) result = target.get(target_request) if (not result or result.get("username") != credential["username"] or not hmac.compare_digest(result.get("password", b""), credential["password"])): raise MigrationError("The secure-store copy could not be verified; no Git configuration was changed.") except MigrationError: raise except Exception: raise MigrationError("Credential migration failed; no sensitive details are displayed.") from None def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--site", required=True, help="HTTPS site origin without userinfo or a repository path") parser.add_argument("--account", required=True, help="confirmed Gitea account") parser.add_argument("--source-file", required=True, help="existing git-credential-store file") args = parser.parse_args(argv) try: checked_request(args.site, args.account) if sys.platform != "win32": raise MigrationError("Automatic migration currently requires native Windows.") source_file = Path(args.source_file).expanduser().resolve() inspect_source_file(args.site, args.account, source_file) source_command = ["git", "credential-store", "--file=" + str(source_file)] namespace = "gitea-migrated-" + secrets.token_hex(16) source_env = safe_environment() target_env = safe_environment() target_env.update({"GCM_CREDENTIAL_STORE": "wincredman", "GCM_NAMESPACE": namespace}) source = GitHelper(source_command, source_env, os.getcwd()) target = NewWindowsNamespace(["git", "credential-manager"], target_env, os.getcwd(), namespace) print("New Windows credential namespace: " + namespace) migrate(args.site, args.account, source, target) print("Copied and read back the credential. The source remains unchanged and may still be plaintext.") print("The server has not been contacted; verify authentication before changing Git configuration.") return 0 except MigrationError as exc: print("Migration stopped: " + str(exc), file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())