Skip to content

feat: Moonlight-host (Sunshine/Apollo/Wolf) client support - #170

Open
emir-hasanbegovic wants to merge 8 commits into
mainfrom
feat/moonlight-host-support
Open

feat: Moonlight-host (Sunshine/Apollo/Wolf) client support#170
emir-hasanbegovic wants to merge 8 commits into
mainfrom
feat/moonlight-host-support

Conversation

@emir-hasanbegovic

@emir-hasanbegovic emir-hasanbegovic commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

What and why

Adds a second connection path so each dish can speak the Moonlight (GameStream) protocol directly to any Moonlight-compatible host (Sunshine, Apollo, Vibepollo, Wolf), alongside the existing Satellite protocol-1 path. The dish becomes a controller for a Moonlight host: it pairs, launches an app, sets up the streams, and forwards controller input (on-screen and physical) while applying rumble/trigger/motion/LED events coming back.

No video or audio decoding. The streams are negotiated at the lowest settings and their payloads are discarded; only the control channel carries real work.

Protocol scope

  • Discovery: mDNS _nvstream._tcp plus manual host entry (probed via /serverinfo before being added).
  • Pairing: the 5-phase PIN flow. The dish generates a 4-digit PIN, the user types it into the host's web UI, phases 2-4 run the AES-ECB challenge/response with SHA-256 hashes and RSA signatures, phase 5 confirms over client-cert mutual TLS. The server cert is pinned on first use (TOFU).
  • Launch: /applist then /launch carrying the client-generated rikey/rikeyid that key the control stream, or /resume.
  • RTSP: OPTIONS to DESCRIBE to SETUP (audio/video/control) to ANNOUNCE to PLAY over TCP, reading the negotiated control port and ENet connect token from the SETUP reply. Ports are taken from serverinfo/RTSP, never hardcoded.
  • Control: a pure-Kotlin ENet client over UDP, AES-GCM-128 per packet. Client sends CONTROLLER_ARRIVAL, CONTROLLER_MULTI on every input change, periodic ping, and TERMINATION on quit. Host events handled: RUMBLE_DATA, RUMBLE_TRIGGERS, MOTION_EVENT, RGB_LED; unknown types ignored gracefully.

User-facing flow

The connections screen grows a MOONLIGHT HOSTS section (Scan + Add, mirroring the satellites section) listing discovered and remembered hosts. Tapping Connect runs the full flow:

  1. Emulated-device pick: Auto, Xbox, PlayStation, Nintendo, seeded from the host's remembered choice and mapped onto CONTROLLER_ARRIVAL types.
  2. Pairing: shows the generated PIN for the user to type into the host's web UI, and resolves when the host accepts it.
  3. App pick: from /applist, seeded with the last app launched on that host.

Both choices persist per host, so a later reconnect is one tap. New strings ship in all six locales.

Hot path and physical pads

