diff --git a/.github/scripts/check_tls_pinning.py b/.github/scripts/check_tls_pinning.py index 759a582..dd29c94 100644 --- a/.github/scripts/check_tls_pinning.py +++ b/.github/scripts/check_tls_pinning.py @@ -26,10 +26,13 @@ import argparse import plistlib import sys +from datetime import datetime, timedelta, timezone from pathlib import Path PINS_KEY = "TLS_PUBLIC_KEY_PINS" ENFORCED_CONFIGURATION = "release" +DEFAULT_EXPIRY_WARNING_DAYS = 60 +DEFAULT_EXPIRY_FAIL_DAYS = 14 def parse_target(raw: str) -> tuple[str, str]: @@ -68,7 +71,79 @@ def check_target(name: str, plist_path: str) -> str | None: return None -def run(configuration: str, targets: list[tuple[str, str]]) -> int: +def parse_certificate_expiry(raw: str) -> datetime: + """Parses YYYY-MM-DD or ISO-8601 timestamps into UTC-aware datetimes.""" + value = raw.strip() + if not value: + raise ValueError("certificate expiry must not be empty") + + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + try: + parsed = datetime.strptime(value, "%Y-%m-%d") + except ValueError as exc: + raise ValueError( + "certificate expiry must be ISO-8601 or YYYY-MM-DD, got " + f"{raw!r}" + ) from exc + + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + else: + parsed = parsed.astimezone(timezone.utc) + return parsed + + +def check_certificate_expiry( + certificate_expiry: str | None, + *, + warning_days: int = DEFAULT_EXPIRY_WARNING_DAYS, + fail_days: int = DEFAULT_EXPIRY_FAIL_DAYS, +) -> tuple[int, str | None]: + """Returns (exit_code, message) for the configured certificate expiry date. + + A warning is emitted when the remaining lifetime falls inside the warning window. + If the cert is within the fail window, the script exits non-zero to force rotation. + """ + if not certificate_expiry: + return 0, None + + try: + expires_at = parse_certificate_expiry(certificate_expiry) + except ValueError as exc: + return 1, f"::error::Invalid certificate expiry '{certificate_expiry}': {exc}" + + now = datetime.now(timezone.utc) + remaining = expires_at - now + if remaining <= timedelta(0): + return 1, ( + "::error::Configured certificate expiry is in the past: " + f"{expires_at.isoformat()} (today is {now.isoformat()}). " + "Rotate the certificate before shipping." + ) + + if remaining <= timedelta(days=warning_days): + message = ( + f"::warning::Current certificate expires on {expires_at.date().isoformat()} " + f"({remaining.days} days remaining). Rotate the certificate before it falls " + f"within {warning_days} days." + ) + if remaining <= timedelta(days=fail_days): + return 1, message.replace("::warning::", "::error::") + return 0, message + + return 0, None + + +def run( + configuration: str, + targets: list[tuple[str, str]], + *, + certificate_expiry: str | None = None, + expiry_warning_days: int = DEFAULT_EXPIRY_WARNING_DAYS, + expiry_fail_days: int = DEFAULT_EXPIRY_FAIL_DAYS, +) -> int: if configuration.strip().lower() != ENFORCED_CONFIGURATION: print( f"Skipping {PINS_KEY} check for '{configuration}' configuration — " @@ -79,12 +154,23 @@ def run(configuration: str, targets: list[tuple[str, str]]) -> int: failures = [msg for name, path in targets for msg in [check_target(name, path)] if msg] + expiry_code, expiry_message = check_certificate_expiry( + certificate_expiry, + warning_days=expiry_warning_days, + fail_days=expiry_fail_days, + ) + if expiry_message: + print(expiry_message) + if failures: print("TLS certificate pinning check FAILED for Release configuration:") for msg in failures: print(f"::error::{msg}") return 1 + if expiry_code != 0: + return expiry_code + names = ", ".join(name for name, _ in targets) print(f"TLS certificate pinning check passed for: {names}") return 0 @@ -107,8 +193,32 @@ def main(argv: list[str] | None = None) -> int: metavar="NAME:PATH", help="Target name and path to its Info.plist. May be repeated.", ) + parser.add_argument( + "--certificate-expiry", + default=None, + help="Documented certificate expiry date in YYYY-MM-DD or ISO-8601 format. " + "If set, the script warns when the cert is within the configured lead time.", + ) + parser.add_argument( + "--expiry-warning-days", + type=int, + default=DEFAULT_EXPIRY_WARNING_DAYS, + help="Warn when the certificate has fewer than this many days remaining.", + ) + parser.add_argument( + "--expiry-fail-days", + type=int, + default=DEFAULT_EXPIRY_FAIL_DAYS, + help="Fail the build when the certificate has fewer than this many days remaining.", + ) args = parser.parse_args(argv) - return run(args.configuration, args.targets) + return run( + args.configuration, + args.targets, + certificate_expiry=args.certificate_expiry, + expiry_warning_days=args.expiry_warning_days, + expiry_fail_days=args.expiry_fail_days, + ) if __name__ == "__main__": diff --git a/.github/scripts/tests/test_check_tls_pinning.py b/.github/scripts/tests/test_check_tls_pinning.py index 5bbad5b..6a0f5c2 100644 --- a/.github/scripts/tests/test_check_tls_pinning.py +++ b/.github/scripts/tests/test_check_tls_pinning.py @@ -12,6 +12,7 @@ import plistlib import tempfile import unittest +from datetime import datetime, timedelta, timezone from pathlib import Path SCRIPT_PATH = Path(__file__).resolve().parents[1] / "check_tls_pinning.py" @@ -79,6 +80,25 @@ def test_passes_once_non_empty_pins_present(self): self.assertEqual(code, 0) self.assertIn("passed", output) + def test_warns_before_certificate_expiry_deadline(self): + with tempfile.TemporaryDirectory() as tmp: + plist_path = write_plist(Path(tmp), "Info.plist", { + **BASE_PLIST, + "TLS_PUBLIC_KEY_PINS": ["k1Vw6WsE9scmn9tRAWjOTNTWyfPpWWx3fV1c/dCLwyQ="], + }) + expiry = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d") + + code, output = run_main([ + "--configuration", "Release", + "--target", f"EthosProtocol:{plist_path}", + "--certificate-expiry", expiry, + "--expiry-warning-days", "60", + ]) + + self.assertEqual(code, 0) + self.assertIn("warning", output.lower()) + self.assertIn("expires", output.lower()) + def test_fails_when_pins_array_is_empty(self): with tempfile.TemporaryDirectory() as tmp: plist_path = write_plist(Path(tmp), "Info.plist", { diff --git a/.github/scripts/verify_cert_pins.py b/.github/scripts/verify_cert_pins.py index dac65af..ec9d038 100755 --- a/.github/scripts/verify_cert_pins.py +++ b/.github/scripts/verify_cert_pins.py @@ -38,6 +38,7 @@ import base64 import re import sys +from datetime import datetime, timedelta, timezone # A Base64-encoded SHA-256 digest: 43 payload characters plus one '=' pad. PIN_PATTERN = re.compile(r"^[A-Za-z0-9+/]{43}=$") @@ -108,7 +109,63 @@ def placeholder_reason(pin): return None -def verify(build_type, build_config, source, warn_if_unconfigured=False): +def parse_certificate_expiry(raw): + """Parses YYYY-MM-DD or ISO-8601 timestamps into UTC-aware datetimes.""" + value = (raw or "").strip() + if not value: + raise ValueError("certificate expiry must not be empty") + + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + try: + parsed = datetime.strptime(value, "%Y-%m-%d") + except ValueError as exc: + raise ValueError( + "certificate expiry must be ISO-8601 or YYYY-MM-DD, got " + f"{raw!r}" + ) from exc + + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + else: + parsed = parsed.astimezone(timezone.utc) + return parsed + + +def check_certificate_expiry(certificate_expiry, warning_days=60, fail_days=14): + if not certificate_expiry: + return 0 + + try: + expires_at = parse_certificate_expiry(certificate_expiry) + except ValueError as exc: + error(f"Invalid certificate expiry '{certificate_expiry}': {exc}") + return 1 + + now = datetime.now(timezone.utc) + remaining = expires_at - now + if remaining <= timedelta(0): + error( + "Configured certificate expiry is in the past: {} (today is {}). " + "Rotate it before release.".format(expires_at.isoformat(), now.isoformat()) + ) + return 1 + + if remaining <= timedelta(days=warning_days): + message = ( + "Current certificate expires on {} ({} days remaining). Rotate the " + "certificate before it falls within {} days." + ).format(expires_at.date().isoformat(), remaining.days, warning_days) + if remaining <= timedelta(days=fail_days): + error(message) + return 1 + warn(message) + return 0 + + +def verify(build_type, build_config, source, warn_if_unconfigured=False, + certificate_expiry=None, expiry_warning_days=60, expiry_fail_days=14): if build_type != "release": print("Build type '{}' is not gated — pinning may legitimately be " "disabled outside release builds.".format(build_type)) @@ -148,6 +205,14 @@ def verify(build_type, build_config, source, warn_if_unconfigured=False): annotate(problem + suffix) return 0 if advisory else 1 + expiry_code = check_certificate_expiry( + certificate_expiry, + warning_days=expiry_warning_days, + fail_days=expiry_fail_days, + ) + if expiry_code: + return expiry_code + print("Release certificate pins OK ({} pin(s) from {}).".format(len(pins), origin)) return 0 @@ -163,8 +228,23 @@ def main(argv=None): parser.add_argument("--warn-if-unconfigured", action="store_true", help="Report (rather than fail on) placeholder pins when no pin set " "was configured at all — for release builds that cannot ship.") + parser.add_argument("--certificate-expiry", + default=None, + help="Documented certificate expiry date in YYYY-MM-DD or ISO-8601 format.") + parser.add_argument("--expiry-warning-days", type=int, default=60, + help="Warn when the certificate has fewer than this many days remaining.") + parser.add_argument("--expiry-fail-days", type=int, default=14, + help="Fail the build when the certificate has fewer than this many days remaining.") args = parser.parse_args(argv) - return verify(args.build_type, args.build_config, args.source, args.warn_if_unconfigured) + return verify( + args.build_type, + args.build_config, + args.source, + args.warn_if_unconfigured, + certificate_expiry=args.certificate_expiry, + expiry_warning_days=args.expiry_warning_days, + expiry_fail_days=args.expiry_fail_days, + ) if __name__ == "__main__": diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index 1ac75ae..c8b2487 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -120,6 +120,7 @@ jobs: env: ETHOS_CERT_PINS: ${{ secrets.ETHOS_CERT_PINS }} ANDROID_KEYSTORE_PATH: ${{ secrets.ANDROID_KEYSTORE_PATH }} + ETHOS_CERTIFICATE_EXPIRY: ${{ vars.ETHOS_CERTIFICATE_EXPIRY }} run: | python3 ../.github/scripts/test_verify_cert_pins.py @@ -127,6 +128,9 @@ jobs: if [ -z "$ETHOS_CERT_PINS" ] && [ -z "$ANDROID_KEYSTORE_PATH" ]; then ARGS="--warn-if-unconfigured" fi + if [ -n "$ETHOS_CERTIFICATE_EXPIRY" ]; then + ARGS="$ARGS --certificate-expiry $ETHOS_CERTIFICATE_EXPIRY" + fi python3 ../.github/scripts/verify_cert_pins.py \ --build-type release $ARGS \ diff --git a/.github/workflows/ios-ci.yml b/.github/workflows/ios-ci.yml index 88d8587..4835608 100644 --- a/.github/workflows/ios-ci.yml +++ b/.github/workflows/ios-ci.yml @@ -33,11 +33,19 @@ jobs: # untouched, matching PinningDelegate's documented "empty pins disables # pinning for local dev" design (see check_tls_pinning.py). - name: Verify TLS certificate pinning is configured (Release) + env: + ETHOS_CERTIFICATE_EXPIRY: ${{ vars.ETHOS_CERTIFICATE_EXPIRY }} run: | - python3 "$GITHUB_WORKSPACE/.github/scripts/check_tls_pinning.py" \ - --configuration Release \ - --target "EthosProtocol:EthosProtocol/Info.plist" \ + ARGS=( + --configuration Release + --target "EthosProtocol:EthosProtocol/Info.plist" --target "TTLWidget:TTLWidget/Info.plist" + ) + if [ -n "$ETHOS_CERTIFICATE_EXPIRY" ]; then + ARGS+=(--certificate-expiry "$ETHOS_CERTIFICATE_EXPIRY") + fi + + python3 "$GITHUB_WORKSPACE/.github/scripts/check_tls_pinning.py" "${ARGS[@]}" - name: Select Xcode run: sudo xcode-select -s /Applications/Xcode.app