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
5 changes: 5 additions & 0 deletions .github/workflows/_security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ jobs:
if: ${{ inputs.action_pin_lint_enabled }}
name: Action pin lint (40-char SHA required)
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
Expand Down Expand Up @@ -123,6 +124,7 @@ jobs:
if: ${{ inputs.allowlist_expiry_enabled }}
name: Vulnerability allowlist expiry check
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
Expand Down Expand Up @@ -166,6 +168,7 @@ jobs:
if: ${{ inputs.osv_scan_paths != '' || inputs.osv_config != '' }}
name: OSV-Scanner (vendored + manifest deps)
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
Expand Down Expand Up @@ -231,6 +234,7 @@ jobs:
if: ${{ inputs.dependency_review_enabled && github.event_name == 'pull_request' }}
name: Dependency review (GitHub advisory DB)
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
Expand All @@ -249,6 +253,7 @@ jobs:
if: ${{ inputs.gitleaks_enabled }}
name: Secret scan (gitleaks)
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout (full history)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/android-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ concurrency:
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30

steps:
- name: Checkout code
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/play-listing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ jobs:
sync-listing:
name: Sync listing to Play
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Gate on Play credentials
id: gate
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/play-reviews.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ jobs:
digest:
name: Reviews + vitals digest
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Gate on Play credentials
id: gate
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ jobs:
name: Required release secrets gate
needs: [gates]
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Verify keystore secrets are present for tagged releases
env:
Expand Down Expand Up @@ -124,6 +125,7 @@ jobs:
name: Build + sign APK/AAB
needs: [gates, required-secrets]
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
Expand Down Expand Up @@ -264,6 +266,7 @@ jobs:
name: Upload AAB to Google Play
needs: [release]
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
steps:
Expand Down Expand Up @@ -366,6 +369,7 @@ jobs:
name: Harden artifacts (scan, SBOM, sign)
needs: [release]
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
id-token: write
Expand Down Expand Up @@ -502,6 +506,7 @@ jobs:
name: Publish to GitHub Releases
needs: [harden, provenance]
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: write
steps:
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
cmake_minimum_required(VERSION 3.22.1)
project(satellite)

# Adopt CMP0135 where available to silence FetchContent's DOWNLOAD_EXTRACT_TIMESTAMP warning.
if(POLICY CMP0135)
cmake_policy(SET CMP0135 NEW)
endif()

find_package(game-activity REQUIRED CONFIG)