CONTROLLER_MULTI encode + encrypt reuses a fixed-offset ByteBuffer and a single reused Cipher, with one monotonic control seq feeding the GCM IV, so a steady input stream does not allocate per packet (mirrors the repo's satellite_jni.cpp discipline). Only the ENet frame itself allocates.

Physical controllers stream through that hot path: the C++ capture path gains SLOT_MOONLIGHT beside SLOT_BLUETOOTH. The Bluetooth report queue and dispatch thread generalize into one bridge queue whose reports carry their slot kind, so a pad bound to a Moonlight host publishes through the same fixed-offset path and upcalls MoonlightGamepadBridge, which seals and sends on the live control session. XUSB wButtons map straight across since Moonlight's low-16 button flags share XInput's bit layout. reconcileSlots gained a Moonlight branch with the same live-session re-check the Bluetooth branch does, and its binds are deduped like the others.

Architecture

The Moonlight path sits beside the Satellite session code as a sibling, surfaced as a new ConnectionKind.MOONLIGHT through the connections composer, coordinator, capability profile, connection glyphs, the connections screen, and the on-screen controller overlay send path, so both paths flow through the same connection model. Identity generation is keystore-backed; the pairing/control crypto is pure JCA (no BouncyCastle) so it unit-tests against Wolf's captured vectors. The satellite protocol-1 path is untouched.

Bring-up against a real Sunshine host

First contact with a live Sunshine host found five bugs that, in a chain, made pairing and then every call after it impossible. All five are fixed here. The user-visible symptom was Sunshine saying "Pairing Failed: Check if the PIN is typed correctly", which was a red herring: the PIN was correct, and the request never reached the host at all.

Each one was hidden behind the one before it, so they surfaced one at a time as each was fixed. Three of them (3, 4 and 5) are invisible to the unit suite by construction, and 5 could only be diagnosed with the host's own debug log and socket table open, which is the argument for having done this on a device.

1. Cleartext was denied app-wide, so /pair never left the device.

W/MoonlightHttpGateway: request failed for /pair: Cleartext HTTP traffic to 192.168.68.98 not permitted

res/xml/network_security_config.xml sets cleartextTrafficPermitted="false" on the base config. Moonlight pairing phases 1-4 and the /serverinfo probe are plaintext on port 47989 by protocol (Wolf http-pairing.adoc), because there is no shared secret to build a TLS session on yet. Every phase-1 request died inside the app, so Sunshine never opened a pairing session and had nothing to match the typed PIN against.

The deny stays. It is deliberate: it is what Play's pre-launch security checks and Android's PlatformVal validator read, and every URL-stack request the app makes really should obey it. Instead, MoonlightPlainHttpClient speaks a minimal HTTP/1.1 GET by hand over a Socket, which the network security config does not gate, exactly as the encrypted UDP gamepad wire and the LAN discovery beacons already are not. Only getHttp routes through it; nothing else in the app gains a cleartext path.

This is safe by protocol design: what crosses the wire is a random salt, the public client certificate, and AES challenges and signatures over them. The PIN itself is never sent, it is shown on the dish and typed into the host's own UI, and both ends only prove knowledge of it through the challenge exchange. Everything from phase 5 on is pinned mutual TLS. The config file's comment now spells this out, so the next reader is not misled into thinking the app sends no cleartext at all.

The client handles Content-Length, chunked and close-delimited bodies, keeps the gateway's connect and read timeouts, returns the gateway's usual unreachable reply on any transport failure, and never throws.

2. The keystore client key could not do TLS client auth.

W/CryptoUpcalls: android.security.KeyStoreException: Incompatible padding mode ... Error::Km(r#INCOMPATIBLE_PADDING_MODE)
W/CryptoUpcalls: Could not find provider for algorithm: RSA/ECB/NoPadding
W/MoonlightHttpGateway: error:04000044:RSA routines:OPENSSL_internal:internal error

The identity key was generated PURPOSE_SIGN + SIGNATURE_PADDING_RSA_PKCS1 + DIGEST_SHA256, which signs the pairing secret but is not what Conscrypt asks for during TLS client auth. BoringSSL reduces both TLS 1.3 and RSA-PSS to a raw private-key operation, which CryptoUpcalls.rsaSignDigestWithPrivateKey requests as a Cipher over RSA/ECB/NoPadding in ENCRYPT_MODE. On AndroidKeyStore that lands in AndroidKeyStoreRSACipherSpi.NoPadding, whose adjustConfigForEncryptingWithPrivateKey() overrides the keymaster purpose to SIGN and asks the key for KM_PAD_NONE with KM_DIGEST_NONE. The key authorized neither.

Generation now also authorizes DIGEST_NONE and ENCRYPTION_PADDING_NONE (the KeyProperties spelling of KM_PAD_NONE; both padding setters feed one KM_TAG_PADDING list at generation). The purpose stays SIGN-only, so this authorizes raw signing rather than decryption, and the key stays non-exportable in the keystore. That also covers the TLS 1.2 route, which asks for RSA/ECB/PKCS1Padding and maps to KM_PAD_RSA_PKCS1_1_5_SIGN + KM_DIGEST_NONE.

A keystore key's authorization list is fixed at generation, so widening the spec does not fix a key already stored under the alias. MoonlightIdentityDecision reads the stored key's own KeyInfo and replaces it when it cannot do the job. Discarding the old identity is harmless because pairing has never succeeded, so no host holds the old certificate.

Only bug 1 was reachable by the user; bug 2 was latent behind it, breaking just the isPaired() pre-check so far. It becomes fatal the moment bug 1 is fixed, since pairing phase 5, /applist and /launch all ride the mutual-TLS channel.

3. Pairing phase 1 was given a 5-second read timeout, so the host's pending session was torn down.

W/MoonlightPlainHttp: plain GET failed for /pair: Read timed out

This one only became reachable once the first two were fixed, and it is the same user-visible dead end: a PIN on screen that the host will never accept. The host does not answer phase 1 until a human has typed the displayed PIN into its own web UI. Sunshine parks the phase-1 response and completes it only on PIN entry, so that request is deliberately long-lived, but the gateway applied its ordinary 5s probe timeout to it. The socket was torn down about the time the user would still be reaching for a browser, and the host dropped its half-open pairing session with it.

Phase 1 now gets PAIR_PIN_TIMEOUT_MS (120s). The plaintext client takes a per-call read timeout so this stays scoped to the one request that waits on a person: /serverinfo probes and phases 2-4, which answer immediately, keep the short timeout and still fail fast on an unreachable host. Connect timeouts are untouched throughout.

The plaintext client also now logs its non-exception give-ups (unusable URL, head never finished, reply that is not HTTP). Those used to return an unreachable reply silently, which is exactly what made this bug hard to see.

4. The XML hardening disabled the XML parser outright on Android.

MoonlightXml.rootOf asked the DOM factory for the Apache DTD switch:

setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)

Android's DocumentBuilderFactoryImpl recognizes exactly two feature names, the SAX namespaces and validation ones, and throws ParserConfigurationException for everything else. rootOf builds its factory inside a runCatching, so on device that exception was swallowed and rootOf returned null for every document. The JVM's Xerces does support the feature, so the entire unit suite passed and the breakage existed only on device.

That broke every Moonlight XML reply on Android: /serverinfo (a manually added host was always rejected with "No Moonlight host answered", which is how this was caught), /pair (pairing would have died the instant the user typed the PIN and phase 1 finally returned), /applist and /launch.

Each switch is now applied best-effort, and the guarantee that actually matters is enforced portably instead: the builder gets an EntityResolver that resolves every external entity to nothing, so no DTD or entity in a host's reply can make the parser open a file or a connection, whatever the underlying factory was willing to admit. On the JVM the feature switches still apply exactly as before, so nothing is weakened there either. The new test pins that to behaviour rather than to a feature name: it feeds a file:// external entity through parseServerInfo and fails if the file's contents reach the parsed document.

5. Every mutual-TLS call after the first one timed out, because the URL stack could never reuse a connection and never closed the ones it made.

W/MoonlightHttpGateway: request failed for /serverinfo: Read timed out
W/MoonlightHttpGateway: request failed for /applist: Read timed out

With the first four fixed, the user typed the PIN and Sunshine recorded the pairing: sunshine_state.json gained a Pixel 8 entry holding the dish's certificate. The dish never found out. Phase 5 (pairchallenge, the first thing after phase 4 and the first thing over mutual TLS) timed out, /applist timed out, and the host never showed as connected. The host knew the dish; the dish did not know the host.

Sunshine's own debug log settles what happened. It received exactly one HTTPS request in the entire session:

[19:36:40.391]: Debug: /CN=NVIDIA GameStream Client -- verified
[19:36:40.394]: Debug: TUNNEL :: HTTPS
[19:36:40.394]: Debug: DESTINATION :: /serverinfo
[19:36:40.394]: Debug: Connection -- Keep-Alive
[19:36:40.394]: Debug: User-Agent -- Dalvik/2.1.0 (Linux; U; Android 17; Pixel 8 Build/CP2A.260805.005)

Not one later HTTPS request reached it, while the plaintext half kept arriving normally throughout, phases 1-4 included. Two things follow. The client certificate is fine and always was, since Sunshine verified it and served the request. And the later calls were not being refused, they were never being answered.

The keystore was the obvious suspect and it was checked on the device against the real paired key before being dropped: KeyStore.getDefaultType() is BKS on Android 17, setKeyEntry accepts the opaque AndroidKeyStoreRSAPrivateKey without complaint, and Conscrypt's KeyManagerImpl returns the alias, the chain and the private key for keyType RSA. The credential works.

What was actually wrong is only visible from the host's side. Its socket table held eleven TLS connections from the dish, one for every call the app had made, none of them closed by us until the app's process died:

TCP  192.168.68.98:47984  192.168.68.128:34614  CLOSE_WAIT
TCP  192.168.68.98:47984  192.168.68.128:34620  CLOSE_WAIT
...                                             (eleven, all from the phone)

Eleven connections for eleven calls, because the URL stack could never reuse one. com.android.okhttp keys its connection pool on an Address, and an Address carries the SSLSocketFactory and HostnameVerifier instances. The gateway built a fresh factory per call and the verifier is necessarily per host, so no two calls ever matched: every call dialled a new TLS connection, and disconnect() returned the previous one to the pool rather than closing it. From that first call on, the host's HTTPS listener answered nothing at all, ours or any other client's: curl from a third machine went from an instant TLS reject (6 ms) to an 8 s timeout against port 47984, while port 47989 kept replying in under a millisecond.

The HTTPS half now rides the same raw socket the plaintext half has always used, which is the half that never had this problem. MoonlightPlainHttpClient becomes MoonlightHttp11Client and takes an optional socket upgrade; the gateway passes the mutual-TLS handshake plus its certificate pin check, which rejects by throwing out of that hook instead of through a HostnameVerifier, so a host that fails the pin never sees a request. The credential itself is untouched, and the SSLSocketFactory it builds is now built once rather than per call. Every request opens one socket, sends the Connection: close the client already sent, and closes it, so the host holds nothing of ours between calls and an HTTPS request looks on the wire exactly like the plaintext ones Sunshine has always handled.

The HTTPS connect and read budget goes from 5 s to 10 s. Every call now pays for its own handshake, and the dish's half of that handshake is a signature from a hardware-backed keystore key, which on a cold or busy device waits on keystore IPC and on the secure element.

On-device verification

Run against the live Sunshine host on the LAN, from a booted Android emulator (API 36) after the physical Pixel 8 dropped off USB mid-session. The emulator reaches the host through NAT, so the pairing traffic is real traffic to the real Sunshine, not a loopback stub.

Driving the app to Connect on a discovered host now reaches the PIN prompt and stays there, and the whole run produces a completely silent logcat on every tag that used to carry the failures:

  • No Cleartext HTTP traffic to <host> not permitted.
  • No CryptoUpcalls ... INCOMPATIBLE_PADDING_MODE, no Could not find provider for algorithm: RSA/ECB/NoPadding, no RSA routines:OPENSSL_internal:internal error.
  • No MoonlightHttpGateway: request failed, and no MoonlightPlainHttp: plain GET failed.

The silence on the HTTPS side is a positive result, not an absence of evidence: isPaired() makes a mutual-TLS /serverinfo call before pairing, and any failure there logs. It no longer does, so the keystore client certificate now completes TLS client auth.

Two positive results beyond the silence:

  • A host added by LAN address is accepted. Typing 192.168.68.98 into Add now yields a Samus Aran • 192.168.68.98 row. That single outcome requires the plaintext client to reach Sunshine on 47989, get a real 200 back, and parse the body on Android, so it exercises bugs 1 and 4 end to end. Before the fixes it always failed.
  • Pairing reaches the PIN and the host holds the session. Connecting to that host shows the PIN and sits at "Waiting for the host to accept the PIN…" well past the old 5s cutoff. From the host's own TCP table at that moment (netsimd is the emulator's network process, so these are the app's connections):
192.168.68.98:59528 -> 192.168.68.98:47989  Established  (netsimd)
192.168.68.98:47989 -> 192.168.68.98:59528  Established  (sunshine)

The plaintext pairing connection is Established on both sides: Sunshine is holding an open, pending pairing request while the dish displays the PIN, which is precisely the state that was unreachable before. In an earlier run the mutual-TLS connection on 47984 was likewise seen completing and closing gracefully rather than dying in the handshake.

Completing the pairing needs a person to type the PIN into Sunshine's web UI, so the last step is not automated here. Everything up to that point is verified end to end.

Test fixtures

MoonlightPairingTest used to read four PEM fixtures out of test resources, two of them RSA private keys. They were throwaway keys, but gitleaks is right to flag PEM key material and the tests never needed byte-stable keys. The test now mints two disposable RSA-2048 self-signed identities at class load via okhttp-tls HeldCertificate, the way the androidTest FakeSatellite already does, and all four fixtures are deleted. See the note on the gitleaks job below.

Test coverage

105 Moonlight unit tests:

  • Crypto vectors pinned byte-for-byte to Wolf's testCrypto.cpp/testControl.cpp (pairing key, ECB, GCM seal/open, IV evolution, tamper rejection).
  • Byte-exact CONTROLLER_MULTI / MOUSE_MOVE_REL / arrival / ping / termination encoders against the input-data.adoc network fixtures; hot-path reused-buffer equivalence.
  • Decoder tests for all four host events including malformed/short/lying-length buffers.
  • ENet client: connect handshake, verify-connect, reliable send + ack + retransmit, host delivery + dedup, ping, disconnect, truncation.
  • RTSP request formatting and response parsing; pairing full round-trip against a reference server (both directions authenticated); the control-session lifecycle.
  • Slot reconciliation for the Moonlight branch (bind when live, unbind when not yet live, dedupe of unchanged binds) and the host-row builder.
  • New: 18 tests for the raw HTTP/1.1 client, driven against a loopback ServerSocket, covering request formatting (request line, Host with the port, Connection: close), Content-Length, chunked, close-delimited and over-long bodies, case-insensitive headers, non-2xx replies, a truncated body, a truncated head, a non-HTTP reply, a refused connection, the read-timeout path, the raised per-call timeout that pairing phase 1 relies on, an unparseable URL, the socket-upgrade hook being handed the host it dialled, and an upgrade that rejects the host being an unreachable reply with nothing written to the wire.
  • New: 6 tests driving MoonlightHttpGateway.getHttps against a real loopback TLS host that demands a client certificate: the client certificate is presented and the reply parses, Connection: close goes out on the wire, each call takes its own accepted connection with the host reading EOF on every one, the host certificate is pinned on first contact, a matching pin keeps working, and a mismatching one is refused before any request is written.
  • New: 11 tests for the identity migration decision, which is factored as a pure function so it runs without a device.
  • New: 2 XML tests, one parsing a /serverinfo body captured verbatim off Sunshine 7.1 and one asserting an external entity in a host's reply never reaches the parsed document.

All existing Satellite tests keep passing; the protocol-1 path is untouched.

CI gates (run locally, JAVA_HOME = Android Studio JBR)

  • ktlintCheck: pass
  • detekt: pass
  • clang-format (JNI, check only): pass
  • lintGithubDebug lintPlayDebug: pass
  • testGithubDebugUnitTest testPlayDebugUnitTest: pass
  • :app:nativeTest: pass (401/401)
  • assembleGithubDebug assemblePlayDebug: pass

shared / Secret scan (gitleaks) is expected to stay red. The workflow checks out with fetch-depth: 0 and scans full history, and the two PEM private keys still exist in commit 7563dd1 even though the working tree no longer carries them. Clearing that needs either a history rewrite (declined: it requires a force-push) or a .gitleaks.toml allowlist (declined: it would leave the keys in history forever and permanently exempt those paths from future real leaks). The findings are the two known test fixtures and nothing else.

Known gaps and risks

  • Pairing now completes against the live Sunshine host: the PIN is accepted and the host stores the dish's certificate. What has not been observed since the bug-5 fix landed is /applist returning and the host showing as connected in the app, because that host's HTTPS listener stopped answering during the diagnosis and has not recovered; confirming it needs the host restarted. /launch, RTSP and the control channel have still never run against a live host, and that is now the main risk in the change.
  • The on-device verification ran on an emulator, not on physical hardware. The keystore path in particular deserves one confirmation on a real TEE-backed device, since the emulator's KeyMint is a software implementation. The authorizations requested are the ones AOSP's AndroidKeyStoreRSACipherSpi documents for this operation, so this is expected to behave identically, but it is untested on hardware.
  • The native SLOT_MOONLIGHT path is compile-verified and symbol-verified (both new JNI entry points are exported and match their Kotlin declarations), but the repo's :app:nativeTest suite does not compile satellite_jni.cpp, so the bridge dispatch itself has no native unit test. Its Kotlin-side routing is covered by the reconcile/dedupe tests.
  • The GCM IV is built from the control seq only, following Wolf's server (control.hpp). moonlight-common-c-based hosts may mix rikeyid into the IV; that variant could not be verified because those sources are GPL and out of bounds for this LGPL repo. If a host rejects the control stream, the IV construction is the first thing to check.
  • Motion and LED events are decoded and dispatched to the connection, but the sinks that would apply them to a local controller are not wired for the Moonlight path yet (the satellite path owns that plumbing).
  • Video/audio payloads are intentionally discarded (out of scope).

Licensing

Protocol facts and struct layouts derive from Wolf (MIT) docs and server source; the ENet subset is ported from the MIT-licensed cgutman/enet C source. Both are attributed in THIRD_PARTY.md. No GPL sources (moonlight-common-c, moonlight-qt/android, Sunshine, Apollo, Vibepollo) were read or copied.

Add a second connection path so each dish can speak the Moonlight
(GameStream) protocol directly to any Moonlight-compatible host, alongside
the existing Satellite protocol-1 path. Scope: discovery, PIN pairing, app
launch, RTSP stream setup, controller input out, and rumble/trigger/motion/
LED events back. No video/audio decoding: streams are negotiated at the
lowest settings and their payloads discarded.

Pure-Kotlin protocol library (core/net/moonlight):
- MoonlightCrypto: pairing-key derivation, AES-ECB challenge exchange, and
  AES-GCM control sealing, pinned byte-for-byte to Wolf's captured vectors.
- MoonlightInputEncoder + MoonlightHotSealer: the hot path encodes
  CONTROLLER_MULTI into a reused ByteBuffer at fixed offsets and seals it with
  a reused Cipher and one monotonic control seq, no per-packet allocation.
- MoonlightEventDecoder: rumble/trigger/motion/LED decode with malformed and
  short-buffer handling.
- MoonlightControlPacket, MoonlightRtsp, MoonlightPairing, MoonlightXml,
  MoonlightUrls: framing, RTSP codec, 5-phase client pairing, response parsing.
- enet/: a minimal pure-Kotlin ENet client subset (connect handshake, reliable
  send/receive on channel 0, acks, ping, disconnect) ported from the
  MIT-licensed cgutman/enet C source.

Runtime + integration (source/connection/moonlight): keystore-backed identity,
mutual-TLS + TOFU gateway, mDNS (_nvstream._tcp) discovery, UDP control
transport, per-host connection and orchestration manager. Surfaced as a
ConnectionKind.MOONLIGHT sibling through the connections composer, coordinator,
capability profile, and the on-screen controller overlay send path.

66 new unit tests cover the crypto vectors, the byte-exact encoders/decoders,
the ENet client, RTSP, pairing round-trip, and the session lifecycle. Wolf and
cgutman/enet attributed in THIRD_PARTY.md.
…d bridge

Completes the two pieces the first Moonlight commit left staged.

User-facing flow. The connections screen grows a MOONLIGHT HOSTS section
(Scan + Add, mirroring the satellites section) listing discovered and
remembered hosts. Tapping Connect runs the full flow: an emulated-device pick
[Auto, Xbox, PlayStation, Nintendo] seeded from the host's remembered choice,
then pairing that shows the generated PIN for the user to type into the host's
web UI and resolves when the host accepts it, then an app pick from /applist
seeded with the last app launched there. Both choices persist per host, so a
later reconnect is one tap. Manual host entry probes /serverinfo before the
host is added. New strings ship in all six locales.

Native physical-pad bridge. The C++ capture path gains SLOT_MOONLIGHT beside
SLOT_BLUETOOTH: the Bluetooth report queue and dispatch thread generalize into
one bridge queue whose reports carry their slot kind, so a physical pad bound
to a Moonlight host publishes through the same fixed-offset hot path and
upcalls MoonlightGamepadBridge, which seals and sends on the live control
session. The XUSB wButtons map straight across since Moonlight's low-16 button
flags share XInput's bit layout. reconcileSlots gains a Moonlight branch with
the same live-session re-check the Bluetooth branch does, and its binds are
deduped like the others so an unchanged bind never replays a destructive
baseline sync.

Manager gains pairHost/fetchApps/launch as discrete steps for the screens to
orchestrate, keeping one-tap connect for a remembered host.

Test delta: 5 new tests (3 reconcile/dedupe cases for the Moonlight branch,
2 for the host-row builder), 68 Moonlight tests total.
…itted PEMs

MoonlightPairingTest read four PEM fixtures out of test resources, two of them
RSA private keys. They were throwaway keys that protected nothing, but gitleaks
is right to flag PEM key material and the tests never needed byte-stable keys:
the only pinned assertion is a hardcoded clientSalt, and everything else is a
round trip through MoonlightCrypto with client and reference server driven by
the same primitives.

The test now mints two disposable RSA-2048 self-signed identities at class load
via okhttp-tls HeldCertificate, in a companion object so JUnit's per-method
instances do not re-generate them. .rsa2048() is explicit because the builder
defaults to ECDSA and pairing signs SHA256withRSA. This is how the androidTest
FakeSatellite already mints its cert. All four fixtures are deleted;
test/resources held nothing else.

libs.okhttp.tls was already a vetted androidTestImplementation in the version
catalog, so the new testImplementation adds no supply chain.

Note that this does not turn the gitleaks job green on its own: the workflow
scans full history, where the blobs still live. Clearing that needs a history
rewrite, which was deliberately not taken.
…ent key)

