286 lines
13 KiB
Python
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())
|