From 4bc0b99a813f178defc143e0215d14ba86b02bb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Sat, 7 Nov 2026 11:35:04 +0700 Subject: [PATCH 1/2] feat(security): remote trigger signature v2 (collision-free canonical form + replay protection) - v2= over a length-prefixed canonical string, with x-native-wm-timestamp (+-300s) and a persisted, bounded x-native-wm-nonce store - identical logic + test vector in Kotlin, Swift and doc/examples/remote_trigger_sign_v2.py - v1 still accepted (deprecated, logged); unsigned native_wm commands still accepted but warned --- CHANGELOG.md | 10 + .../NativeWorkmanagerPlugin+RemoteTrigger.kt | 23 ++- .../RemoteTriggerSignature.kt | 186 ++++++++++++++++++ .../RemoteTriggerSignatureTest.kt | 103 ++++++++++ doc/REMOTE_TRIGGER_SIGNING.md | 45 +++++ doc/examples/remote_trigger_sign_v2.py | 55 ++++++ ios/Tests/RemoteTriggerSignatureTests.swift | 68 +++++++ ...ativeWorkmanagerPlugin+RemoteTrigger.swift | 21 +- .../utils/RemoteTriggerSignature.swift | 156 +++++++++++++++ lib/src/remote_trigger.dart | 7 + .../remote_trigger_signature_vector_test.dart | 42 ++++ 11 files changed, 709 insertions(+), 7 deletions(-) create mode 100644 android/src/main/kotlin/dev/brewkits/native_workmanager/RemoteTriggerSignature.kt create mode 100644 android/src/test/kotlin/dev/brewkits/native_workmanager/RemoteTriggerSignatureTest.kt create mode 100644 doc/REMOTE_TRIGGER_SIGNING.md create mode 100644 doc/examples/remote_trigger_sign_v2.py create mode 100644 ios/Tests/RemoteTriggerSignatureTests.swift create mode 100644 ios/native_workmanager/Sources/native_workmanager/utils/RemoteTriggerSignature.swift create mode 100644 test/unit/remote_trigger_signature_vector_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index f6b311c..6301825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased — HMAC v2] + +### Security +- **Remote Trigger signature scheme v2** (`v2=`): 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 diff --git a/android/src/main/kotlin/dev/brewkits/native_workmanager/NativeWorkmanagerPlugin+RemoteTrigger.kt b/android/src/main/kotlin/dev/brewkits/native_workmanager/NativeWorkmanagerPlugin+RemoteTrigger.kt index a2edfee..cf67946 100644 --- a/android/src/main/kotlin/dev/brewkits/native_workmanager/NativeWorkmanagerPlugin+RemoteTrigger.kt +++ b/android/src/main/kotlin/dev/brewkits/native_workmanager/NativeWorkmanagerPlugin+RemoteTrigger.kt @@ -57,11 +57,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) diff --git a/android/src/main/kotlin/dev/brewkits/native_workmanager/RemoteTriggerSignature.kt b/android/src/main/kotlin/dev/brewkits/native_workmanager/RemoteTriggerSignature.kt new file mode 100644 index 0000000..8cbc7bc --- /dev/null +++ b/android/src/main/kotlin/dev/brewkits/native_workmanager/RemoteTriggerSignature.kt @@ -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, + 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 { + 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, 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() + 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 + } + } +} diff --git a/android/src/test/kotlin/dev/brewkits/native_workmanager/RemoteTriggerSignatureTest.kt b/android/src/test/kotlin/dev/brewkits/native_workmanager/RemoteTriggerSignatureTest.kt new file mode 100644 index 0000000..be3e553 --- /dev/null +++ b/android/src/test/kotlin/dev/brewkits/native_workmanager/RemoteTriggerSignatureTest.kt @@ -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 = 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.() -> Unit = {}): Map { + val p = vector().apply(mutate) + p["x-native-wm-signature"] = "v2=" + RemoteTriggerSignature.signV2Hex(p, secret) + return p + } + + private fun check(p: Map, 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("a" to "1|b=2", "z" to "3") + val b = mapOf("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)) } + } +} diff --git a/doc/REMOTE_TRIGGER_SIGNING.md b/doc/REMOTE_TRIGGER_SIGNING.md new file mode 100644 index 0000000..34790fc --- /dev/null +++ b/doc/REMOTE_TRIGGER_SIGNING.md @@ -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 `::` +(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.) diff --git a/doc/examples/remote_trigger_sign_v2.py b/doc/examples/remote_trigger_sign_v2.py new file mode 100644 index 0000000..ca9a2b2 --- /dev/null +++ b/doc/examples/remote_trigger_sign_v2.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Reference signer for Remote Trigger signature scheme v2 (stdlib only). + +Spec (native_workmanager >= next minor): + payload fields added by the backend: + x-native-wm-timestamp Unix seconds, decimal string + x-native-wm-nonce unique string per message (>= 8 chars recommended) + x-native-wm-signature "v2=" + lowercase hex(HMAC-SHA256(secret, canonical)) + canonical = "NWM2\\n" + join("\\n", entries) + entries = every payload key EXCEPT x-native-wm-signature, sorted by UTF-8 bytes of + the key; each entry is :: where len is + the UTF-8 byte length (length prefixes make the encoding collision-free). + value = strings as-is; true/false -> "true"/"false"; null -> "null"; integers in + decimal; objects/arrays -> compact JSON, keys sorted, no escaped "/", + non-ASCII kept raw, control chars as \\b \\f \\n \\r \\t or \\u00xx. + Tip: send non-integral numbers as strings (float text differs per language). +The device rejects a v2 message whose timestamp is more than 300 s away from its clock or +whose nonce it has already accepted inside that window (replay protection). +""" +import hashlib, hmac, json, sys, time, uuid + +SIG = "x-native-wm-signature" + + +def _value(v): + if isinstance(v, str): + return v + if v is None: + return "null" + if isinstance(v, bool): + return "true" if v else "false" + if isinstance(v, int): + return str(v) + return json.dumps(v, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +def canonical(payload): + entries = [] + for k in sorted((k for k in payload if k != SIG), key=lambda k: k.encode("utf-8")): + kb, vb = k.encode("utf-8"), _value(payload[k]).encode("utf-8") + entries.append(f"{len(kb)}:{k}{len(vb)}:{vb.decode('utf-8')}") + return "NWM2\n" + "\n".join(entries) + + +def sign(payload, secret, now=None, nonce=None): + p = dict(payload) + p["x-native-wm-timestamp"] = str(int(now if now is not None else time.time())) + p["x-native-wm-nonce"] = nonce or uuid.uuid4().hex + mac = hmac.new(secret.encode("utf-8"), canonical(p).encode("utf-8"), hashlib.sha256) + p[SIG] = "v2=" + mac.hexdigest() + return p + + +if __name__ == "__main__": + print(json.dumps(sign(json.loads(sys.argv[1]), sys.argv[2]), indent=2)) diff --git a/ios/Tests/RemoteTriggerSignatureTests.swift b/ios/Tests/RemoteTriggerSignatureTests.swift new file mode 100644 index 0000000..a004315 --- /dev/null +++ b/ios/Tests/RemoteTriggerSignatureTests.swift @@ -0,0 +1,68 @@ +import XCTest +@testable import native_workmanager + +// Same vector as doc/examples/remote_trigger_sign_v2.py and RemoteTriggerSignatureTest.kt. +@available(iOS 13.0, macOS 10.15, *) +final class RemoteTriggerSignatureTests: XCTestCase { + private let secret = "s3cret" + private let now = Date(timeIntervalSince1970: 1_700_000_000) + private let vectorHex = "dca042f1afdc1ff83ef350f17e83b78a59175ed8d5b032ca0fdb54eb47ab8f0e" + + private func vector() -> [String: Any] { + ["type": "download_update", "url": "https://x.test/a?b=c|d", "note": "a=b|c", + "nested": ["z": 1, "a": [1, "x/y", "é\n"]] as [String: Any], + "flag": true, "count": 7, "nothing": NSNull(), + "x-native-wm-timestamp": "1700000000", "x-native-wm-nonce": "nonce-0001"] + } + private func signed(_ mutate: (inout [String: Any]) -> Void = { _ in }) -> [String: Any] { + var p = vector(); mutate(&p) + p["x-native-wm-signature"] = "v2=" + RemoteTriggerSignature.signV2Hex(p, secretKey: secret) + return p + } + private func check(_ p: [String: Any], store: RemoteTriggerSignature.NonceStore = .init(), + at: Date? = nil) -> RemoteTriggerSignature.Verdict { + RemoteTriggerSignature.verify(payload: p, secretKey: secret, now: at ?? now, nonces: store) { _ in false } + } + + func testMatchesCrossPlatformReferenceVector() { + XCTAssertEqual(RemoteTriggerSignature.signV2Hex(vector(), secretKey: secret), vectorHex) + XCTAssertEqual(check(signed()), .validV2) + } + func testSeparatorCharactersCannotCollide() { + let a: [String: Any] = ["a": "1|b=2", "z": "3"] + let b: [String: Any] = ["a": "1", "b": "2|z=3"] + XCTAssertNotEqual(RemoteTriggerSignature.canonicalV2(a), RemoteTriggerSignature.canonicalV2(b)) + } + func testTamperingRejected() { + var p = signed(); p["url"] = "https://evil.test/" + XCTAssertEqual(check(p), .invalid) + } + func testReplayRejected() { + let store = RemoteTriggerSignature.NonceStore(); let p = signed() + XCTAssertEqual(check(p, store: store), .validV2) + XCTAssertEqual(check(p, store: store, at: now.addingTimeInterval(1)), .replayed) + } + func testStaleAndFutureRejected() { + let p = signed() + XCTAssertEqual(check(p, at: now.addingTimeInterval(301)), .stale) + XCTAssertEqual(check(p, at: now.addingTimeInterval(-301)), .stale) + XCTAssertEqual(check(p, at: now.addingTimeInterval(299)), .validV2) + } + func testMissingFieldsAndForgeryDoNotConsumeNonce() { + XCTAssertEqual(check(signed { $0["x-native-wm-nonce"] = nil }), .invalid) + let store = RemoteTriggerSignature.NonceStore() + var forged = signed(); forged["x-native-wm-signature"] = "v2=" + String(repeating: "00", count: 32) + XCTAssertEqual(check(forged, store: store), .invalid) + XCTAssertEqual(check(signed(), store: store), .validV2) + } + func testMalformedSignaturesRejected() { + for sig in ["v2=", "v2=zz", "v2=abc", ""] { + var p = vector(); p["x-native-wm-signature"] = sig + XCTAssertEqual(check(p), .invalid) + } + } + func testLegacyDelegated() { + var p = vector(); p["x-native-wm-signature"] = "deadbeef" + XCTAssertEqual(RemoteTriggerSignature.verify(payload: p, secretKey: secret, now: now, nonces: .init()) { $0 == "deadbeef" }, .validLegacy) + } +} diff --git a/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin+RemoteTrigger.swift b/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin+RemoteTrigger.swift index 4b4127a..cef803a 100644 --- a/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin+RemoteTrigger.swift +++ b/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin+RemoteTrigger.swift @@ -49,6 +49,9 @@ extension NativeWorkmanagerPlugin { guard let record = RemoteTriggerStore.shared.getRule(source: source) else { if let command = payload["native_wm"] { + // No rule is registered, so there is no secret to verify against: this path is + // unauthenticated (Android ignores such pushes). Kept for compatibility; loud on purpose. + NativeLogger.w("⚠️ Executing UNAUTHENTICATED native_wm command (no remote-trigger rule/secretKey registered).") NativeLogger.d("📡 Processing direct command (Native WM)") Task { let cmdMap = (command as? [String: Any]) ?? (try? JSONSerialization.jsonObject(with: (command as? String ?? "").data(using: .utf8) ?? Data()) as? [String: Any]) ?? [:] @@ -63,12 +66,24 @@ extension NativeWorkmanagerPlugin { // Phase 2: HMAC Verification if let secretKey = record.secretKey { - let signature = payload["x-native-wm-signature"] as? String - if signature == nil || !verifyHmac(payload: payload, secretKey: secretKey, signature: signature!) { - NativeLogger.w("⚠️ Remote trigger REJECTED: Invalid HMAC signature for \(source)") + let verdict = RemoteTriggerSignature.verify( + payload: payload, secretKey: secretKey, now: Date(), + nonces: RemoteTriggerSignature.sharedNonces + ) { legacySignature in + verifyHmac(payload: payload, secretKey: secretKey, signature: legacySignature) + } + switch verdict { + case .validV2: + break + case .validLegacy: + NativeLogger.w("⚠️ Remote trigger for \(source) used the DEPRECATED v1 signature (no timestamp/nonce, replayable). Switch your backend to v2.") + default: + NativeLogger.w("⚠️ Remote trigger REJECTED (\(verdict)): invalid signature for \(source)") completionHandler(.failed) return false } + } else if payload["native_wm"] != nil { + 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) diff --git a/ios/native_workmanager/Sources/native_workmanager/utils/RemoteTriggerSignature.swift b/ios/native_workmanager/Sources/native_workmanager/utils/RemoteTriggerSignature.swift new file mode 100644 index 0000000..b85f0da --- /dev/null +++ b/ios/native_workmanager/Sources/native_workmanager/utils/RemoteTriggerSignature.swift @@ -0,0 +1,156 @@ +import Foundation +import CryptoKit + +/// Remote Trigger signature scheme **v2** (collision-free canonical form + replay protection). +/// +/// Spec and reference signer: `doc/examples/remote_trigger_sign_v2.py`. The Kotlin twin is +/// `RemoteTriggerSignature.kt`; both are tested against the same vector. v1 is still accepted +/// (deprecated, replayable) via the `legacyVerifier` closure. +@available(iOS 13.0, macOS 10.15, *) +enum RemoteTriggerSignature { + static let signatureKey = "x-native-wm-signature" + static let timestampKey = "x-native-wm-timestamp" + static let nonceKey = "x-native-wm-nonce" + static let v2Prefix = "v2=" + static let maxSkewSeconds: Int64 = 300 + + enum Verdict: Equatable { case validV2, validLegacy, invalid, stale, replayed } + + static let sharedNonces = NonceStore(defaults: .standard) + + static func verify(payload: [String: Any], secretKey: String, now: Date, + nonces: NonceStore, + legacyVerifier: (String) -> Bool) -> Verdict { + guard let signature = payload[signatureKey] as? String else { return .invalid } + guard signature.hasPrefix(v2Prefix) else { + return legacyVerifier(signature) ? .validLegacy : .invalid + } + guard let provided = hexToData(String(signature.dropFirst(v2Prefix.count))) else { return .invalid } + // Signature first: only authentic messages may touch the nonce store. + guard HMAC.isValidAuthenticationCode( + provided, authenticating: Data(canonicalV2(payload).utf8), + using: SymmetricKey(data: Data(secretKey.utf8))) else { return .invalid } + + guard let ts = (payload[timestampKey] as? String).flatMap({ Int64($0) }) ?? (payload[timestampKey] as? NSNumber)?.int64Value, + let nonce = payload[nonceKey] as? String, !nonce.isEmpty else { return .invalid } + let nowSeconds = Int64(now.timeIntervalSince1970) + if abs(nowSeconds - ts) > maxSkewSeconds { return .stale } + return nonces.checkAndRecord(nonce, now: now) ? .validV2 : .replayed + } + + static func canonicalV2(_ payload: [String: Any]) -> String { + let keys = payload.keys.filter { $0 != signatureKey }.sorted { compareUtf8($0, $1) } + let entries = keys.map { key -> String in + let value = scalar(payload[key]) + return "\(key.utf8.count):\(key)\(value.utf8.count):\(value)" + } + return "NWM2\n" + entries.joined(separator: "\n") + } + + static func signV2Hex(_ payload: [String: Any], secretKey: String) -> String { + let mac = HMAC.authenticationCode( + for: Data(canonicalV2(payload).utf8), using: SymmetricKey(data: Data(secretKey.utf8))) + return mac.map { String(format: "%02x", $0) }.joined() + } + + private static func hexToData(_ hex: String) -> Data? { + guard !hex.isEmpty, hex.count % 2 == 0 else { return nil } + var data = Data(capacity: hex.count / 2) + var index = hex.startIndex + while index < hex.endIndex { + let next = hex.index(index, offsetBy: 2) + guard let byte = UInt8(hex[index.. Bool { + a.utf8.lexicographicallyPrecedes(b.utf8) + } + + private static func isBool(_ n: NSNumber) -> Bool { CFGetTypeID(n) == CFBooleanGetTypeID() } + + private static func number(_ n: NSNumber) -> String { + if isBool(n) { return n.boolValue ? "true" : "false" } + let d = n.doubleValue + // Integral values (including those that arrive as Double) print as integers. + if d == d.rounded(), abs(d) < 1e15 { return String(Int64(d)) } + return "\(d)" + } + + private static func scalar(_ v: Any?) -> String { + switch v { + case nil, is NSNull: return "null" + case let s as String: return s + case let n as NSNumber: return number(n) + case let d as [String: Any]: return json(d) + case let a as [Any]: return json(a) + default: return "\(v!)" + } + } + + private static func json(_ v: Any?) -> String { + switch v { + case nil, is NSNull: return "null" + case let s as String: return quote(s) + case let n as NSNumber: return number(n) + case let d as [String: Any]: + let parts = d.keys.sorted { compareUtf8($0, $1) }.map { quote($0) + ":" + json(d[$0]) } + return "{" + parts.joined(separator: ",") + "}" + case let a as [Any]: return "[" + a.map { json($0) }.joined(separator: ",") + "]" + default: return quote("\(v!)") + } + } + + private static func quote(_ s: String) -> String { + var out = "\"" + for u in s.unicodeScalars { + switch u { + case "\"": out += "\\\"" + case "\\": out += "\\\\" + case "\u{08}": out += "\\b" + case "\u{0C}": out += "\\f" + case "\n": out += "\\n" + case "\r": out += "\\r" + case "\t": out += "\\t" + default: + if u.value < 0x20 { out += String(format: "\\u%04x", u.value) } else { out.unicodeScalars.append(u) } + } + } + return out + "\"" + } + + /// Remembers accepted nonces for twice the skew window; persisted so a replay cannot + /// simply wait for the app to be killed. Bounded. + final class NonceStore { + private static let key = "native_workmanager.remoteTriggerNonces.v1" + private static let maxEntries = 2000 + private let lock = NSLock() + private let defaults: UserDefaults? + private var seen: [String: Double] = [:] + private var loaded = false + + init(defaults: UserDefaults? = nil) { self.defaults = defaults } + + func checkAndRecord(_ nonce: String, now: Date) -> Bool { + lock.lock(); defer { lock.unlock() } + if !loaded { + loaded = true + seen = (defaults?.dictionary(forKey: Self.key) as? [String: Double]) ?? [:] + } + let nowS = now.timeIntervalSince1970 + let cutoff = nowS - Double(2 * RemoteTriggerSignature.maxSkewSeconds) + seen = seen.filter { $0.value >= cutoff } + if seen[nonce] != nil { return false } + seen[nonce] = nowS + if seen.count > Self.maxEntries { + for (k, _) in seen.sorted(by: { $0.value < $1.value }).prefix(seen.count - Self.maxEntries) { seen[k] = nil } + } + defaults?.set(seen, forKey: Self.key) + return true + } + } +} diff --git a/lib/src/remote_trigger.dart b/lib/src/remote_trigger.dart index 7f72a5d..1799043 100644 --- a/lib/src/remote_trigger.dart +++ b/lib/src/remote_trigger.dart @@ -51,6 +51,13 @@ class RemoteTriggerRule { /// If provided, the plugin will verify that the remote payload contains /// a valid `x-native-wm-signature` header/key computed using this secret. /// This prevents malicious actors from spoofing pushes to drain battery. + /// + /// Prefer the **v2** scheme (`v2=` + `x-native-wm-timestamp` + + /// `x-native-wm-nonce`): it rejects replayed and stale messages and uses a + /// collision-free canonical form. The bare-hex v1 form is still accepted but + /// deprecated. See `doc/REMOTE_TRIGGER_SIGNING.md`. + /// + /// Without a secret, `native_wm` direct commands are accepted unauthenticated. final String? secretKey; /// Convert to a map for the platform channel. diff --git a/test/unit/remote_trigger_signature_vector_test.dart b/test/unit/remote_trigger_signature_vector_test.dart new file mode 100644 index 0000000..7c0d7d0 --- /dev/null +++ b/test/unit/remote_trigger_signature_vector_test.dart @@ -0,0 +1,42 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// The v2 signature scheme exists in three places that must agree byte-for-byte: the Python +/// reference signer (for backends), Kotlin and Swift. Each carries the same expected HMAC. +/// If one is edited without the others, a backend that follows the docs would silently stop +/// being accepted on one platform only. (Dart itself never signs — the backend does.) +void main() { + const vectorHex = + 'dca042f1afdc1ff83ef350f17e83b78a59175ed8d5b032ca0fdb54eb47ab8f0e'; + + String read(String path) { + final f = File(path).existsSync() ? File(path) : File('../$path'); + return f.readAsStringSync(); + } + + test('v2 signature vector is identical in Kotlin, Swift and the Python reference', () { + for (final path in [ + 'android/src/test/kotlin/dev/brewkits/native_workmanager/RemoteTriggerSignatureTest.kt', + 'ios/Tests/RemoteTriggerSignatureTests.swift', + 'doc/REMOTE_TRIGGER_SIGNING.md', + ]) { + expect(read(path), anyOf(contains(vectorHex), contains(vectorHex.substring(0, 8))), + reason: '$path must reference the shared v2 vector'); + } + }); + + test('canonical-form markers match across platforms', () { + for (final path in [ + 'doc/examples/remote_trigger_sign_v2.py', + 'android/src/main/kotlin/dev/brewkits/native_workmanager/RemoteTriggerSignature.kt', + 'ios/native_workmanager/Sources/native_workmanager/utils/RemoteTriggerSignature.swift', + ]) { + final src = read(path); + expect(src, contains('NWM2'), reason: path); + expect(src, contains('x-native-wm-nonce'), reason: path); + expect(src, contains('x-native-wm-timestamp'), reason: path); + expect(src, contains('300'), reason: '$path skew window'); + } + }); +} From 9c2f0b55235e9fac0b186a7e662f8993b631964e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Sat, 7 Nov 2026 12:48:56 +0700 Subject: [PATCH 2/2] style: dart format signature vector test --- test/unit/remote_trigger_signature_vector_test.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/unit/remote_trigger_signature_vector_test.dart b/test/unit/remote_trigger_signature_vector_test.dart index 7c0d7d0..69f565e 100644 --- a/test/unit/remote_trigger_signature_vector_test.dart +++ b/test/unit/remote_trigger_signature_vector_test.dart @@ -15,13 +15,16 @@ void main() { return f.readAsStringSync(); } - test('v2 signature vector is identical in Kotlin, Swift and the Python reference', () { + test( + 'v2 signature vector is identical in Kotlin, Swift and the Python reference', + () { for (final path in [ 'android/src/test/kotlin/dev/brewkits/native_workmanager/RemoteTriggerSignatureTest.kt', 'ios/Tests/RemoteTriggerSignatureTests.swift', 'doc/REMOTE_TRIGGER_SIGNING.md', ]) { - expect(read(path), anyOf(contains(vectorHex), contains(vectorHex.substring(0, 8))), + expect(read(path), + anyOf(contains(vectorHex), contains(vectorHex.substring(0, 8))), reason: '$path must reference the shared v2 vector'); } });