diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e3d28f..b7a262d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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] + +### Added +- `RemoteTriggerRule.rejectUnsignedCommands` (default `false`): opt in to refuse `native_wm` + direct commands on a rule that has no `secretKey`. Default behaviour is unchanged. + ## [1.9.0] - 2026-11-07 Minor release: new remote-trigger signature scheme (v2, backwards compatible), `maxConcurrentTasks` 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 6e5d532..6175395 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 @@ -37,6 +37,7 @@ internal fun NativeWorkmanagerPlugin.handleRegisterRemoteTrigger(call: MethodCal workerMappingsJson = mappingsJson, secretKey = secretKey ) + remoteTriggerStore.setRejectUnsignedCommands(source, ruleMap["rejectUnsignedCommands"] == true) } NativeLogger.d("✅ Remote trigger registered for $source (key: $payloadKey, hmac: ${secretKey != null})") @@ -78,6 +79,10 @@ fun NativeWorkmanagerPlugin.Companion.onRemoteMessage(context: Context, source: } } } else if (payload["native_wm"] != null || (payload["data"] as? Map<*, *>)?.get("native_wm") != null) { + if (RemoteTriggerSignature.shouldRejectUnsignedCommand(false, true, store.rejectsUnsignedCommands(source))) { + NativeLogger.w("⚠️ Unsigned native_wm command REJECTED for $source (rejectUnsignedCommands=true).") + return false + } 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.") } diff --git a/android/src/main/kotlin/dev/brewkits/native_workmanager/RemoteTriggerSignature.kt b/android/src/main/kotlin/dev/brewkits/native_workmanager/RemoteTriggerSignature.kt index 8cbc7bc..9a0644c 100644 --- a/android/src/main/kotlin/dev/brewkits/native_workmanager/RemoteTriggerSignature.kt +++ b/android/src/main/kotlin/dev/brewkits/native_workmanager/RemoteTriggerSignature.kt @@ -27,6 +27,10 @@ internal object RemoteTriggerSignature { fun sharedNonceStore(prefs: SharedPreferences): NonceStore = sharedStore ?: synchronized(this) { sharedStore ?: NonceStore(prefs).also { sharedStore = it } } + /** An unsigned `native_wm` command is refused only when the rule opted in and has no secret. */ + fun shouldRejectUnsignedCommand(hasSecret: Boolean, hasNativeWmCommand: Boolean, rejectFlag: Boolean): Boolean = + !hasSecret && hasNativeWmCommand && rejectFlag + enum class Verdict { VALID_V2, VALID_LEGACY, INVALID, STALE, REPLAYED } fun verify( diff --git a/android/src/main/kotlin/dev/brewkits/native_workmanager/store/RemoteTriggerStore.kt b/android/src/main/kotlin/dev/brewkits/native_workmanager/store/RemoteTriggerStore.kt index d10c8c4..65488f7 100644 --- a/android/src/main/kotlin/dev/brewkits/native_workmanager/store/RemoteTriggerStore.kt +++ b/android/src/main/kotlin/dev/brewkits/native_workmanager/store/RemoteTriggerStore.kt @@ -120,8 +120,19 @@ internal class RemoteTriggerStore(private val context: Context) { } } + // Not a secret, so plain SharedPreferences (no DB migration needed). + private fun flagPrefs() = + context.getSharedPreferences("dev.brewkits.native_workmanager.remote_trigger_flags", android.content.Context.MODE_PRIVATE) + + fun setRejectUnsignedCommands(source: String, reject: Boolean) { + flagPrefs().edit().apply { if (reject) putBoolean("reject_unsigned_$source", true) else remove("reject_unsigned_$source") }.apply() + } + + fun rejectsUnsignedCommands(source: String): Boolean = flagPrefs().getBoolean("reject_unsigned_$source", false) + fun delete(source: String) { try { + flagPrefs().edit().remove("reject_unsigned_$source").apply() getEncryptedPrefs()?.edit()?.remove("secret_$source")?.apply() dbHelper.writableDatabase.delete("remote_triggers", "source = ?", arrayOf(source)) } catch (e: android.database.sqlite.SQLiteFullException) { diff --git a/android/src/test/kotlin/dev/brewkits/native_workmanager/RemoteTriggerSignatureTest.kt b/android/src/test/kotlin/dev/brewkits/native_workmanager/RemoteTriggerSignatureTest.kt index be3e553..0ab2f8b 100644 --- a/android/src/test/kotlin/dev/brewkits/native_workmanager/RemoteTriggerSignatureTest.kt +++ b/android/src/test/kotlin/dev/brewkits/native_workmanager/RemoteTriggerSignatureTest.kt @@ -100,4 +100,12 @@ class RemoteTriggerSignatureTest { assertTrue(store.checkAndRecord("n", now + 2 * 300_000L + 1)) repeat(2500) { assertTrue(store.checkAndRecord("bulk$it", now + it)) } } + + @Test fun unsigned_command_is_rejected_only_when_rule_opted_in_and_has_no_secret() { + val f = RemoteTriggerSignature::shouldRejectUnsignedCommand + assertTrue(f(false, true, true)) + assertFalse("default keeps old behaviour", f(false, true, false)) + assertFalse("a secret means signatures are enforced elsewhere", f(true, true, true)) + assertFalse("no command, nothing to reject", f(false, false, true)) + } } diff --git a/doc/REMOTE_TRIGGER_SIGNING.md b/doc/REMOTE_TRIGGER_SIGNING.md index 34790fc..794bb58 100644 --- a/doc/REMOTE_TRIGGER_SIGNING.md +++ b/doc/REMOTE_TRIGGER_SIGNING.md @@ -40,6 +40,19 @@ 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.) +By default this is unchanged for compatibility, but it logs a warning on every such message. + +To refuse them instead, opt in per rule: + +```dart +RemoteTriggerRule( + payloadKey: 'type', + workerMappings: {...}, + rejectUnsignedCommands: true, // only meaningful without a secretKey +) +``` + +With `secretKey` set, signatures are always enforced and this flag has no effect. Always set a `secretKey` in production. + +On iOS, `native_wm` commands are also executed when **no rule is registered at all** (Android ignores them); +that path has no rule to carry the flag, so register a rule for the source if you want it protected. diff --git a/ios/Tests/RemoteTriggerSignatureTests.swift b/ios/Tests/RemoteTriggerSignatureTests.swift index a004315..0f93119 100644 --- a/ios/Tests/RemoteTriggerSignatureTests.swift +++ b/ios/Tests/RemoteTriggerSignatureTests.swift @@ -65,4 +65,11 @@ final class RemoteTriggerSignatureTests: XCTestCase { var p = vector(); p["x-native-wm-signature"] = "deadbeef" XCTAssertEqual(RemoteTriggerSignature.verify(payload: p, secretKey: secret, now: now, nonces: .init()) { $0 == "deadbeef" }, .validLegacy) } + func testUnsignedCommandRejectedOnlyWhenOptedInAndNoSecret() { + let f = RemoteTriggerSignature.shouldRejectUnsignedCommand + XCTAssertTrue(f(false, true, true)) + XCTAssertFalse(f(false, true, false)) + XCTAssertFalse(f(true, true, true)) + XCTAssertFalse(f(false, false, true)) + } } diff --git a/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin+RemoteTrigger.swift b/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin+RemoteTrigger.swift index cef803a..386d8c1 100644 --- a/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin+RemoteTrigger.swift +++ b/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin+RemoteTrigger.swift @@ -30,6 +30,8 @@ extension NativeWorkmanagerPlugin { workerMappingsJson: mappingsJson, secretKey: secretKey ) + RemoteTriggerStore.shared.setRejectUnsignedCommands( + source: source, reject: (ruleMap["rejectUnsignedCommands"] as? Bool) == true) } NativeLogger.d("✅ Remote trigger registered for \(source) (key: \(payloadKey), hmac: \(secretKey != nil))") @@ -83,6 +85,13 @@ extension NativeWorkmanagerPlugin { return false } } else if payload["native_wm"] != nil { + if RemoteTriggerSignature.shouldRejectUnsignedCommand( + hasSecret: false, hasNativeWmCommand: true, + rejectFlag: RemoteTriggerStore.shared.rejectsUnsignedCommands(source: source)) { + NativeLogger.w("⚠️ Unsigned native_wm command REJECTED for \(source) (rejectUnsignedCommands=true).") + completionHandler(.failed) + return false + } 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.") } diff --git a/ios/native_workmanager/Sources/native_workmanager/utils/RemoteTriggerSignature.swift b/ios/native_workmanager/Sources/native_workmanager/utils/RemoteTriggerSignature.swift index b85f0da..9918ae7 100644 --- a/ios/native_workmanager/Sources/native_workmanager/utils/RemoteTriggerSignature.swift +++ b/ios/native_workmanager/Sources/native_workmanager/utils/RemoteTriggerSignature.swift @@ -14,6 +14,11 @@ enum RemoteTriggerSignature { static let v2Prefix = "v2=" static let maxSkewSeconds: Int64 = 300 + /// An unsigned `native_wm` command is refused only when the rule opted in and has no secret. + static func shouldRejectUnsignedCommand(hasSecret: Bool, hasNativeWmCommand: Bool, rejectFlag: Bool) -> Bool { + !hasSecret && hasNativeWmCommand && rejectFlag + } + enum Verdict: Equatable { case validV2, validLegacy, invalid, stale, replayed } static let sharedNonces = NonceStore(defaults: .standard) diff --git a/ios/native_workmanager/Sources/native_workmanager/utils/RemoteTriggerStore.swift b/ios/native_workmanager/Sources/native_workmanager/utils/RemoteTriggerStore.swift index 6adfb37..36bb292 100644 --- a/ios/native_workmanager/Sources/native_workmanager/utils/RemoteTriggerStore.swift +++ b/ios/native_workmanager/Sources/native_workmanager/utils/RemoteTriggerStore.swift @@ -129,7 +129,20 @@ final class RemoteTriggerStore { } } + // Not a secret, so plain UserDefaults (no SQLite migration needed). + private static func rejectFlagKey(_ source: String) -> String { "native_workmanager.rt.rejectUnsigned.\(source)" } + + func setRejectUnsignedCommands(source: String, reject: Bool) { + if reject { UserDefaults.standard.set(true, forKey: Self.rejectFlagKey(source)) } + else { UserDefaults.standard.removeObject(forKey: Self.rejectFlagKey(source)) } + } + + func rejectsUnsignedCommands(source: String) -> Bool { + UserDefaults.standard.bool(forKey: Self.rejectFlagKey(source)) + } + func delete(source: String) { + UserDefaults.standard.removeObject(forKey: Self.rejectFlagKey(source)) KeystorePasswordVault.shared.deletePersistent(account: "rt_\(source)") queue.async(flags: .barrier) { let sql = "DELETE FROM remote_triggers WHERE source = ?" diff --git a/lib/src/remote_trigger.dart b/lib/src/remote_trigger.dart index 1799043..013e7e7 100644 --- a/lib/src/remote_trigger.dart +++ b/lib/src/remote_trigger.dart @@ -25,6 +25,7 @@ class RemoteTriggerRule { required this.payloadKey, required this.workerMappings, this.secretKey, + this.rejectUnsignedCommands = false, }); /// The key in the remote message payload to look for. @@ -60,6 +61,15 @@ class RemoteTriggerRule { /// Without a secret, `native_wm` direct commands are accepted unauthenticated. final String? secretKey; + /// When `true` and this rule has **no** [secretKey], `native_wm` direct commands arriving for + /// this source are rejected instead of executed. + /// + /// Default `false` keeps the historical behaviour (unsigned commands are accepted, with a + /// warning in the log). Set it to `true` if you only use rule-based mappings and never send + /// `native_wm` commands unsigned. Has no effect when a [secretKey] is set (signatures are + /// always enforced then). + final bool rejectUnsignedCommands; + /// Convert to a map for the platform channel. Map toMap() { return { @@ -69,6 +79,7 @@ class RemoteTriggerRule { 'workerConfig': worker.toMap(), })), if (secretKey != null) 'secretKey': secretKey, + if (rejectUnsignedCommands) 'rejectUnsignedCommands': true, }; } } diff --git a/test/unit/remote_trigger_reject_unsigned_test.dart b/test/unit/remote_trigger_reject_unsigned_test.dart new file mode 100644 index 0000000..e367813 --- /dev/null +++ b/test/unit/remote_trigger_reject_unsigned_test.dart @@ -0,0 +1,41 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:native_workmanager/native_workmanager.dart'; + +void main() { + RemoteTriggerRule rule({bool? reject, String? secret}) => RemoteTriggerRule( + payloadKey: 'type', + workerMappings: const {}, + secretKey: secret, + rejectUnsignedCommands: reject ?? false, + ); + + test('rejectUnsignedCommands defaults to false and is omitted from the map', + () { + expect(rule().rejectUnsignedCommands, isFalse); + expect(rule().toMap().containsKey('rejectUnsignedCommands'), isFalse); + }); + + test('rejectUnsignedCommands: true is serialized for the platform channel', + () { + expect(rule(reject: true).toMap()['rejectUnsignedCommands'], isTrue); + }); + + // The toMap test above cannot catch a bridge that stops reading the key (issue #30 lesson). + test('both native bridges read the same rejectUnsignedCommands key', () { + String read(String path) { + final f = File(path).existsSync() ? File(path) : File('../$path'); + return f.readAsStringSync(); + } + + expect( + read('android/src/main/kotlin/dev/brewkits/native_workmanager/' + 'NativeWorkmanagerPlugin+RemoteTrigger.kt'), + contains('ruleMap["rejectUnsignedCommands"]')); + expect( + read('ios/native_workmanager/Sources/native_workmanager/' + 'NativeWorkmanagerPlugin+RemoteTrigger.swift'), + contains('ruleMap["rejectUnsignedCommands"]')); + }); +}