Propagate the full frame integrity verdict to every consumer - #1985
Conversation
|
CI update: the first macOS run failed on three synchronous tests in |
…055def) into integration/100hz-step-ground-truth
bd1ec3a to
1cca838
Compare
|
Rebased onto current main ( |
|
Reviewed. #1977 and #1978 are merged, thanks. Two things on this one. The draft gate. The body still opens with "Draft: please do not merge before the hardware run", One comment to correct before it goes in. That composition sums to 12. The constant 11 is the right value, and I would not want it changed: The rest holds up on the parts I went through. The reassembler guards The Understood on the residual risk, and I agree the hardware run is the right gate for it: the header |
1cca838 to
340222c
Compare
|
Thanks — you are right. I corrected the identical Swift and Kotlin comments: the 11-byte WHOOP 4.0 minimum deliberately admits real zero-payload metadata frames and preserves the old |
|
Reviewed at 4b6d82a. This is careful work and the write-up is the most useful part of it: you named the failure mode that would make this change dangerous rather than leaving me to find it. I am not merging it today, but nothing below is a rejection of the approach. What I verified rather than took on trustThe bounds are structurally derived, and the description undersells that. I read them expecting values fitted to the captures, which is what "the smallest real WHOOP 4.0 frame sits exactly on the bound" reads like, and that had me worried there was no margin. There is no margin because there cannot be: 11 is The two oracle copies are byte-identical, and the file holds what you say: 45 cases, 26 WHOOP 4.0 and 19 WHOOP 5.0/MG, 14 accepting and 31 rejecting. The self-describing The empty-section ack path is as you describe it. I followed it in What I want before it goes in1. It conflicts with main, in one file. 2. One declared rejection reason has no oracle case, and may not be reachable.
Tracing it in the 4.0 verifier, the fixed order is minimum length, then exact length, then header checksum, then the CRC32 switch. By the time the switch is reached the frame has cleared the length rules, and I could not construct an input that arrives there with an uncomputable CRC32. If that is right, the "unverifiable counts as passing" class from your problem statement is actually closed by the length bound rather than by this reason, and the reason is dead on that path. I do not want to guess which. Either add a case that pins it, or say where it is still reachable, or remove it. What I want to avoid is a fail-closed branch that the parity oracle does not pin, because the Kotlin 3. The two families are justified differently, and only one is structural. 4.0 admits its real zero-payload metadata frames deliberately, which is why the bound is 11 and not 12. 5.0/MG requires at least one payload byte, and the support for that is empirical: the smallest observed frame is 124 bytes. That is an enormous margin, but it is a different kind of claim from the 4.0 one. Is a zero-payload 5.0/MG frame structurally impossible, or merely unobserved? If it is the latter, the 13-byte bound rejects a frame class we have not seen, and I would rather know that is a deliberate bet than discover it from a wrist. 4. The WHOOP 4.0 hardware run, which I can do. R-03 is honest and it is also the residual I care about most, because the 4.0 bound is the exactly-on-the-boundary one and the failure mode for a wrong rejection is a section that acks empty. I have a 4.0. Rebase onto main and I will run live traffic and repeated history offloads against your abort criterion, and report the counters here. That closes the gap you cannot close, and it is a better use of the hardware than another 5.0 run. On splittingYou offered to split the diagnostics and the Python alignment into a follow-up. I would rather you did not. The counters are what the hardware criterion reads, and pulling them out means the 4.0 run has nothing to observe. Your argument for keeping them is sound and I am accepting it. Not in questionThe trim-ack hold you considered and rejected: I agree, and for the reason you give. An unbounded hold turns a strap emitting rejectable records into an offload that never advances and that the user cannot clear. Measuring the class is the right call while the class is still theoretical. No authenticity claim, no checksum algorithm changes, no new writes to hardware. Good. |
|
Status update, and a heads-up about what moved under you. Main gained two console-decode changes today, and both touch lines this PR rewrites:
I rebased your branch locally to check it is still tractable, and it is. Two conflicts, one per platform, both the same shape and both in the console decoder: The resolution is to keep the new field split and add your private fun decodeConsoleLogsWhoop5(frame: ByteArray, limit: Int, parsed: MutableMap<String, Any?>) {
frame.u8(9, limit)?.let { parsed["console_sequence"] = it }
frame.u8(10, limit)?.let { parsed["console_header_byte_10"] = it }
frame.u32(12, limit)?.let { parsed["unix"] = it.toInt() }
frame.u16(16, limit)?.let { parsed["subsec"] = it }
val payEnd = limitThe Swift side is the same idea. Your With that resolution the Kotlin protocol package is 611 tests, 0 failures on the rebased branch, including your two new frame-integrity suites. So the collision is cosmetic rather than semantic: nothing about your clamping and the new field split disagree. Rebase when convenient and that is the merge conflict dealt with. I am not pushing to your branch. What is still open from my review, unchanged:
Nothing here changes my view of the change itself. |
…nsumer The frame verifiers computed a combined integrity result (header checksum, payload CRC32, structural length) but the parsers discarded it and returned a constant ok=true; downstream gates only rejected a proven-bad payload CRC, so bad-header and unverifiable frames drove live state and history metadata. ParsedFrame now carries the verifier's full verdict plus a non-optional rejection reason on both platforms; family minimum and exact lengths are enforced in the verifier; inner field reads are bounded by the CRC trailer; the six state-driving gates (router, historical classifier, stream/history extraction, clock correlation, data-range reply, offload) require the full verdict; evidence-preserving readers keep archiving rejected frames; a shared Swift-generated oracle pins verdict, reason and history classification on both sides; the Python capture tools apply the same family minimums and filter on the full verdict; docs updated. Fixes #5.
Upstream CI (macOS, xcodebuild test) rejected three synchronous test methods in BackfillMetaForgeryTests that call the @mainactor Backfiller.endData(from:family:) from a nonisolated context. Mark them @mainactor like the rest of the file; no product code changes. Post-archive fix, evidence bound to the CI rerun. (cherry picked from commit 2599c6988cfae2decf25266f6d00cee02565e805)
4b6d82a to
0ca11d9
Compare
|
Addressed the open review items and force-with-lease updated the PR branch after rebasing onto current
Targeted verification is green: 26 Swift frame-integrity tests, the Kotlin frame-integrity build/tests, 47 Python framing tests, oracle byte-identity, documentation lint, and focused stale-text checks. The remaining gate is the WHOOP 4.0 hardware run you offered. The branch is ready for that run; the documented abort criterion and counters are unchanged. |
Frame-integrity behaviour stays in PROTOCOL_IMPLEMENTATION.md; the WHOOP 4 envelope and transport pages are restored to main.
#1985 changed product source across twenty files inside the scanned globs, so the stored base authority no longer reproduced and a plain --refresh-derived was rejected. Re-derived with --migrate-authority, which waives manifest reproducibility only. Manifest counts and hashes move as the change implies: files 497 to 498, functions 4414 to 4431, properties 454 to 458, constants 1930 to 1943, with the unpaired tallies rising in step. function_pairs stays at 164 with a different hash, the shape a rename inside a refactor makes. Tools/parity_ledger_baseline.json is untouched and the total stays at 300 known findings, so nothing was accepted, waived or silently dropped: this is the manifest catching up with the source, not a change to what the ledger tolerates. Derived in a pristine worktree. PRODUCTION_GLOBS walks into .build/checkouts, and that pollution is subtractive: it deletes findings rather than adding them, and the local acceptance test still passes because both sides of its comparison come from the same polluted tree. Parity Governance CI is path-filtered to Tools/, by design, so it does not run on a product-source PR. Its own header records that the resulting drift has landed on an outside contributor's PR that caused none of it, four times. Doing this now keeps the next one clean.
Problem
Both platforms' verifiers compute a combined integrity result — header checksum and payload
CRC32 — but the parsers discard it, return a hardcoded
ok = true, and forward only the payload CRCas a tri-state. Every gate therefore rejects only a provably wrong payload CRC, so two classes pass:
frames with a broken header but valid payload (the header result is never read), and
unverifiable frames too short to compute a payload CRC at all — "unknown" counts as passing. A
structural gap compounds it: the reassembler's only lower bound is four bytes, yielding WHOOP 4.0
frames of 8–10 bytes whose payload CRC is never computed, and at the WHOOP 5.0/MG parser bound the
first byte of the CRC trailer is read as the inner packet type. So a noisy stream or a nearby,
interfering or impersonating peer can drive live state and, at worst, forge history metadata that
advances the trim cursor and tells the strap to discard data that was never provably stored.
Reported as bhelm#5, rated P0.
Change
Swift protocol core.
ParsedFrame.oknow carries the verifier's full verdict — header checksumvalid and payload CRC provably valid, unverifiable means rejected — plus a non-optional
rejection reason. The reason sits on the parse result, not the verifier result, because consumers
only ever call the parser: the frame is deliberately parsed exactly once (now pinned by a debug
assertion), so a verifier-only reason would force every reporting consumer to verify twice. It
decodes with a default, so hand-built deserialization of
ParsedFramedoes not turn strict andbreak. Per-family minimum total lengths (WHOOP 4.0 eleven bytes, deliberately admitting its real zero-payload metadata frames; 5.0/MG thirteen bytes, requiring at least one payload byte), the exact
declared length and rejection of surplus trailing bytes are enforced in the verifier, not per
parser — five Swift and three Kotlin call sites invoke the verifier directly and would otherwise
stay unprotected. The reassembler gets the same bound and resynchronises to the next frame start
instead of emitting a short frame. Every read of a named inner field is clamped to the minimum of
the CRC-trailer start and the actual frame size, so a frame at the bound cannot fake a metadata
field out of its own trailer and a truncated frame cannot read past the buffer; a field counts as
present when start + length does not exceed that bound. The eight-byte history-end acknowledgement
block is explicitly exempt — it reaches into the trailer by construction and is echoed back to the
strap unchanged; clipping it would alter the most consequential write the system performs. The
payload-only frame builder now computes a real header checksum instead of a zero placeholder.
Swift consumers, gates and diagnostics. The six state-driving gates — router, historical
classifier, stream extraction, history extraction, clock correlation, data-range reply — require the
full verdict instead of "not explicitly wrong". The data-range reply is in scope because it sets the
two bounds the offload plausibility-checks records against: a forged reply narrows the window until
genuine records fail and an empty section is acked anyway, which is the P0's end state by another
route. Evidence-preserving readers keep their direction: the history-path reader deciding which
raw frames are archived before the trim ack treats a negative verdict as a reason to archive, and
mechanically tightening it would have deleted the only durable copy of exactly the frames the strap
is about to release. Diagnostic surfaces that read
okas parsability moved to a real parsabilitysignal, so a CRC-failing frame keeps its decoded packet type.
Kotlin twin. The same core and the same gates, including the second, inline-verifying ECG
payload path — which the Swift side inherits from the central verifier for free and Kotlin did not.
Shared oracle. A Swift-generated file of frame bytes with verdict, reason and history
classification per frame, byte-identical in both test trees; both suites check every row and all
three fields, so a one-sided change fails on the other side.
Python capture tools. Two points only: the same family minimums in the verify helper, and the
feature evaluation now filters on the full verdict instead of the payload CRC alone, where a
missing value counted as passing.
Docs.
PROTOCOL_IMPLEMENTATION.md,LIBRARY.md,CONTRIBUTING.md,ANDROID.md,PRIVACY_SECURITY.md,BLE_REVERSE_ENGINEERING.mdstate the new bounds; two showed the loose gate as the example.No new connection behaviour: rejected frames cause no disconnect, no reset, no reconnect change.
Holding the trim ack for a section the gate rejects wholesale was considered and rejected — that
hold path is unbounded, so a strap producing persistently rejectable records would stall the offload
with no user recourse. The class is made measurable instead: counters per rejection reason, plus
exactly one named counter for the previously-passing class (header-or-length wrong while the
payload CRC verifies). A single reason bucket cannot express that conjunction, and the length bucket
also collects harmless post-dropped-notification aborts. Nothing leaves the device.
Size, and how to read the diff
62 files, +6,641 / −549. Where the lines are:
WhoopProtocol)Strandapp)StrandTests)Two thirds of the added lines are tests and test data. Of the product code, by role:
ParsedFrameverdict + reason, clamped inner readswhoop-decode,whoop-re, Python capture)Roughly half of the product-code additions are comments explaining a bound or a decision at the
place where it is made; the code itself is smaller than the total suggests.
What is one atomic change, and why. The parser used to return a constant
ok = trueon bothplatforms (
Interpreter.swift:188,Framing.kt:295onmain). Five of the six gates alreadyread
ok, so the core alone — verdict plus verifier bounds plus clamped reads — already tightensthem. Two seams did not, and they are the reason core and gates cannot land separately:
still have narrowed the offload window;
central verdict never reached it.
Both platforms have to land together because the oracle is what proves parity: both suites
check the same byte-identical file, field by field. Landing one side first would either fail the
other side's suite against the new expectations or defer the parity proof to a later PR. It
appears twice because each test tree must be self-contained;
cmpin Verification pins thecopies identical. Its 498 lines are 45 readable-JSON cases (26 WHOOP 4.0, 19 WHOOP 5.0/MG; 14
positive, 31 negative), not a binary blob.
What is deliberately in scope but separable. The diagnostics layer (counters per reason,
the one named counter for the previously-passing class, reason fields in captures) does not
decide any frame. It is in this PR because the hardware abort criterion below reads it; without
it the strap run has nothing to observe. The Python capture alignment (+160) is an independent
third implementation of the same rule and only fixes the two points named above. Four of the six
docs contained normative statements about
crcOKthat became false with this change; the othertwo are additive. If you would rather review the P0 alone, the diagnostics package and the Python
alignment can be split off into a follow-up — say so and I will do it.
Suggested reading order:
Framing.swift/Framing.kt(verifier bounds,ParsedFrameverdict and reason) →
Interpreter.swift,PostHooks.swift/HistoricalStreams.kt(clampedinner reads, the ack-block exemption, the v26 gate) → the two new gates (
DataRange.swift/DataRange.kt, wired inBLEManager.swift/WhoopBleClient.kt) → the existing gates that nowreceive the full verdict (
FrameRouter.swift,HistoricalMeta.swift,Streams.*,HistoricalStreams.swift,ClockCorrelation.swift) → the oracle JSON and the two oracle tests →FrameDiagnostics.swift/FrameRejectTally.kt→ docs.Verification
Run by me on Linux, on the integrated tree:
swift build && swift testinPackages/WhoopProtocol→ 757 tests, 1 skipped, 0 failures../gradlew testFullDebugUnitTest --no-build-cache --rerun-tasks→ 5683 tests, 6 skipped, 0failures, 0 errors.
python3 -m unittest discover Tools/linux-capture→ 234 tests, OK.python3 Tools/doc_comment_lint.py→ OK (24 baselined sites, baseline unchanged);python3 Tools/i18n_audit.py --ci main→ exit 0.cmpof the two oracle copies → identical;grep -rn "crcOK != false\|crcOK == false" docs/→ no hits, so no doc still shows the old gate.
The smallest real WHOOP 4.0 history frame sits exactly on the bound; the smallest real 5.0 frame
is 124 bytes.
App build (macOS + iOS)workflow on this repositoryat
bd1ec3ad9and the same six workflows on my fork —xcodegen generate,Strandbuild andxcodebuild test→ 1,650StrandTests, 1 skipped, 0 failures, including the three newfiles (
FrameIntegrityGateTests18 cases,BackfillMetaForgeryTests9,AppTargetFrameCorpusIntegrityTests3);NOOPiOSbuild green.payloadCRCOKButEnvelopeRejectedevent, no critical valid-payload-CRC warning, and no rejected frame in an acknowledged offload section. This is the only device family available to me for physical testing. No macOS box of my own; the app-target evidence above is CI.Acceptance
Items 1–8 were closed by the CI run cited under Verification (this repository and the fork). The available WHOOP 5.0/MG hardware run is now complete; other device families are unavailable to me and are recorded as residual coverage rather than a draft blocker.
xcodegen generateplusxcodebuildforStrandandNOOPiOS(this diff touches
Strand/BLEandStrand/Collect, which no default CI compiles).StrandTests: real recorded frames stay valid — the frame literals in three app-target tests.StrandTests: router rejects a broken-header frame (end to end).StrandTests: router rejects an under-length frame (end to end).StrandTests: router rejects a truncated frame (end to end).StrandTests: history metadata cannot be forged across offload and ack.StrandTests: clock correlation takes no invalid frame as an anchor.StrandTests: the data-range reply does not narrow the offload window unchecked.Behaviour change / rollout notes
Frames that previously passed are now rejected: broken header with valid payload, unverifiable,
under-length per family, wrong declared length, surplus trailing bytes. Clamped inner reads mean a
frame near the bound no longer yields metadata mined from its own trailer. One visible decision
changes with it: the unbonded-offload probe no longer counts a broken-header frame as evidence that
the strap delivers notifications — intended, and documented in the probe. No schema change, no
stored-value change, no migration, no backup-whitelist key; diagnostic and capture formats gain a
reason field additively and keep the packet type for rejected frames. Older captures still
parse; violating frames are marked invalid rather than silently processed.
Rollback: a single revert restores the code. It does not restore a trim cursor already
advanced at runtime — the offload acks a section as soon as persisting did not fail, and a section
whose records the new gate rejects wholesale does not fail, it is empty. The raw-frame archive
catches that for the history data type only, not for event, metadata and protocol frames. That
combination is the one way this change could lose user data, and it is why the hardware abort
criterion is worded as above.
Scope limits and follow-ups
effect stay unhardened — including two probe outputs that pronounce a finding about the strap and
one that persists its payload. The one such path with offload effect (the data-range reply) was
pulled into scope instead. The promise is "the six state-driving gates are hardened", not "every
frame consumer".
implementation of the same rule; only the two points above are aligned. Full unification would be
its own change with its own oracle.
not excluded. The trust boundary is the BLE seam.
paths; no new write commands to hardware, no foreign frame bytes or firmware literals.
Branch:
fix/issue-5-frame-integrity. Contribution offered under the repository's PolyFormNoncommercial 1.0.0 license.