#!/bin/bash
set -euo pipefail

# =============================================================================
# verify_fix.sh - Verifies the CVE-2026-48558 fix for SimpleHelp 5.5.15
#
# Applies the proposed fix to a clean SimpleHelp 5.5.15 server and confirms
# that a forged OIDC ID token (alg:none) is REJECTED (status stays
# UNAUTHENTICATED), while a legitimately-structured negative path is exercised
# against the real /oidc callback. Idempotent: can be re-run.
#
# Fix mechanism:
#   * utils/oauth/oidc/IDTokenVerifier.class (+ inner classes) are added to
#     simplehelp.jar. These are in a non-protected package, so SimpleHelp's
#     SHClassLoader loads them as plain class files.
#   * com/aem/shelp/.../OIDCAuthenticator is encrypted in the jar, so a plain
#     replacement would be re-decrypted and corrupted. Instead a Java agent
#     (fixagent.jar) substitutes the fixed OIDCAuthenticator bytes at class-load
#     time (after the classloader has decrypted the original).
#
# The fixed OIDCAuthenticator calls IDTokenVerifier.verify(...) before
# oidcSuccess(), which rejects alg:none / unsigned / bad-signature tokens.
# =============================================================================

ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CODING="$ROOT/coding"
ARTIFACTS="$ROOT/artifacts"
VWORK="$CODING/verify_work"
LOGS="$CODING/verify_logs"
mkdir -p "$VWORK" "$LOGS"

VULN_TGZ="$ARTIFACTS/SimpleHelp-5.5.15-vuln.tar.gz"
VULN_SHA256="26cd904ebf78ac4b2c5b99f6a9659a562390777dc8fe730469e2ecfc8ad139ab"
FIXAGENT="$CODING/work/fixagent.jar"
IDTOKENVERIFIER_CLASSES="$CODING/work/out/utils/oauth/oidc"

# Output artifacts
FLOW_JSON="$LOGS/fixed_flow.json"
IDP_LOG="$LOGS/fixed_idp.log"
SERVER_LOG="$LOGS/fixed_server.log"
RUNTIME_TAIL="$LOGS/fixed_runtime_tail.log"
RESULT_JSON="$LOGS/verify_result.json"
EVIDENCE_LOG="$LOGS/fix_evidence.log"

log() { echo "[verify] $*"; }

# -----------------------------------------------------------------------------
# Prerequisites
# -----------------------------------------------------------------------------
[ -f "$FIXAGENT" ] || { echo "FATAL: $FIXAGENT not found" >&2; exit 2; }
[ -d "$IDTOKENVERIFIER_CLASSES" ] || { echo "FATAL: IDTokenVerifier classes not found" >&2; exit 2; }

if [ ! -f "$VULN_TGZ" ] || [ "$(sha256sum "$VULN_TGZ" | awk '{print $1}')" != "$VULN_SHA256" ]; then
  echo "FATAL: SimpleHelp 5.5.15 tarball missing or bad SHA256 at $VULN_TGZ" >&2
  exit 2
fi

# -----------------------------------------------------------------------------
# Port cleanup (IdP 8080, SimpleHelp 443)
# -----------------------------------------------------------------------------
cleanup_ports() {
  python3 - <<'PY'
import os, re, signal, subprocess
out = subprocess.run(['ss','-ltnp'], text=True, capture_output=True).stdout
for line in out.splitlines():
    if any(p in line for p in [':8080', ':443 ']):
        for pid in re.findall(r'pid=(\d+)', line):
            try:
                cmd = open(f'/proc/{pid}/cmdline','rb').read().replace(b'\0', b' ').decode('utf-8','ignore')
            except Exception:
                cmd = ''
            if 'SimpleHelp' in cmd or 'oidc_idp' in cmd:
                try: os.kill(int(pid), signal.SIGTERM)
                except ProcessLookupError: pass
PY
  sleep 1
}

stop_server() {
  local dir="$1"
  if [ -x "$dir/SimpleHelp/jre/bin/java" ] && [ -f "$dir/SimpleHelp/lib/simplehelp.jar" ]; then
    (cd "$dir/SimpleHelp" && timeout 8 ./jre/bin/java -Djava.awt.headless=true -cp lib/simplehelp.jar SimpleHelpStop) >/dev/null 2>&1 || true
  fi
  rm -f "$dir/SimpleHelp/lib/server.lock" 2>/dev/null || true
}

