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

286 lines
13 KiB
Python

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