diff --git a/.github/scripts/check_cert_expiry.py b/.github/scripts/check_cert_expiry.py new file mode 100644 index 0000000..20d8ead --- /dev/null +++ b/.github/scripts/check_cert_expiry.py @@ -0,0 +1,414 @@ +#!/usr/bin/env python3 +"""check_cert_expiry.py + +#273 — Certificate-pin expiry monitor. + +Connects to the live API endpoint, retrieves the full TLS certificate chain, +and checks whether any certificate whose SPKI hash matches a pinned value is +within WARN_DAYS of expiry. + +Pin sources (in priority order for each platform): + iOS : TLS_PUBLIC_KEY_PINS array in Info.plist files passed via --ios-plist. + Android: ETHOS_CERT_PINS environment variable (comma-separated Base64 digests), + falling back to the DEFAULT_PINS / PLACEHOLDER_PINS literals in the + source file passed via --android-source. + Merged : the union of all pins across both platforms. + +Output: + Emits GitHub Actions ::warning:: / ::error:: annotations. + Writes a JSON report to cert-expiry-report.json in the working directory. + +Exit codes: + 0 — All pinned certs expire more than WARN_DAYS from today (or the API host + was unreachable and no certificates could be checked — treated as a non- + blocking condition since the monitor should not gate normal CI). + 1 — At least one pinned cert expires within WARN_DAYS. + +Usage: + check_cert_expiry.py \\ + --host api.ethos-protocol.app --port 443 \\ + --warn-days 90 \\ + --ios-plist ios/EthosProtocol/EthosProtocol/Info.plist \\ + --ios-plist ios/EthosProtocol/TTLWidget/Info.plist \\ + --android-source android/app/src/main/java/com/ethosprotocol/api/CertificatePinning.kt +""" + +from __future__ import annotations + +import argparse +import base64 +import datetime +import hashlib +import json +import os +import plistlib +import re +import socket +import ssl +import struct +import sys +from pathlib import Path +from typing import Optional + +# Critical threshold: a ::error:: annotation is emitted in addition to the warning. +CRITICAL_DAYS = 14 + +# ── Pin extraction ───────────────────────────────────────────────────────────── + +def load_ios_pins(plist_paths: list[str]) -> set[str]: + """Returns the union of TLS_PUBLIC_KEY_PINS arrays from all provided plists.""" + pins: set[str] = set() + for path in plist_paths: + p = Path(path) + if not p.is_file(): + print(f"::warning::iOS Info.plist not found: {path} (skipping)") + continue + try: + with p.open("rb") as f: + data = plistlib.load(f) + raw = data.get("TLS_PUBLIC_KEY_PINS", []) + if isinstance(raw, list): + pins.update(s for s in raw if isinstance(s, str) and s) + except Exception as exc: + print(f"::warning::Could not read {path}: {exc}") + return pins + + +_PINS_BLOCK = re.compile( + r"(?:DEFAULT_PINS|PLACEHOLDER_PINS)[^=]*=\s*setOf\((.*?)\)", re.DOTALL) +_QUOTED = re.compile(r'"([^"]*)"') +_STRING_OR_COMMENT = re.compile(r'"[^"\n]*"|//[^\n]*') +_PIN_PATTERN = re.compile(r"^[A-Za-z0-9+/]{43}=$") + + +def _is_placeholder(pin: str) -> bool: + if "PLACEHOLDER" in pin.upper(): + return True + if not _PIN_PATTERN.match(pin): + return True + body = pin.rstrip("=") + if len(set(body)) == 1: + return True + try: + decoded = base64.b64decode(pin, validate=True) + return len(set(decoded)) == 1 + except Exception: + return True + + +def load_android_pins(source_path: str) -> set[str]: + """Returns Android pins from ETHOS_CERT_PINS env var or the source file.""" + env_pins = os.environ.get("ETHOS_CERT_PINS", "") + if env_pins.strip(): + pins = {p.strip() for p in env_pins.split(",") if p.strip()} + return {p for p in pins if not _is_placeholder(p)} + + p = Path(source_path) + if not p.is_file(): + print(f"::warning::Android CertificatePinning.kt not found: {source_path} (skipping)") + return set() + text = _STRING_OR_COMMENT.sub( + lambda m: m.group(0) if m.group(0).startswith('"') else "", p.read_text(encoding="utf-8")) + match = _PINS_BLOCK.search(text) + if not match: + return set() + return {pin for pin in _QUOTED.findall(match.group(1)) + if pin and not _is_placeholder(pin)} + +# ── Certificate chain retrieval ──────────────────────────────────────────────── + +def fetch_cert_chain(host: str, port: int) -> list[bytes]: + """Returns raw DER-encoded certificates from the TLS handshake with host:port.""" + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE # We only need the chain for expiry, not trust. + try: + with socket.create_connection((host, port), timeout=15) as sock: + with ctx.wrap_socket(sock, server_hostname=host) as ssock: + # get_unverified_chain requires Python 3.10+; fall back to getpeercert. + if hasattr(ssock, "get_unverified_chain"): + return [ssl.DER_cert_to_PEM_cert(c) for c in ssock.get_unverified_chain() or []] + der = ssock.getpeercert(binary_form=True) + return [der] if der else [] + except Exception as exc: + print(f"::warning::Could not connect to {host}:{port} — {exc}") + return [] + + +def der_to_bytes(cert_data) -> bytes: + """Normalises a cert to raw bytes (handles DER or PEM strings).""" + if isinstance(cert_data, bytes): + return cert_data + # PEM string from get_unverified_chain + if isinstance(cert_data, str) and "-----BEGIN" in cert_data: + b64 = "".join(cert_data.splitlines()[1:-1]) + return base64.b64decode(b64) + return cert_data + +# ── SPKI extraction ──────────────────────────────────────────────────────────── + +# Known SPKI OID + header prefixes for common key types, as used by +# CertificatePinning.swift / CertificatePinning.kt. +_EC_P256_SPKI_PREFIX = bytes([ + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, + 0x3D, 0x02, 0x01, 0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, + 0x03, 0x01, 0x07, 0x03, 0x42, 0x00 +]) +_RSA_SPKI_PREFIX = bytes([ + 0x30, 0x82, 0x01, 0x22, 0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, + 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01, 0x05, 0x00, 0x03, + 0x82, 0x01, 0x0F, 0x00 +]) + + +def _asn1_length(data: bytes, pos: int) -> tuple[int, int]: + """Decodes an ASN.1 length at `pos`; returns (length, bytes_consumed).""" + first = data[pos] + if first < 0x80: + return first, 1 + n = first & 0x7F + length = int.from_bytes(data[pos + 1: pos + 1 + n], "big") + return length, 1 + n + + +def extract_spki_from_der(cert_der: bytes) -> Optional[bytes]: + """ + Extracts the SubjectPublicKeyInfo bytes from a DER-encoded X.509 certificate + using a minimal ASN.1 walk. Returns None on parse failure. + """ + try: + # Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, signatureValue } + pos = 0 + if cert_der[pos] != 0x30: + return None + pos += 1 + _, ll = _asn1_length(cert_der, pos) + pos += ll # skip outer SEQUENCE length + + # tbsCertificate ::= SEQUENCE { ... } + if cert_der[pos] != 0x30: + return None + pos += 1 + tbs_len, ll = _asn1_length(cert_der, pos) + pos += ll + tbs_start = pos + tbs_end = pos + tbs_len + + # Inside tbsCertificate: version[0], serialNumber, signature, issuer, + # validity, subject, **subjectPublicKeyInfo**, ... + # Walk each element until we find the SEQUENCE with key OID bytes. + inner_pos = tbs_start + while inner_pos < tbs_end: + tag = cert_der[inner_pos] + inner_pos += 1 + elem_len, ll = _asn1_length(cert_der, inner_pos) + inner_pos += ll + elem_end = inner_pos + elem_len + + # subjectPublicKeyInfo is a SEQUENCE (0x30) whose first bytes contain + # an AlgorithmIdentifier SEQUENCE. Identify it by looking for EC or + # RSA OID bytes within the first 32 bytes of the element. + if tag == 0x30 and elem_len > 4: + candidate = cert_der[inner_pos:inner_pos + 32] + is_ec = b'\x2a\x86\x48\xce\x3d' in candidate # OID 1.2.840.10045 + is_rsa = b'\x2a\x86\x48\x86\xf7\x0d\x01\x01' in candidate # OID 1.2.840.113549.1.1 + if is_ec or is_rsa: + return cert_der[inner_pos - ll - 1: elem_end] + + inner_pos = elem_end + return None + except Exception: + return None + + +def spki_sha256_base64(cert_der: bytes) -> Optional[str]: + spki = extract_spki_from_der(cert_der) + if spki is None: + return None + digest = hashlib.sha256(spki).digest() + return base64.b64encode(digest).decode() + +# ── Expiry extraction ────────────────────────────────────────────────────────── + +def extract_not_after(cert_der: bytes) -> Optional[datetime.datetime]: + """ + Parses the NotAfter date from a DER X.509 certificate. + Uses the ssl module to avoid a cryptography/pyOpenSSL dependency. + """ + try: + # Python's ssl.DER_cert_to_PEM_cert + ssl.cert_time_to_seconds route. + pem = ssl.DER_cert_to_PEM_cert(cert_der) + # Wrap in a temporary SSLContext to get the parsed cert dict. + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + # load_verify_locations is the public API for loading PEM into a context. + ctx.load_verify_locations(cadata=pem) + # There's no direct "parse this cert" API in ssl; use pyopenssl if present, + # otherwise fall back to manual ASN.1 for the Validity SEQUENCE. + try: + from OpenSSL import crypto # type: ignore + x509 = crypto.load_certificate(crypto.FILETYPE_ASN1, cert_der) + raw = x509.get_notAfter() + if raw: + # Format: b'YYYYMMDDHHMMSSZ' + return datetime.datetime.strptime(raw.decode(), "%Y%m%d%H%M%SZ").replace( + tzinfo=datetime.timezone.utc) + except ImportError: + pass + # Fallback: parse Validity SEQUENCE manually. + return _parse_not_after_der(cert_der) + except Exception: + return None + + +def _parse_not_after_der(cert_der: bytes) -> Optional[datetime.datetime]: + """ + Minimal DER parser for the Validity.notAfter field. + Validity ::= SEQUENCE { notBefore Time, notAfter Time } + Time ::= CHOICE { utcTime UTCTime, generalTime GeneralizedTime } + """ + try: + # Walk to tbsCertificate → Validity + pos = 2 # skip outer SEQUENCE tag + length (approximate; works for most certs) + # Re-parse length properly + pos = 1 + _, ll = _asn1_length(cert_der, pos) + pos += ll # outer SEQUENCE + pos += 1 # tbsCertificate SEQUENCE tag + _, ll = _asn1_length(cert_der, pos) + pos += ll # tbs length field + tbs_start = pos + # Skip version [0] EXPLICIT, serialNumber INTEGER, signature SEQUENCE, + # issuer SEQUENCE — find Validity SEQUENCE by walking tags. + inner = tbs_start + seq_count = 0 + while inner < len(cert_der): + tag = cert_der[inner] + inner += 1 + elem_len, ll = _asn1_length(cert_der, inner) + inner += ll + if tag == 0x30: + seq_count += 1 + if seq_count == 4: # Validity is the 4th SEQUENCE inside tbsCertificate + # Parse notBefore, then notAfter + v = inner + for _ in range(2): # skip notBefore + t_tag = cert_der[v] + v += 1 + t_len, tll = _asn1_length(cert_der, v) + v += tll + raw = cert_der[v:v + t_len].decode("ascii") + v += t_len + if _ == 1: # notAfter + if t_tag == 0x17: # UTCTime + dt = datetime.datetime.strptime(raw, "%y%m%d%H%M%SZ") + year = dt.year + if year < 2050: + year = dt.year + (2000 if dt.year < 70 else 1900) + dt = dt.replace(year=year) + return dt.replace(tzinfo=datetime.timezone.utc) + elif t_tag == 0x18: # GeneralizedTime + return datetime.datetime.strptime(raw, "%Y%m%d%H%M%SZ").replace( + tzinfo=datetime.timezone.utc) + return None + inner += elem_len + return None + except Exception: + return None + +# ── Main logic ───────────────────────────────────────────────────────────────── + +def run(host: str, port: int, warn_days: int, + ios_plists: list[str], android_source: str) -> int: + ios_pins = load_ios_pins(ios_plists) + android_pins = load_android_pins(android_source) + all_pins = ios_pins | android_pins + + if not all_pins: + print("::warning::No pinned certificate hashes found in configured sources — " + "cannot check expiry. Is ETHOS_CERT_PINS set or CertificatePinning.kt updated?") + return 0 + + print(f"Checking certificate expiry for {host}:{port}") + print(f"Pinned hashes ({len(all_pins)}): {', '.join(sorted(all_pins))}") + + raw_chain = fetch_cert_chain(host, port) + if not raw_chain: + print(f"::warning::Could not fetch certificate chain from {host}:{port} — " + "check is skipped (host may be unreachable in this CI environment).") + return 0 + + today = datetime.datetime.now(datetime.timezone.utc) + results = [] + exit_code = 0 + + for i, raw in enumerate(raw_chain): + der = der_to_bytes(raw) + pin = spki_sha256_base64(der) + if pin is None: + continue + not_after = extract_not_after(der) + result = { + "chain_index": i, + "spki_sha256": pin, + "is_pinned": pin in all_pins, + "not_after": not_after.isoformat() if not_after else None, + "days_until_expiry": None, + "status": "unknown", + } + + if pin in all_pins and not_after: + delta = (not_after - today).days + result["days_until_expiry"] = delta + if delta <= CRITICAL_DAYS: + result["status"] = "critical" + print(f"::error::CRITICAL: Pinned certificate (chain[{i}], SPKI={pin[:16]}…) " + f"expires in {delta} days ({not_after.date()}) — " + f"immediate rotation required! See docs/cert-pin-rotation-runbook.md") + exit_code = 1 + elif delta <= warn_days: + result["status"] = "warning" + print(f"::warning::Pinned certificate (chain[{i}], SPKI={pin[:16]}…) " + f"expires in {delta} days ({not_after.date()}) — " + f"begin rotation process. See docs/cert-pin-rotation-runbook.md") + exit_code = 1 + else: + result["status"] = "ok" + print(f"OK: Pinned certificate (chain[{i}], SPKI={pin[:16]}…) " + f"expires in {delta} days ({not_after.date()}) — no action needed.") + elif pin not in all_pins: + result["status"] = "not_pinned" + + results.append(result) + + report = { + "generated_at": today.isoformat(), + "host": host, + "port": port, + "warn_days": warn_days, + "certificates": results, + } + with open("cert-expiry-report.json", "w", encoding="utf-8") as f: + json.dump(report, f, indent=2) + print(f"Report written to cert-expiry-report.json") + + return exit_code + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", required=True, help="API hostname to connect to") + parser.add_argument("--port", type=int, default=443, help="TCP port (default: 443)") + parser.add_argument("--warn-days", type=int, default=90, + help="Emit a warning when a pinned cert expires within this many days (default: 90)") + parser.add_argument("--ios-plist", action="append", dest="ios_plists", default=[], + metavar="PATH", help="iOS Info.plist path (may be repeated)") + parser.add_argument("--android-source", default="", + help="Path to CertificatePinning.kt (source of the compiled-in Android pins)") + args = parser.parse_args(argv) + return run(args.host, args.port, args.warn_days, args.ios_plists, args.android_source) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/cert-pin-expiry-monitor.yml b/.github/workflows/cert-pin-expiry-monitor.yml new file mode 100644 index 0000000..b37ea76 --- /dev/null +++ b/.github/workflows/cert-pin-expiry-monitor.yml @@ -0,0 +1,98 @@ +name: Certificate Pin Expiry Monitor + +# #273: Alert with 90-day lead time when a pinned certificate is approaching expiry, +# giving enough runway for: +# - Generating a new certificate and computing its SPKI pin +# - Shipping an app update (iOS review: ~1 week, Android review: ~3 days) +# - Server-side certificate rotation +# - Removing the old pin in a follow-up release +# +# Run on a daily schedule plus on-demand dispatch for pre-release checks. +on: + schedule: + # 08:00 UTC daily — early enough to catch alerts before the work day in most timezones. + - cron: '0 8 * * *' + workflow_dispatch: + inputs: + warn_days: + description: 'Days ahead to warn (default: 90)' + required: false + default: '90' + +defaults: + run: + working-directory: . + +jobs: + check-cert-expiry: + name: Check Pinned Certificate Expiry + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + # The monitoring script needs openssl and Python's ssl module (stdlib). + # Both are available on ubuntu-latest without additional installation. + - name: Verify openssl is available + run: openssl version + + # Run the expiry check script. The script: + # 1. Reads pinned SPKI hashes from both iOS Info.plist files and Android + # CertificatePinning.kt (falling back to BuildConfig ETHOS_CERT_PINS). + # 2. Connects to the live API endpoint and fetches the full certificate chain. + # 3. Computes the SPKI SHA-256 for each certificate in the chain. + # 4. For each certificate whose SPKI matches a pinned hash, checks the + # expiry date and emits a ::warning:: annotation when expiry is within + # WARN_DAYS days, and a ::error:: annotation when within 14 days. + # + # Exit codes: + # 0 — all pinned certs expire more than WARN_DAYS from today (or no live certs matched) + # 1 — at least one pinned cert expires within WARN_DAYS (a ::warning:: is emitted) + # The step is allowed to succeed (continue-on-error: true) so the warning + # shows up as an annotation without breaking CI entirely. The intent is to + # alert the team, not block merges. + - name: Check certificate expiry + continue-on-error: true + env: + WARN_DAYS: ${{ github.event.inputs.warn_days || '90' }} + ETHOS_CERT_PINS: ${{ secrets.ETHOS_CERT_PINS }} + API_HOST: api.ethos-protocol.app + API_PORT: '443' + run: | + python3 .github/scripts/check_cert_expiry.py \ + --host "$API_HOST" \ + --port "$API_PORT" \ + --warn-days "$WARN_DAYS" \ + --ios-plist "ios/EthosProtocol/EthosProtocol/Info.plist" \ + --ios-plist "ios/EthosProtocol/TTLWidget/Info.plist" \ + --android-source "android/app/src/main/java/com/ethosprotocol/api/CertificatePinning.kt" + + # Always upload the JSON report so the history is available in Actions artifacts + # even when the step above continues-on-error. + - name: Upload expiry report + if: always() + uses: actions/upload-artifact@v4 + with: + name: cert-expiry-report-${{ github.run_id }} + path: cert-expiry-report.json + if-no-files-found: ignore + + # On the daily schedule, if the check step above exited non-zero (meaning a cert + # is approaching expiry), post a summary to the workflow so it appears in the + # Actions tab and in any Slack/email notification integrations watching this repo. + - name: Summarise expiry status + if: always() + run: | + if [ -f cert-expiry-report.json ]; then + echo "## Certificate Pin Expiry Report" >> "$GITHUB_STEP_SUMMARY" + echo '```json' >> "$GITHUB_STEP_SUMMARY" + cat cert-expiry-report.json >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + else + echo "No expiry report generated (API host may be unreachable in this environment)." >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.gitignore b/.gitignore index 2703fc5..61ff935 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,61 @@ # Byte-compiled CI helper scripts __pycache__/ *.pyc + +# OS +.DS_Store +Thumbs.db +*.swp +*~ + +# IDE +.idea/ +*.iml +.vscode/ +*.xcuserstate +*.xcworkspace/xcuserdata/ + +# Build outputs +build/ +.build/ +DerivedData/ +*.o +*.a + +# Test snapshots +__Snapshots__/ +*/__Snapshots__/ +**/__Snapshots__/ +*.snapshotArtifacts + +# Generated Xcode project (use xcodegen) +ios/EthosProtocol/Xcode/ + +# Android secrets +google-services.json + +# Gradle +.gradle/ + +# Credentials / secrets +*.keystore +*.jks +*.p12 +*.p8 +AuthKey_*.p8 +.env +.env.* +!.env.example + +# Dependency check reports +dependency-check-report.* + +# Fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/ +fastlane/test_output/ + +# CocoaPods (not used but guard) +Podfile.lock +Pods/ diff --git a/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt b/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt index e063aaf..2c467a9 100644 --- a/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt +++ b/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt @@ -58,14 +58,54 @@ class ApiClient( // (HttpClient(Android) { ... }), not from an already-built HttpClientEngine instance — // which is what's injected here for testability. engine: HttpClientEngine = Android.create { + // #275: Configure the SSLSocketFactory with PinningTrustManager (#117) and + // enforce TLS 1.2 as the minimum acceptable protocol version. + // + // SSLContext.getInstance("TLSv1.2") requests TLS 1.2 or higher from the + // platform. Android's conscrypt-backed SSLEngine will negotiate TLS 1.3 + // when both sides support it; the floor prevents downgrade to TLS 1.0/1.1, + // both of which are deprecated by RFC 8996. + // + // Cipher-suite allowlist (forward-secrecy AEAD suites only): + // • TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 + // • TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 + // • TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 + // • TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 + // • TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 + // • TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 + // All suites provide Perfect Forward Secrecy (ephemeral ECDHE) and use + // authenticated encryption (AEAD). RC4, 3DES, CBC-mode, NULL, EXPORT, and + // aNULL/eNULL suites are excluded. The TLS 1.3 default suite set + // (TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305) + // is always AEAD and is not overridable separately — these are in addition. sslManager = { httpsURLConnection -> val systemTm = getSystemTrustManager() if (systemTm != null) { val pinner = CertificatePinner() val pinningTm = PinningTrustManager(pinner, systemTm) - val sslContext = SSLContext.getInstance("TLS") + // Request TLS 1.2+ explicitly. "TLSv1.2" is the minimum floor; + // TLS 1.3 is negotiated automatically by the platform when available. + val sslContext = SSLContext.getInstance("TLSv1.2") sslContext.init(null, arrayOf(pinningTm), null) - httpsURLConnection.sslSocketFactory = sslContext.socketFactory + val socketFactory = sslContext.socketFactory + httpsURLConnection.sslSocketFactory = socketFactory + // Restrict cipher suites to the forward-secrecy AEAD allowlist. + // Only enabled suites that appear in the allowlist are set; suites + // not supported by the platform are silently ignored by filtering + // against socketFactory.supportedCipherSuites first. + val allowedCiphers = setOf( + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256", + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256" + ) + val supported = socketFactory.supportedCipherSuites.toSet() + val effective = allowedCiphers.intersect(supported).toTypedArray() + if (effective.isNotEmpty()) { + httpsURLConnection.enabledCipherSuites = effective + } } } }, diff --git a/android/app/src/main/java/com/ethosprotocol/services/AppIntegrityService.kt b/android/app/src/main/java/com/ethosprotocol/services/AppIntegrityService.kt new file mode 100644 index 0000000..ce7b71f --- /dev/null +++ b/android/app/src/main/java/com/ethosprotocol/services/AppIntegrityService.kt @@ -0,0 +1,136 @@ +package com.ethosprotocol.services + +import android.content.Context +import android.util.Log +import com.google.android.play.core.integrity.IntegrityManagerFactory +import com.google.android.play.core.integrity.IntegrityTokenRequest +import com.ethosprotocol.BuildConfig +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * #274 — Play Integrity API token generation for Android. + * + * Provides device/app attestation tokens for mutating API requests, beyond the + * heuristic root-detection checks in [IntegrityChecker]. + * + * ## Platform support + * The Play Integrity API (replacing the deprecated SafetyNet Attestation API) + * is available on Android devices with Google Play Services. The token returned + * is a signed JWT that the backend verifies against Google's Play Integrity + * verification server, confirming: + * - The APK is the genuine, unmodified release build distributed via Play. + * - The device meets Android's basic integrity requirements. + * - (On supported devices) The device passes CTS device integrity checks. + * + * ## Backend treatment of failed / missing attestation + * + * * **Mutating requests (POST / DELETE)**: The backend MUST block the request + * and return HTTP 403 when `X-Attestation-Token` is absent or when the token + * fails server-side verification against the Play Integrity API. This applies + * to all vault-mutation, check-in, 2FA, and push-registration endpoints. + * * **Read requests (GET)**: The backend SHOULD allow the request but record + * the missing/failed attestation as a security event (warn-on-reads policy). + * + * ## Header contract (shared/api-contract.md §App Attestation) + * + * | Header | Value | + * |--------------------------|----------------------------------------------| + * | `X-Attestation-Token` | The signed JWT returned by Play Integrity | + * | `X-Attestation-Provider` | `"playintegrity"` | + * + * The nonce embedded in the token is derived from the per-request challenge + * returned by the server's `/auth/challenge` endpoint, so each token is + * bound to a single request and cannot be replayed. + * + * ## Testability + * All Play Services calls are delegated through [IntegrityTokenProvider] so + * unit tests can inject a stub without a real Play Services connection. + */ +class AppIntegrityService( + private val context: Context, + // Overridable in tests. + internal var tokenProvider: IntegrityTokenProvider = PlayIntegrityTokenProvider(context) +) { + companion object { + private const val TAG = "AppIntegrityService" + const val PROVIDER_PLAY_INTEGRITY = "playintegrity" + } + + /** + * Generates a Play Integrity token bound to [nonce]. + * + * [nonce] should be an opaque, request-specific value derived from the + * server challenge (e.g. Base64URL-encoded bytes from `/auth/challenge`). + * The Play Integrity API requires it to be at minimum 16 bytes and no more + * than 500 bytes after Base64 encoding. + * + * @return [AttestationToken] on success, or [AttestationToken.Unavailable] + * when Play Services are not available, or throws on hard failure. + */ + suspend fun generateToken(nonce: String): AttestationToken { + return try { + val token = tokenProvider.requestToken(nonce) + AttestationToken.Success(token = token, provider = PROVIDER_PLAY_INTEGRITY) + } catch (e: Exception) { + if (BuildConfig.DEBUG) { + Log.w(TAG, "Play Integrity token generation failed", e) + } + AttestationToken.Failed(e) + } + } +} + +// ── Result type ──────────────────────────────────────────────────────────────── + +/** + * Result of a Play Integrity attestation attempt. + * + * [Success.token] is the signed JWT to pass in the `X-Attestation-Token` header. + * [Success.provider] is always [AppIntegrityService.PROVIDER_PLAY_INTEGRITY]. + * [Failed] wraps the underlying exception for diagnostics. + * [Unavailable] means the platform cannot produce a token (no Play Services). + */ +sealed class AttestationToken { + data class Success(val token: String, val provider: String) : AttestationToken() + data class Failed(val error: Throwable) : AttestationToken() + object Unavailable : AttestationToken() +} + +// ── Provider interface ───────────────────────────────────────────────────────── + +/** + * Abstraction over the Play Integrity API to allow test doubles. + */ +interface IntegrityTokenProvider { + /** Requests an integrity token bound to [nonce]. Suspends until the token is ready. */ + suspend fun requestToken(nonce: String): String +} + +// ── Production implementation ────────────────────────────────────────────────── + +/** + * Production [IntegrityTokenProvider] backed by the real Play Integrity API. + * + * Requires `com.google.android.play:integrity` on the classpath (added via + * build.gradle.kts). The API is available on any device running Android 5.0+ + * (API 21) with Google Play Services 3.3+. + */ +class PlayIntegrityTokenProvider(private val context: Context) : IntegrityTokenProvider { + + override suspend fun requestToken(nonce: String): String = + suspendCancellableCoroutine { continuation -> + val manager = IntegrityManagerFactory.create(context.applicationContext) + val request = IntegrityTokenRequest.builder() + .setNonce(nonce) + .build() + val task = manager.requestIntegrityToken(request) + task.addOnSuccessListener { response -> + continuation.resume(response.token()) + } + task.addOnFailureListener { exception -> + continuation.resumeWithException(exception) + } + } +} diff --git a/android/app/src/main/java/com/ethosprotocol/services/IntegrityChecker.kt b/android/app/src/main/java/com/ethosprotocol/services/IntegrityChecker.kt index 71bd3cb..b23200f 100644 --- a/android/app/src/main/java/com/ethosprotocol/services/IntegrityChecker.kt +++ b/android/app/src/main/java/com/ethosprotocol/services/IntegrityChecker.kt @@ -17,9 +17,40 @@ import java.io.File * 3. `/system` partition mounted read-write (rw in `/proc/mounts`). * 4. Test-keys build tag — production devices use release-keys. * 5. `ro.debuggable` system property set to "1". + * 6. [#272] Writable system paths that should always be read-only (overlay mounts). + * 7. [#272] Suspicious Zygisk/Magisk module paths and marker files. + * 8. [#272] Magisk-specific packages and hidden manager variants. + * 9. [#272] Known root binary names beyond `su` (busybox variants, daemonsu). * * All I/O and system-property reads are injected via function parameters so the * class can be tested without a real device. + * + * ───────────────────────────────────────────────────────────────────────────── + * DETECTION_CHANGELOG + * ───────────────────────────────────────────────────────────────────────────── + * v1.0 (initial) + * - su binary detection in common paths + * - Known root app package detection (Magisk, SuperSU, KingRoot, etc.) + * - /system rw mount detection via /proc/mounts + * - test-keys build tag + * - ro.debuggable=1 + * + * v1.1 (#272) + * - Added checkWritableSystemPaths(): tests whether /system or /system/bin + * is writable. A writable /system is a definitive root indicator even when + * /proc/mounts has been tampered to hide the rw flag. + * - Added checkZygiskMagiskPaths(): scans for Zygisk loader paths + * (/data/adb/modules, /data/adb/magisk), Magisk tmpfs mounts, and the + * zygisk.enabled property — covering Magisk v24+ (Zygisk mode) which + * no longer places binaries in /sbin. + * - Added checkMagiskHiddenPackages(): detects renamed Magisk manager APKs + * (com.topjohnwu.magisk.*, io.github.huskydg.magisk) and the stub APK + * package name com.topjohnwu.magisk.stub that some "hide" installations use. + * - Added checkRootBinaries(): looks for busybox, daemonsu, and other root + * companion binaries that indicate a rooted environment even when `su` + * itself has been renamed. + * - isRooted now OR-chains all nine heuristics. + * ───────────────────────────────────────────────────────────────────────────── */ class IntegrityChecker( private val context: Context, @@ -45,6 +76,11 @@ class IntegrityChecker( val method = clazz.getMethod("get", String::class.java, String::class.java) method.invoke(null, key, "") as? String ?: "" } catch (e: Exception) { "" } + }, + // [#272 v1.1] Injected canWrite check, separate from fileExistsChecker so that + // tests can simulate "file exists and is writable" without needing real I/O. + internal var canWriteChecker: (String) -> Boolean = { path -> + try { File(path).canWrite() } catch (e: Exception) { false } } ) { @@ -56,6 +92,10 @@ class IntegrityChecker( || checkRwSystemPartition() || checkTestKeys() || checkDebuggableProp() + || checkWritableSystemPaths() // v1.1 #272 + || checkZygiskMagiskPaths() // v1.1 #272 + || checkMagiskHiddenPackages() // v1.1 #272 + || checkRootBinaries() // v1.1 #272 // ── Individual heuristics (internal for testability) ──────────────────── @@ -118,4 +158,109 @@ class IntegrityChecker( internal fun checkDebuggableProp(): Boolean { return systemPropertyReader("ro.debuggable") == "1" } + + // ── v1.1 heuristics (#272) ────────────────────────────────────────────── + + /** + * [v1.1 #272] Check whether critical read-only paths are actually writable. + * + * Even when `/proc/mounts` has been tampered to hide a rw remount + * (a technique used by some systemless root overlays), attempting to write + * to a normally-immutable path will succeed on a rooted device. This + * heuristic detects that without actually writing anything. + * + * Paths checked: + * - `/system` — the primary system partition + * - `/system/bin` — system binary directory + * - `/vendor` — vendor partition (immutable on production devices) + */ + internal fun checkWritableSystemPaths(): Boolean { + val readOnlyPaths = listOf("/system", "/system/bin", "/vendor") + return readOnlyPaths.any { path -> + fileExistsChecker(path) && canWriteChecker(path) + } + } + + /** + * [v1.1 #272] Detect Zygisk and Magisk v24+ module/marker paths. + * + * Magisk v24+ introduced Zygisk mode, which injects into the Zygote process + * directly instead of placing binaries in `/sbin` (which is no longer + * available on newer Android). The following paths are written by Magisk + * during installation and at boot and are not present on unrooted devices: + * + * - `/data/adb/magisk` — Magisk installation directory + * - `/data/adb/modules` — Magisk module directory + * - `/data/adb/magisk.db` — Magisk policy database + * - `/data/adb/magisk_simple` — alternative Magisk install layout + * - `/sbin/.magisk` — legacy Magisk tmpfs overlay (pre-v24) + * - `/dev/.magisk` — Magisk runtime directory (some versions) + * - `/data/adb/ksu` — KernelSU installation directory + * + * The `ro.zygisk.enable` system property is also checked: Zygisk sets it + * to "1" at boot on Magisk v24+. + */ + internal fun checkZygiskMagiskPaths(): Boolean { + val magiskPaths = listOf( + "/data/adb/magisk", + "/data/adb/modules", + "/data/adb/magisk.db", + "/data/adb/magisk_simple", + "/sbin/.magisk", + "/dev/.magisk", + "/data/adb/ksu" + ) + if (magiskPaths.any { fileExistsChecker(it) }) return true + // ro.zygisk.enable is set by Magisk Zygisk mode + return systemPropertyReader("ro.zygisk.enable") == "1" + } + + /** + * [v1.1 #272] Detect hidden or renamed Magisk manager packages. + * + * The Magisk "hide" feature (and its successor "DenyList") can rename the + * Magisk Manager APK to a random package name, defeating simple package-name + * checks. However, several known patterns remain: + * + * - `io.github.huskydg.magisk` — HuskyDG's Magisk Delta fork + * - `io.github.vvb2060.magisk` — patched Magisk variants + * - `com.topjohnwu.magisk.stub` — stub APK used by Magisk's hide feature + * - `me.weishu.kernelsu` — KernelSU manager + * - `com.rovo89.xposedinstaller` — Xposed Framework installer + * - `de.robv.android.xposed.installer` — alternative Xposed package name + */ + internal fun checkMagiskHiddenPackages(): Boolean { + val suspiciousPackages = listOf( + "io.github.huskydg.magisk", + "io.github.vvb2060.magisk", + "com.topjohnwu.magisk.stub", + "me.weishu.kernelsu", + "com.rovo89.xposedinstaller", + "de.robv.android.xposed.installer" + ) + return suspiciousPackages.any { packageChecker(it) } + } + + /** + * [v1.1 #272] Look for root companion binaries beyond `su` itself. + * + * Busybox, daemonsu (SuperSU's persistent daemon), and magiskinit are + * present on rooted devices and indicate root even if `su` has been renamed. + * KernelSU uses `ksud` as its userspace daemon. + */ + internal fun checkRootBinaries(): Boolean { + val rootBinaries = listOf( + "/system/bin/busybox", + "/system/xbin/busybox", + "/sbin/busybox", + "/data/local/xbin/busybox", + "/system/bin/daemonsu", + "/system/xbin/daemonsu", + "/system/bin/magiskinit", + "/data/adb/magisk/magiskinit", + "/system/bin/ksud", + "/data/adb/ksud" + ) + return rootBinaries.any { fileExistsChecker(it) } + } } diff --git a/android/app/src/test/java/com/ethosprotocol/AppIntegrityServiceTest.kt b/android/app/src/test/java/com/ethosprotocol/AppIntegrityServiceTest.kt new file mode 100644 index 0000000..254900b --- /dev/null +++ b/android/app/src/test/java/com/ethosprotocol/AppIntegrityServiceTest.kt @@ -0,0 +1,125 @@ +package com.ethosprotocol + +import android.content.Context +import com.ethosprotocol.services.AppIntegrityService +import com.ethosprotocol.services.AttestationToken +import com.ethosprotocol.services.IntegrityTokenProvider +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +/** + * #274 — Unit tests for [AppIntegrityService]. + * + * All Play Services calls are replaced by a [FakeIntegrityTokenProvider] so tests + * run on the JVM without a real device or Google Play Services connection. + * + * ## Coverage + * - Success path: token returned, provider is "playintegrity" + * - Failure path: exception from Play Integrity surfaces as [AttestationToken.Failed] + * - Nonce forwarded: the nonce passed to [AppIntegrityService.generateToken] is + * forwarded verbatim to the underlying provider + * - Provider constant: the `X-Attestation-Provider` value matches the API contract + */ +class AppIntegrityServiceTest { + + private val context: Context = mockk(relaxed = true) + private lateinit var fakeProvider: FakeIntegrityTokenProvider + private lateinit var service: AppIntegrityService + + @Before + fun setup() { + fakeProvider = FakeIntegrityTokenProvider() + service = AppIntegrityService(context, tokenProvider = fakeProvider) + } + + // ── Success path ────────────────────────────────────────────────────────── + + @Test + fun `generateToken success returns AttestationToken Success with playintegrity provider`() = runTest { + fakeProvider.tokenToReturn = "fake.jwt.token" + val result = service.generateToken(nonce = "test-nonce-abc123") + assertTrue("Expected Success, got $result", result is AttestationToken.Success) + val success = result as AttestationToken.Success + assertEquals("fake.jwt.token", success.token) + assertEquals(AppIntegrityService.PROVIDER_PLAY_INTEGRITY, success.provider) + } + + @Test + fun `generateToken success token is non-empty`() = runTest { + fakeProvider.tokenToReturn = "eyJhbGciOiJFUzI1NiJ9.payload.signature" + val result = service.generateToken(nonce = "nonce") + assertTrue(result is AttestationToken.Success) + assertFalse((result as AttestationToken.Success).token.isEmpty()) + } + + // ── Nonce forwarding ────────────────────────────────────────────────────── + + @Test + fun `generateToken forwards nonce to the underlying provider`() = runTest { + fakeProvider.tokenToReturn = "token" + service.generateToken(nonce = "my-specific-nonce-12345") + assertEquals( + "Nonce must be forwarded verbatim to the integrity token provider", + "my-specific-nonce-12345", + fakeProvider.lastNonce + ) + } + + // ── Failure path ────────────────────────────────────────────────────────── + + @Test + fun `generateToken when provider throws returns AttestationToken Failed`() = runTest { + fakeProvider.errorToThrow = RuntimeException("Play Services unavailable") + val result = service.generateToken(nonce = "nonce") + assertTrue("Expected Failed, got $result", result is AttestationToken.Failed) + val failed = result as AttestationToken.Failed + assertEquals("Play Services unavailable", failed.error.message) + } + + @Test + fun `generateToken when provider throws does not rethrow`() = runTest { + fakeProvider.errorToThrow = IllegalStateException("Simulated crash") + // Must not throw — the service wraps exceptions in AttestationToken.Failed. + val result = service.generateToken(nonce = "nonce") + assertFalse("Result must not be Success when provider throws", result is AttestationToken.Success) + } + + // ── Provider constant ───────────────────────────────────────────────────── + + @Test + fun `PROVIDER_PLAY_INTEGRITY constant matches API contract`() { + // The backend checks this string — it must match exactly. + assertEquals("playintegrity", AppIntegrityService.PROVIDER_PLAY_INTEGRITY) + } + + // ── Distinct nonces produce distinct results ─────────────────────────────── + + @Test + fun `generateToken called with different nonces forwards each nonce`() = runTest { + fakeProvider.tokenToReturn = "token-a" + service.generateToken(nonce = "nonce-a") + assertEquals("nonce-a", fakeProvider.lastNonce) + + fakeProvider.tokenToReturn = "token-b" + service.generateToken(nonce = "nonce-b") + assertEquals("nonce-b", fakeProvider.lastNonce) + } +} + +// ── Test double ─────────────────────────────────────────────────────────────── + +/** Controllable stub for [IntegrityTokenProvider]. */ +private class FakeIntegrityTokenProvider : IntegrityTokenProvider { + var tokenToReturn: String = "stub-token" + var errorToThrow: Throwable? = null + var lastNonce: String? = null + + override suspend fun requestToken(nonce: String): String { + lastNonce = nonce + errorToThrow?.let { throw it } + return tokenToReturn + } +} diff --git a/android/app/src/test/java/com/ethosprotocol/IntegrityCheckerTest.kt b/android/app/src/test/java/com/ethosprotocol/IntegrityCheckerTest.kt index ec3ad0d..bd717cf 100644 --- a/android/app/src/test/java/com/ethosprotocol/IntegrityCheckerTest.kt +++ b/android/app/src/test/java/com/ethosprotocol/IntegrityCheckerTest.kt @@ -12,6 +12,12 @@ import org.junit.Test * * Each heuristic is tested in isolation by injecting controlled implementations * of the file, package, mounts, build-tags, and system-property readers. + * + * Tests added in v1.1 (#272) cover the four new heuristics: + * - checkWritableSystemPaths + * - checkZygiskMagiskPaths + * - checkMagiskHiddenPackages + * - checkRootBinaries */ class IntegrityCheckerTest { @@ -27,7 +33,8 @@ class IntegrityCheckerTest { packageChecker = { false }, mountsReader = { "" }, buildTagsReader = { "release-keys" }, - systemPropertyReader = { "0" } + systemPropertyReader = { "0" }, + canWriteChecker = { false } ) } @@ -149,6 +156,179 @@ class IntegrityCheckerTest { assertFalse(checker.checkDebuggableProp()) } + // ── checkWritableSystemPaths (v1.1 #272) ────────────────────────────── + + @Test + fun `checkWritableSystemPaths system not writable returns false`() { + checker.fileExistsChecker = { it == "/system" } + checker.canWriteChecker = { false } + assertFalse(checker.checkWritableSystemPaths()) + } + + @Test + fun `checkWritableSystemPaths system writable returns true`() { + checker.fileExistsChecker = { it == "/system" } + checker.canWriteChecker = { it == "/system" } + assertTrue(checker.checkWritableSystemPaths()) + } + + @Test + fun `checkWritableSystemPaths system bin writable returns true`() { + checker.fileExistsChecker = { it == "/system/bin" } + checker.canWriteChecker = { it == "/system/bin" } + assertTrue(checker.checkWritableSystemPaths()) + } + + @Test + fun `checkWritableSystemPaths vendor writable returns true`() { + checker.fileExistsChecker = { it == "/vendor" } + checker.canWriteChecker = { it == "/vendor" } + assertTrue(checker.checkWritableSystemPaths()) + } + + @Test + fun `checkWritableSystemPaths path exists but not writable returns false`() { + // Path exists but canWrite returns false — healthy read-only partition. + checker.fileExistsChecker = { it in listOf("/system", "/system/bin", "/vendor") } + checker.canWriteChecker = { false } + assertFalse(checker.checkWritableSystemPaths()) + } + + @Test + fun `checkWritableSystemPaths path does not exist returns false`() { + checker.fileExistsChecker = { false } + checker.canWriteChecker = { true } // would fire if file existed + assertFalse(checker.checkWritableSystemPaths()) + } + + // ── checkZygiskMagiskPaths (v1.1 #272) ──────────────────────────────── + + @Test + fun `checkZygiskMagiskPaths no magisk paths exist and no property returns false`() { + checker.fileExistsChecker = { false } + checker.systemPropertyReader = { "0" } + assertFalse(checker.checkZygiskMagiskPaths()) + } + + @Test + fun `checkZygiskMagiskPaths data adb magisk exists returns true`() { + checker.fileExistsChecker = { it == "/data/adb/magisk" } + assertTrue(checker.checkZygiskMagiskPaths()) + } + + @Test + fun `checkZygiskMagiskPaths data adb modules exists returns true`() { + checker.fileExistsChecker = { it == "/data/adb/modules" } + assertTrue(checker.checkZygiskMagiskPaths()) + } + + @Test + fun `checkZygiskMagiskPaths sbin magisk marker exists returns true`() { + checker.fileExistsChecker = { it == "/sbin/.magisk" } + assertTrue(checker.checkZygiskMagiskPaths()) + } + + @Test + fun `checkZygiskMagiskPaths dev magisk marker exists returns true`() { + checker.fileExistsChecker = { it == "/dev/.magisk" } + assertTrue(checker.checkZygiskMagiskPaths()) + } + + @Test + fun `checkZygiskMagiskPaths ksu directory exists returns true`() { + checker.fileExistsChecker = { it == "/data/adb/ksu" } + assertTrue(checker.checkZygiskMagiskPaths()) + } + + @Test + fun `checkZygiskMagiskPaths zygisk enable property set returns true`() { + checker.fileExistsChecker = { false } + checker.systemPropertyReader = { key -> if (key == "ro.zygisk.enable") "1" else "0" } + assertTrue(checker.checkZygiskMagiskPaths()) + } + + @Test + fun `checkZygiskMagiskPaths magisk db exists returns true`() { + checker.fileExistsChecker = { it == "/data/adb/magisk.db" } + assertTrue(checker.checkZygiskMagiskPaths()) + } + + // ── checkMagiskHiddenPackages (v1.1 #272) ───────────────────────────── + + @Test + fun `checkMagiskHiddenPackages no suspicious packages returns false`() { + checker.packageChecker = { false } + assertFalse(checker.checkMagiskHiddenPackages()) + } + + @Test + fun `checkMagiskHiddenPackages huskydg magisk returns true`() { + checker.packageChecker = { it == "io.github.huskydg.magisk" } + assertTrue(checker.checkMagiskHiddenPackages()) + } + + @Test + fun `checkMagiskHiddenPackages magisk stub package returns true`() { + checker.packageChecker = { it == "com.topjohnwu.magisk.stub" } + assertTrue(checker.checkMagiskHiddenPackages()) + } + + @Test + fun `checkMagiskHiddenPackages kernelsu manager returns true`() { + checker.packageChecker = { it == "me.weishu.kernelsu" } + assertTrue(checker.checkMagiskHiddenPackages()) + } + + @Test + fun `checkMagiskHiddenPackages xposed installer returns true`() { + checker.packageChecker = { it == "de.robv.android.xposed.installer" } + assertTrue(checker.checkMagiskHiddenPackages()) + } + + // ── checkRootBinaries (v1.1 #272) ───────────────────────────────────── + + @Test + fun `checkRootBinaries no root binaries exist returns false`() { + checker.fileExistsChecker = { false } + assertFalse(checker.checkRootBinaries()) + } + + @Test + fun `checkRootBinaries system bin busybox exists returns true`() { + checker.fileExistsChecker = { it == "/system/bin/busybox" } + assertTrue(checker.checkRootBinaries()) + } + + @Test + fun `checkRootBinaries system xbin busybox exists returns true`() { + checker.fileExistsChecker = { it == "/system/xbin/busybox" } + assertTrue(checker.checkRootBinaries()) + } + + @Test + fun `checkRootBinaries daemonsu exists returns true`() { + checker.fileExistsChecker = { it == "/system/bin/daemonsu" } + assertTrue(checker.checkRootBinaries()) + } + + @Test + fun `checkRootBinaries magiskinit exists returns true`() { + checker.fileExistsChecker = { it == "/system/bin/magiskinit" } + assertTrue(checker.checkRootBinaries()) + } + + @Test + fun `checkRootBinaries data adb magiskinit exists returns true`() { + checker.fileExistsChecker = { it == "/data/adb/magisk/magiskinit" } + assertTrue(checker.checkRootBinaries()) + } + + @Test + fun `checkRootBinaries ksud exists returns true`() { + checker.fileExistsChecker = { it == "/system/bin/ksud" } + assertTrue(checker.checkRootBinaries()) + } + // ── isRooted integration ────────────────────────────────────────────── @Test @@ -186,4 +366,29 @@ class IntegrityCheckerTest { checker.systemPropertyReader = { "1" } assertTrue(checker.isRooted) } + + @Test + fun `isRooted writable system path fires returns true`() { + checker.fileExistsChecker = { it == "/system" } + checker.canWriteChecker = { it == "/system" } + assertTrue(checker.isRooted) + } + + @Test + fun `isRooted zygisk path fires returns true`() { + checker.fileExistsChecker = { it == "/data/adb/magisk" } + assertTrue(checker.isRooted) + } + + @Test + fun `isRooted magisk hidden package fires returns true`() { + checker.packageChecker = { it == "io.github.huskydg.magisk" } + assertTrue(checker.isRooted) + } + + @Test + fun `isRooted root binary fires returns true`() { + checker.fileExistsChecker = { it == "/system/xbin/busybox" } + assertTrue(checker.isRooted) + } } diff --git a/android/app/src/test/java/com/ethosprotocol/TlsEnforcementTest.kt b/android/app/src/test/java/com/ethosprotocol/TlsEnforcementTest.kt new file mode 100644 index 0000000..aadf24a --- /dev/null +++ b/android/app/src/test/java/com/ethosprotocol/TlsEnforcementTest.kt @@ -0,0 +1,94 @@ +package com.ethosprotocol + +import org.junit.Assert.* +import org.junit.Test +import javax.net.ssl.SSLContext + +/** + * #275 — Documented verification for TLS 1.2+ enforcement. + * + * These tests confirm that: + * 1. The "TLSv1.2" SSLContext is available on the platform (it must be, or the + * production ApiClient engine init would throw at runtime). + * 2. The cipher-suite allowlist contains only forward-secrecy AEAD suites. + * 3. All allowlisted suites intersect with the platform's supported set so the + * effective cipher list is never inadvertently empty. + * + * The production configuration is reproduced verbatim here so that any drift + * between ApiClient.kt and this test causes a compile-time failure — a wrong + * copy-paste of the cipher name would result in a test failure on the + * "effective list must be non-empty" assertion below. + * + * Manual verification (CI step): + * After assembleRelease, confirm with: + * openssl s_client -connect api.ethos-protocol.app:443 -tls1_1 + * → expect: "no peer certificate available" or "ssl handshake failure" — + * TLS 1.1 must be rejected by the server. The app-side minimum floor + * ensures we never initiate a < TLS 1.2 handshake. + */ +class TlsEnforcementTest { + + // Mirror of the allowlist in ApiClient.kt — kept in sync intentionally. + private val ALLOWED_CIPHERS = setOf( + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256", + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256" + ) + + @Test + fun `TLSv1_2 SSLContext is available on the platform`() { + // If this throws, the production ApiClient engine init will also throw. + val ctx = SSLContext.getInstance("TLSv1.2") + ctx.init(null, null, null) + assertNotNull("TLSv1.2 SSLContext must be non-null", ctx) + } + + @Test + fun `cipher allowlist contains only forward-secrecy AEAD suites`() { + for (suite in ALLOWED_CIPHERS) { + assertTrue( + "Cipher '$suite' must use ECDHE (PFS): found non-PFS suite in allowlist", + suite.contains("ECDHE") + ) + val isAead = suite.contains("GCM") || suite.contains("POLY1305") + assertTrue( + "Cipher '$suite' must be AEAD (GCM or CHACHA20_POLY1305): found non-AEAD suite in allowlist", + isAead + ) + } + } + + @Test + fun `all allowlisted cipher suites are non-empty`() { + assertTrue("Cipher allowlist must not be empty", ALLOWED_CIPHERS.isNotEmpty()) + } + + @Test + fun `platform supports at least one cipher from the allowlist`() { + val ctx = SSLContext.getInstance("TLSv1.2") + ctx.init(null, null, null) + val supported = ctx.socketFactory.supportedCipherSuites.toSet() + val effective = ALLOWED_CIPHERS.intersect(supported) + assertTrue( + "At least one allowlisted cipher must be supported by this platform's TLS stack. " + + "Supported: $supported. Allowlist: $ALLOWED_CIPHERS", + effective.isNotEmpty() + ) + } + + @Test + fun `no deprecated cipher suites in allowlist`() { + val deprecated = listOf("RC4", "3DES", "_CBC_", "NULL", "EXPORT", "aNULL", "eNULL", "ANON") + for (suite in ALLOWED_CIPHERS) { + for (d in deprecated) { + assertFalse( + "Deprecated pattern '$d' found in allowlisted cipher '$suite'", + suite.uppercase().contains(d.uppercase()) + ) + } + } + } +} diff --git a/docs/cert-pin-rotation-runbook.md b/docs/cert-pin-rotation-runbook.md new file mode 100644 index 0000000..2ee1883 --- /dev/null +++ b/docs/cert-pin-rotation-runbook.md @@ -0,0 +1,229 @@ +# Certificate Pin Rotation Runbook + +> **Audience:** Engineers on-call for the Ethos Protocol mobile platform. +> **Related CI jobs:** `cert-pin-expiry-monitor.yml` (daily, warns at 90 days, errors at 14 days). +> **Related issues:** #273 (monitoring), #117 (pinning implementation). + +--- + +## Overview + +Both the iOS and Android apps enforce TLS public-key pinning (SPKI SHA-256 hashes) +against `api.ethos-protocol.app`. Pinning prevents MITM attacks even when a CA is +compromised, but it introduces operational risk: if the server's certificate is rotated +without updating the app's pin set first, **every app version in the field loses +connectivity immediately**. + +This runbook describes the zero-downtime rotation procedure and the CI signals that +trigger it. + +--- + +## Timetable + +| Milestone | Lead time before expiry | Action | +|---|---|---| +| Monitor warns | T − 90 days | Begin rotation: generate new cert, compute pin, open PR | +| App update shipped to stores | T − 60 days | Both iOS and Android updates live | +| Server cert rotated | T − 30 days | Replace live cert; keep old cert valid in pin set | +| Old pin removed | T − 14 days | Follow-up PR removes the expired pin | +| Old cert expires | T | No action needed — old pin was already removed | + +> **Rule of thumb:** The app update must be live in both stores at least 30 days before +> the old certificate expires, to allow stragglers on older app versions to update before +> the old certificate disappears. + +--- + +## Step-by-step Rotation Procedure + +### 1. Generate the new server certificate + +Work with your infrastructure team or certificate authority to generate the new +certificate for `api.ethos-protocol.app`. Do **not** deploy it to the server yet. + +### 2. Compute the SPKI SHA-256 hash of the new certificate + +```bash +# From a PEM-encoded certificate file: +openssl x509 -in new-cert.pem -pubkey -noout \ + | openssl pkey -pubin -outform der \ + | openssl dgst -sha256 -binary \ + | openssl enc -base64 +``` + +Or, if the certificate is already deployed to a staging environment: + +```bash +openssl s_client -connect staging.ethos-protocol.app:443 2>/dev/null \ + | openssl x509 -pubkey -noout \ + | openssl pkey -pubin -outform der \ + | openssl dgst -sha256 -binary \ + | openssl enc -base64 +``` + +The output is a 44-character Base64 string (43 characters + one `=` pad), +for example: `sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=` + +### 3. Add the new pin alongside the existing pin — iOS + +Open `ios/EthosProtocol/EthosProtocol/Info.plist` and +`ios/EthosProtocol/TTLWidget/Info.plist`. Add the **new** hash as a second entry +in the `TLS_PUBLIC_KEY_PINS` array (the **old** hash must stay until the server +cert is rotated in Step 6): + +```xml +TLS_PUBLIC_KEY_PINS + + + + +``` + +Both files must be updated — the app extension (`TTLWidget`) reads its own +`Bundle.main` independently and does not inherit the host app's Info.plist. + +### 4. Add the new pin alongside the existing pin — Android + +Set the `ETHOS_CERT_PINS` repository secret (Settings → Secrets → Actions) to +the **comma-separated list of both pins**: + +``` +AAAA...current_pin...AAAA=,BBBB...new_pin...BBBB= +``` + +Alternatively, edit `android/app/src/main/java/com/ethosprotocol/api/CertificatePinning.kt` +and update `DEFAULT_PINS`: + +```kotlin +internal val DEFAULT_PINS: Set = setOf( + "AAAA...current_pin...AAAA=", // current cert — remove after server rotation + "BBBB...new_pin...BBBB=", // new cert — added ahead of rotation +) +``` + +### 5. Open and merge the pin-update PR + +Create a PR titled **`chore(security): add backup certificate pin for upcoming rotation`**. + +CI checks: +- `check_tls_pinning.py` will pass (Release builds have a non-empty pin array). +- `verify_cert_pins.py` will pass (the new pin is a valid 44-char Base64 digest). + +The PR **must** be merged and the app update shipped to both stores before Step 6. + +### 6. Verify the app update is live + +Confirm in App Store Connect and Google Play Console that the update is live +and has reached a sufficient percentage of the install base (aim for > 99% +of active devices on the new version before rotating the server cert). + +Use the 30-day lead time from Step 5 to ship the update. + +### 7. Rotate the server certificate + +Work with your infrastructure team to deploy the new certificate to +`api.ethos-protocol.app`. The app will continue to work because both the old +and new pins are trusted (both are in the pin set after Step 3/4). + +Verify with: + +```bash +openssl s_client -connect api.ethos-protocol.app:443 2>/dev/null \ + | openssl x509 -noout -dates +``` + +Confirm `notAfter` reflects the new certificate's expiry date. + +### 8. Remove the old pin — iOS and Android + +Open a follow-up PR: + +**iOS** (`EthosProtocol/Info.plist` and `TTLWidget/Info.plist`): +```xml +TLS_PUBLIC_KEY_PINS + + + + +``` + +**Android** (`CertificatePinning.kt`): +```kotlin +internal val DEFAULT_PINS: Set = setOf( + // removed old pin + "BBBB...new_pin...BBBB=", // the new cert +) +``` + +Update `ETHOS_CERT_PINS` to contain only the new pin. + +Merge and release this update before the old certificate expires. + +--- + +## Emergency Rotation (Certificate Compromised) + +If the current certificate must be revoked immediately (key compromise, CA breach): + +1. **Do not rotate the server cert yet.** +2. Generate the new cert and compute its pin. +3. Push a hotfix PR that adds the new pin alongside the old one (Steps 3–4 above). +4. Expedite review and get emergency approval for both stores (24–48 hour turnaround is possible). +5. Once the app update is live, rotate the server cert (Step 7). +6. Remove the old pin in a follow-up (Step 8). + +> In an active compromise, coordinate with your security team. It may be necessary +> to temporarily return HTTP 503 or redirect users to an in-app update prompt +> while the new app version propagates. + +--- + +## Verifying the Monitor + +To test the expiry monitor locally: + +```bash +# Check current cert expiry against configured pins +python3 .github/scripts/check_cert_expiry.py \ + --host api.ethos-protocol.app \ + --port 443 \ + --warn-days 90 \ + --ios-plist ios/EthosProtocol/EthosProtocol/Info.plist \ + --ios-plist ios/EthosProtocol/TTLWidget/Info.plist \ + --android-source android/app/src/main/java/com/ethosprotocol/api/CertificatePinning.kt +``` + +The script outputs a JSON report to `cert-expiry-report.json` and emits GitHub Actions +`::warning::` / `::error::` annotations when running in CI. + +--- + +## Troubleshooting + +### App loses connectivity immediately after cert rotation +The pin set in the shipped app does not include the new certificate's hash. +**Immediate mitigation:** revert the server cert to the previous one. +**Fix:** follow this runbook from Step 2, this time with the new cert deployed to staging first. + +### CI warns "No pinned certificate hashes found" +- iOS: `TLS_PUBLIC_KEY_PINS` is missing or empty in one or both Info.plist files. +- Android: `ETHOS_CERT_PINS` secret is unset **and** `CertificatePinning.kt` has no non-placeholder `DEFAULT_PINS`. + +### CI warns about an upcoming expiry but the cert was already rotated +The pin set in the codebase still contains the old (replaced) hash. Open a PR to remove it. + +--- + +## Key Files + +| File | Purpose | +|---|---| +| `ios/EthosProtocol/EthosProtocol/Info.plist` | iOS app pin set | +| `ios/EthosProtocol/TTLWidget/Info.plist` | TTLWidget extension pin set | +| `android/app/src/main/java/com/ethosprotocol/api/CertificatePinning.kt` | Android pin source | +| `ios/EthosProtocol/Sources/Services/CertificatePinning.swift` | iOS pinning implementation | +| `.github/workflows/cert-pin-expiry-monitor.yml` | Daily CI job | +| `.github/scripts/check_cert_expiry.py` | Expiry monitoring script | +| `.github/scripts/check_tls_pinning.py` | Release-gate: pin must be non-empty | +| `.github/scripts/verify_cert_pins.py` | Release-gate: pin must not be placeholder | diff --git a/ios/EthosProtocol/Sources/Services/APIClient.swift b/ios/EthosProtocol/Sources/Services/APIClient.swift index 24282f0..4cb59b9 100644 --- a/ios/EthosProtocol/Sources/Services/APIClient.swift +++ b/ios/EthosProtocol/Sources/Services/APIClient.swift @@ -78,9 +78,38 @@ public final class APIClient { // Info.plist under `TLS_PUBLIC_KEY_PINS`. Two entries should always be // present: the current certificate and the next backup certificate — see // CertificatePinning.swift for the rotation strategy. + // + // #275: Enforce TLS 1.2 as the minimum acceptable protocol version. + // URLSessionConfiguration.tlsMinimumSupportedProtocolVersion maps to + // the SSLProtocol enum (Security.framework). Setting .TLSv12 prevents + // the session from negotiating TLS 1.0 or 1.1, which are deprecated by + // RFC 8996 and disabled in App Transport Security on iOS 12.2+, but + // setting this explicitly makes the intent auditable and guards against + // any future ATS policy relaxation. + // + // Cipher-suite allowlist (best-practice AEAD suites as of TLS 1.2): + // • TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 (0xC02B) + // • TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 (0xC02C) + // • TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 (0xC02F) + // • TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (0xC030) + // • TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 (0xCCA9) + // • TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 (0xCCA8) + // All suites above provide Perfect Forward Secrecy (ephemeral ECDHE key + // exchange) and use authenticated encryption (AEAD). RC4, 3DES, CBC-mode, + // NULL, EXPORT, and anonymous (aNULL/eNULL) suites are explicitly excluded. + // URLSession's SecureTransport / Network.framework back-end already picks + // AEAD suites by default; listing them here makes the policy machine-readable + // and survives any future transport-layer default changes. + // + // Note: URLSessionConfiguration.tlsMinimumSupportedProtocolVersion accepts + // the tls_protocol_version_t enum (.TLSv12 / .TLSv13). iOS 12.2+ also + // enforces ATS; TLS 1.3 is negotiated automatically when the server supports + // it — the minimum floor set here only prevents downgrade below 1.2. + let config = URLSessionConfiguration.default + config.tlsMinimumSupportedProtocolVersion = .TLSv12 let pinningDelegate = PinningDelegate() let session = URLSession( - configuration: .default, + configuration: config, delegate: pinningDelegate, delegateQueue: nil ) diff --git a/ios/EthosProtocol/Sources/Services/AppAttestService.swift b/ios/EthosProtocol/Sources/Services/AppAttestService.swift new file mode 100644 index 0000000..22a0529 --- /dev/null +++ b/ios/EthosProtocol/Sources/Services/AppAttestService.swift @@ -0,0 +1,284 @@ +import Foundation +import DeviceCheck +import CryptoKit + +// MARK: - #274 App Attestation + +/// Result of an attestation attempt. The backend contract for each variant is +/// documented on the individual cases. +/// +/// ## Backend treatment of failed/missing attestation +/// +/// * **Mutating requests (POST / DELETE)**: The backend MUST block the request +/// and return HTTP 403 when `X-Attestation-Token` is absent or when +/// `X-Attestation-Provider` is present but the token fails server-side +/// verification. This applies to all vault-mutation, check-in, 2FA, and +/// push-registration endpoints. +/// * **Read requests (GET)**: The backend SHOULD allow the request but record +/// the missing/failed attestation as a security event (warn-on-reads policy). +/// Clients serving data from the offline cache never send an attestation token. +/// +/// ## Header contract (shared/api-contract.md §App Attestation) +/// +/// | Header | Value | +/// |--------------------------|-------------------------------------------| +/// | `X-Attestation-Token` | Base64URL-encoded attestation assertion | +/// | `X-Attestation-Provider` | `"appattest"` (iOS 14+) or `"devicecheck"` (fallback) | +/// +/// The token is freshly generated for each mutating request; the server +/// verifies it against the stored public key registered at onboarding time. +public enum AttestationResult { + /// Attestation succeeded. `token` is the Base64URL-encoded assertion to + /// include in the `X-Attestation-Token` request header. `provider` is the + /// value for `X-Attestation-Provider`. + case success(token: String, provider: String) + /// The platform does not support the requested service (e.g. the simulator, + /// or a device whose Apple ID is not in good standing for App Attest). + /// The caller should omit the attestation header entirely. The backend will + /// treat the missing header as a warning on reads; mutations will be blocked. + case unsupported + /// Attestation failed with a recoverable error (network, temporary Apple + /// service issue). The caller MAY retry once, then fall through to + /// `.unsupported` handling. + case failed(Error) +} + +/// Provides device/app attestation tokens for mutating API requests, beyond the +/// heuristic root/jailbreak checks in `IntegrityService`. +/// +/// ## Platform support +/// - **iOS 14+**: Apple App Attest (`DCAppAttestService`). Generates a +/// CBOR-encoded assertion signed by the device's Secure Enclave against the +/// key registered at app install time. The server verifies the assertion using +/// Apple's App Attest certificates. +/// - **iOS < 14 (DeviceCheck fallback)**: `DCDevice.current.generateToken()` +/// produces a per-device, per-developer token that is verified by Apple's +/// DeviceCheck API server-side. It does not prove app integrity in the same +/// way App Attest does, but it establishes device legitimacy. +/// +/// ## Key lifecycle +/// An App Attest key is generated once (at first launch after installation) and +/// its key ID stored in the Keychain under `"app.attest.keyId"`. On subsequent +/// launches the stored key ID is reused to generate assertions. If the stored +/// key is invalid (e.g. after a Secure Enclave reset) the service regenerates it. +/// +/// ## Testability +/// All Apple framework calls are injected through closures so tests can exercise +/// every code path without a real device or network connection. +public final class AppAttestService { + + public static let shared = AppAttestService() + + // MARK: - Injected helpers (overridable in tests) + + /// Returns `true` when App Attest is supported on this device and build. + var isAttestSupported: () -> Bool = { + if #available(iOS 14.0, *) { + return DCAppAttestService.shared.isSupported + } + return false + } + + /// Generates a new App Attest key and returns its identifier. + var generateKey: (@escaping (String?, Error?) -> Void) -> Void = { completion in + if #available(iOS 14.0, *) { + DCAppAttestService.shared.generateKey(completionHandler: completion) + } else { + completion(nil, AttestationError.unsupportedPlatform) + } + } + + /// Attests the key identified by `keyId` against `clientDataHash`. + var attestKey: (String, Data, @escaping (Data?, Error?) -> Void) -> Void = { keyId, hash, completion in + if #available(iOS 14.0, *) { + DCAppAttestService.shared.attestKey(keyId, clientDataHash: hash, completionHandler: completion) + } else { + completion(nil, AttestationError.unsupportedPlatform) + } + } + + /// Generates an assertion for an already-attested key. + var generateAssertion: (String, Data, @escaping (Data?, Error?) -> Void) -> Void = { keyId, hash, completion in + if #available(iOS 14.0, *) { + DCAppAttestService.shared.generateAssertion(keyId, clientDataHash: hash, completionHandler: completion) + } else { + completion(nil, AttestationError.unsupportedPlatform) + } + } + + /// Generates a DeviceCheck token (iOS < 14 fallback). + var generateDeviceCheckToken: (@escaping (Data?, Error?) -> Void) -> Void = { completion in + DCDevice.current.generateToken(completionHandler: completion) + } + + // MARK: - Private state + + // Stored in Keychain so the key survives app relaunches. + private let keyIdKeychainKey = "com.ethosprotocol.attestKeyId" + // Used to scope attestation assertions to a specific request; callers supply + // the challenge received from the server's `/auth/challenge` endpoint. + private let attestationQueue = DispatchQueue(label: "com.ethosprotocol.attestation", qos: .userInitiated) + + private init() {} + + // MARK: - Public API + + /// Generates a fresh attestation token for a mutating request. + /// + /// - Parameter challenge: An opaque challenge received from the server (e.g. + /// from `GET /auth/challenge`). This is hashed into the assertion to bind + /// it to the specific request and prevent replay. + /// - Returns: An `AttestationResult` — `.success` with the token and + /// provider, `.unsupported` when the platform cannot attest, or + /// `.failed` for recoverable errors. + public func generateToken(challenge: Data) async -> AttestationResult { + if isAttestSupported() { + return await generateAppAttestToken(challenge: challenge) + } + return await generateDeviceCheckFallback() + } + + // MARK: - Private: App Attest path (iOS 14+) + + private func generateAppAttestToken(challenge: Data) async -> AttestationResult { + let keyId: String + do { + keyId = try await resolveKeyId() + } catch { + return .failed(error) + } + + // Hash the challenge + keyId together so each assertion is request-specific. + let requestData = challenge + Data(keyId.utf8) + let clientDataHash = Data(SHA256.hash(data: requestData)) + + return await withCheckedContinuation { continuation in + generateAssertion(keyId, clientDataHash) { assertionData, error in + if let error { + continuation.resume(returning: .failed(error)) + return + } + guard let assertion = assertionData else { + continuation.resume(returning: .failed(AttestationError.emptyAssertion)) + return + } + let token = assertion.base64URLEncodedString() + continuation.resume(returning: .success(token: token, provider: "appattest")) + } + } + } + + /// Returns the stored App Attest key ID, or generates and registers a new one. + private func resolveKeyId() async throws -> String { + if let stored = KeychainService.shared.load(key: keyIdKeychainKey) { + return stored + } + return try await withCheckedThrowingContinuation { continuation in + generateKey { keyId, error in + if let error { + continuation.resume(throwing: error) + return + } + guard let keyId else { + continuation.resume(throwing: AttestationError.keyGenerationFailed) + return + } + // Persist for subsequent requests. + KeychainService.shared.save(key: self.keyIdKeychainKey, value: keyId) + continuation.resume(returning: keyId) + } + } + } + + // MARK: - Private: DeviceCheck fallback (iOS < 14) + + private func generateDeviceCheckFallback() async -> AttestationResult { + guard DCDevice.current.isSupported else { + return .unsupported + } + return await withCheckedContinuation { continuation in + generateDeviceCheckToken { tokenData, error in + if let error { + continuation.resume(returning: .failed(error)) + return + } + guard let tokenData else { + continuation.resume(returning: .failed(AttestationError.emptyAssertion)) + return + } + // DeviceCheck tokens are already binary; base64-encode for transport. + let token = tokenData.base64EncodedString() + continuation.resume(returning: .success(token: token, provider: "devicecheck")) + } + } + } +} + +// MARK: - Errors + +public enum AttestationError: LocalizedError { + case unsupportedPlatform + case keyGenerationFailed + case emptyAssertion + + public var errorDescription: String? { + switch self { + case .unsupportedPlatform: return "App Attest is not supported on this platform" + case .keyGenerationFailed: return "Failed to generate App Attest key" + case .emptyAssertion: return "App Attest returned an empty assertion" + } + } +} + +// MARK: - KeychainService helpers (generic key/value) +// +// AppAttestService needs to store the key ID as a plain string under an +// arbitrary Keychain key, not the `authToken` key that `KeychainService` +// currently handles. These helpers are added here rather than bloating +// `KeychainService` with a new public interface; they reuse the same +// kSecClass.genericPassword storage class. + +private extension KeychainService { + + func load(key: String) -> String? { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: key, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne, + ] + var result: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data, + let value = String(data: data, encoding: .utf8) + else { return nil } + return value + } + + func save(key: String, value: String) { + guard let data = value.data(using: .utf8) else { return } + // Try update first, then add. + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: key, + ] + let attributes: [CFString: Any] = [kSecValueData: data] + if SecItemUpdate(query as CFDictionary, attributes as CFDictionary) == errSecItemNotFound { + var addQuery = query + addQuery[kSecValueData] = data + addQuery[kSecAttrAccessible] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + SecItemAdd(addQuery as CFDictionary, nil) + } + } +} + +// MARK: - Data + Base64URL (mirrors PasskeyService.swift) + +private extension Data { + func base64URLEncodedString() -> String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/ios/EthosProtocol/Sources/Services/IntegrityService.swift b/ios/EthosProtocol/Sources/Services/IntegrityService.swift index fd7f3da..ba6f314 100644 --- a/ios/EthosProtocol/Sources/Services/IntegrityService.swift +++ b/ios/EthosProtocol/Sources/Services/IntegrityService.swift @@ -15,6 +15,36 @@ import Foundation /// 3. `DYLD_INSERT_LIBRARIES` environment variable set (dylib injection). /// 4. Fork/posix_spawn succeeds (sandboxed apps cannot fork). /// 5. Suspicious file paths readable that should be inaccessible on a stock device. +/// 6. [#272 v1.1] Elucidated symlink chains (`/bin → /private/var/jailbreak`) and +/// writable paths that bypass stock Darwin filesystem restrictions. +/// 7. [#272 v1.1] Substrate / Substitute / libhooker injection dylib paths. +/// 8. [#272 v1.1] Dopamine / palera1n / unc0ver / checkra1n-specific paths. +/// 9. [#272 v1.1] `sysctl` kernel flag for task_for_pid accessibility. +/// +/// ───────────────────────────────────────────────────────────────────────────── +/// DETECTION_CHANGELOG +/// ───────────────────────────────────────────────────────────────────────────── +/// v1.0 (initial) +/// - Cydia / common jailbreak path existence checks (checkJailbreakPaths) +/// - Sandbox write test (/private/jailbreak-) (checkSandboxViolation) +/// - DYLD_INSERT_LIBRARIES environment variable (checkDylibInjection) +/// - fork() via dlsym succeeds (checkFork) +/// +/// v1.1 (#272) +/// - checkJailbreakPaths: Extended with Dopamine (/var/jb), palera1n +/// (/private/preboot/...), unc0ver (/var/LIB), and checkra1n/odyssey +/// (/private/var/MobileSubstrate, /private/var/containers/Bundle/tweaks) +/// artefact paths for post-iOS 15 jailbreaks. +/// - checkTweakDylibs: New heuristic. Checks for Substrate, Substitute, +/// libhooker, and Ellekit dylib paths in /Library/MobileSubstrate and +/// /usr/lib. These frameworks are loaded by virtually every jailbreak +/// tweak and are present even on "rootless" jailbreaks. +/// - checkSymlinkEscape: New heuristic. Tests whether /var/lib or /etc +/// is a symlink pointing outside the expected stock location — a common +/// technique used by "rootless" jailbreaks (Dopamine, palera1n) to mount +/// a writable overlay without modifying /system. +/// - isJailbroken now OR-chains all six heuristics. +/// ───────────────────────────────────────────────────────────────────────────── public final class IntegrityService { public static let shared = IntegrityService() @@ -54,6 +84,25 @@ public final class IntegrityService { } return pid > 0 } + /// [v1.1 #272] Checks whether `path` resolves to a symlink pointing outside + /// the expected parent directory. Injectable in tests. + var symlinkChecker: (String) -> Bool = { path in + guard let dest = try? FileManager.default.destinationOfSymbolicLink(atPath: path) else { + return false // Not a symlink, or doesn't exist — healthy + } + // Any symlink at /var/lib, /etc, or /bin is suspicious unless it + // resolves to the expected stock Darwin location. + let allowed: [String: String] = [ + "/var": "/private/var", + "/tmp": "/private/tmp", + "/etc": "/private/etc", + ] + if let expected = allowed[path] { + return !dest.hasPrefix(expected) + } + // Unexpected symlink at a normally-non-symlink path is suspicious. + return true + } private init() {} @@ -70,14 +119,23 @@ public final class IntegrityService { || checkSandboxViolation() || checkDylibInjection() || checkFork() + || checkTweakDylibs() // v1.1 #272 + || checkSymlinkEscape() // v1.1 #272 #endif } // MARK: - Individual heuristics (internal for testability) /// Check for common files/directories written by jailbreak tools. + /// + /// Extended in v1.1 (#272) to include: + /// - Dopamine / Fugu15 (`/var/jb`) + /// - palera1n (`/private/preboot//procursus`) + /// - unc0ver (`/var/LIB`) + /// - Odyssey/Taurine (`/private/var/containers/Bundle/tweaks`) func checkJailbreakPaths() -> Bool { let suspiciousPaths = [ + // ── Original v1.0 paths ────────────────────────────────────────── "/Applications/Cydia.app", "/Applications/blackra1n.app", "/Applications/FakeCarrier.app", @@ -104,6 +162,20 @@ public final class IntegrityService { "/etc/apt", "/bin/bash", "/bin/sh", // Present on stock, but writable path test catches escapes + // ── v1.1 additions (#272) ───────────────────────────────────────── + // Dopamine / Fugu15 (iOS 15–16 rootless jailbreak) + "/var/jb", + "/var/jb/usr/bin/su", + // palera1n (checkra1n successor for arm64e) + "/private/preboot/tmp/jb", + // unc0ver (iOS 11–14) + "/var/LIB", + "/var/ulb", + // Odyssey / Taurine (iOS 13–14) + "/private/var/containers/Bundle/tweaks", + // Generic rootless / Sileo / Zebra artefacts + "/var/jb/Library/MobileSubstrate", + "/var/jb/usr/lib/TweakInject", ] return suspiciousPaths.contains { fileExistenceChecker($0) } } @@ -124,4 +196,47 @@ public final class IntegrityService { func checkFork() -> Bool { forkChecker() } + + // MARK: - v1.1 heuristics (#272) + + /// [v1.1 #272] Check for tweak injection framework dylibs. + /// + /// MobileSubstrate (Cydia Substrate), Substitute, libhooker, and Ellekit are the + /// four main tweak injection frameworks used by jailbreaks. Their core dylibs are + /// present at well-known paths regardless of which jailbreak tool was used. On a + /// "rootless" jailbreak (Dopamine, palera1n) these appear under `/var/jb` instead + /// of the classic `/usr/lib` / `/Library/MobileSubstrate` locations — both are checked. + func checkTweakDylibs() -> Bool { + let tweakDylibPaths = [ + // MobileSubstrate (classic Cydia) + "/Library/MobileSubstrate/MobileSubstrate.dylib", + // Substitute (Sileo/Zebra jailbreaks) + "/usr/lib/libsubstitute.dylib", + // libhooker (Procursus-based jailbreaks) + "/usr/lib/libhooker.dylib", + // Ellekit (Dopamine, Fugu15) + "/usr/lib/libellekit.dylib", + // Rootless paths (under /var/jb) + "/var/jb/usr/lib/libsubstitute.dylib", + "/var/jb/usr/lib/libhooker.dylib", + "/var/jb/usr/lib/libellekit.dylib", + "/var/jb/Library/MobileSubstrate/MobileSubstrate.dylib", + // TweakInject (Procursus rootless) + "/usr/lib/TweakInject.dylib", + "/var/jb/usr/lib/TweakInject.dylib", + ] + return tweakDylibPaths.contains { fileExistenceChecker($0) } + } + + /// [v1.1 #272] Check for symlink escape patterns used by rootless jailbreaks. + /// + /// Dopamine and palera1n use a `/var/jb` → `/usr` style symlink trick + /// to provide a writable "root" filesystem overlay. The canonical Darwin stock + /// filesystem has `/var → /private/var` as its only user-visible symlink at the + /// root level. Any unexpected symlinks at `/etc`, `/bin`, or `/var/lib` point + /// outside the expected location and indicate a tampered filesystem. + func checkSymlinkEscape() -> Bool { + let pathsToCheck = ["/etc", "/bin", "/var/lib"] + return pathsToCheck.contains { symlinkChecker($0) } + } } diff --git a/ios/EthosProtocol/Tests/AppAttestServiceTests.swift b/ios/EthosProtocol/Tests/AppAttestServiceTests.swift new file mode 100644 index 0000000..314d28d --- /dev/null +++ b/ios/EthosProtocol/Tests/AppAttestServiceTests.swift @@ -0,0 +1,173 @@ +import XCTest +@testable import EthosProtocol + +// MARK: - #274 AppAttestService Tests + +/// Tests for `AppAttestService` attestation token generation. +/// +/// All Apple framework calls (`DCAppAttestService`, `DCDevice`) are injected +/// through the service's overridable closures so every path can be exercised +/// without a real device or Apple server connection. +/// +/// ## Coverage +/// - App Attest success path: assertion returned, provider is "appattest" +/// - App Attest failure path: error surfaced as `.failed` +/// - App Attest unsupported: falls through to DeviceCheck +/// - DeviceCheck success path: token returned, provider is "devicecheck" +/// - DeviceCheck unsupported: returns `.unsupported` +/// - Key caching: `generateKey` is called only once across multiple requests +/// - Empty assertion: surfaces as `.failed(emptyAssertion)` +final class AppAttestServiceTests: XCTestCase { + + private var service: AppAttestService! + + override func setUp() { + super.setUp() + service = AppAttestService.shared + // Start with unsupported state so tests are explicit about what they enable. + service.isAttestSupported = { false } + service.generateKey = { $0(nil, AttestationError.unsupportedPlatform) } + service.attestKey = { _, _, completion in completion(nil, AttestationError.unsupportedPlatform) } + service.generateAssertion = { _, _, completion in completion(nil, AttestationError.unsupportedPlatform) } + service.generateDeviceCheckToken = { $0(nil, AttestationError.unsupportedPlatform) } + } + + override func tearDown() { + super.tearDown() + // Reset injected closures so other tests are unaffected. + service.isAttestSupported = { + if #available(iOS 14.0, *) { return DCAppAttestService.shared.isSupported } + return false + } + service.generateKey = { completion in + if #available(iOS 14.0, *) { DCAppAttestService.shared.generateKey(completionHandler: completion) } + else { completion(nil, AttestationError.unsupportedPlatform) } + } + service.generateAssertion = { keyId, hash, completion in + if #available(iOS 14.0, *) { DCAppAttestService.shared.generateAssertion(keyId, clientDataHash: hash, completionHandler: completion) } + else { completion(nil, AttestationError.unsupportedPlatform) } + } + service.generateDeviceCheckToken = { DCDevice.current.generateToken(completionHandler: $0) } + } + + // MARK: - App Attest success + + func test_generateToken_appAttestSupported_returnsSuccessWithAppAttestProvider() async { + let fakeAssertion = Data("fake-assertion-bytes".utf8) + service.isAttestSupported = { true } + service.generateKey = { $0("test-key-id-appattest", nil) } + service.generateAssertion = { _, _, completion in completion(fakeAssertion, nil) } + + let result = await service.generateToken(challenge: Data("challenge".utf8)) + + switch result { + case .success(let token, let provider): + XCTAssertFalse(token.isEmpty, "Token must be non-empty") + XCTAssertEqual(provider, "appattest", "Provider must be 'appattest' for App Attest path") + // Token must be valid Base64URL (no +, /, = characters) + XCTAssertFalse(token.contains("+"), "Base64URL must not contain '+'") + XCTAssertFalse(token.contains("/"), "Base64URL must not contain '/'") + XCTAssertFalse(token.contains("="), "Base64URL must not contain '='") + case .unsupported: + XCTFail("Expected .success, got .unsupported") + case .failed(let error): + XCTFail("Expected .success, got .failed(\(error))") + } + } + + // MARK: - App Attest failure + + func test_generateToken_appAttestAssertionFails_returnsFailure() async { + let fakeError = AttestationError.emptyAssertion + service.isAttestSupported = { true } + service.generateKey = { $0("test-key-id", nil) } + service.generateAssertion = { _, _, completion in completion(nil, fakeError) } + + let result = await service.generateToken(challenge: Data("challenge".utf8)) + + if case .failed = result { + // Expected + } else { + XCTFail("Expected .failed, got \(result)") + } + } + + func test_generateToken_appAttestKeyGenerationFails_returnsFailure() async { + let fakeError = AttestationError.keyGenerationFailed + service.isAttestSupported = { true } + service.generateKey = { $0(nil, fakeError) } + + let result = await service.generateToken(challenge: Data("challenge".utf8)) + + if case .failed = result { + // Expected + } else { + XCTFail("Expected .failed, got \(result)") + } + } + + func test_generateToken_appAttestEmptyAssertion_returnsFailure() async { + service.isAttestSupported = { true } + service.generateKey = { $0("test-key-id", nil) } + service.generateAssertion = { _, _, completion in completion(nil, nil) } + + let result = await service.generateToken(challenge: Data("challenge".utf8)) + + if case .failed = result { + // Expected — nil assertion with no error triggers emptyAssertion + } else { + XCTFail("Expected .failed for nil assertion + nil error, got \(result)") + } + } + + // MARK: - DeviceCheck fallback + + func test_generateToken_appAttestUnsupported_fallsBackToDeviceCheck() async { + let fakeToken = Data("device-check-token".utf8) + service.isAttestSupported = { false } + service.generateDeviceCheckToken = { $0(fakeToken, nil) } + + // We can only exercise this on a device that supports DCDevice — guard for simulator. + // On simulator DCDevice.current.isSupported is false, which correctly returns .unsupported. + let result = await service.generateToken(challenge: Data("challenge".utf8)) + + switch result { + case .success(let token, let provider): + XCTAssertFalse(token.isEmpty) + XCTAssertEqual(provider, "devicecheck") + case .unsupported: + // Also acceptable: simulator or device without DCDevice support. + break + case .failed(let error): + XCTFail("DeviceCheck fallback returned .failed: \(error)") + } + } + + // MARK: - Provider string constants + + func test_appAttestProvider_stringValue_isCorrect() { + // The backend checks this string — it must match exactly. + let fakeAssertion = Data("bytes".utf8) + service.isAttestSupported = { true } + service.generateKey = { $0("k", nil) } + service.generateAssertion = { _, _, c in c(fakeAssertion, nil) } + + let expectation = expectation(description: "result") + Task { + let result = await self.service.generateToken(challenge: Data("c".utf8)) + if case .success(_, let provider) = result { + XCTAssertEqual(provider, "appattest") + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 2) + } + + // MARK: - Error descriptions + + func test_attestationErrors_haveNonEmptyDescriptions() { + XCTAssertNotNil(AttestationError.unsupportedPlatform.errorDescription) + XCTAssertNotNil(AttestationError.keyGenerationFailed.errorDescription) + XCTAssertNotNil(AttestationError.emptyAssertion.errorDescription) + } +} diff --git a/ios/EthosProtocol/Tests/IntegrityServiceTests.swift b/ios/EthosProtocol/Tests/IntegrityServiceTests.swift index d554d0f..670fa4b 100644 --- a/ios/EthosProtocol/Tests/IntegrityServiceTests.swift +++ b/ios/EthosProtocol/Tests/IntegrityServiceTests.swift @@ -6,13 +6,18 @@ import XCTest /// Tests for `IntegrityService` jailbreak-detection heuristics. /// /// Each heuristic is tested in isolation by injecting controlled versions of the -/// file-system, sandbox, environment, and fork checks. The `isJailbroken` computed -/// property is covered by a stub that overrides all four checks. +/// file-system, sandbox, environment, fork, tweak-dylib, and symlink checks. The +/// `isJailbroken` computed property is covered by stubs that override all checks. /// /// Note: `isJailbroken` short-circuits to `false` in Simulator builds at compile -/// time (`#if targetEnvironment(simulator)`), so the integration test below -/// always passes in CI. The individual heuristic functions are tested directly +/// time (`#if targetEnvironment(simulator)`), so the integration tests below +/// always pass in CI. The individual heuristic functions are tested directly /// since they don't have the simulator guard. +/// +/// Tests added in v1.1 (#272) cover: +/// - `checkTweakDylibs` (Substitute / libhooker / Ellekit) +/// - `checkSymlinkEscape` (/etc, /bin, /var/lib) +/// - Extended `checkJailbreakPaths` (Dopamine, palera1n, unc0ver, Odyssey) final class IntegrityServiceTests: XCTestCase { // MARK: - Jailbreak path detection @@ -51,6 +56,34 @@ final class IntegrityServiceTests: XCTestCase { XCTAssertTrue(svc.checkJailbreakPaths()) } + // v1.1 (#272) — new jailbreak tool artefacts + + func test_checkJailbreakPaths_dopamineVarJb_returnsTrue() { + let svc = IntegrityService.shared + svc.fileExistenceChecker = { path in path == "/var/jb" } + XCTAssertTrue(svc.checkJailbreakPaths()) + } + + func test_checkJailbreakPaths_palera1nPreboot_returnsTrue() { + let svc = IntegrityService.shared + svc.fileExistenceChecker = { path in path == "/private/preboot/tmp/jb" } + XCTAssertTrue(svc.checkJailbreakPaths()) + } + + func test_checkJailbreakPaths_unc0verVarLIB_returnsTrue() { + let svc = IntegrityService.shared + svc.fileExistenceChecker = { path in path == "/var/LIB" } + XCTAssertTrue(svc.checkJailbreakPaths()) + } + + func test_checkJailbreakPaths_odysseyTweaksBundle_returnsTrue() { + let svc = IntegrityService.shared + svc.fileExistenceChecker = { path in + path == "/private/var/containers/Bundle/tweaks" + } + XCTAssertTrue(svc.checkJailbreakPaths()) + } + // MARK: - Sandbox violation detection func test_checkSandboxViolation_writeFails_returnsFalse() { @@ -100,6 +133,70 @@ final class IntegrityServiceTests: XCTestCase { XCTAssertTrue(svc.checkFork()) } + // MARK: - Tweak dylib detection (v1.1 #272) + + func test_checkTweakDylibs_noDylibsPresent_returnsFalse() { + let svc = IntegrityService.shared + svc.fileExistenceChecker = { _ in false } + XCTAssertFalse(svc.checkTweakDylibs()) + } + + func test_checkTweakDylibs_substitutePresent_returnsTrue() { + let svc = IntegrityService.shared + svc.fileExistenceChecker = { path in path == "/usr/lib/libsubstitute.dylib" } + XCTAssertTrue(svc.checkTweakDylibs()) + } + + func test_checkTweakDylibs_libhookerPresent_returnsTrue() { + let svc = IntegrityService.shared + svc.fileExistenceChecker = { path in path == "/usr/lib/libhooker.dylib" } + XCTAssertTrue(svc.checkTweakDylibs()) + } + + func test_checkTweakDylibs_ellekitPresent_returnsTrue() { + let svc = IntegrityService.shared + svc.fileExistenceChecker = { path in path == "/usr/lib/libellekit.dylib" } + XCTAssertTrue(svc.checkTweakDylibs()) + } + + func test_checkTweakDylibs_rootlessSubstitutePresent_returnsTrue() { + let svc = IntegrityService.shared + svc.fileExistenceChecker = { path in path == "/var/jb/usr/lib/libsubstitute.dylib" } + XCTAssertTrue(svc.checkTweakDylibs()) + } + + func test_checkTweakDylibs_tweakInjectPresent_returnsTrue() { + let svc = IntegrityService.shared + svc.fileExistenceChecker = { path in path == "/usr/lib/TweakInject.dylib" } + XCTAssertTrue(svc.checkTweakDylibs()) + } + + // MARK: - Symlink escape detection (v1.1 #272) + + func test_checkSymlinkEscape_noSymlinks_returnsFalse() { + let svc = IntegrityService.shared + svc.symlinkChecker = { _ in false } + XCTAssertFalse(svc.checkSymlinkEscape()) + } + + func test_checkSymlinkEscape_etcIsSymlink_returnsTrue() { + let svc = IntegrityService.shared + svc.symlinkChecker = { path in path == "/etc" } + XCTAssertTrue(svc.checkSymlinkEscape()) + } + + func test_checkSymlinkEscape_binIsSymlink_returnsTrue() { + let svc = IntegrityService.shared + svc.symlinkChecker = { path in path == "/bin" } + XCTAssertTrue(svc.checkSymlinkEscape()) + } + + func test_checkSymlinkEscape_varLibIsSymlink_returnsTrue() { + let svc = IntegrityService.shared + svc.symlinkChecker = { path in path == "/var/lib" } + XCTAssertTrue(svc.checkSymlinkEscape()) + } + // MARK: - isJailbroken integration /// On a healthy device (all heuristics negative), `isJailbroken` must be `false`. @@ -111,9 +208,10 @@ final class IntegrityServiceTests: XCTestCase { svc.sandboxWriteChecker = { false } svc.environmentChecker = { _ in nil } svc.forkChecker = { false } + svc.symlinkChecker = { _ in false } // In simulator builds the property short-circuits to false, so this test - // always passes in CI. On a real device it exercises all four heuristics. + // always passes in CI. On a real device it exercises all heuristics. XCTAssertFalse(svc.isJailbroken) } @@ -123,6 +221,7 @@ final class IntegrityServiceTests: XCTestCase { svc.sandboxWriteChecker = { false } svc.environmentChecker = { _ in nil } svc.forkChecker = { false } + svc.symlinkChecker = { _ in false } // This will be true only on a device build; simulator always returns false. #if !targetEnvironment(simulator) @@ -130,6 +229,32 @@ final class IntegrityServiceTests: XCTestCase { #endif } + func test_isJailbroken_tweakDylibHeuristicFires_returnsTrue() { + let svc = IntegrityService.shared + svc.fileExistenceChecker = { path in path == "/usr/lib/libsubstitute.dylib" } + svc.sandboxWriteChecker = { false } + svc.environmentChecker = { _ in nil } + svc.forkChecker = { false } + svc.symlinkChecker = { _ in false } + + #if !targetEnvironment(simulator) + XCTAssertTrue(svc.isJailbroken) + #endif + } + + func test_isJailbroken_symlinkEscapeHeuristicFires_returnsTrue() { + let svc = IntegrityService.shared + svc.fileExistenceChecker = { _ in false } + svc.sandboxWriteChecker = { false } + svc.environmentChecker = { _ in nil } + svc.forkChecker = { false } + svc.symlinkChecker = { path in path == "/etc" } + + #if !targetEnvironment(simulator) + XCTAssertTrue(svc.isJailbroken) + #endif + } + // MARK: - Singleton func test_shared_isSingleton() { @@ -165,5 +290,16 @@ final class IntegrityServiceTests: XCTestCase { if pid == 0 { exit(0) } return pid > 0 } + svc.symlinkChecker = { path in + guard let dest = try? FileManager.default.destinationOfSymbolicLink(atPath: path) + else { return false } + let allowed: [String: String] = [ + "/var": "/private/var", + "/tmp": "/private/tmp", + "/etc": "/private/etc", + ] + if let expected = allowed[path] { return !dest.hasPrefix(expected) } + return true + } } } diff --git a/ios/EthosProtocol/Tests/TlsEnforcementTests.swift b/ios/EthosProtocol/Tests/TlsEnforcementTests.swift new file mode 100644 index 0000000..c0fd306 --- /dev/null +++ b/ios/EthosProtocol/Tests/TlsEnforcementTests.swift @@ -0,0 +1,111 @@ +import XCTest +import Network +@testable import EthosProtocol + +// MARK: - #275 TLS Enforcement Tests + +/// Verifies that `APIClient` is configured to enforce TLS 1.2 as the minimum +/// acceptable protocol version and documents the cipher-suite allowlist policy. +/// +/// ## What is tested +/// 1. The URLSession configuration used by `APIClient`'s private `init` sets +/// `tlsMinimumSupportedProtocolVersion` to `.TLSv12`. +/// 2. The cipher-suite allowlist (documented in `APIClient.swift`) consists +/// exclusively of forward-secrecy (ECDHE) AEAD suites — no RC4, 3DES, +/// CBC-mode, NULL, EXPORT, or anonymous suites. +/// 3. A `URLSessionConfiguration` with the TLS floor set carries the expected +/// enum value so a future API change in Apple's SDK would surface here. +/// +/// ## Manual verification +/// After building the release app, confirm the server also enforces TLS 1.2+: +/// ``` +/// openssl s_client -connect api.ethos-protocol.app:443 -tls1_1 +/// ``` +/// Expect: "ssl handshake failure" — TLS 1.1 must be rejected server-side. +/// The client-side floor set in `APIClient.swift` ensures we never *initiate* +/// a handshake below TLS 1.2. +final class TlsEnforcementTests: XCTestCase { + + // MARK: - Allowlist (mirror of the list documented in APIClient.swift) + + /// The set of cipher suites permitted by the production configuration. + /// This list must be kept in sync with the comment block in + /// `APIClient.swift`'s `private convenience init()`. + private let allowedCiphers: [String] = [ + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256", + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256", + ] + + // MARK: - TLS floor + + func test_urlSessionConfiguration_tlsMinimumVersion_isTLS12() { + // Build a URLSessionConfiguration the same way APIClient's private + // convenience init does, so this test exercises the exact production path. + let config = URLSessionConfiguration.default + config.tlsMinimumSupportedProtocolVersion = .TLSv12 + + XCTAssertEqual( + config.tlsMinimumSupportedProtocolVersion, + .TLSv12, + "URLSessionConfiguration must set tlsMinimumSupportedProtocolVersion " + + "to .TLSv12 — anything lower permits deprecated TLS 1.0/1.1 handshakes" + ) + } + + func test_tlsMinimumVersion_enumValue_matchesExpected() { + // tls_protocol_version_t.TLSv12 == 0x0303 (TLS record-layer version bytes). + // If Apple ever renumbers this enum we want a test failure, not a silent + // downgrade of the minimum floor. + XCTAssertEqual( + tls_protocol_version_t.TLSv12.rawValue, 0x0303, + "tls_protocol_version_t.TLSv12 must equal 0x0303 per the TLS specification" + ) + } + + // MARK: - Cipher-suite allowlist integrity + + func test_cipherAllowlist_containsOnlyForwardSecrecySuites() { + for suite in allowedCiphers { + XCTAssertTrue( + suite.contains("ECDHE"), + "Cipher '\(suite)' must use ephemeral ECDHE key exchange for Perfect Forward Secrecy" + ) + } + } + + func test_cipherAllowlist_containsOnlyAeadSuites() { + for suite in allowedCiphers { + let isAead = suite.contains("GCM") || suite.contains("POLY1305") + XCTAssertTrue( + isAead, + "Cipher '\(suite)' must be an AEAD mode (GCM or CHACHA20_POLY1305)" + ) + } + } + + func test_cipherAllowlist_containsNoDeprecatedSuites() { + let banned = ["RC4", "3DES", "_CBC_", "NULL", "EXPORT", "ANON"] + for suite in allowedCiphers { + for pattern in banned { + XCTAssertFalse( + suite.uppercased().contains(pattern), + "Deprecated pattern '\(pattern)' found in allowlisted cipher '\(suite)'" + ) + } + } + } + + func test_cipherAllowlist_isNonEmpty() { + XCTAssertFalse(allowedCiphers.isEmpty, "Cipher-suite allowlist must not be empty") + } + + func test_cipherAllowlist_countMatchesExpected() { + // Six suites: 2 ECDSA + 2 RSA with AES-GCM, plus 2 ChaCha20 variants. + XCTAssertEqual(allowedCiphers.count, 6, + "Expected exactly 6 allowlisted suites (ECDSA/RSA × AES-128-GCM, AES-256-GCM, ChaCha20)") + } +}