# ── libsodium (built from source via FetchContent) ──────────────────────────
Expand Down Expand Up @@ -52,6 +57,11 @@ target_compile_definitions(sodium_static PRIVATE
HAVE_POSIX_MEMALIGN=1
HAVE_ARC4RANDOM=0
HAVE_SYS_MMAN_H=1
# HAVE_SYSCONF: sodium_mlock/guarded allocations round to the RUNTIME page
# size via sysconf(_SC_PAGESIZE) instead of a compiled-in guess — required
# for Android 15's 16 KB-page devices (and clears the "Unknown page size"
# build warning).
HAVE_SYSCONF=1
__STDC_LIMIT_MACROS=1
__STDC_CONSTANT_MACROS=1
)
Expand Down
14 changes: 12 additions & 2 deletions app/src/main/cpp/satellite_jni.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
#include "dispatch.h"
#include "gamepad_input.h"
#include "hotpath_latency.h"
#include "send_counter.h"
#include "thread_priority.h"
#include "usb_host.h"
#include "usb_parsers.h"
Expand Down Expand Up @@ -83,7 +84,8 @@ struct Session {
struct sockaddr_in dest = {};
uint8_t token[4] = {};
uint8_t key[32] = {}; // per-session key (HKDF-derived in Kotlin), never the pairing key
std::atomic<uint32_t> counter{1};
// 64-bit so exhaustion goes silent instead of wrapping (send_counter.h).
std::atomic<uint64_t> counter{1};
// Linux UDP sendto is thread-safe per-socket; userspace lock would only serialise stalls.

std::thread heartbeatThread;
Expand Down Expand Up @@ -462,7 +464,8 @@ static bool sendEncrypted(Session* s, uint16_t msgType, const uint8_t* payload,
putBE16(inner + 2, payloadLen);
if (payloadLen > 0) memcpy(inner + 4, payload, payloadLen);

uint32_t ctr = s->counter.fetch_add(1, std::memory_order_relaxed);
uint32_t ctr = 0;
if (!dish_counter::acquireSendCounter(s->counter, &ctr)) return false;

// Nonce: dir(1) | 0×7 | counter(4 BE). The direction byte keeps this
// direction's nonces disjoint from the server's under the shared key.
Expand Down Expand Up @@ -757,6 +760,13 @@ JNIEXPORT jint JNICALL Java_com_tinkernorth_dish_core_jni_SatelliteNative_getSes
return s->closeReason.load(std::memory_order_acquire);
}

JNIEXPORT jlong JNICALL
Java_com_tinkernorth_dish_core_jni_SatelliteNative_getSendCounter(JNIEnv*, jobject, jint handle) {
auto s = getSession(handle);
if (!s) return 0;
return (jlong)dish_counter::sendCounterView(s->counter);
}

JNIEXPORT jint JNICALL Java_com_tinkernorth_dish_core_jni_SatelliteNative_getVigemAvailable(
JNIEnv*, jobject, jint handle) {
auto s = getSession(handle);
Expand Down
31 changes: 31 additions & 0 deletions app/src/main/cpp/send_counter.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: LGPL-3.0-or-later

#pragma once

#include <atomic>
#include <cstdint>

namespace dish_counter {

// Counters never wrap (contract §Crypto): sealing two plaintexts under one
// (key, nonce) would be catastrophic. 64-bit storage so exhaustion parks the
// sender silent instead of wrapping the 32-bit wire field into nonce reuse.
inline constexpr uint64_t kCounterMaxWire = 0xFFFFFFFFull;

// Draws the next wire counter; false once the 32-bit space is exhausted (the
// caller must go silent, never send). A drawn value is never repeated.
inline bool acquireSendCounter(std::atomic<uint64_t>& counter, uint32_t* out) {
const uint64_t seq = counter.fetch_add(1, std::memory_order_relaxed);
if (seq > kCounterMaxWire) return false;
*out = static_cast<uint32_t>(seq);
return true;
}

// Clamped, not truncated, for the Kotlin re-key poll: past exhaustion it must
// keep reading re-PUT needed, never wrap under the threshold.
inline uint32_t sendCounterView(const std::atomic<uint64_t>& counter) {
const uint64_t v = counter.load(std::memory_order_relaxed);
return v > kCounterMaxWire ? static_cast<uint32_t>(kCounterMaxWire) : static_cast<uint32_t>(v);
}

} // namespace dish_counter
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class PhysicalReachabilityComposer
// registrations (every auto-reconnect) are picked up.
val slotFlows = conns.values.map { it.slots }
val slotsTrigger: Flow<Unit> =
if (slotFlows.isEmpty()) flowOf(Unit) else combine(slotFlows) { Unit }
if (slotFlows.isEmpty()) flowOf(Unit) else combine(slotFlows) { }
combine(
registry.devices,
hub.bindings,
Expand Down Expand Up @@ -91,7 +91,7 @@ internal object PhysicalReachability {
connections.flatMapLatest { conns ->
val slotFlows = conns.values.map { it.slots }
val slotsTrigger: Flow<Unit> =
if (slotFlows.isEmpty()) flowOf(Unit) else combine(slotFlows) { Unit }
if (slotFlows.isEmpty()) flowOf(Unit) else combine(slotFlows) { }
combine(devices, bindings, summaries, slotsTrigger) { devs, binds, summ, _ ->
PhysicalReachabilityComposer.resolve(devs.keys, binds, summ, conns)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ class ControllerRepository

fun getSessionCloseReason(handle: Int): Int = SatelliteNative.getSessionCloseReason(handle)

fun getSendCounter(handle: Int): Long = SatelliteNative.getSendCounter(handle)

@Suppress("LongParameterList")
fun sendMotion(
handle: Int,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ object SatelliteNative {
// Terminal: the session is gone server-side the moment this is non-negative.
external fun getSessionCloseReason(handle: Int): Int

// Send counter for the proactive re-key poll. Clamped at 0xFFFFFFFF past
// exhaustion so it can never read below the re-PUT threshold again.
external fun getSendCounter(handle: Int): Long

external fun getVigemAvailable(handle: Int): Int

external fun getActiveControllerCount(handle: Int): Int
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ class PhysicalSlotBindingObserver
// Outer Map only re-emits on session add/remove; slotsTrigger re-pushes when a session's slot flips `registered`.
val slotFlows = conns.values.map { it.slots }
val slotsTrigger: Flow<Unit> =
if (slotFlows.isEmpty()) flowOf(Unit) else combine(slotFlows) { Unit }
if (slotFlows.isEmpty()) flowOf(Unit) else combine(slotFlows) { }
combine(
registry.devices,
hub.bindings,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ class AndroidHidProxyClient(
val hid = hidDevice ?: return false
val device = connectedDevice ?: return false
// Strip report-id byte into per-thread scratch: sendReport takes it separately from the payload.
val payload = payloadScratch.get()
val payload = payloadScratch.get() ?: return false
System.arraycopy(report, 1, payload, 0, REPORT_SIZE - 1)
hid.sendReport(device, REPORT_ID, payload)
}.getOrDefault(false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch

// Contract §Crypto: counters never wrap; clients SHOULD re-PUT once their send
// counter crosses 0xF0000000. Rotating token/salt/key restarts the counter at 1.
internal const val COUNTER_REPUSH_THRESHOLD = 0xF000_0000L

internal fun counterNeedsRepush(sendCounter: Long): Boolean = sendCounter >= COUNTER_REPUSH_THRESHOLD

/**
* One satellite session. Slots are DECLARATIVE: this class holds the desired
* descriptor per slot plus the applied state the satellite last confirmed;
Expand Down Expand Up @@ -105,7 +111,8 @@ class SatelliteConnection(
* response. [onDead] fires on heartbeat death; [onClosedByServer] on an
* authenticated close-notify (immediate, no death-timeout wait);
* [onReconcileNeeded] when the heartbeat-ack epoch/bitmap stops matching
* what we believe is applied.
* what we believe is applied; [onRekeyNeeded] once per session when the
* send counter crosses the re-PUT threshold.
*/
internal fun markConnected(
handle: Int,
Expand All @@ -116,6 +123,7 @@ class SatelliteConnection(
onDead: () -> Unit,
onClosedByServer: (reason: Int) -> Unit = { onDead() },
onReconcileNeeded: () -> Unit = {},
onRekeyNeeded: () -> Unit = {},
onApplyFailures: (failures: List<ControllerApplyDto>) -> Unit = {},
) {
if (_state.value != SatelliteSessionState.Linking) return
Expand All @@ -139,6 +147,7 @@ class SatelliteConnection(
aliveJob =
scope.launch {
var consecutiveMisses = 0
var rekeyRequested = false
while (isActive) {
delay(ALIVE_POLL_MS)
// An authenticated close-notify is terminal NOW: the
Expand All @@ -156,6 +165,7 @@ class SatelliteConnection(
}
consecutiveMisses = 0
checkReconcile(onReconcileNeeded)
rekeyRequested = checkRekey(rekeyRequested, onRekeyNeeded)
continue
}
consecutiveMisses += 1
Expand Down Expand Up @@ -186,6 +196,20 @@ class SatelliteConnection(
}
}

// Proactive re-key before the send counter can exhaust (contract §Crypto:
// re-PUT past 0xF0000000). Single-shot per crossing: the re-PUT rotates the
// token/key and restarts the counter, which re-arms the latch. A session
// that exhausts anyway goes silent natively and heals via heartbeat death.
private fun checkRekey(
alreadyRequested: Boolean,
onRekeyNeeded: () -> Unit,
): Boolean {
val snap = live ?: return alreadyRequested
if (!counterNeedsRepush(controllerRepo.getSendCounter(snap.handle))) return false
if (!alreadyRequested) onRekeyNeeded()
return true
}

private fun registeredBitmap(): Int {
var bitmap = 0
for (binding in _slots.value.values) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,7 @@ class SatelliteConnectionManager
},
onClosedByServer = { reason -> handleServerClose(conn, server, reason) },
onReconcileNeeded = { scope.launch(ioDispatcher) { reconcile(conn, server) } },
onRekeyNeeded = { scope.launch(ioDispatcher) { rekey(conn, server) } },
onApplyFailures = { failures ->
scope.launch {
_events.emit(
Expand Down Expand Up @@ -663,6 +664,20 @@ class SatelliteConnectionManager
}
}

// The send counter crossed the re-PUT threshold: converge with a fresh
// session PUT for new token/salt/key (counter back to 1). Reconcile
// can't carry this — its matched-view early-exit adopts the epoch
// without rotating anything.
private suspend fun rekey(
conn: SatelliteConnection,
server: DiscoveredServer,
) {
if (conn.state.value != SatelliteSessionState.Live) return
conn.markDisconnected()
conn.markConnecting()
openSession(conn, server, ConnectIntent.RETRY_AFTER_DEATH)
}

// Single-slot converge while the session is live (PUT .../controllers/{idx}).
// The session (and its UDP keys) never churn for a toggle.
@Suppress("ReturnCount") // converge guard-chain: every early return is a distinct no-op case
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,11 @@ class DishNotifications

owner.lifecycle.addObserver(
object : DefaultLifecycleObserver {
override fun onResume(o: LifecycleOwner) {
override fun onResume(owner: LifecycleOwner) {
activate(attachment)
}

override fun onDestroy(o: LifecycleOwner) {
override fun onDestroy(owner: LifecycleOwner) {
drop(attachment)
attachment.dismissAll()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,7 @@ class UsbGamepadManager
private fun friendlyName(device: UsbDevice): String {
val known = native.lookupKnownModelName(device.vendorId, device.productId)
if (known.isNotEmpty()) return known
val product = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) device.productName else null
val product = device.productName
return product?.takeIf { it.isNotBlank() } ?: device.deviceName
}

Expand Down
Loading
Loading