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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 112 additions & 2 deletions .github/scripts/check_tls_pinning.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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 — "
Expand All @@ -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
Expand All @@ -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__":
Expand Down
20 changes: 20 additions & 0 deletions .github/scripts/tests/test_check_tls_pinning.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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", {
Expand Down
84 changes: 82 additions & 2 deletions .github/scripts/verify_cert_pins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}=$")
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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

Expand All @@ -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__":
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/android-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,17 @@ 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

ARGS=""
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 \
Expand Down
14 changes: 11 additions & 3 deletions .github/workflows/ios-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down