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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
This limits worker invocations, not OS scheduling states or connections within
a worker; OS-managed background transfers can outlive their worker invocation.

### Security
- **Remote Trigger signature scheme v2** (`v2=<hex>`): length-prefixed canonical form (no more
`|`/`=` collisions), plus `x-native-wm-timestamp` (±300 s) and `x-native-wm-nonce` (persisted,
bounded) for replay protection. Same logic and test vector on Android, iOS and the Python
reference signer in `doc/examples/`.
- v1 signatures are **still accepted** (deprecated, now logged). Rules without a `secretKey`
still accept `native_wm` commands unauthenticated, but now log a warning every time.

## [1.8.3] - 2026-09-24

Fixes 3 issues found by a full `lib/` audit (2026-09-23), plus 4 more found
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,26 @@ fun NativeWorkmanagerPlugin.Companion.onRemoteMessage(context: Context, source:

// Phase 2: HMAC Verification
if (record.secretKey != null) {
val signature = payload["x-native-wm-signature"]?.toString()
if (signature == null || !verifyHmac(payload, record.secretKey, signature)) {
NativeLogger.w("⚠️ Remote trigger REJECTED: Invalid HMAC signature for $source")
return false
val prefs = context.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE)
val verdict = RemoteTriggerSignature.verify(
payload = payload,
secretKey = record.secretKey,
nowMs = System.currentTimeMillis(),
nonces = RemoteTriggerSignature.sharedNonceStore(prefs),
) { legacySignature -> verifyHmac(payload, record.secretKey, legacySignature) }
when (verdict) {
RemoteTriggerSignature.Verdict.VALID_V2 -> Unit
RemoteTriggerSignature.Verdict.VALID_LEGACY ->
NativeLogger.w("⚠️ Remote trigger for $source used the DEPRECATED v1 signature " +
"(no timestamp/nonce, replayable). Switch your backend to v2.")
else -> {
NativeLogger.w("⚠️ Remote trigger REJECTED ($verdict): invalid signature for $source")
return false
}
}
} else if (payload["native_wm"] != null || (payload["data"] as? Map<*, *>)?.get("native_wm") != null) {
NativeLogger.w("⚠️ Unsigned remote command for $source: no secretKey is set on this rule, so " +
"anyone who can deliver a push can run native_wm commands. Set RemoteTriggerRule.secretKey.")
}

// Mode 1: Direct Command (Highest priority)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package dev.brewkits.native_workmanager

import android.content.SharedPreferences
import org.json.JSONObject
import java.security.MessageDigest
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import kotlin.math.abs

/**
* Remote Trigger signature scheme **v2** (collision-free canonical form + replay protection).
*
* The exact spec, plus a reference signer, lives in `doc/examples/remote_trigger_sign_v2.py`;
* `RemoteTriggerSignatureTest` and the Swift counterpart share one test vector with it.
* v1 (`key=value|key=value`, no timestamp) is still accepted and flagged as deprecated.
*/
internal object RemoteTriggerSignature {
const val SIGNATURE_KEY = "x-native-wm-signature"
const val TIMESTAMP_KEY = "x-native-wm-timestamp"
const val NONCE_KEY = "x-native-wm-nonce"
const val V2_PREFIX = "v2="
const val MAX_SKEW_SECONDS = 300L

@Volatile private var sharedStore: NonceStore? = null

/** One store per process, so two concurrent pushes carrying the same nonce cannot both pass. */
fun sharedNonceStore(prefs: SharedPreferences): NonceStore =
sharedStore ?: synchronized(this) { sharedStore ?: NonceStore(prefs).also { sharedStore = it } }

enum class Verdict { VALID_V2, VALID_LEGACY, INVALID, STALE, REPLAYED }

fun verify(
payload: Map<String, Any?>,
secretKey: String,
nowMs: Long,
nonces: NonceStore,
legacyVerifier: (String) -> Boolean,
): Verdict {
val signature = payload[SIGNATURE_KEY]?.toString() ?: return Verdict.INVALID
if (!signature.startsWith(V2_PREFIX)) {
return if (legacyVerifier(signature)) Verdict.VALID_LEGACY else Verdict.INVALID
}
val provided = hexToBytes(signature.removePrefix(V2_PREFIX)) ?: return Verdict.INVALID
// The signature is checked FIRST: only authentic messages may touch the nonce store
// (otherwise anyone could fill it with junk nonces).
val expected = hmac(secretKey, canonicalV2(payload))
if (!MessageDigest.isEqual(expected, provided)) return Verdict.INVALID

val tsSeconds = payload[TIMESTAMP_KEY]?.toString()?.toLongOrNull() ?: return Verdict.INVALID
val nonce = payload[NONCE_KEY]?.toString()?.takeIf { it.isNotEmpty() } ?: return Verdict.INVALID
if (abs(nowMs / 1000 - tsSeconds) > MAX_SKEW_SECONDS) return Verdict.STALE
return if (nonces.checkAndRecord(nonce, nowMs)) Verdict.VALID_V2 else Verdict.REPLAYED
}

fun canonicalV2(payload: Map<String, Any?>): String {
val entries = payload.keys.filter { it != SIGNATURE_KEY }
.sortedWith { a, b -> compareUtf8(a, b) }
.map { key ->
val value = scalar(payload[key])
"${key.toByteArray(Charsets.UTF_8).size}:$key${value.toByteArray(Charsets.UTF_8).size}:$value"
}
return "NWM2\n" + entries.joinToString("\n")
}

fun signV2Hex(payload: Map<String, Any?>, secretKey: String): String =
hmac(secretKey, canonicalV2(payload)).joinToString("") { "%02x".format(it) }

private fun hmac(secretKey: String, message: String): ByteArray {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(secretKey.toByteArray(Charsets.UTF_8), "HmacSHA256"))
return mac.doFinal(message.toByteArray(Charsets.UTF_8))
}

private fun hexToBytes(hex: String): ByteArray? {
if (hex.isEmpty() || hex.length % 2 != 0) return null
val out = ByteArray(hex.length / 2)
for (i in out.indices) {
val hi = Character.digit(hex[2 * i], 16)
val lo = Character.digit(hex[2 * i + 1], 16)
if (hi < 0 || lo < 0) return null
out[i] = ((hi shl 4) or lo).toByte()
}
return out
}

/** Byte-wise (unsigned) UTF-8 order, identical on every platform. */
private fun compareUtf8(a: String, b: String): Int {
val x = a.toByteArray(Charsets.UTF_8)
val y = b.toByteArray(Charsets.UTF_8)
for (i in 0 until minOf(x.size, y.size)) {
val d = (x[i].toInt() and 0xFF) - (y[i].toInt() and 0xFF)
if (d != 0) return d
}
return x.size - y.size
}

private fun scalar(v: Any?): String = when (v) {
null -> "null"
is String -> v
is Map<*, *>, is List<*> -> json(v)
else -> number(v) ?: v.toString()
}

private fun number(v: Any?): String? = when (v) {
is Boolean -> v.toString()
is Byte, is Short, is Int, is Long -> v.toString()
is Float, is Double -> {
val d = (v as Number).toDouble()
if (d == Math.rint(d) && abs(d) < 1e15) d.toLong().toString() else d.toString()
}
else -> null
}

/** Compact JSON, keys in UTF-8 byte order, "/" unescaped, non-ASCII raw. */
private fun json(v: Any?): String = when (v) {
null -> "null"
is String -> quote(v)
is Map<*, *> -> v.entries
.map { it.key.toString() to it.value }
.sortedWith { a, b -> compareUtf8(a.first, b.first) }
.joinToString(",", "{", "}") { quote(it.first) + ":" + json(it.second) }
is List<*> -> v.joinToString(",", "[", "]") { json(it) }
else -> number(v) ?: quote(v.toString())
}

private fun quote(s: String): String {
val sb = StringBuilder("\"")
for (c in s) when {
c == '"' -> sb.append("\\\"")
c == '\\' -> sb.append("\\\\")
c == '\b' -> sb.append("\\b")
c == '\u000C' -> sb.append("\\f")
c == '\n' -> sb.append("\\n")
c == '\r' -> sb.append("\\r")
c == '\t' -> sb.append("\\t")
c < ' ' -> sb.append("\\u%04x".format(c.code))
else -> sb.append(c)
}
return sb.append('"').toString()
}

/**
* Remembers accepted nonces for twice the skew window (survives process death when a
* [SharedPreferences] is supplied). Bounded, so it cannot grow without limit.
*/
class NonceStore(private val prefs: SharedPreferences? = null) {
private val seen = LinkedHashMap<String, Long>()
private var loaded = false

@Synchronized
fun checkAndRecord(nonce: String, nowMs: Long): Boolean {
load()
val cutoff = nowMs - 2 * MAX_SKEW_SECONDS * 1000
seen.entries.removeAll { it.value < cutoff }
if (seen.containsKey(nonce)) return false
seen[nonce] = nowMs
while (seen.size > MAX_ENTRIES) seen.remove(seen.keys.first())
persist()
return true
}

private fun load() {
if (loaded) return
loaded = true
val raw = prefs?.getString(PREF_KEY, null) ?: return
try {
val o = JSONObject(raw)
o.keys().forEach { seen[it] = o.getLong(it) }
} catch (_: Exception) {
// corrupt state: start clean rather than fail closed forever
}
}

private fun persist() {
val p = prefs ?: return
val o = JSONObject()
seen.forEach { (k, v) -> o.put(k, v) }
p.edit().putString(PREF_KEY, o.toString()).apply()
}

companion object {
private const val PREF_KEY = "remote_trigger_nonces_v1"
private const val MAX_ENTRIES = 2000
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package dev.brewkits.native_workmanager

import dev.brewkits.native_workmanager.RemoteTriggerSignature.NonceStore
import dev.brewkits.native_workmanager.RemoteTriggerSignature.Verdict
import org.junit.Assert.*
import org.junit.Test

class RemoteTriggerSignatureTest {
private val secret = "s3cret"
private val now = 1_700_000_000_000L

// Shared with doc/examples/remote_trigger_sign_v2.py and the Swift test. Keep in sync.
private fun vector(): MutableMap<String, Any?> = mutableMapOf(
"type" to "download_update",
"url" to "https://x.test/a?b=c|d",
"note" to "a=b|c",
"nested" to mapOf("z" to 1, "a" to listOf(1, "x/y", "é\n")),
"flag" to true,
"count" to 7,
"nothing" to null,
"x-native-wm-timestamp" to "1700000000",
"x-native-wm-nonce" to "nonce-0001",
)
private val vectorHex = "dca042f1afdc1ff83ef350f17e83b78a59175ed8d5b032ca0fdb54eb47ab8f0e"

private fun signed(mutate: MutableMap<String, Any?>.() -> Unit = {}): Map<String, Any?> {
val p = vector().apply(mutate)
p["x-native-wm-signature"] = "v2=" + RemoteTriggerSignature.signV2Hex(p, secret)
return p
}

private fun check(p: Map<String, Any?>, store: NonceStore = NonceStore(), at: Long = now) =
RemoteTriggerSignature.verify(p, secret, at, store) { false }

@Test fun v2_matches_cross_platform_reference_vector() {
assertEquals(vectorHex, RemoteTriggerSignature.signV2Hex(vector(), secret))
assertEquals(Verdict.VALID_V2, check(signed()))
}

@Test fun v2_separator_characters_in_values_cannot_collide() {
// v1 joined "k=v|k=v": these two different payloads produced the same string.
val a = mapOf<String, Any?>("a" to "1|b=2", "z" to "3")
val b = mapOf<String, Any?>("a" to "1", "b" to "2|z=3")
assertNotEquals(RemoteTriggerSignature.canonicalV2(a), RemoteTriggerSignature.canonicalV2(b))
assertNotEquals(
RemoteTriggerSignature.signV2Hex(a, secret), RemoteTriggerSignature.signV2Hex(b, secret))
}

@Test fun v2_tampering_is_rejected() {
val p = signed().toMutableMap().apply { put("url", "https://evil.test/") }
assertEquals(Verdict.INVALID, check(p))
}

@Test fun v2_replay_of_the_same_message_is_rejected() {
val store = NonceStore()
val p = signed()
assertEquals(Verdict.VALID_V2, check(p, store))
assertEquals(Verdict.REPLAYED, check(p, store, now + 1_000))
}

@Test fun v2_stale_and_future_timestamps_are_rejected() {
val p = signed()
assertEquals(Verdict.STALE, check(p, at = now + 301_000))
assertEquals(Verdict.STALE, check(p, at = now - 301_000))
assertEquals(Verdict.VALID_V2, check(p, at = now + 299_000))
}

@Test fun v2_missing_timestamp_or_nonce_is_rejected() {
assertEquals(Verdict.INVALID, check(signed { remove("x-native-wm-nonce") }))
assertEquals(Verdict.INVALID, check(signed { remove("x-native-wm-timestamp") }))
}

@Test fun invalid_signature_does_not_consume_the_nonce() {
val store = NonceStore()
val forged = signed().toMutableMap().apply { put("x-native-wm-signature", "v2=" + "00".repeat(32)) }
assertEquals(Verdict.INVALID, check(forged, store))
assertEquals(Verdict.VALID_V2, check(signed(), store))
}

@Test fun malformed_signatures_are_rejected_not_thrown() {
for (sig in listOf("v2=", "v2=zz", "v2=abc", "")) {
val p = vector().apply { put("x-native-wm-signature", sig) }
assertEquals(Verdict.INVALID, check(p))
}
assertEquals(Verdict.INVALID, check(vector()))
}

@Test fun legacy_signature_is_delegated_to_the_v1_verifier() {
val p = vector().apply { put("x-native-wm-signature", "deadbeef") }
assertEquals(Verdict.VALID_LEGACY,
RemoteTriggerSignature.verify(p, secret, now, NonceStore()) { it == "deadbeef" })
assertEquals(Verdict.INVALID,
RemoteTriggerSignature.verify(p, secret, now, NonceStore()) { false })
}

@Test fun nonce_store_forgets_after_window_and_stays_bounded() {
val store = NonceStore()
assertTrue(store.checkAndRecord("n", now))
assertFalse(store.checkAndRecord("n", now + 1))
assertTrue(store.checkAndRecord("n", now + 2 * 300_000L + 1))
repeat(2500) { assertTrue(store.checkAndRecord("bulk$it", now + it)) }
}
}
45 changes: 45 additions & 0 deletions doc/REMOTE_TRIGGER_SIGNING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Remote Trigger signing

How a backend must sign FCM/APNs payloads for a `RemoteTriggerRule` that has a `secretKey`.

## Backend signing (v2, recommended)

Add three fields to the payload. Reference signer: [`doc/examples/remote_trigger_sign_v2.py`](examples/remote_trigger_sign_v2.py).

| Field | Value |
|---|---|
| `x-native-wm-timestamp` | Unix seconds, decimal string |
| `x-native-wm-nonce` | Unique string per message (>= 8 chars; a UUID is fine) |
| `x-native-wm-signature` | `v2=` + lowercase hex `HMAC-SHA256(secret, canonical)` |

`canonical = "NWM2\n" + join("\n", entries)`, where `entries` are all payload keys **except** the signature,
sorted by the UTF-8 bytes of the key, each encoded as `<len(key)>:<key><len(value)>:<value>`
(lengths are UTF-8 byte counts, which makes the encoding collision-free even if values contain `|` or `=`).
Values: strings as-is, `true`/`false`, `null`, integers in decimal; objects/arrays as compact JSON with sorted keys,
no escaped `/`, non-ASCII kept raw. Send non-integral numbers as strings.

The device rejects a message when the signature is wrong, when the timestamp is more than 300 s away from
the device clock, or when the nonce was already accepted within that window (replay protection).
A device with a badly wrong clock will therefore reject valid pushes.

**Example signed payload:**
```json
{
"type": "sync",
"data_id": "999",
"x-native-wm-timestamp": "1700000000",
"x-native-wm-nonce": "9f1c6e0a4b7d4a52",
"x-native-wm-signature": "v2=dca042f1..."
}
```

## Legacy v1 (deprecated)
A bare hex signature over `key1=val1|key2=val2` (sorted keys, signature removed) is still accepted so existing
backends keep working, but it has **no replay protection**, and values containing `|`/`=` can collide. Each
accepted v1 message logs a deprecation warning. Plan to migrate to v2.

## Rules without a `secretKey`
A rule with no `secretKey` accepts `native_wm` direct commands from anyone who can deliver a push to the app.
This behaviour is unchanged for compatibility, but it now logs a warning on every such message. Always set a
`secretKey` in production. (On iOS, `native_wm` commands are also executed when **no rule is registered at all**;
Android ignores them. Same unauthenticated risk; same advice.)
Loading
Loading