#!/usr/bin/env python3 """Store one Git OAuth credential in an isolated Windows vault namespace. Git invokes this as a credential helper. The access token, expiry and refresh token are compressed into one UTF-8 vault blob; Git for Windows' wincred helper can exceed the Windows blob limit when it concatenates long tokens as UTF-16. This helper never deletes a credential in response to Git's automatic erase. """ import argparse from contextlib import contextmanager import ctypes from ctypes import wintypes import hashlib import hmac import json import os import re import subprocess import sys import time from urllib.parse import urlencode, urljoin, urlsplit from urllib.request import HTTPRedirectHandler, Request, build_opener import zlib MAX_BLOB = 2560 MAX_INPUT = 131072 FIELDS = ("username", "password", "password_expiry_utc", "oauth_refresh_token") class VaultError(Exception): """Contains no sensitive data.""" class CREDENTIAL_ATTRIBUTE(ctypes.Structure): _fields_ = [("Keyword", wintypes.LPWSTR), ("Flags", wintypes.DWORD), ("ValueSize", wintypes.DWORD), ("Value", ctypes.POINTER(ctypes.c_ubyte))] class CREDENTIAL(ctypes.Structure): _fields_ = [("Flags", wintypes.DWORD), ("Type", wintypes.DWORD), ("TargetName", wintypes.LPWSTR), ("Comment", wintypes.LPWSTR), ("LastWritten", wintypes.FILETIME), ("CredentialBlobSize", wintypes.DWORD), ("CredentialBlob", ctypes.POINTER(ctypes.c_ubyte)), ("Persist", wintypes.DWORD), ("AttributeCount", wintypes.DWORD), ("Attributes", ctypes.POINTER(CREDENTIAL_ATTRIBUTE)), ("TargetAlias", wintypes.LPWSTR), ("UserName", wintypes.LPWSTR)] def target_name(namespace, protocol, host, username): if not re.fullmatch(r"gitea-oauth-[0-9a-f]{32}", namespace): raise VaultError("Invalid isolated vault namespace.") if (protocol != "https" and not (protocol == "http" and re.fullmatch(r"127\.0\.0\.1:[0-9]+", host))): raise VaultError("Only HTTPS sites are supported.") if username != "oauth2" or not host or any(c in host for c in "\x00\r\n/@"): raise VaultError("Invalid OAuth credential context.") context = "\0".join((protocol.lower(), host.lower(), username)).encode("utf-8") return namespace + ":" + hashlib.sha256(context).hexdigest() def api(): if sys.platform != "win32": raise VaultError("This helper requires Windows Credential Manager.") library = ctypes.WinDLL("Advapi32.dll", use_last_error=True) library.CredReadW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(ctypes.POINTER(CREDENTIAL))] library.CredReadW.restype = wintypes.BOOL library.CredWriteW.argtypes = [ctypes.POINTER(CREDENTIAL), wintypes.DWORD] library.CredWriteW.restype = wintypes.BOOL library.CredFree.argtypes = [ctypes.c_void_p] return library def read_record(target): library = api() pointer = ctypes.POINTER(CREDENTIAL)() if not library.CredReadW(target, 1, 0, ctypes.byref(pointer)): if ctypes.get_last_error() == 1168: return None raise VaultError("Could not read the Windows credential vault.") try: size = pointer.contents.CredentialBlobSize if not size or size > MAX_BLOB: raise VaultError("Stored credential has an invalid size.") raw = ctypes.string_at(pointer.contents.CredentialBlob, size) decompressor = zlib.decompressobj() decoded = decompressor.decompress(raw, MAX_INPUT + 1) if len(decoded) > MAX_INPUT or not decompressor.eof or decompressor.unused_data: raise VaultError("Stored credential has invalid data.") result = json.loads(decoded.decode("utf-8")) if not isinstance(result, dict): raise VaultError("Stored credential has invalid data.") return result except (ValueError, UnicodeError, zlib.error): raise VaultError("Stored credential has invalid data.") from None finally: library.CredFree(pointer) def write_record(target, record): data = json.dumps(record, ensure_ascii=True, separators=(",", ":")).encode("ascii") blob = zlib.compress(data, 9) if len(blob) > MAX_BLOB: raise VaultError("OAuth credential exceeds the Windows vault size limit; existing data was preserved.") buffer = (ctypes.c_ubyte * len(blob)).from_buffer_copy(blob) credential = CREDENTIAL() credential.Type = 1 # CRED_TYPE_GENERIC credential.TargetName = target credential.Comment = "Gitea OAuth credential (isolated Git helper namespace)" credential.CredentialBlobSize = len(blob) credential.CredentialBlob = ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte)) credential.Persist = 2 # CRED_PERSIST_LOCAL_MACHINE: same Windows user and machine credential.UserName = "oauth2" if not api().CredWriteW(ctypes.byref(credential), 0): raise VaultError("Could not write the Windows credential vault; existing data was preserved.") def parse_request(stream): fields, consumed = {}, 0 for line in stream: consumed += len(line) if consumed > MAX_INPUT: raise VaultError("Git credential request is too large.") line = line.rstrip(b"\r\n") if not line: break if b"=" not in line: raise VaultError("Invalid Git credential request.") key, value = line.split(b"=", 1) try: key, value = key.decode("ascii"), value.decode("utf-8") except UnicodeError: raise VaultError("Invalid Git credential request.") from None if key in ("protocol", "host", *FIELDS): if key in fields or "\x00" in value or "\r" in value: raise VaultError("Invalid Git credential request.") fields[key] = value return fields def complete(fields): if not all(isinstance(fields.get(k), str) and fields[k] and "\n" not in fields[k] and "\r" not in fields[k] and "\x00" not in fields[k] for k in FIELDS): return False expiry = fields["password_expiry_utc"] return fields["username"] == "oauth2" and expiry.isascii() and expiry.isdigit() and len(expiry) <= 20 @contextmanager def credential_lock(target): """Serialize refresh and store across Git processes using this namespace.""" if sys.platform != "win32": raise VaultError("This helper requires Windows Credential Manager.") kernel = ctypes.WinDLL("kernel32.dll", use_last_error=True) kernel.CreateMutexW.argtypes = [ctypes.c_void_p, wintypes.BOOL, wintypes.LPCWSTR] kernel.CreateMutexW.restype = wintypes.HANDLE kernel.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] kernel.WaitForSingleObject.restype = wintypes.DWORD kernel.ReleaseMutex.argtypes = [wintypes.HANDLE] kernel.CloseHandle.argtypes = [wintypes.HANDLE] name = "Global\\gitea-oauth-" + hashlib.sha256(target.encode("utf-8")).hexdigest() handle = kernel.CreateMutexW(None, False, name) if not handle: raise VaultError("Could not lock the OAuth credential.") acquired = False try: # WAIT_ABANDONED also grants ownership; re-read the vault after acquiring. status = kernel.WaitForSingleObject(handle, 50000) if status not in (0, 0x80): raise VaultError("Could not lock the OAuth credential.") acquired = True yield finally: if acquired: kernel.ReleaseMutex(handle) kernel.CloseHandle(handle) class NoRedirect(HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): return None def oauth_setting(site, name): env = {key: value for key, value in os.environ.items() if not key.startswith(("GIT_TRACE", "GCM_TRACE")) and key != "GIT_CURL_VERBOSE"} env.update({"GIT_TRACE": "0", "GIT_TRACE_CURL": "0", "GCM_TRACE": "0"}) try: result = subprocess.run(["git", "config", "--get-urlmatch", "credential." + name, site], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10, check=True) value = result.stdout.decode("utf-8").strip() except (OSError, subprocess.SubprocessError, UnicodeError): raise VaultError("OAuth refresh configuration is unavailable.") from None if not value or any(c in value for c in "\r\n\x00"): raise VaultError("OAuth refresh configuration is invalid.") return value def refresh_record(protocol, host, record): """Rotate, then return the new record; caller persists it while holding the lock.""" site = protocol + "://" + host client_id = oauth_setting(site, "oauthClientId") endpoint = urljoin(site + "/", oauth_setting(site, "oauthTokenURL")) parsed = urlsplit(endpoint) if (parsed.scheme != protocol or parsed.netloc.lower() != host.lower() or parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment or not parsed.path): raise VaultError("OAuth token endpoint must remain on the same site.") body = urlencode({"grant_type": "refresh_token", "client_id": client_id, "refresh_token": record["oauth_refresh_token"]}).encode("utf-8") request = Request(endpoint, data=body, headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, method="POST") try: with build_opener(NoRedirect()).open(request, timeout=20) as response: data = response.read(MAX_INPUT + 1) if len(data) > MAX_INPUT: raise ValueError() result = json.loads(data) lifetime = int(result["expires_in"]) updated = dict(record) updated.update({"password": result["access_token"], "password_expiry_utc": str(int(time.time()) + lifetime), "oauth_refresh_token": result.get("refresh_token") or record["oauth_refresh_token"], "rejected": False}) if lifetime <= 0 or not complete(updated): raise ValueError() return updated except Exception: # HTTP errors can include token-bearing response text. Never print it. raise VaultError("OAuth refresh failed; preserved the existing vault record.") from None def run(namespace, operation, fields, output): if not all(fields.get(k) for k in ("protocol", "host", "username")): raise VaultError("Git did not supply a complete OAuth credential context.") target = target_name(namespace, fields["protocol"], fields["host"], fields["username"]) with credential_lock(target): record = read_record(target) if record is not None and not complete(record): raise VaultError("Stored OAuth credential is incomplete.") if operation == "store": if record is None: # Only the identity-verifying bootstrap may create a record. raise VaultError("An unverified OAuth credential cannot be stored.") elif fields.get("password") and hmac.compare_digest(fields["password"], record["password"]): if record.get("rejected") or record.get("rejections"): record.update({"rejected": False, "rejections": 0}) write_record(target, record) # A delayed approve must never overwrite a newer rotated token. elif operation == "erase" and record is not None: if fields.get("password") and hmac.compare_digest(fields["password"], record["password"]): record.update({"rejected": True}) write_record(target, record) elif operation == "get": if record is None: raise VaultError("No verified OAuth credential exists in this namespace.") if record.get("rejected") or int(record["password_expiry_utc"]) <= int(time.time()) + 30: record = refresh_record(fields["protocol"], fields["host"], record) # Persist before returning to Git, even when the Git operation fails. write_record(target, record) for key in FIELDS: output.write((key + "=" + record[key] + "\n").encode("utf-8")) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--namespace", required=True) parser.add_argument("operation", choices=("get", "store", "erase")) args = parser.parse_args() try: run(args.namespace, args.operation, parse_request(sys.stdin.buffer), sys.stdout.buffer) except VaultError as exc: if args.operation == "get": # Prevent a failed refresh from falling through to an interactive # helper or returning an obsolete token. sys.stdout.buffer.write(b"quit=1\n") print("Gitea OAuth vault helper stopped: " + str(exc), file=sys.stderr) return 1 except (OSError, ValueError, TypeError): if args.operation == "get": sys.stdout.buffer.write(b"quit=1\n") print("Gitea OAuth vault helper failed; inspect its configuration without exposing secrets.", file=sys.stderr) return 1 return 0 if __name__ == "__main__": raise SystemExit(main())