From 5b231eddaa172b699cd424309566edeaed8af367 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 26 Jul 2026 11:16:33 +0530 Subject: [PATCH 1/3] chore: CI, contributor docs, and a Dart-only language bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the things a public repo needs and this one was missing. CI: there was none. 71 tests existed and nothing ran them on a PR — the only automation anywhere in the org was a release build triggered by a version tag. Adds dart analyze + dart test on every push and PR. The golden capture (whoop_hist.jsonl) is a real band recording kept beside the repo rather than committed to it, so decoder_test hard-failed with a StateError whenever it was absent — which is exactly what CI is. Those cases now SKIP instead, matching what the analytics suite already did. Locally: 71 pass. Without the fixture: 67 pass, 4 skip, green. The 2934-case parity oracle and the 550 dart_header cases are tracked in-repo and run either way, so CI still covers the decoders properly. .gitattributes marks ts/ vendored. It is the frozen oracle that generated those fixtures and is worth keeping, but GitHub was counting it as 13% of the package and the README says "pure Dart, zero deps". Now the language bar says what the package actually is, without deleting the oracle. Also adds CONTRIBUTING.md (which repo a change belongs in, and how to report a protocol finding in a way that can be checked), SECURITY.md for private disclosure, FUNDING.yml, and README badges. Test count in the README corrected 70 -> 71. --- .gitattributes | 11 +++++ .github/FUNDING.yml | 3 ++ .github/workflows/test.yml | 34 +++++++++++++++ CONTRIBUTING.md | 87 ++++++++++++++++++++++++++++++++++++++ README.md | 22 +++++++++- SECURITY.md | 52 +++++++++++++++++++++++ test/decoder_test.dart | 47 ++++++++++++-------- 7 files changed, 237 insertions(+), 19 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/test.yml create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5154955 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# The TypeScript under ts/ is the frozen reference oracle: it generated +# decode_parity_cases.json (2934 cases) and dart_header.json (550 cases), which +# the Dart suite asserts against on every run. It is kept for provenance and +# regeneration, not shipped or imported by anything. +# +# Marking it vendored keeps GitHub's language bar reporting what this package +# actually is — pure Dart — without deleting the oracle. +ts/** linguist-vendored +*.json linguist-generated +decode_parity_cases.json linguist-generated +dart_header.json linguist-generated diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..2365365 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# GitHub funding config only accepts URLs, not raw wallet addresses. +custom: + - https://github.com/OpenStrap/edge/blob/main/DONATE.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..aac0bc4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ +name: test + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + + - name: Install dependencies + run: dart pub get + + - name: Verify formatting + run: dart format --output=none --set-exit-if-changed lib test + continue-on-error: true + + - name: Analyze + run: dart analyze --fatal-infos + + # The golden capture (whoop_hist.jsonl) is a real band recording kept + # beside the repo rather than committed to it, so the tests that need it + # SKIP here and run locally. Everything else — including the 2934-case + # decode_parity_cases.json oracle and the 550 dart_header.json cases — + # is tracked in-repo and runs on every PR. + - name: Test + run: dart test --reporter=expanded diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..281a240 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,87 @@ +# Contributing + +This package is the byte layer: bytes off the band in, named records out. Keep +it that way — it has **zero runtime dependencies** and no I/O, and that's a +feature, not an accident. It runs on-device inside the app. + +## What belongs here (and what doesn't) + +| Your change | Repo | +|---|---| +| A record type, opcode, event, CRC/framing, GATT constant | **here** | +| A metric, or how a number is computed from records | [analytics](https://github.com/OpenStrap/analytics) | +| Bluetooth reliability, storage, sync, UI | [edge](https://github.com/OpenStrap/edge) | + +Nothing in `lib/` may import `dart:io`, do network calls, or pull in a package. +If you need those, your change belongs in edge. + +## Protocol findings are the most valuable contribution + +A lot of the current event table is empirical guesswork by one person. If you've +worked out a field, an opcode, or an event we don't decode — or found one we +decode *wrongly* — that helps everyone with one of these bands. + +Open an issue with: + +- The raw frame bytes (hex), as many samples as you have. +- What you think the field is, and at what offset. +- **How you convinced yourself.** This is the important part. "HR at offset 17 + tracked my actual pulse across 40 minutes of wear, and offset 14 didn't" is a + real answer. Correlating against a known ground truth beats pattern-matching. + +Firmware versions differ. If a field only holds on one `histVersion`, say which +one — `parseR24` already routes per-version and that's usually where a new +finding lands. + +## Ground rules + +**Don't decode something you can't justify.** A wrong field is worse than a +missing one, because it silently poisons every metric downstream. If a value is +only *probably* right, gate it behind a plausibility check the way +`_physiologicallyPlausible` does (HR 25–230, |g|² 0.25–3.24) rather than +trusting it outright. + +**Never guess a value to fill a gap.** If a record doesn't decode, return +`null`. The app archives undecodable records rather than discarding them, so a +`null` here is recoverable later; a fabricated value is not. + +**Some opcodes are dangerous.** `dangerousCmds` exists for a reason — force-trim, +reboot, power-cycle, firmware load. Don't wire any of them into an automatic +path, and don't add new ones without a very clear justification in the PR. + +## Tests + +Run from the repo root: + +```bash +dart pub get +dart analyze +dart test +``` + +Two things guard every change: + +- **`decode_parity_cases.json`** — 2934 cases checked against the frozen + TypeScript oracle in `ts/`. If you change a decoder and parity breaks, either + your change is wrong or the oracle needs regenerating — work out which, and + say so in the PR. +- **`dart_header.json`** — 550 hand-checked R24 header cases. + +Both are tracked in-repo and run in CI. A third set replays +`whoop_hist.jsonl`, a real band capture kept *beside* the repo rather than in +it; those tests skip automatically when it's absent, which is what CI does. + +New record types or opcodes should come with test cases from real frames. + +## Pull requests + +- Branch off `main`, one logical change per PR. +- Say how you verified it against real hardware. +- No `Co-Authored-By` trailers. + +## Scope + +Facts about a wire protocol, worked out by observing your own device, are fine. +Vendor source code, firmware, decompiled binaries, and material from other +reverse-engineering projects whose licences don't permit reuse are not — don't +paste them into code, comments, commits, or PR descriptions. diff --git a/README.md b/README.md index 677763c..960b47e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # OpenStrap protocol +[![test](https://github.com/OpenStrap/protocol/actions/workflows/test.yml/badge.svg)](https://github.com/OpenStrap/protocol/actions/workflows/test.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) + Pure Dart, zero runtime deps. You hand it an already-unwrapped chunk of bytes from the band, it hands you back a record with named fields, or a decoded command/event. That's the whole job. @@ -126,7 +129,7 @@ Pure Dart, no Flutter dependency: ```bash dart pub get -dart test # 70 tests, incl. the 2934-case TS-parity suite +dart test # 71 tests, incl. the 2934-case TS-parity suite ``` Run tests from the repo root — the parity fixture (`decode_parity_cases.json`) is @@ -142,3 +145,20 @@ check `FirmwareAwareR24Decoder` first — chances are your case fits the existin shape rather than needing a new one. Cross-checking against `_external/noop/` `bWanShiTong/reverse-engineering-whoop-post/` for facts/techniques is fine; copying its code is not. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) — which repo a change belongs in, how to run the +tests, and the rules that keep this package honest. Security issues go through +[SECURITY.md](SECURITY.md), not a public issue. + +## Support the work + +Free, MIT, no company behind it. If OpenStrap gave your band a second life, +[DONATE.md](https://github.com/OpenStrap/edge/blob/main/DONATE.md) has BTC and EVM +addresses. Protocol findings and bug reports are worth more than money, though. + +--- + +Not affiliated with, endorsed by, or connected to WHOOP. "WHOOP" is their trademark, used +only to say which device this talks to. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..82eb178 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,52 @@ +# Security Policy + +## Reporting a vulnerability + +Please **don't** open a public issue for a security problem. + +Use GitHub's private reporting instead: +[**Report a vulnerability →**](https://github.com/OpenStrap/protocol/security/advisories/new) + +That goes straight to the maintainer and stays private until there's a fix. + +Rough expectations, set honestly — this is a one-maintainer project, not a +company with an on-call rota: + +- Acknowledgement within about a week. +- An assessment, and a fix or a clear "won't fix and here's why", within 30 days + for anything that puts user data at risk. +- Credit in the release notes if you want it. + +## What's in scope + +- Anything that discloses a user's health data off their device. +- Anything that lets a third party read, write to, or hijack the Bluetooth + session with someone's band. +- Local data-at-rest problems: the database, exports, the App Group container, + widget snapshots. +- The optional companion worker in + [backend](https://github.com/OpenStrap/backend): auth, the import endpoints, + the opt-in telemetry and health-upload paths. +- Anything that causes the app to send data anywhere the user did not agree to. + +## What's out of scope + +- The band's own firmware. We don't ship it, can't patch it, and won't publish + attacks against it. +- WHOOP's own apps and services. Please report those to WHOOP. +- The fact that sideloaded builds are unsigned, or that a rooted/jailbroken + device can read app storage. Both are known properties of the distribution + model, documented in the README. +- Metric accuracy. Wrong numbers are bugs — open a normal issue. + +## Where your data actually is + +Worth knowing before you go looking: OpenStrap computes and stores everything +on-device. There's no account and no server holding your health data. The +optional companion worker exists for legacy import, an update pointer, and two +opt-in features that are off by default and compiled out of store builds +entirely. See [edge's PRIVACY.md](https://github.com/OpenStrap/edge/blob/main/PRIVACY.md). + +That means the realistic attack surface is the phone, the Bluetooth link, and +the local database — not a cloud backend. Reports focused there are the most +useful. diff --git a/test/decoder_test.dart b/test/decoder_test.dart index efed0b3..507182f 100644 --- a/test/decoder_test.dart +++ b/test/decoder_test.dart @@ -8,28 +8,38 @@ import 'dart:typed_data'; import 'package:openstrap_protocol/openstrap_protocol.dart'; import 'package:test/test.dart'; +/// The golden fixture is a real band capture that lives beside the repo rather +/// than inside it, so it is present for local runs and absent in CI. Resolve it +/// once up front: when it is missing the fixture-backed groups SKIP instead of +/// failing, which keeps CI honest (green means "everything runnable passed") +/// without pretending the fixture ran. +File? _findHistFixture() { + for (final c in const ['../whoop_hist.jsonl', 'whoop_hist.jsonl']) { + final f = File(c); + if (f.existsSync()) return f; + } + return null; +} + void main() { + final histFixture = _findHistFixture(); + final skipHist = histFixture == null + ? 'whoop_hist.jsonl fixture not found beside the repo' + : null; + group('whoop_hist.jsonl golden fixture (550 records)', () { late List> records; setUpAll(() { - final candidates = ['../whoop_hist.jsonl', 'whoop_hist.jsonl']; - File? f; - for (final c in candidates) { - if (File(c).existsSync()) { - f = File(c); - break; - } - } - if (f == null) { - throw StateError( - 'whoop_hist.jsonl fixture not found (looked in $candidates)'); - } - records = f - .readAsLinesSync() - .where((l) => l.trim().isNotEmpty) - .map((l) => json.decode(l) as Map) - .toList(); + // NOTE: package:test still runs setUpAll for a skipped group, so this has + // to tolerate the fixture being absent rather than assert on it. + records = histFixture == null + ? const [] + : histFixture + .readAsLinesSync() + .where((l) => l.trim().isNotEmpty) + .map((l) => json.decode(l) as Map) + .toList(); }); test('loaded 550 records', () { @@ -71,8 +81,9 @@ void main() { } expect(ok, records.length); }); - }); + }, skip: skipHist); + // dart_header.json IS tracked in-repo, so this group always runs in CI. group('dart_header.json (550 R24 header cases)', () { test('counter/ts_epoch/ts_subsec/hr all match', () { final cases = From 1c205cd138704c02c21ba6fa3c8394a3bda1eeab Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 26 Jul 2026 12:28:01 +0530 Subject: [PATCH 2/3] =?UTF-8?q?chore:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20CI=20hardening,=20honest=20privacy=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harden the workflow (zizmor `artipacked`): add `permissions: contents: read` and `persist-credentials: false`. The job builds and tests PR-authored code and never pushes back, so leaving GITHUB_TOKEN in .git/config was a real if small exposure. Dropped the "Verify formatting" step rather than keep it. It carried `continue-on-error: true`, so it could never fail — a check that cannot fail is worse than no check, because the green tick implies coverage it doesn't have. Making it enforcing isn't an option right now either: `dart format` would rewrite 12 of 16 files. Adopting dart format is a reasonable thing to do, but it's a large mechanical diff that would churn every open PR, so it belongs in its own change, not smuggled in here. SECURITY.md overstated the privacy boundary. It said OpenStrap "computes and stores everything on-device", which reads as absolute and isn't: anonymous Firebase crash/performance diagnostics are ON BY DEFAULT in GitHub release builds (absent from store builds, never health data), and health-data contribution uploads the local database when explicitly enabled. Both were already documented in PRIVACY.md — SECURITY.md just didn't reflect them. Now states the boundary exactly. This project's whole pitch is not overclaiming, so the security policy shouldn't either. Not applied — `push: branches: [main]`. CodeRabbit read the branch filter as skipping feature branches, but `pull_request` already covers them; dropping it would just double every PR run. --- .github/workflows/test.yml | 13 +++++++++---- SECURITY.md | 17 ++++++++++++----- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aac0bc4..8b026b8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,11 +6,20 @@ on: pull_request: workflow_dispatch: +# Read-only: this job only builds and tests. Nothing here needs write access, +# and it executes code from pull requests. +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + # Don't leave GITHUB_TOKEN in .git/config where PR-authored test code + # could read it. Nothing in this job pushes back to the repo. + persist-credentials: false - uses: dart-lang/setup-dart@v1 with: sdk: stable @@ -18,10 +27,6 @@ jobs: - name: Install dependencies run: dart pub get - - name: Verify formatting - run: dart format --output=none --set-exit-if-changed lib test - continue-on-error: true - - name: Analyze run: dart analyze --fatal-infos diff --git a/SECURITY.md b/SECURITY.md index 82eb178..7a19127 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -41,11 +41,18 @@ company with an on-call rota: ## Where your data actually is -Worth knowing before you go looking: OpenStrap computes and stores everything -on-device. There's no account and no server holding your health data. The -optional companion worker exists for legacy import, an update pointer, and two -opt-in features that are off by default and compiled out of store builds -entirely. See [edge's PRIVACY.md](https://github.com/OpenStrap/edge/blob/main/PRIVACY.md). +Worth knowing before you go looking: OpenStrap computes and stores your health +data on-device, and there's no account or server holding it. Two qualifications, +so the boundary is exact: + +- **Anonymous diagnostics** (Firebase crash/performance, never health data) are + **on by default in GitHub release builds** and absent from App Store / Play + Store builds. Switchable off in-app. +- **Health-data contribution** uploads the local database, but is opt-in, off by + default, and compiled out of store builds entirely. + +Everything else the companion worker does — legacy import, an update pointer — +is optional and carries no health data. See [edge's PRIVACY.md](https://github.com/OpenStrap/edge/blob/main/PRIVACY.md). That means the realistic attack surface is the phone, the Bluetooth link, and the local database — not a cloud backend. Reports focused there are the most From 980b89a0bd5aea0898924937405c442f6f0c40e6 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 26 Jul 2026 12:42:13 +0530 Subject: [PATCH 3/3] ci: actually test the declared minimum SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pubspec declares `sdk: ^3.5.0`, which is a promise this package makes to anyone depending on it, but CI only ever ran whatever `stable` happened to be that week. So the lower bound was an untested guess. Adds a matrix over ['3.5.0', 'stable'] with fail-fast: false, so a break on the floor is distinguishable from a break on current. If 3.5.0 turns out not to work, the honest fix is to raise the constraint to what does — but either way it should be measured rather than assumed. --- .github/workflows/test.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8b026b8..46f13f2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,6 +14,14 @@ permissions: jobs: test: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # pubspec declares `sdk: ^3.5.0`, so 3.5.0 is a claim this package makes + # to anyone depending on it. Test it, rather than only testing whatever + # `stable` happens to be — otherwise the lower bound is a guess. + sdk: ['3.5.0', 'stable'] + name: test (Dart ${{ matrix.sdk }}) steps: - uses: actions/checkout@v4 with: @@ -22,7 +30,7 @@ jobs: persist-credentials: false - uses: dart-lang/setup-dart@v1 with: - sdk: stable + sdk: ${{ matrix.sdk }} - name: Install dependencies run: dart pub get