Skip to content

Implement Constellation + Asterism services for RCS on Google Messages#3385

Closed
Lyapsus wants to merge 19 commits into
microg:masterfrom
Lyapsus:rcs-constellation
Closed

Implement Constellation + Asterism services for RCS on Google Messages#3385
Lyapsus wants to merge 19 commits into
microg:masterfrom
Lyapsus:rcs-constellation

Conversation

@Lyapsus
Copy link
Copy Markdown

@Lyapsus Lyapsus commented Apr 8, 2026

Summary

Implements the two GMS services Google Messages requires for RCS:

  • Constellation (svc 155) - phone number verification via Google's phonedeviceverification-pa.googleapis.com
  • Asterism (svc 199) - consent management (Google ToS acceptance for RCS)

Also fixes several DroidGuard issues that prevented tachyon transport registration - the final gate for actual message delivery. This has been in development since early February 2026, with the tachyon breakthrough coming from forensic comparison of stock GMS vs microG /proc/self/maps.

End result: end-to-end encrypted RCS messages sent and received on microG. Tested on 3 SIMs across 2 carriers after complete data wipe (pm clear + DG cache deletion) - fresh sessions with zero inherited data.

What works

  • Jibe UPI carriers (carriers where Google handles phone verification - covers most of Europe, many worldwide): Full flow from fresh Messages launch to E2EE message delivery. Tested on Spusu (232/17) and Georg (232/12), both Austrian carriers on Jibe.
  • TS.43 carriers (carriers using SIM-based EAP-AKA verification - T-Mobile US, Freedom Mobile, Deutsche Telekom, etc.): Implementation present (Ts43Client.java, full EAP-AKA + FIPS 186 PRF), but I had no opportunity to acquire a TS.43 carrier SIM for testing. The algorithm matches what's known to work on these carriers, so it should work. Any testing here would be immensely appreciated.

DroidGuard fixes

The main technical breakthrough was identifying why tachyon_registration DG tokens were being rejected. Root cause: three issues making microG's DG cache paths differ from stock GMS, visible in /proc/self/maps (which DG reads via direct syscall twice per session):

  1. Cache directory name: getDir("cache_dg") creates app_cache_dg/, but stock GMS uses getDir("dg_cache")app_dg_cache/
  2. VM key hex casing: okio ByteString.hex() returns lowercase, but stock uses uppercase for cache subdirectory names
  3. Extra native libraries: Previous attempts to improve DG quality by loading 32 stock .so files via dlopen() backfired - stock GMS .unstable process loads zero native .so from GmsCore's lib directory (it loads native code from within the APK). The 32 extra entries in dl_iterate_phdr were a massive detection signal.

This was confirmed by deploying stock GMS on the same device and forensically comparing /proc/self/maps: stock had 306 .so (all system/framework), microG had 338 (306 + 32 extra).