# -----------------------------------------------------------------------------
# Extract a clean 5.5.15 server
# -----------------------------------------------------------------------------
extract_clean() {
  log "Extracting clean SimpleHelp 5.5.15"
  rm -rf "$VWORK/fixed"
  mkdir -p "$VWORK/fixed"
  tar -xzf "$VULN_TGZ" -C "$VWORK/fixed"
}

# -----------------------------------------------------------------------------
# Patch simplehelp.jar: add IDTokenVerifier (non-protected utils package)
# -----------------------------------------------------------------------------
patch_jar() {
  local jar="$VWORK/fixed/SimpleHelp/lib/simplehelp.jar"
  log "Patching $jar with IDTokenVerifier classes"
  # Backup once
  [ -f "$jar.orig" ] || cp "$jar" "$jar.orig"
  # Add the new (plain) IDTokenVerifier classes into the utils/oauth/oidc path.
  ( cd "$CODING/work/out" && jar uf "$jar" utils/oauth/oidc/IDTokenVerifier.class utils/oauth/oidc/IDTokenVerifier\$CachedJwks.class utils/oauth/oidc/IDTokenVerifier\$IssuerInfo.class ) 2>&1 || {
    # Fallback: use zip if jar update has issues
    ( cd "$CODING/work/out" && zip -j "$jar" utils/oauth/oidc/IDTokenVerifier.class ) >/dev/null 2>&1 || true
  }
  # Verify presence
  if ! unzip -l "$jar" 2>/dev/null | grep -q "utils/oauth/oidc/IDTokenVerifier.class"; then
    echo "FATAL: IDTokenVerifier.class not added to jar" >&2; exit 2
  fi
  log "IDTokenVerifier present in patched jar"
}

# -----------------------------------------------------------------------------
# First-launch config initialization (creates serverconfig.xml)
# -----------------------------------------------------------------------------
initialise_config() {
  local dir="$VWORK/fixed"
  if [ -f "$dir/SimpleHelp/configuration/serverconfig.xml" ]; then
    log "serverconfig.xml already present"
    return
  fi
  log "Initialising first-launch config"
  rm -f "$dir/SimpleHelp/lib/server.lock"
  ( cd "$dir/SimpleHelp" && exec ./jre/bin/java -Djava.net.preferIPv4Stack=true -Djava.awt.headless=true -Xmx384m -cp lib/simplehelp.jar SimpleHelp ) > "$LOGS/fixed_initial_start.log" 2>&1 &
  local pid=$!
  # wait for HTTPS
  for i in $(seq 1 30); do
    curl -ksS --max-time 3 https://127.0.0.1/ >/dev/null 2>&1 && break
    sleep 1
  done
  curl -ksS --max-time 5 https://127.0.0.1/setupmode_new_config -o /dev/null || true
  sleep 2
  stop_server "$dir"
  kill "$pid" >/dev/null 2>&1 || true
  [ -f "$dir/SimpleHelp/configuration/serverconfig.xml" ] || { echo "FATAL: serverconfig.xml not created" >&2; exit 2; }
}

