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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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})")
Expand Down Expand Up @@ -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.")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
19 changes: 16 additions & 3 deletions doc/REMOTE_TRIGGER_SIGNING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 7 additions & 0 deletions ios/Tests/RemoteTriggerSignatureTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))")
Expand Down Expand Up @@ -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.")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ?"
Expand Down
11 changes: 11 additions & 0 deletions lib/src/remote_trigger.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<String, dynamic> toMap() {
return {
Expand All @@ -69,6 +79,7 @@ class RemoteTriggerRule {
'workerConfig': worker.toMap(),
})),
if (secretKey != null) 'secretKey': secretKey,
if (rejectUnsignedCommands) 'rejectUnsignedCommands': true,
};
}
}
41 changes: 41 additions & 0 deletions test/unit/remote_trigger_reject_unsigned_test.dart
Original file line number Diff line number Diff line change
@@ -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"]'));
});
}
Loading