Two runtime bugs kept pairing from ever reaching the host. Sunshine reported
"Pairing Failed: Check if the PIN is typed correctly", which was a red herring:
the PIN was fine, the request never arrived.

Cleartext. res/xml/network_security_config.xml denies cleartext app-wide, so
every /pair phase died inside the app with "Cleartext HTTP traffic to <host>
not permitted" and the host never opened a pairing session. Moonlight pairing
phases 1-4 and the /serverinfo probe are plaintext on port 47989 by protocol
(Wolf http-pairing.adoc): there is no shared secret to build a TLS session on
yet. Rather than weaken a policy that exists to be read by Play's pre-launch
checks, and which every URL-stack request really should obey, this adds
MoonlightPlainHttpClient: a minimal HTTP/1.1 GET spoken by hand over a Socket,
which the network security config does not gate, exactly as the encrypted UDP
gamepad wire and the discovery beacons already are not. Only getHttp routes
through it. Safe by protocol design: what crosses the wire is a salt, the
public client cert, and AES challenges over them; the PIN is shown on the dish
and typed into the host's own UI, never sent, and phase 5 onwards is pinned
mutual TLS. The config's comment now says all this, so the next reader is not
misled into thinking the app sends no cleartext at all.

The client handles Content-Length, chunked and close-delimited bodies, keeps
the gateway's timeouts and its unreachable reply on any transport failure, and
never throws. getHttps loses its dead non-secure branch now that nothing else
shares it.

