Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions app/src/main/res/raw/apple_root_ca_g3.pem
Original file line number Diff line number Diff line change
@@ -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-----
25 changes: 19 additions & 6 deletions app/src/main/res/xml/network_security_config.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
-->
<domain-config>
<domain includeSubdomains="false">gsa.apple.com</domain>
<domain includeSubdomains="false">gsd.apple.com</domain>
<trust-anchors>
<certificates src="system" />
<certificates src="@raw/apple_root_ca" />
<certificates src="@raw/apple_root_ca_g3" />
</trust-anchors>
</domain-config>
</network-security-config>
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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<String> 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<String> 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<String> 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");
}
}
Loading