Other fixes included

  • DroidGuardInitReply.createFromParcel() returning null → lost DroidGuardResultsRequest Bundle on every DG call
  • Named DgVmClassLoader (prevents anonymous class name leaking org.microg to DG)
  • Checkin androidId used in Constellation proto (was using per-app Settings.Secure.ANDROID_ID - mismatch with DG token's device ID)
  • X-Goog-Spatula header for Constellation gRPC
  • gmsVersion bumped to 25.19.31 (MobileConfig requires ≥25.19 for UPI)
  • hasAccount populated dynamically from AccountManager
  • Chimera service proxy fixes for proper class hierarchy

Testing limitations

For testing I sadly have only a Samsung Galaxy S10+ (rooted with KernelSU for log access and build deployment). I believe no modifications made outside the code affect the result on a proper device - the code changes are all pure Java/Kotlin with zero root dependency, and a locked-bootloader device is actually better-positioned for DroidGuard (real hardware attestation, no root artifacts to detect). But I haven't been able to verify this empirically.

Not tested:

  • Non-Samsung devices (manual phone number entry flow - Samsung auto-discovers numbers via proprietary API)
  • Locked bootloader without root
  • Never-verified SIM (all tested SIMs had prior server-side verification; full OTP flow untested end-to-end)
  • TS.43 carriers (no SIM available)

I'm going to clean the code up a bit more, but any testing would be immensely appreciated already.

Verification steps (what I did)

  1. pm clear com.google.android.apps.messaging (total Messages wipe)
  2. Cleared all GMS DG caches, constellation prefs, DG database
  3. Fresh Messages launch → welcome screen → "Continue as Yakov"
  4. Auto-provisioning: Constellation verified, tachyon fresh registration
  5. NOT_REGISTERED → REGISTERED_WITH_PREKEYS
  6. BindHandler active, receiving messages
  7. RCS chat: "This chat is now end-to-end encrypted"
  8. Message sent → Delivered. End-to-end encrypted message.

Repeated on 3 SIMs (Spusu, Georg #1, Georg #3), two carriers (232/17, 232/12).

Closes #2994

- Add AIDL interfaces for Constellation (IConstellationApiService, IConstellationCallbacks)
- Add SafeParcelable classes for Constellation requests/responses
- Implement ConstellationService and ConstellationServiceImpl
- Add Ts43Client scaffolding for EAP-AKA authentication
- Register service in AndroidManifest
- Enable linting in build.gradle
- Refactor Ts43Client to decouple from Android dependencies for testing
- Add Ts43ClientTest to verify EAP-AKA challenge parsing
- Fix off-by-one error in EAP packet parsing
- Update ConstellationServiceImpl to use Ts43Client
- Add required permissions to AndroidManifest
- Enable unit tests in build.gradle
Major changes:
- Implemented GoogleConstellationClient with real Constellation API calls
- Fixed DroidGuard classloader caching to prevent Shared library already opened errors
- Added support for GetConsent and Sync requests with proper proto structure
- Implemented ClientCredentials with ECDSA signature generation
- Added TelephonyInfoContainer with Gaia IDs for field 20
- Fixed include_asterism_consents field position (moved to field 8)
- Added required params (policy_id, calling_api, calling_package) to GetConsent
- Fixed SIMAssociation structure with proper field mapping
- Added params field to Verification message (field 6)
- Implemented proper DeviceId with android_id fields
- Added support for real DroidGuard tokens (~43k chars) instead of fallback
- Created AsterismService for consent management
- Fixed SSL provider loading in conscrypt

Known issues:
- Still getting INVALID_ARGUMENT - likely due to OAuth token project mismatch
- OAuth tokens from project 745476177629 but Constellation expects 496232013492

Refs: #2994
…nment

Constellation parity with stock GMS (bevw.java):
- mapExceptionToStatusCode: gRPC→Status(500x) mapping with stock parity
- decideVerificationOutcome: enforce VERIFIED↔non-empty-token invariant
- extractVerificationMethodFromJwt + mapVerificationMethodString: JWT-based method
- Error callback sends null response on non-success (P0.1 fix, bevw.java:53,83)
- Remove stub-token scaffold (generateStubToken, EntitlementResult.stub)

Multi-SIM correctness:
- findMatchingVerifiedNumber: phone-match before firstOrNull at 4 GPNV sites
- Pending verification phone matching in Proceed path
- State priority: hasNone guarded by !hasPending (no OTP short-circuit)

PII sanitization:
- OTP SMS: log sender+length only, mask code to G-***XX
- Verification response: IMSI/MSISDN redacted to ***last4
- OTP regex tightened to G-(\d{6}) only (no bare 6-digit match)

Also: FID computation (cdqp.b parity), 23 unit tests, stale TODO cleanup
… DG now passes

Stock .unstable loads ZERO native .so from GmsCore/lib/. loadStockNativeLibs()
added 32 entries to dl_iterate_phdr that stock never has (306 system .so vs our 338).
This was the #1 detection signal causing tachyon_registration PERMISSION_DENIED.

- Remove loadStockNativeLibs() call (32 extra .so gone)
- Remove libgcore_jni.so loading (no longer needed)
- Remove link_map unlink (nothing to hide)
- Fix CACHE_FOLDER_NAME "cache_dg" → "dg_cache" (stock uses app_dg_cache/)
- Fix vmKey hex to uppercase (stock uses uppercase DG cache path)

Verified: pm clear + fresh start → tachyon REGISTERED_WITH_PREKEYS → E2EE RCS
message sent and received on microG for the first time.
Full working state with tachyon_registration passing. Includes:
- S220 3-line DG fix (loadStockNativeLibs removed, cache dir, vmKey uppercase)
- DgSpoofContext PM proxy (S213-S215)
- DgIntrospect native helper declarations (S213)
- DgClassLoaders (S212-S214)
- StockGmsData permissions list (S215)
- Asterism service (S205)
- Constellation client improvements (S207-S220)
- Chimera service proxy fixes
- Permission renames in AndroidManifest
- force_manual_msisdn test setting
- All debugging/logging additions

This is the safety checkpoint before stripping dead code for bounty PR.
…InstrumentedContext

Zero-caller functions proven unnecessary by S220 tachyon breakthrough:
- loadStockNativeLibs(): loaded 32 .so stock never loads (was THE detection signal)
- mmapStockApk(): created non-stock artifact SUSFS had to hide
- getEnrichedClassLoader() + StockFirstClassLoader: stock-first ordering gave zero benefit
- spoofContextClassLoader(): never actually called (originalClassLoader always null)
- DgIntrospect native declarations: loadNativeLibOnly, nativeDlopenLazy,
  nativeHideSelfFromLinkMap, nativeHookDlIteratePhdr, tryLoadNativeHooks,
  loadAndHideNativeLib, unhookNative, InstrumentedContext
- Removed loadNativeLibOnly() call from NetworkHandleProxyFactory.createHandleProxy()

All removed code required libgcore_jni.so (native helper) which won't exist on
locked-bootloader microG ROMs. Active code paths unchanged.

-448 lines. Build verified.
…h hybrid APK)

Targets stock GMS classes not present in normal microG APK — all 4 Class.forName
calls throw ClassNotFoundException. Only useful with augment-apk-with-stock-dex.sh
hybrid APK (proven irrelevant for DG quality in S206).

Tested: pm clear + full DG cache wipe → auto-provisioned → ConfiguredState →
tachyon REGISTERED_WITH_PREKEYS → E2EE MLS PROVISIONED. Zero regressions.
Reads pif.prop from filesDir or /data/adb/ — neither exists on locked-BL
microG ROMs. Falls back to real Build.* values which is the correct behavior
(DG reads native __system_property_get which returns real device props;
Java-level mismatch would be a detection signal, not a fix).

Tested: pm clear + full DG/constellation wipe → ConfiguredState →
tachyon REGISTERED_WITH_PREKEYS. Zero regressions.
Resolves 4 merge conflicts:
- HandleProxyFactory.kt: keep dg_cache fix + named DgVmClassLoader
- LoginActivity.java: keep real DroidGuard token generation
- PhenotypeService.kt: keep both RCS flags and Photos flag
- WorkAccountAuthenticator.kt: take upstream refactor
- Replace ad-hoc HMAC-SHA1 key derivation with FIPS 186-2 PRF
  (RFC 4187 Section 7). The old deriveKey() produced wrong K_aut,
  causing AT_MAC verification failure on TS.43 servers.
- Fix SIM response parsing: 3GPP TS 31.102 returns sequential
  length-value pairs (DB [len] [RES] [len] [CK] [len] [IK]),
  not nested TLV with inner tags. Old parser failed to extract
  RES/CK/IK from real SIM responses.
- Fix AT_RES length byte order: was writing [bits_low][0x00]
  instead of big-endian [0x00][bits_low] per RFC 4187 Section 10.3.
- Accept server-provided eap_aka_realm for NAI derivation,
  falling back to default 3GPP TS 23.003 realm.
- Remove dead LengthInfo/readLength code from old TLV parser.
- HandleProxy: log VM constructor calls and failures with exception
  class name and message (was completely silent on init failure)
- HandleProxy: log run() result size and init() result via android.util.Log
  (was only via DgIntrospect which requires opt-in)
- HandleProxyFactory: log class cache hit/miss/fresh-load with vmKey
- HandleProxyFactory: log cache directory details on validation failure
- NetworkHandleProxyFactory: log DG availability check failure with
  guidance ("check microG Settings > SafetyNet > DroidGuard enabled")
- NetworkHandleProxyFactory: log DB cache hit/miss for ALL flows
  (was only constellation_verify)
- NetworkHandleProxyFactory: add vmKey and path details to cache write
  failure exception (was empty IllegalStateException)
Replace HTTP 401/WWW-Authenticate challenge-response with the standard
GSMA TS.43 v5.0+ JSON body relay protocol:

Phase 1 - EAP-AKA auth:
  GET with EAP_ID param -> server returns {"eap-relay-packet": "base64"}
  -> SIM auth -> POST {"eap-relay-packet": "response"} back
  -> server returns {"Token": {"token": "..."}} (up to 3 rounds)

Phase 2 - ODSA request (handled by caller):
  GET with token param -> server returns phone number or temp token

Key changes:
- Accept entitlement_url + eap_aka_realm from Ts43Challenge proto
  (server-provided URL, not CarrierConfig guesswork)
- Session cookies preserved across EAP rounds (required by many servers)
- Token extraction handles both JSON (Token.token) and OMA DM XML formats
- Remove old handleEapAkaChallenge (HTTP 401 nonce parsing)
- Wire handleTs43Challenge to pass server URL and realm to Ts43Client
Adversarial review found 3 remaining bugs in the TS.43 flow:

1. FIPS 186-2 PRF carry initialization: spec says
   XKEY = (1 + XKEY + w_i) mod 2^b - the +1 was missing.
   carry=0 -> carry=1 (produces correct key material from iteration 2+)

2. Session cookies not applied to POST requests in EAP relay loop.
   HttpURLConnection sends headers on getOutputStream(), so cookies
   set at loop top were too late for the POST created at loop bottom.
   Fix: apply cookies immediately after creating POST connection.

3. Missing ODSA Phase 2: auth token is intermediate, not the final
   result. After EAP-AKA auth, must make GET with token param to
   get the actual phone number / temp token response body. Added
   performOdsaRequest() for Phase 2, falls back to auth token if
   ODSA fails. Route response to correct proto field based on
   client_challenge vs server_challenge.
Codex steelman found 2 more bugs:

1. EAP relay loop dropped the final POST response: old for-loop
   sent POST at bottom, exited without reading response. Restructured
   to while-loop that always reads response before deciding next step.
   Extracted applyCookies/collectCookies helpers.

2. MK derivation used String.length() (char count) instead of
   UTF-8 byte length for identity. For ASCII NAIs these are equal,
   but non-ASCII realms would produce wrong MK. Fixed to use
   getBytes(UTF-8) consistently.

Also: thread eapAkaRealm all the way through processEapPacket
into generateEapAkaResponse for correct key derivation with
server-provided realms. Make processEapPacket package-private
for testability.

Updated Ts43ClientTest to match new API:
- Test processEapPacket directly (verifies EAP response format,
  AT_RES byte order, AT_MAC presence)
- Test SIM response format (3GPP TS 31.102 sequential LV)
- Test FIPS 186-2 PRF determinism (same inputs = same outputs)
- Test EntitlementResult type behavior
- Removed old handleEapAkaChallenge test (method removed)
@Lyapsus Lyapsus marked this pull request as draft April 8, 2026 23:18
@Lyapsus Lyapsus closed this Apr 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BOUNTY] RCS Support [14999$]

1 participant