# -----------------------------------------------------------------------------
# Configure OIDC provider + non-admin Technicians group (mirrors repro)
# -----------------------------------------------------------------------------
configure_oidc() {
  local dir="$VWORK/fixed"
  python3 - "$dir/SimpleHelp/configuration/serverconfig.xml" <<'PY'
from pathlib import Path
import sys
p = Path(sys.argv[1])
s = p.read_text()
s = s.replace('<Encrypt>on</Encrypt>', '<Encrypt>off</Encrypt>')
start = s.find('\n\t<AuthenticationProvider type="OIDC">')
while start != -1:
    end = s.find('\n\t</AuthenticationProvider>', start)
    if end == -1: break
    end += len('\n\t</AuthenticationProvider>')
    s = s[:start] + s[end:]
    start = s.find('\n\t<AuthenticationProvider type="OIDC">')
provider = '''
	<AuthenticationProvider type="OIDC">
		<Enabled>on</Enabled>
		<Name>Test OIDC</Name>
		<ID>oidc-test-provider</ID>
		<ClientID>test-client-id</ClientID>
		<Secret>test-client-secret</Secret>
		<AutoAssignGroups>on</AutoAssignGroups>
		<DiscoveryURL>http://127.0.0.1:8080/.well-known/openid-configuration</DiscoveryURL>
		<Scope>openid email profile</Scope>
		<AccessTokenEndpoint>http://127.0.0.1:8080/token</AccessTokenEndpoint>
		<AuthorizationBaseURL>http://127.0.0.1:8080/auth</AuthorizationBaseURL>
		<UserInfoEndpoint>http://127.0.0.1:8080/userinfo</UserInfoEndpoint>
		<SupportedScopes>openid email profile</SupportedScopes>
	</AuthenticationProvider>
'''
s = s.replace('\n\t<TechnicianGroup', provider + '\n\t<TechnicianGroup', 1)
if '<ID>2000002</ID>' not in s:
    group = '''
	<TechnicianGroup machineFilters="all">
		<ID>2000002</ID>
		<Name>Technicians</Name>
		<AnonymousLogins>on</AnonymousLogins>
		<RemoteSupport>on</RemoteSupport>
		<RemoteAccess>on</RemoteAccess>
		<OIDC>
			<Enabled>on</Enabled>
			<ID>oidc-test-provider</ID>
		</OIDC>
	</TechnicianGroup>
'''
    s = s.replace('\n\t<Technician>\n', group + '\n\t<Technician>\n', 1)
p.write_text(s)
PY
  cp "$dir/SimpleHelp/configuration/serverconfig.xml" "$LOGS/fixed_serverconfig.xml"
  log "OIDC provider configured"
}

# -----------------------------------------------------------------------------
# Fake OIDC IdP (returns a forged alg:none id_token via the token endpoint)
# -----------------------------------------------------------------------------
write_idp() {
  cat > "$LOGS/oidc_idp.py" <<'PY'
#!/usr/bin/env python3
import json, base64, time, os
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
LOG=os.environ.get('IDP_LOG','idp.log')
def b64(o): return base64.urlsafe_b64encode(json.dumps(o,separators=(',',':')).encode()).decode().rstrip('=')
def make_token():
    return b64({'alg':'none','typ':'JWT'})+'.'+b64({
        'iss':'http://127.0.0.1:8080','aud':'test-client-id',
        'sub':'attacker@example.com','email':'attacker@example.com',
        'preferred_username':'attacker','name':'Forged Attacker',
        'groups':['Technicians'], 'iat':int(time.time()), 'exp':int(time.time())+3600
    })+'.bogus'
class H(BaseHTTPRequestHandler):
    def do_GET(self):
        p=urlparse(self.path)
        if p.path=='/.well-known/openid-configuration':
            self.js({'issuer':'http://127.0.0.1:8080','authorization_endpoint':'http://127.0.0.1:8080/auth','token_endpoint':'http://127.0.0.1:8080/token','userinfo_endpoint':'http://127.0.0.1:8080/userinfo','jwks_uri':'http://127.0.0.1:8080/jwks','response_types_supported':['code'],'grant_types_supported':['authorization_code'],'subject_types_supported':['public'],'id_token_signing_alg_values_supported':['RS256'],'scopes_supported':['openid','email','profile']})
        elif p.path=='/auth':
            q=parse_qs(p.query); red=q.get('redirect_uri',['https://127.0.0.1/oidc'])[0]; st=q.get('state',[''])[0]
            self.send_response(302); self.send_header('Location', red+('?' if '?' not in red else '&')+'code=fakecode&state='+st); self.end_headers()
        elif p.path=='/jwks': self.js({'keys':[]})
        elif p.path=='/userinfo': self.js({'sub':'attacker@example.com','email':'attacker@example.com','preferred_username':'attacker','name':'Forged Attacker'})
        else: self.send_response(404); self.end_headers()
    def do_POST(self):
        n=int(self.headers.get('Content-Length','0')); body=self.rfile.read(n).decode()
        if urlparse(self.path).path=='/token':
            tok=make_token()
            with open(LOG,'a') as f: f.write('FORGED_ID_TOKEN '+tok+'\n')
            self.js({'access_token':'unused-access-token','token_type':'Bearer','expires_in':3600,'id_token':tok})
        else: self.send_response(404); self.end_headers()
    def js(self,o):
        b=json.dumps(o).encode(); self.send_response(200); self.send_header('Content-Type','application/json'); self.send_header('Content-Length',str(len(b))); self.end_headers(); self.wfile.write(b)
    def log_message(self,*a): pass
if __name__=='__main__':
    open(LOG,'a').write('START\n')
    ThreadingHTTPServer(('127.0.0.1',8080),H).serve_forever()
PY
  chmod +x "$LOGS/oidc_idp.py"
}

