diff --git a/app/src/main/res/raw/apple_root_ca_g3.pem b/app/src/main/res/raw/apple_root_ca_g3.pem new file mode 100644 index 00000000..561fc80d --- /dev/null +++ b/app/src/main/res/raw/apple_root_ca_g3.pem @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS +QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u +IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN +MTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBS +b290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9y +aXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49 +AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtf +TjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517 +IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySr +MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gA +MGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4 +at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM +6BgD56KyKA== +-----END CERTIFICATE----- diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 4c29bb30..40b3f2c2 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -19,19 +19,32 @@ not: this is an authentication flow, and disabling verification would make it interceptable by anything on the network path. - Instead the one missing root is added, and only for these hosts. The certificate is - Apple's own, published at https://www.apple.com/appleca/, SHA-256 - b0b1730ecbc7ff4505142c49f1295e6eda6bcaed7e2c68c5be91b5a11001f024, and stored as PEM - text rather than a binary .cer so it can be reviewed in a diff. + Instead the missing roots are added, and only for these hosts. Both certificates are + Apple's own, published at https://www.apple.com/certificateauthority/, and stored as + PEM text rather than binary .cer so they can be reviewed in a diff: - "system" is kept alongside it so the usual roots still apply - this adds a trust - anchor, it does not replace them. + - "Apple Root CA" (2006), SHA-256 + b0b1730ecbc7ff4505142c49f1295e6eda6bcaed7e2c68c5be91b5a11001f024 - what + gsa.apple.com (the URL bag and sign-in) chains to. + - "Apple Root CA - G3" (2014), SHA-256 + 63343abfb89a6a03ebb57e9b3f5fa7be7c4f5c756f3017b3a8c488c3653e9179 - what + gsd.apple.com (the two provisioning endpoints the URL bag points at) chains to. + Without it, provisioning fails the handshake. Public Anisette servers running + the same libraries began answering "invalid Trust Key (-45003)" and 502s in + October 2026, which this is the likely cause of (issue #248). + + "system" is kept alongside them so the usual roots still apply - this adds trust + anchors, it does not replace them. gsd.apple.com gets both because the URL bag decides + which host provisioning talks to, and a change of host must not become a handshake + failure that reads as Apple refusing the app. --> gsa.apple.com + gsd.apple.com + \ No newline at end of file diff --git a/app/src/test/java/dev/wander/android/opentagviewer/anisette/TrustingApplesProvisioningHostsTest.java b/app/src/test/java/dev/wander/android/opentagviewer/anisette/TrustingApplesProvisioningHostsTest.java new file mode 100644 index 00000000..17ca0a35 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/anisette/TrustingApplesProvisioningHostsTest.java @@ -0,0 +1,91 @@ +package dev.wander.android.opentagviewer.anisette; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import java.io.FileInputStream; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; + +import javax.xml.parsers.DocumentBuilderFactory; + +/** + * The two Apple roots that local provisioning needs, and the hosts they are trusted for. + * + *

Android trusts neither root, so without these anchors the handshake fails, provisioning + * falls back to a remote server, and the user sees whatever that server says rather than a + * certificate error. {@code gsd.apple.com} began chaining to Apple Root CA - G3 in October 2026, + * which is how this was found (issue #248). Nothing else fails when an anchor or a host goes + * missing from the XML, so this pins both, and pins each PEM to Apple's published SHA-256 so a + * swapped certificate cannot pass as the real one. + */ +public class TrustingApplesProvisioningHostsTest { + + // Unit tests run from the module directory + private static final String RES = "src/main/res/"; + + private static final String APPLE_ROOT_CA = + "b0b1730ecbc7ff4505142c49f1295e6eda6bcaed7e2c68c5be91b5a11001f024"; + private static final String APPLE_ROOT_CA_G3 = + "63343abfb89a6a03ebb57e9b3f5fa7be7c4f5c756f3017b3a8c488c3653e9179"; + + @Test + public void bothRootsAreApplesOwn() throws Exception { + assertEquals(APPLE_ROOT_CA, sha256Of("raw/apple_root_ca.pem")); + assertEquals(APPLE_ROOT_CA_G3, sha256Of("raw/apple_root_ca_g3.pem")); + } + + @Test + public void signInAndProvisioningHostsTrustBothRootsAndTheSystemOnes() throws Exception { + for (final String host : List.of("gsa.apple.com", "gsd.apple.com")) { + final List anchors = anchorsFor(host); + assertTrue(host + " lost the system roots: " + anchors, anchors.contains("system")); + assertTrue(host + " does not trust Apple Root CA: " + anchors, + anchors.contains("@raw/apple_root_ca")); + assertTrue(host + " does not trust Apple Root CA - G3: " + anchors, + anchors.contains("@raw/apple_root_ca_g3")); + } + } + + private static String sha256Of(final String resource) throws Exception { + try (InputStream in = new FileInputStream(RES + resource)) { + final X509Certificate certificate = (X509Certificate) + CertificateFactory.getInstance("X.509").generateCertificate(in); + final StringBuilder hex = new StringBuilder(); + for (final byte b : MessageDigest.getInstance("SHA-256").digest(certificate.getEncoded())) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } + } + + private static List anchorsFor(final String host) throws Exception { + final Document config = DocumentBuilderFactory.newInstance().newDocumentBuilder() + .parse(RES + "xml/network_security_config.xml"); + final NodeList domainConfigs = config.getElementsByTagName("domain-config"); + for (int i = 0; i < domainConfigs.getLength(); i++) { + final Element domainConfig = (Element) domainConfigs.item(i); + final NodeList domains = domainConfig.getElementsByTagName("domain"); + for (int j = 0; j < domains.getLength(); j++) { + if (host.equals(domains.item(j).getTextContent().trim())) { + final List anchors = new ArrayList<>(); + final NodeList certificates = domainConfig.getElementsByTagName("certificates"); + for (int k = 0; k < certificates.getLength(); k++) { + anchors.add(((Element) certificates.item(k)).getAttribute("src")); + } + return anchors; + } + } + } + throw new AssertionError(host + " has no domain-config of its own"); + } +}