Sign explicit acks under Strict; bind request_id/reply_id in the XEdDSA signing buffer - #11422
Sign explicit acks under Strict; bind request_id/reply_id in the XEdDSA signing buffer#11422jp-bennett wants to merge 4 commits into
Conversation
…uffer Two Strict nodes could never complete reliable delivery: Strict drops unsigned non-PKI packets, and explicit acks/naks (ROUTING_APP unicasts, PKC-excluded) were never signed. perhapsEncode now signs self-originated ROUTING_APP unicasts when packet_signature_policy is STRICT, alongside the existing broadcast and licensed-mode classes. Balanced/Compatible senders are unchanged, and the Balanced downgrade mirror deliberately does NOT mirror the new clause - a receiver cannot know the sender's policy, so unsigned unicast acks are never treated as downgrades. The signing buffer grows to v2: [fromNode | packetId | portnum | request_id | reply_id | payload] Data.request_id/reply_id sat outside the signed material while channel AES-CTR carries no MAC, so a signed ack could be retargeted at a different outstanding request (and a signed reply/tapback re-pointed at a different message) by flipping known-plaintext bits. Binding both closes that. The format change is a one-time break shipped before any published XEdDSA release (2.8.0 is draft-only; 2.7.26 has no XEdDSA), so only draft-alpha meshes need to upgrade in lockstep. Tests: crypto-primitive negative flips for the two new fields; B14/B15 pin the Strict-signs-acks gate (via a real allocAckNak product) and that Balanced/Compatible keep unsigned acks; E14/E15 pin the retargeting drops; A14b pins solicited (nonzero request_id) first-contact NodeInfo bootstrap; MQTT ingress verifies a signed ack and drops a retargeted one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012hLcVif8GDEmA2k77hmFG8
…pass The native harness boots the test binary with -s (test_testing_command), so force_simradio is set and wouldEncryptWithPKC() hard-disables PKC, making B11/B12 fail regardless of the change under test - they fail identically at the branch base. Reset force_simradio in setUp, the same documented workaround test_admin_session_repro already uses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012hLcVif8GDEmA2k77hmFG8
Append request_id/reply_id to the signing buffer only when at least one is nonzero. Packets without either field - broadcasts, NodeInfo, telemetry, licensed traffic without request linkage, i.e. everything existing 2.8 draft firmware actually signs today except tapbacks - keep the original [from|id|portnum|payload] layout, so their signatures stay verifiable across the upgrade boundary in both directions. The extended layout is used exactly where the binding matters: explicit acks/naks (always carry request_id, and were never signed before) and signed replies/tapbacks. Both ends derive the layout from the packet's own decoded fields, so no format flag is transmitted. The conditional layout's theoretical ambiguity (a base-layout payload whose prefix parses as the extended header) is documented at buildSigningBuffer: a forgery additionally needs identical from/id/portnum and an honest zero-request signed packet on the same portnum, which does not exist for ROUTING_APP. test_XEdDSA_base_format_backwards_compat pins all four directions: a hand-built legacy signature verifies with 0/0 fields, cannot be re-framed with nonzero fields, and an extended signature verifies only with its own fields. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012hLcVif8GDEmA2k77hmFG8
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
⚡ Try this PR in the Web FlasherNote Building this pull request… the flash button, badges and supported-board |
…false positive trunk check on the PR flagged three things this environment could not run trunk to catch (trunk.io is unreachable from here): - clang-format across the five touched sources. Reproduced locally with the repo's pinned config at .trunk/configs/.clang-format; the base commit's versions of these files are already clean under it, so the reformat only rewraps lines this branch introduced. - ascii-dash: one em-dash left in a Router.cpp comment, now an ASCII hyphen. - trufflehog/Lob "secret detected" on the new compat test. False positive: test_XEdDSA_base_format_backwards_compat is "test_" followed by exactly 35 alphanumeric/underscore characters, which matches Lob's API-key pattern. Renamed to test_XEdDSA_legacy_layout_compat rather than adding a suppression comment; the file's existing trunk-ignore-all(gitleaks) header does not cover trufflehog. No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012hLcVif8GDEmA2k77hmFG8
What this does
Explicit acks/naks (
ROUTING_APPunicasts) are never signed today, andROUTING_APPis PKC-excluded, so ack authenticity rests entirely on the channel PSK — which is public knowledge on default LongFast. A forged ack fakes delivery confirmation and, worse, makes honest relayersstopRetransmission()for the real packet. Strict-policy meshes have it worst: Strict drops unsigned non-PKI packets, acks are unsigned, so two Strict peers can never complete a reliable delivery at all.Two changes:
Strict signs explicit acks/naks. The
perhapsEncodesign gate gains a third class: self-originatedROUTING_APPunicasts are signed whenpacket_signature_policy == STRICT(licensed/ham signing is unchanged; Balanced/Compatible keep today's unsigned acks for interop). The Balanced downgrade mirror deliberately does not mirror this clause — a receiver can't know the sender's policy, so unsigned unicast acks are never treated as downgrades.The signing buffer binds
request_id/reply_idwhen present. Channel crypto is AES-CTR with no MAC, so without this a signed ack'srequest_idis malleable in flight — a captured "delivered" ack could be retargeted at a different outstanding request (and a signed tapback re-pointed at a different message). Packets where either field is nonzero now sign over[from | id | portnum | request_id | reply_id | payload].Backwards compatibility
Packets with no request/reply linkage (broadcasts, NodeInfo, position/telemetry, licensed traffic without request ids — the overwhelming majority of signed traffic) keep the existing
[from | id | portnum | payload]layout byte-for-byte, so their signatures verify across the upgrade boundary in both directions. The only pre-existing traffic whose signatures change are signed tapbacks/replies — exactly the retargetable class. Explicit acks were never signed before, so nothing existing breaks there. Both sides derive the layout from the packet's own decoded fields; no wire-format flag is added and no protobuf changes are needed. The conditional layout's theoretical ambiguity (and why it isn't exploitable for acks) is documented atbuildSigningBuffer.XEdDSA has shipped in no published release (2.8.0 is draft-only), so the tapback-signature change affects draft-alpha testers only.
Also in this PR
test_packet_signingB11/B12 (PKC unicast tests) failed at the branch base: the native harness boots with-s, andwouldEncryptWithPKC()hard-disables PKC underforce_simradio. The suite now models a real device insetUp, the same documented workaroundtest_admin_session_reprouses.Known limitations
MeshService::sendRoutingErrorResponseacksmp->fromraw, so a phone-originated packet yields ato=0ack, which Strict now signs harmlessly. Worth a follow-up issue.Testing
./bin/run-tests.sh— GREEN 44/44 suites, 352/352 test cases, all CLEAN.test_XEdDSA_base_format_backwards_compat); B14/B15 (Strict signs a realallocAckNakproduct, Balanced/Compatible don't); E14/E15 (retargetedrequest_id/reply_iddropped); A14b (solicited first-contact NodeInfo with nonzerorequest_idstill bootstraps); MQTT ingress signed-ack verify + retarget-drop.🤝 Attestations
🤖 Generated with Claude Code
https://claude.ai/code/session_012hLcVif8GDEmA2k77hmFG8
Generated by Claude Code