Client key. The identity key was generated PURPOSE_SIGN + PKCS1 + SHA-256,
which signs the pairing secret but is not what Conscrypt asks for during TLS
client auth: BoringSSL reduces both TLS 1.3 and RSA-PSS to a raw private-key
operation, which CryptoUpcalls requests as a Cipher over RSA/ECB/NoPadding in
ENCRYPT_MODE. On AndroidKeyStore that lands in
AndroidKeyStoreRSACipherSpi.NoPadding, which overrides the keymaster purpose to
SIGN and needs KM_PAD_NONE with KM_DIGEST_NONE. The key authorized neither, so
mutual TLS died with INCOMPATIBLE_PADDING_MODE and then an RSA internal error.
That only broke the isPaired() pre-check so far, but phase 5, /applist and
/launch all ride the same channel. Generation now adds DIGEST_NONE and
ENCRYPTION_PADDING_NONE (the KeyProperties spelling of KM_PAD_NONE; both
padding setters feed one KM_TAG_PADDING list). The purpose stays SIGN-only, so
this authorizes raw signing and not decryption, and the key stays
non-exportable in the keystore.

A keystore key's authorizations are fixed at generation, so a wider spec does
not fix the key already stored under the alias. MoonlightIdentityDecision reads
the stored key's own KeyInfo and replaces it when it cannot do the job. Pairing
has never succeeded, so no host holds the old cert; any that somehow did would
see an unknown client and ask to pair again.

