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
414 changes: 414 additions & 0 deletions .github/scripts/check_cert_expiry.py

Large diffs are not rendered by default.

98 changes: 98 additions & 0 deletions .github/workflows/cert-pin-expiry-monitor.yml
Original file line number Diff line number Diff line change
@@ -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
58 changes: 58 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
44 changes: 42 additions & 2 deletions android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<TrustManager>(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
}
}
}
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading