243 lines
11 KiB
Python
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())
|