Test delta: 15 tests for the raw client (request formatting, Content-Length,
chunked, close-delimited, case-insensitive headers, truncated body, truncated
head, non-HTTP reply, refused connect, read timeout, bad URL) driven against a
loopback ServerSocket, and 11 for the migration decision, which is factored to
be pure so it runs without a device.
Found on device once the cleartext and client-key fixes let the request
actually reach Sunshine:

    W/MoonlightPlainHttp: plain GET failed for /pair: Read timed out

The host does not answer pairing phase 1 until a human has typed the displayed
PIN into its own web UI. Sunshine parks the phase-1 response and only completes
it on PIN entry, so the request is deliberately long-lived. The gateway applied
its ordinary 5s probe timeout to it, which tore the socket down about the time
the user would still be reaching for a browser, and the host dropped its
half-open pairing session with it. That is the same user-visible dead end as
before: a PIN on screen that the host will never accept.

Phase 1 now gets PAIR_PIN_TIMEOUT_MS instead. The plaintext client takes a
per-call read timeout so this stays scoped to the one request that waits on a
person; /serverinfo probes and pairing phases 2-4, which answer immediately,
keep the short timeout and still fail fast on an unreachable host. The connect
timeout is untouched either way.

Also logs the plaintext client's non-exception give-ups (unusable URL, head
never finished, reply that is not HTTP). Those previously returned an
unreachable reply silently, which is what made this bug take a rebuild to find:
the failure was invisible in logcat.

Verified against a live Sunshine host: the phase-1 request now stays open past
the old cutoff and the host holds an established connection while the PIN is
displayed.
…roid

MoonlightXml.rootOf asked the DOM factory for the Apache DTD switch:

    setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)

Android's DocumentBuilderFactoryImpl recognizes exactly two feature names, the
SAX namespaces and validation ones, and throws ParserConfigurationException for
everything else. rootOf builds the factory inside a runCatching, so on device
that exception was swallowed and rootOf returned null for EVERY document. The
JVM's Xerces does support the feature, so the unit tests all passed and the
breakage existed only on device.

Every Moonlight XML reply therefore failed to parse on Android: /serverinfo (so
a manually added host was always rejected as "No Moonlight host answered"),
/pair (so pairing would have died the instant the user typed the PIN and phase
1 finally returned), /applist and /launch.

Each switch is now applied best-effort, and the guarantee that actually matters
is enforced portably instead: the builder gets an EntityResolver that resolves
every external entity to nothing, so no DTD or entity in a host's reply can make
the parser open a file or a connection, whatever the underlying factory was
willing to admit. On the JVM the feature switches still apply exactly as before,
so nothing is weakened there either.

Test delta: two tests. One parses a /serverinfo body captured verbatim off
Sunshine 7.1, single-line and in the field order it really sends, so a parser
that only copes with the pretty-printed samples cannot pass. One feeds a
file:// external entity through parseServerInfo and fails if the file's contents
reach the parsed document, which pins the XXE guarantee to behaviour rather than
to a feature name.