# -----------------------------------------------------------------------------
# Start the patched server WITH the fix agent and run the forged-token flow
# -----------------------------------------------------------------------------
run_flow() {
  local dir="$VWORK/fixed"
  : > "$IDP_LOG"
  : > "$EVIDENCE_LOG"
  IDP_LOG="$IDP_LOG" python3 "$LOGS/oidc_idp.py" &
  local idp_pid=$!
  rm -f "$dir/SimpleHelp/lib/server.lock"
  # KEY: start the server with -javaagent so OIDCAuthenticator is replaced at load
  ( cd "$dir/SimpleHelp" && exec ./jre/bin/java -javaagent:"$FIXAGENT"="$EVIDENCE_LOG" -Djava.net.preferIPv4Stack=true -Djava.awt.headless=true -Xmx384m -cp lib/simplehelp.jar SimpleHelp ) > "$SERVER_LOG" 2>&1 &
  local srv_pid=$!
  # wait for HTTPS
  local ready=0
  for i in $(seq 1 40); do
    if curl -ksS --max-time 3 https://127.0.0.1/ >/dev/null 2>&1; then ready=1; break; fi
    sleep 1
  done
  if [ "$ready" != "1" ]; then
    echo "FATAL: patched server did not become ready (see $SERVER_LOG)" >&2
    tail -40 "$SERVER_LOG" >&2 || true
    kill "$srv_pid" "$idp_pid" >/dev/null 2>&1 || true
    exit 2
  fi
  log "Patched server is up; running forged-token OIDC flow"
  curl -ksS --max-time 5 https://127.0.0.1/setupmode_new_config -o /dev/null || true

  python3 - "$FLOW_JSON" <<'PY'
import json, ssl, urllib.request, urllib.parse, urllib.error, http.cookiejar, sys
out = sys.argv[1]
ctx=ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
cj=http.cookiejar.CookieJar()
op=urllib.request.build_opener(urllib.request.HTTPSHandler(context=ctx), urllib.request.HTTPHandler(), urllib.request.HTTPCookieProcessor(cj))
result = {}
def read(url, timeout=15):
    r=op.open(url,timeout=timeout); body=r.read(20000).decode('utf-8','replace'); return r, body
try:
    r, body = read('https://127.0.0.1/auth/v1/account/login_options')
    result['login_options_status'] = r.status
    payload={'name':'Test OIDC','type':'oidc','callback':'https://127.0.0.1/technician'}
    r, body = read('https://127.0.0.1/auth/v1/account/oidc_get?payload='+urllib.parse.quote(json.dumps(payload,separators=(',',':'))), 15)
    result['oidc_get_status'] = r.status
    result['oidc_get_body'] = body
    auth_url = json.loads(body)
    result['auth_url'] = auth_url
    class NoRedirect(urllib.request.HTTPRedirectHandler):
        def redirect_request(self, req, fp, code, msg, headers, newurl): return None
    op2=urllib.request.build_opener(urllib.request.HTTPHandler(), NoRedirect)
    try:
        op2.open(auth_url, timeout=5)
        raise RuntimeError('IdP did not redirect')
    except urllib.error.HTTPError as e:
        loc=e.headers['Location']
    result['idp_redirect_location'] = loc
    u=urllib.parse.urlparse(loc)
    callback_url='https://127.0.0.1'+u.path+'?'+u.query
    result['callback_url'] = callback_url
    r, cb_body = read(callback_url, 20)
    result['callback_status'] = r.status
    result['callback_contains_login_failed'] = 'Login Failed' in cb_body
    result['callback_contains_login_complete'] = 'Login Complete' in cb_body and 'Login Failed' not in cb_body
    r, st_body = read('https://127.0.0.1/auth/v1/account/status')
    result['status_after_body'] = st_body
    result['status_unauthenticated'] = '"UNAUTHENTICATED"' in st_body
    result['status_fully_authenticated'] = '"FULLY_AUTHENTICATED"' in st_body
except Exception as e:
    result['flow_error'] = str(e)
with open(out,'w') as f: json.dump(result,f,indent=2)
print(json.dumps(result, indent=2))
PY

  sleep 2
  find "$dir/SimpleHelp/logs" -maxdepth 2 -type f -name server.log -print -exec tail -400 {} \; > "$RUNTIME_TAIL" 2>/dev/null || true
  stop_server "$dir"
  kill "$srv_pid" "$idp_pid" >/dev/null 2>&1 || true
  rm -f "$dir/SimpleHelp/lib/server.lock"
}

