[fix] improve iOS privacy and App Store distribution compliance

This commit is contained in:
dijunkun
2026-09-28 10:23:41 +08:00
parent e894f4a763
commit 0b28d1bc99
21 changed files with 2848 additions and 58 deletions
+20 -2
View File
@@ -57,6 +57,10 @@ fi
XMAKE_DEVELOPER_DIR="${DEVELOPER_DIR:-$(xcode-select -p)}"
XMAKE_TOOLCHAIN_BIN="${XMAKE_DEVELOPER_DIR}/Toolchains/XcodeDefault.xctoolchain/usr/bin"
run_xmake() {
local xmake_env=()
if [[ -n "${XMAKE_GLOBALDIR:-}" ]]; then
xmake_env+=("XMAKE_GLOBALDIR=${XMAKE_GLOBALDIR}")
fi
/usr/bin/env -i \
HOME="${HOME}" \
PATH="${XMAKE_TOOLCHAIN_BIN}:${PATH}" \
@@ -69,11 +73,16 @@ run_xmake() {
DEVELOPER_DIR="${XMAKE_DEVELOPER_DIR}" \
XMAKE_PKG_INSTALLDIR="${XMAKE_PKG_INSTALLDIR}" \
CROSSDESK_SOURCE_DIR="${REPO_DIR}" \
"${xmake_env[@]}" \
"${XMAKE_BIN}" "$@"
}
OUTPUT_DIR="${IOS_DIR}/Vendor/iphoneos/${CONFIG_NAME}"
OUTPUT_LIBRARY="${OUTPUT_DIR}/libCrossDeskMiniRTC.a"
XMAKE_REPOSITORY_DIR="$(run_xmake lua -c 'import("core.base.global"); print(path.join(global.directory(), "repositories", "xmake-repo"))')"
mkdir -p "${OUTPUT_DIR}"
print -r -- "${XMAKE_REPOSITORY_DIR}" > "${OUTPUT_DIR}/xmake-repository.txt"
run_xmake --version > "${OUTPUT_DIR}/xmake-version.txt"
MINIRTC_BUILD_DIR="${IOS_DIR}/.xmake/minirtc-build"
MINIRTC_LIBRARY="${MINIRTC_BUILD_DIR}/iphoneos/arm64/${MODE}/libminirtc.a"
WIRE_BUILD_DIR="${IOS_DIR}/.xmake/wire-build"
@@ -106,7 +115,8 @@ configure_xmake() {
clean_flags=(-c)
fi
run_xmake f -P "${project_dir}" "${clean_flags[@]}" -o "${build_dir}" \
-p iphoneos -a arm64 -m "${MODE}" --target_minver=16.0 -y "$@"
-p iphoneos -a arm64 -m "${MODE}" --target_minver=16.0 \
--policies=package.precompiled:n -y "$@"
mkdir -p "${build_dir}"
print -r -- "${CONFIG_SIGNATURE}" > "${stamp}"
}
@@ -148,6 +158,8 @@ TARGET_INFO="$(cd "${MINIRTC_DIR}" && TERM=dumb NO_COLOR=1 \
CLEAN_INFO="$(print -r -- "${TARGET_INFO}" | sed $'s/\033\\[[0-9;]*[[:alpha:]]//g')"
LINK_DIRS=("${(@f)$(print -r -- "${CLEAN_INFO}" | sed -nE 's|.*-> (/.*)/lib -> package.*|\1/lib|p' | sort -u)}")
print -r -- "${CLEAN_INFO}" > "${OUTPUT_DIR}/minirtc-target-info.txt"
REQUIRED_LINKS=(
nice glib-2.0 gobject-2.0 gmodule-2.0 gio-2.0 gthread-2.0 intl
gupnp-igd-1.6 gupnp-1.6 gssdp-1.6 soup-3.0 nghttp2 sqlite3 psl xml2
@@ -158,6 +170,7 @@ if [[ "${MINIRTC_ENABLE_AOM}" == "true" ]]; then
REQUIRED_LINKS+=(aom)
fi
DEPENDENCY_ARCHIVES=()
MERGE_ARCHIVES=()
for link_name in "${REQUIRED_LINKS[@]}"; do
archive_path=""
@@ -173,6 +186,11 @@ for link_name in "${REQUIRED_LINKS[@]}"; do
exit 66
fi
DEPENDENCY_ARCHIVES+=("${archive_path}")
# OpenSSL is linked separately as a static framework with its SDK privacy
# resources. Keep both archives in the input manifest for source validation.
if [[ "${link_name}" != "ssl" && "${link_name}" != "crypto" ]]; then
MERGE_ARCHIVES+=("${archive_path}")
fi
done
TEMP_LIBRARY="${OUTPUT_LIBRARY}.tmp"
@@ -204,7 +222,7 @@ if [[ -f "${OUTPUT_LIBRARY}" ]] && cmp -s "${TEMP_MANIFEST}" "${MERGE_MANIFEST}"
fi
/usr/bin/libtool -static -o "${TEMP_LIBRARY}" \
"${MINIRTC_LIBRARY}" "${DEPENDENCY_ARCHIVES[@]}" \
"${MINIRTC_LIBRARY}" "${MERGE_ARCHIVES[@]}" \
"${WIRE_LIBRARY}"
verify_no_openh264 "${TEMP_LIBRARY}"
mv -f "${TEMP_LIBRARY}" "${OUTPUT_LIBRARY}"
@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""Collect iOS notices from pinned source archives; validate them offline at build time."""
import argparse
from collections import defaultdict
from contextlib import contextmanager
import hashlib
import json
from pathlib import Path
import re
import subprocess
import sys
import tarfile
import urllib.request
import zipfile
IOS = Path(__file__).resolve().parents[1]
REPO = IOS.parents[1]
LOCK = IOS / "licenses/sources.json"
OUTPUT = IOS / "CrossDeskMobile/Resources/ThirdPartyLicenses.json"
COMMENT = re.compile(rb"/\*.*?\*/|(?://[^\n]*(?:\n|$))+", re.S)
RIGHTS = re.compile(rb"copyright|SPDX-License|permission is hereby|author disclaims|public domain", re.I)
def digest(data):
return hashlib.sha256(data).hexdigest()
def json_bytes(value):
return (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode()
def checked(data, expected, label):
if digest(data) != expected:
raise ValueError(f"Source checksum mismatch: {label}")
return data
def archive_path(component, cache):
source = component["source"]
target = cache / source["sha256"]
if target.exists():
checked(target.read_bytes(), source["sha256"], component["id"])
return target
cache.mkdir(parents=True, exist_ok=True)
# Xmake may remove extracted sources or COPYING; read the original archive.
candidates = (Path.home() / ".xmake/cache/packages").glob(
f"*/{component['id'][0]}/{component['id']}/{component['version']}/*")
for candidate in candidates:
if candidate.is_file() and candidate.name.endswith((".tar.gz", ".tar.xz", ".zip")):
data = candidate.read_bytes()
if digest(data) == source["sha256"]:
target.write_bytes(data)
return target
request = urllib.request.Request(source["url"], headers={"User-Agent": "CrossDesk-license-collector"})
with urllib.request.urlopen(request, timeout=90) as response:
data = checked(response.read(), source["sha256"], component["id"])
target.write_bytes(data)
return target
@contextmanager
def source_files(component, cache):
if component["source"]["kind"] == "repository":
yield {}, lambda name: (REPO / name).read_bytes()
return
path = archive_path(component, cache)
if zipfile.is_zipfile(path):
with zipfile.ZipFile(path) as archive:
names = {name.split("/", 1)[1]: name for name in archive.namelist()
if "/" in name and not name.endswith("/")}
yield names, lambda name: archive.read(names[name])
else:
with tarfile.open(path) as archive:
names = {member.name.split("/", 1)[1]: member.name for member in archive
if "/" in member.name and not member.isdir()}
# No filesystem extraction. tarfile resolves COPYING symlinks within
# the verified archive, including GLib's LICENSES directory.
yield names, lambda name: archive.extractfile(names[name]).read()
def component_documents(component, names, read):
documents = []
for item in component["documents"]:
data = checked(read(item["path"]), item["sha256"], item["path"])
text = data.decode(item.get("encoding", "utf-8"))
if "end_before" in item:
if item["end_before"] not in text:
raise ValueError(f"Missing notice boundary: {item['path']}")
text = text.split(item["end_before"], 1)[0]
documents.append({"title": item["path"], "text": text})
notices = defaultdict(list)
for name in sorted(names):
path = Path(name)
selected = any(name.startswith(prefix) for prefix in component.get("notice_prefixes", []))
selected |= component.get("notice_root_files", False) and "/" not in name
if not selected or path.suffix not in (".h", ".hpp", ".c", ".cc", ".cpp", ".S", ".s", ".inc"):
continue
if any(part.lower() in ("test", "tests", "benchmarks", "examples") for part in path.parts):
continue
for block in COMMENT.findall(read(name)[:16384]):
if RIGHTS.search(block):
# Preserve complete original comment blocks, including bundled
# fmt, semaphore and per-file copyright/permission exceptions.
encoding = component.get("notice_encodings", {}).get(name, "utf-8")
notices[block.decode(encoding)].append(name)
if notices:
documents.append({"title": "Source copyright and license notices", "text": "\n\n".join(
"Files: " + ", ".join(files) + "\n\n" + text for text, files in notices.items())})
if not documents:
raise ValueError(f"No notices collected for {component['id']}")
return documents
def generate(lock, cache):
components = []
for component in lock["components"]:
with source_files(component, cache) as (names, read):
documents = component_documents(component, names, read)
source = component["source"]
source_url = source["url"]
if source["kind"] == "repository" and source.get("revision"):
source_url += "/tree/" + source["revision"]
entry = {key: component[key] for key in ("id", "name", "version", "license")} | {
"sourceURL": source_url, "documents": documents}
recipe = component.get("recipe_path", "")
if recipe.startswith("deps/submodules/minirtc/"):
mini = next(c for c in lock["components"] if c["id"] == "minirtc")
entry["buildSourceURL"] = (mini["source"]["url"] + "/tree/" +
mini["source"]["revision"] + "/" +
str(Path(recipe).relative_to("deps/submodules/minirtc").parent))
components.append(entry)
print(f"Collected {component['name']} {component['version']}")
return json_bytes({"schemaVersion": 1, "components": components})
def verify(lock, inputs, target_info, xmake_repository=None, xmake_version=None):
checked(OUTPUT.read_bytes(), lock["bundle_sha256"], str(OUTPUT))
if xmake_version is not None:
version = re.search(r"xmake v(\d+\.\d+\.\d+)", xmake_version.read_text())
if not version or version[1] != lock["toolchain"]["xmake_version"]:
raise ValueError("Xmake version differs from the reviewed iOS toolchain; see apps/ios/README.md")
if xmake_repository is not None:
revision = subprocess.check_output(
["git", "-C", str(xmake_repository), "rev-parse", "HEAD"], text=True).strip()
if revision != lock["toolchain"]["xmake_repository_revision"]:
raise ValueError("Xmake package repository differs from the reviewed iOS source catalog")
changes = subprocess.check_output(
["git", "-C", str(xmake_repository), "status", "--porcelain", "--untracked-files=normal"],
text=True).strip()
if changes:
raise ValueError("Xmake package repository has local changes; use the unmodified pinned revision")
for component in lock["components"]:
recipe = component.get("recipe_path", "")
if recipe.startswith("deps/"):
checked((REPO / recipe).read_bytes(), component["recipe_sha256"], recipe)
elif recipe.startswith("xmake-repo/") and xmake_repository is not None:
checked((xmake_repository / Path(recipe).relative_to("xmake-repo")).read_bytes(),
component["recipe_sha256"], recipe)
if component["source"]["kind"] == "repository":
for item in component["documents"]:
checked((REPO / item["path"]).read_bytes(), item["sha256"], item["path"])
if inputs is None:
return
if target_info is None:
raise ValueError("--target-info is required with --inputs (includes header-only dependencies)")
known = {c["package"]: c["version"] for c in lock["components"] if "package" in c}
seen = {}
# Package names/versions come from resolved Xmake link and include paths,
# never from an inventory of all packages left in the installation cache.
pattern = r"/packages/[^/\s]+/([^/\s]+)/([^/\s]+)/[a-f0-9]+/"
for name, version in re.findall(pattern, inputs.read_text() + target_info.read_text()):
if name not in known or known[name] != version:
raise ValueError(f"Unreviewed iOS dependency: {name} {version}; regenerate licenses from its source")
seen[name] = version
if set(seen) != set(known):
raise ValueError(f"License/build dependency mismatch; missing: {sorted(set(known) - set(seen))}")
for line in inputs.read_text().splitlines():
sha, path = line.split(maxsplit=1)
checked(Path(path).read_bytes(), sha, path)
revision = subprocess.check_output(
["git", "-C", str(REPO / "deps/submodules/minirtc"), "rev-parse", "HEAD"], text=True).strip()
mini = next(c for c in lock["components"] if c["id"] == "minirtc")
if revision != mini["source"]["revision"]:
raise ValueError("MiniRTC revision changed; review embedded notices and update sources.json")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--generate", action="store_true", help="download/check pinned sources and regenerate the bundled JSON")
parser.add_argument("--cache", type=Path, default=Path.home() / ".cache/crossdesk/licenses")
parser.add_argument("--inputs", type=Path, help="native archive .inputs.sha256 manifest")
parser.add_argument("--target-info", type=Path, help="resolved Xmake minirtc target information")
parser.add_argument("--xmake-repository", type=Path, help="verify the active pinned package repository and recipes")
parser.add_argument("--xmake-version", type=Path, help="Xmake version output recorded by the native build")
args = parser.parse_args()
lock = json.loads(LOCK.read_text())
if args.generate:
data = generate(lock, args.cache)
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
OUTPUT.write_bytes(data)
lock["bundle_sha256"] = digest(data)
LOCK.write_bytes(json_bytes(lock))
verify(lock, args.inputs, args.target_info, args.xmake_repository, args.xmake_version)
print(f"Verified {len(lock['components'])} iOS license components")
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as error:
print(f"Third-party notices: {error}", file=sys.stderr)
sys.exit(1)
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Package source-built iOS OpenSSL as a static framework with privacy resources."""
import argparse
import hashlib
import json
from pathlib import Path
import plistlib
import subprocess
import sys
import tempfile
IOS = Path(__file__).resolve().parents[1]
PRIVACY = IOS / "OpenSSL/PrivacyInfo.xcprivacy"
# The reviewed iOS configuration removes these calls rather than declaring
# unrelated purposes for system entropy devices and certificate directories.
FORBIDDEN_APIS = {
"stat", "fstat", "lstat", "fstatat", "stat64", "fstat64", "lstat64",
"getattrlist", "fgetattrlist", "getattrlistbulk", "getdirentriesattr",
"statfs", "fstatfs", "statvfs", "fstatvfs", "getfsstat",
"mach_absolute_time", "mach_continuous_time", "sysctl",
}
def package(inputs, output):
catalog = json.loads((IOS / "CrossDeskMobile/Resources/ThirdPartyLicenses.json").read_text())
component = next(c for c in catalog["components"] if c["id"] == "openssl3")
archives = {}
for line in inputs.read_text().splitlines():
sha, name = line.split(maxsplit=1)
path = Path(name)
if path.name in {"libssl.a", "libcrypto.a"}:
if path.name in archives:
raise ValueError(f"Ambiguous OpenSSL input: {path.name}")
if hashlib.sha256(path.read_bytes()).hexdigest() != sha:
raise ValueError(f"OpenSSL input checksum mismatch: {path}")
archives[path.name] = (path, sha)
if set(archives) != {"libssl.a", "libcrypto.a"}:
raise ValueError("Both source-built OpenSSL archives must be present")
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="openssl-framework-", dir=output.parent) as temp:
binary = Path(temp) / "OpenSSL"
subprocess.run(["/usr/bin/libtool", "-static", "-o", str(binary),
*(str(archives[name][0]) for name in sorted(archives))], check=True)
subprocess.run(["xcrun", "lipo", str(binary), "-verify_arch", "arm64"], check=True)
symbols = subprocess.check_output(["xcrun", "nm", "-u", str(binary)], text=True)
imports = {line.split()[-1].lstrip("_").split("$")[0]
for line in symbols.splitlines() if line.strip() and not line.endswith(":")}
unexpected = imports & FORBIDDEN_APIS
if unexpected:
raise ValueError(f"OpenSSL privacy configuration has unreviewed APIs: {sorted(unexpected)}")
# armcap.c queries only CPU instruction support/brand to select crypto
# implementations. This is not boot time, disk space or fingerprinting.
member = ""
for line in symbols.splitlines():
if line.endswith(":"):
member = line
elif "_sysctlbyname" in line and member != "libcrypto-lib-armcap.o:":
raise ValueError(f"Unreviewed OpenSSL sysctl caller: {member}")
# getrandom uses Darwin's getentropy via OpenSSL's existing DSO lookup;
# the random device fallback is excluded at compile time.
output.mkdir(parents=True, exist_ok=True)
files = {
"OpenSSL": binary.read_bytes(),
"PrivacyInfo.xcprivacy": PRIVACY.read_bytes(),
"Info.plist": plistlib.dumps({
"CFBundleIdentifier": "cn.crossdesk.OpenSSL",
"CFBundleName": "OpenSSL",
"CFBundleExecutable": "OpenSSL",
"CFBundlePackageType": "FMWK",
"CFBundleInfoDictionaryVersion": "6.0",
"CFBundleVersion": component["version"],
"CFBundleShortVersionString": component["version"],
"CFBundleSupportedPlatforms": ["iPhoneOS"],
"MinimumOSVersion": "16.0",
}),
"LICENSE.txt": "\n\n".join(d["text"] for d in component["documents"]).encode(),
"BuildMetadata.json": (json.dumps({
"version": component["version"],
"sourceURL": component["sourceURL"],
"buildSourceURL": component["buildSourceURL"],
"archives": {name: sha for name, (_, sha) in sorted(archives.items())},
"privacySHA256": hashlib.sha256(PRIVACY.read_bytes()).hexdigest(),
}, indent=2) + "\n").encode(),
}
for name, data in files.items():
target = output / name
if not target.exists() or target.read_bytes() != data:
target.write_bytes(data)
print(f"Packaged source-built OpenSSL {component['version']}: {output}")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--inputs", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
package(args.inputs, args.output)
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, KeyError, StopIteration, subprocess.CalledProcessError) as error:
print(f"OpenSSL framework: {error}", file=sys.stderr)
sys.exit(1)
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Record the source corresponding to an iOS build without changing the checkout."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import subprocess
import sys
from urllib.parse import quote
REPO = Path(__file__).resolve().parents[3]
REPOSITORY_URL = "https://github.com/kunkundi/crossdesk"
MINIRTC_URL = "https://github.com/kunkundi/minirtc"
def git(*args, root=REPO):
return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip()
def source_metadata(require_release=False, tag=None):
revision = git("rev-parse", "HEAD")
dirty = bool(git("status", "--porcelain", "--untracked-files=normal", "--ignore-submodules=none"))
tags = git("tag", "--points-at", "HEAD").splitlines()
if tag:
if tag not in tags:
raise ValueError(f"Source tag {tag!r} does not point to the build commit")
else:
# 'latest' is a moving alias, never a corresponding-source identifier.
versions = sorted(t for t in tags if t.startswith("v"))
tag = versions[0] if versions else None
if require_release and (dirty or not tag or not tag.startswith("v")):
raise ValueError("Release source must be a clean checkout of a version tag, with matching submodules")
mini_revision = git("rev-parse", "HEAD", root=REPO / "deps/submodules/minirtc")
lock_path = REPO / "apps/ios/licenses/sources.json"
lock = json.loads(lock_path.read_text())
ref = quote(tag if tag and not dirty else revision, safe="")
return {
"schemaVersion": 1,
"repositoryURL": REPOSITORY_URL,
"revision": revision,
"tag": tag,
"isModified": dirty,
"sourceURL": f"{REPOSITORY_URL}/tree/{ref}",
"buildInstructionsURL": f"{REPOSITORY_URL}/blob/{ref}/apps/ios/README.md",
"privacyPolicyURL": f"{REPOSITORY_URL}/blob/{ref}/PRIVACY.md",
"miniRTCRevision": mini_revision,
"miniRTCSourceURL": f"{MINIRTC_URL}/tree/{mini_revision}",
"licenseCatalogSHA256": lock["bundle_sha256"],
"sourceCatalogSHA256": hashlib.sha256(lock_path.read_bytes()).hexdigest(),
"toolchain": lock["toolchain"],
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--tag", default=os.environ.get("CROSSDESK_SOURCE_TAG") or None)
parser.add_argument("--require-release", action="store_true",
default=os.environ.get("CROSSDESK_RELEASE_BUILD") == "YES")
args = parser.parse_args()
metadata = source_metadata(args.require_release, args.tag)
data = (json.dumps(metadata, ensure_ascii=False, indent=2) + "\n").encode()
args.output.parent.mkdir(parents=True, exist_ok=True)
if not args.output.exists() or args.output.read_bytes() != data:
args.output.write_bytes(data)
print(f"Recorded iOS source: {metadata['revision']} (modified: {metadata['isModified']})")
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, subprocess.CalledProcessError) as error:
print(f"iOS source metadata: {error}", file=sys.stderr)
sys.exit(1)
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Check release resources and known restricted imports in a built iOS app."""
import argparse
import hashlib
import json
from pathlib import Path
import plistlib
import re
import subprocess
import sys
def verify(app, require_release=False):
info = plistlib.loads((app / "Info.plist").read_bytes())
binary = app / info["CFBundleExecutable"]
subprocess.run(["xcrun", "lipo", str(binary), "-verify_arch", "arm64"], check=True)
symbols = subprocess.check_output(["xcrun", "nm", "-u", str(binary)], text=True)
imports = set(line.split()[-1] for line in symbols.splitlines() if line.strip())
disk_apis = {"_statfs", "_fstatfs", "_statvfs", "_fstatvfs", "_getfsstat"}
if imports & disk_apis:
raise ValueError(f"Unreviewed disk-space APIs remain in iOS app: {sorted(imports & disk_apis)}")
defined = subprocess.check_output(["xcrun", "nm", "-g", str(binary)], text=True)
if re.search(r"\b_Wels(?:CreateDecoder|CreateSVCEncoder|GetCodecVersion)\b", defined):
raise ValueError("OpenH264 must not be present in the iOS app")
if re.search(r"\b_of_(?:hweight\w*|hw8table|popcount_3)\b", defined):
raise ValueError("OpenFEC's optional CC-BY-SA-3.0 hamming-weight code requires a separate distribution review")
privacy = plistlib.loads((app / "PrivacyInfo.xcprivacy").read_bytes())
reasons = {entry["NSPrivacyAccessedAPIType"]: entry["NSPrivacyAccessedAPITypeReasons"]
for entry in privacy["NSPrivacyAccessedAPITypes"]}
required = {
"NSPrivacyAccessedAPICategoryUserDefaults": {"CA92.1"},
"NSPrivacyAccessedAPICategoryFileTimestamp": {"C617.1", "3B52.1"},
"NSPrivacyAccessedAPICategorySystemBootTime": {"35F9.1", "8FFB.1"},
}
for category, codes in required.items():
if set(reasons.get(category, [])) != codes:
raise ValueError(f"Missing or changed reviewed privacy reasons: {category}")
data = (app / "ThirdPartyLicenses.json").read_bytes()
catalog = json.loads(data)
ids = {c["id"] for c in catalog["components"]}
if not {"crossdesk", "minirtc", "openfec"} <= ids or "openh264" in ids:
raise ValueError("Unexpected iOS license catalog")
openssl = next(c for c in catalog["components"] if c["id"] == "openssl3")
framework = app / "Frameworks/OpenSSL.framework"
sdk_info = plistlib.loads((framework / "Info.plist").read_bytes())
sdk_privacy_data = (framework / "PrivacyInfo.xcprivacy").read_bytes()
sdk_privacy = plistlib.loads(sdk_privacy_data)
expected_privacy = {
"NSPrivacyTracking": False,
"NSPrivacyTrackingDomains": [],
"NSPrivacyCollectedDataTypes": [],
"NSPrivacyAccessedAPITypes": [],
}
if sdk_privacy != expected_privacy:
raise ValueError("OpenSSL SDK privacy declaration differs from the reviewed iOS configuration")
sdk_build = json.loads((framework / "BuildMetadata.json").read_text())
if (sdk_info["CFBundleShortVersionString"] != openssl["version"]
or sdk_build["version"] != openssl["version"]
or sdk_build["sourceURL"] != openssl["sourceURL"]
or sdk_build["buildSourceURL"] != openssl["buildSourceURL"]
or sdk_build["privacySHA256"] != hashlib.sha256(sdk_privacy_data).hexdigest()):
raise ValueError("OpenSSL SDK resources do not match the source/license catalog")
if (framework / "LICENSE.txt").read_text() != "\n\n".join(d["text"] for d in openssl["documents"]):
raise ValueError("OpenSSL SDK license resource differs from its source notices")
embedded_binary = framework / sdk_info["CFBundleExecutable"]
# Xcode removes the archive and may generate a small codeless-framework
# stub. It must not ship a second static archive or dynamic OpenSSL copy.
if embedded_binary.exists():
if embedded_binary.read_bytes().startswith(b"!<arch>\n"):
raise ValueError("Xcode must remove the static OpenSSL archive when embedding its resources")
sdk_symbols = subprocess.check_output(["xcrun", "nm", "-gU", str(embedded_binary)], text=True)
if re.search(r"\b_(?:SSL_new|OPENSSL_init_crypto)\b", sdk_symbols):
raise ValueError("Unexpected dynamic OpenSSL implementation in resource framework")
linked = subprocess.check_output(["xcrun", "otool", "-L", str(binary)], text=True)
if "OpenSSL.framework" in linked:
raise ValueError("OpenSSL must be statically linked into the application")
source = json.loads((app / "SourceMetadata.json").read_text())
if source["licenseCatalogSHA256"] != hashlib.sha256(data).hexdigest():
raise ValueError("Bundled licenses differ from the recorded source metadata")
if require_release and (source["isModified"] or not source["tag"]):
raise ValueError("The app was not built from an unmodified version tag")
if not re.fullmatch(r"[0-9a-f]{40}", source["revision"]):
raise ValueError("Invalid source revision")
print("Verified arm64 app, source/license resources, app privacy declarations and OpenSSL SDK privacy packaging")
print("Signing, App Store validation and license authorization require separate review.")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("app", type=Path)
parser.add_argument("--require-release", action="store_true")
args = parser.parse_args()
verify(args.app, args.require_release)
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as error:
print(f"iOS bundle verification: {error}", file=sys.stderr)
sys.exit(1)