Verified on device: with this fix a host added by LAN address is accepted and
appears in the list, and pairing against it reaches the PIN prompt with the host
holding an established connection.
Every HTTPS call after the first one against the live Sunshine host timed out:

    W/MoonlightHttpGateway: request failed for /serverinfo: Read timed out
    W/MoonlightHttpGateway: request failed for /applist: Read timed out

so pairing phase 5 never confirmed, /applist never returned, and the host never
showed as connected even though it had recorded the pairing. The host knew the
dish; the dish did not know the host.

Sunshine's own debug log settles what happened. It received exactly ONE HTTPS
request in the whole session: our /serverinfo, client certificate verified as
"/CN=NVIDIA GameStream Client -- verified", served. Not one later HTTPS request
reached it, while the plaintext half kept arriving normally throughout, pairing
phases 1-4 included. So the requests were not being refused, they were not being
answered at all, and the mutual-TLS credential was never the suspect it looked
like: instrumented on the device, KeyStore.getDefaultType() is BKS, setKeyEntry
accepts the opaque AndroidKeyStoreRSAPrivateKey, and Conscrypt's KeyManagerImpl
hands back the alias, chain and key for keyType RSA.

From the host's own socket table, eleven TLS connections from the dish were
sitting open, one per call the app had made, none of them closed by us until the
app's process died. The host's HTTPS listener answered nothing from that point
on, ours or any other client's: curl from a third machine went from an instant
reject to an 8s timeout against that same port while port 47989 kept replying
in under a millisecond.

Eleven connections for eleven calls, because the URL stack could never reuse
one. com.android.okhttp keys its connection pool on an Address, and an Address
carries the SSLSocketFactory and HostnameVerifier instances. The gateway built
both per call and the verifier is necessarily per host, so no two calls ever
matched: each one dialled a fresh TLS connection, and disconnect() parked the
previous one in the pool instead of closing it.

The HTTPS half now rides the same raw socket the plaintext half has always
used, which is the half that never had this problem. MoonlightPlainHttpClient
becomes MoonlightHttp11Client and takes an optional socket upgrade; the gateway
passes the mutual-TLS handshake plus its certificate pin check, which now
rejects by throwing out of that hook rather than through a HostnameVerifier, so
a host that fails the pin never sees a request. The credential itself is
untouched, and the SSLSocketFactory it builds is built once instead of per call.
Every request opens one socket, sends the Connection: close the client already
sent, and closes it, so the host holds nothing of ours between calls.

The HTTPS connect and read budget goes from 5s to 10s. Every call now pays for
its own handshake, and the dish's half of that handshake is a signature from a
hardware-backed keystore key, which on a cold or busy device waits on keystore
IPC and on the secure element.

Test delta: eight tests. Six drive MoonlightHttpGateway.getHttps against a real
loopback TLS host that demands a client certificate: the client certificate is
presented and the reply parses, Connection: close goes out on the wire, each
call takes its own accepted connection and the host reads EOF on every one, the
host certificate is pinned on first contact, a matching pin keeps working, and a
mismatching one is refused before any request is written. Two more cover the
upgrade hook itself, including that a hook which throws is an unreachable reply
rather than an exception. The throwaway-identity helper the pairing test already
had moves to ThrowawayIdentity so both suites mint their certs the same way.

Still unconfirmed end to end: the host's HTTPS listener has not recovered since
it stopped answering, so /applist returning and the host showing as connected
need a Sunshine restart to observe.
The header claimed the Moonlight mutual-TLS endpoints go through the platform
URL stack, and named MoonlightPlainHttpClient. Neither is true any more: both
Moonlight halves now speak HTTP/1.1 over their own socket, the plaintext one
because this config denies the URL stack cleartext on purpose and the TLS one
because the URL stack's connection pool leaked a live session per call.

Nothing about the policy changes. The file still denies cleartext to everything
that can reach the URL stack, and the TLS half is still TLS, with a client
certificate and the host's certificate pinned on first use.
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.

1 participant