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..46f13f2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,47 @@ +name: test + +on: + push: + branches: [main] + 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 + 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: + # 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: ${{ matrix.sdk }} + + - name: Install dependencies + run: dart pub get + + - 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..7a19127 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,59 @@ +# 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 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 +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 =