# -----------------------------------------------------------------------------
# Main
# -----------------------------------------------------------------------------
cleanup_ports
extract_clean
patch_jar
initialise_config
configure_oidc
write_idp
run_flow
cleanup_ports

# -----------------------------------------------------------------------------
# Verdict
# -----------------------------------------------------------------------------
python3 - "$FLOW_JSON" "$RESULT_JSON" "$RUNTIME_TAIL" "$SERVER_LOG" <<'PY'
import json, sys, pathlib
flow = json.loads(pathlib.Path(sys.argv[1]).read_text())
tail = pathlib.Path(sys.argv[3]).read_text(errors='ignore')
srvlog = pathlib.Path(sys.argv[4]).read_text(errors='ignore')
evidence = ''
evpath = pathlib.Path(sys.argv[1]).parent / 'fix_evidence.log'
if evpath.exists():
    evidence = evpath.read_text(errors='ignore')
fix_agent_applied = 'AGENT_APPLIED' in evidence
premain_loaded = 'FixAgent premain' in evidence
verifier_start = 'VERIFY_START' in evidence
verifier_rejected_algnone = ("alg is 'none'" in evidence) or ('alg is none' in evidence)
verifier_rejected_any = 'REJECTED' in evidence
verifier_ok = 'VERIFY_OK' in evidence
status_unauth = flow.get('status_unauthenticated', False)
status_full = flow.get('status_fully_authenticated', False)
login_failed = flow.get('callback_contains_login_failed', False)
idp_returned_forged_token = True  # the flow always reaches /token; confirmed via idp log separately
verdict = {
  'fix_agent_premain_loaded': premain_loaded,
  'fix_agent_applied': fix_agent_applied,
  'verifier_invoked': verifier_start or verifier_rejected_any or verifier_ok,
  'verifier_rejected_algnone': verifier_rejected_algnone,
  'verifier_rejected_any': verifier_rejected_any,
  'verifier_ok': verifier_ok,
  'forged_token_rejected': status_unauth and not status_full,
  'callback_login_failed': login_failed,
  'status_unauthenticated': status_unauth,
  'status_fully_authenticated': status_full,
  'flow_error': flow.get('flow_error'),
  'evidence_log_excerpt': evidence[:2000],
}
passed = premain_loaded and fix_agent_applied and (verifier_rejected_algnone or verifier_rejected_any) and status_unauth and (not status_full)
verdict['PASS'] = bool(passed)
pathlib.Path(sys.argv[2]).write_text(json.dumps(verdict, indent=2))
print(json.dumps(verdict, indent=2))
if not passed:
    print('\n=== server.log tail ===', file=sys.stderr)
    print(srvlog[-3000:], file=sys.stderr)
    print('\n=== runtime tail ===', file=sys.stderr)
    print(tail[-3000:], file=sys.stderr)
    sys.exit(1)
PY

echo ""
log "VERIFICATION COMPLETE - see $RESULT_JSON"
