Files
skill-git-push/skills/updating-gitea-repositories/scripts/credential_migrate.py
T

243 lines
11 KiB
Python

#!/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())