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
10 changes: 7 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,13 @@ 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.
oracle. This file *is* the oracle now — it was generated from a TypeScript
reference implementation (`ts/`) that was later deleted once parity was
locked in (still recoverable from git history if you need to regenerate
cases from scratch), but nothing in `dart test` spawns node/tsc against it
any more. If you change a decoder and parity breaks, either your change is
wrong or the fixture 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
Expand Down
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@

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.
the whole job. Covers WHOOP 4 (gen4) and WHOOP 5 (gen5), a generic Bluetooth Heart Rate
Service (0x180D) sensor, and the Oura ring's wire format.

This isn't backend-side anymore — the app ([edge](https://github.com/OpenStrap/edge))
depends on this package directly and calls it on-device. There's no cloud, no upload, no
Expand All @@ -30,6 +31,13 @@ server that ever sees your raw bytes.
etc.) and the control-plane decoders (HELLO, events, command responses, metadata/sync
markers).
- `constants.dart` — the GATT UUIDs, opcode tables, event IDs.
- `band.dart` — the multi-generation ("multi-band") wire-format profile: WHOOP 5 (gen5 /
"fd4b") is WHOOP 4 (gen4 / "Harvard") in a different envelope, and this is what lets
framing/records/edge stay band-agnostic instead of forking per generation.
- `gen5_records.dart` — WHOOP 5 (gen5) historical record decoders: v18/v20/v21/v22/v26.
- `hrs.dart` — the Bluetooth SIG's generic Heart Rate Service (0x180D) as a pure
function, for any standard chest strap or optical armband, not one vendor's device.
- `oura.dart` — the Oura ring's wire format, as pure functions.

## The one record that matters most

Expand Down Expand Up @@ -140,7 +148,7 @@ Pure Dart, no Flutter dependency:

```bash
dart pub get
dart test # 71 tests, incl. the 2934-case TS-parity suite
dart test # full suite, incl. the 2934-case TS-parity suite
```

Run tests from the repo root — the parity fixture (`decode_parity_cases.json`) is
Expand All @@ -155,7 +163,10 @@ an honest "not sure." If you're touching `records.dart`'s multi-version decode c
check `FirmwareAwareR24Decoder` first — chances are your case fits the existing fallback
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.
Cross-checking against other WHOOP reverse-engineering write-ups (e.g. the `noop`
project, or bWanShiTong's `reverse-engineering-whoop-post`) for facts/techniques is
fine; copying their code is not. These are external projects, not paths inside this
repo — go find and clone them separately if you want to compare.

## Contributing

Expand Down
50 changes: 19 additions & 31 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,43 +17,31 @@ company with an on-call rota:
for anything that puts user data at risk.
- Credit in the release notes if you want it.

## What this package actually is

This repo is a pure-Dart library with zero runtime dependencies: bytes in,
decoded records/frames/commands out. It doesn't run on its own, ship an app,
talk to a network, or store anything — no database, no telemetry, no
Firebase. It's a dependency of [edge](https://github.com/OpenStrap/edge),
which is where the app, its distribution model, and its data-handling
questions live.

## 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.
- A decoder that misparses bytes in a way that's exploitable, not just wrong
(buffer overreads, crashes on malformed input, anything an attacker could
use by controlling bytes the band or a proxy sends).
- Anything in this package's command builders that could be used to send a
command to a band the caller didn't ask for.

## 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.
- App-level distribution and privacy questions (signing, sideloading, device
storage access, telemetry, health-data upload) — those belong to
[edge's SECURITY.md](https://github.com/OpenStrap/edge/blob/main/SECURITY.md)
and [PRIVACY.md](https://github.com/OpenStrap/edge/blob/main/PRIVACY.md),
not this repo.
- 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.
46 changes: 16 additions & 30 deletions lib/src/commands.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ enum WristSelection {
final int value;
}

/// LE-encode a WHOOP (whole-seconds, subseconds) timestamp pair as the 6 bytes
/// `[sec:u32le][subsec:u16le]` shared by SET_CLOCK and every SET_ALARM form.
List<int> _leTimestamp(int sec, int subsec) => [
sec & 0xff,
(sec >> 8) & 0xff,
(sec >> 16) & 0xff,
(sec >> 24) & 0xff,
subsec & 0xff,
(subsec >> 8) & 0xff,
];

/// Build a framed command packet: [type][seq][opcode][payload].
/// [profile] selects the generation's frame envelope (default gen4 = WHOOP 4).
/// The inner bytes are identical across generations — command opcodes are
Expand Down Expand Up @@ -135,12 +146,7 @@ Uint8List cmdSetClock(int seq,
final sec = ms ~/ 1000;
final subsec = ((ms % 1000) * 32768) ~/ 1000; // 0..32767, 1/32768 s units
final payload = <int>[
sec & 0xff,
(sec >> 8) & 0xff,
(sec >> 16) & 0xff,
(sec >> 24) & 0xff,
subsec & 0xff,
(subsec >> 8) & 0xff,
..._leTimestamp(sec, subsec),
0,
0,
];
Expand Down Expand Up @@ -365,12 +371,7 @@ Uint8List cmdSetAlarmSimple(int seq, DateTime when,
final subsec = _alarmSubsec(when);
final p = <int>[
0x01,
sec & 0xff,
(sec >> 8) & 0xff,
(sec >> 16) & 0xff,
(sec >> 24) & 0xff,
subsec & 0xff,
(subsec >> 8) & 0xff,
..._leTimestamp(sec, subsec),
];
return buildCommand(seq, Cmd.setAlarmTime, p, profile);
}
Expand Down Expand Up @@ -422,12 +423,7 @@ List<int> alarmRev1Payload(DateTime when, {int hapticMode = 0}) {
final subsec = _alarmSubsec(when);
return <int>[
0x01,
sec & 0xff,
(sec >> 8) & 0xff,
(sec >> 16) & 0xff,
(sec >> 24) & 0xff,
subsec & 0xff,
(subsec >> 8) & 0xff,
..._leTimestamp(sec, subsec),
hapticMode & 0xff,
(hapticMode >> 8) & 0xff,
];
Expand Down Expand Up @@ -509,12 +505,7 @@ Uint8List cmdSetAlarm(
final p = <int>[
0x04,
slot & 0xff,
sec & 0xff,
(sec >> 8) & 0xff,
(sec >> 16) & 0xff,
(sec >> 24) & 0xff,
subsec & 0xff,
(subsec >> 8) & 0xff,
..._leTimestamp(sec, subsec),
...pattern.map((b) => b & 0xff),
if (profile.isGen5) crescendo,
];
Expand Down Expand Up @@ -652,12 +643,7 @@ Uint8List cmdSetClockGen5(int seq, {DateTime? now}) {
final subsec = ((ms % 1000) * 32768) ~/ 1000;
final payload = <int>[
revision1,
sec & 0xff,
(sec >> 8) & 0xff,
(sec >> 16) & 0xff,
(sec >> 24) & 0xff,
subsec & 0xff,
(subsec >> 8) & 0xff,
..._leTimestamp(sec, subsec),
0,
0,
];
Expand Down
65 changes: 40 additions & 25 deletions lib/src/control.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// NOT duplicated here: decodeFrame delegates the R24 branch to the now-native
// Dart parseR24 (records.dart, Source 1). PURE Dart.

import 'dart:math' as math;
import 'dart:typed_data';
import 'band.dart';
import 'constants.dart';
Expand All @@ -20,18 +21,10 @@ double f32(Uint8List b, int o) => _bd(b).getFloat32(o, Endian.little);

double _round(double v, int decimals) {
if (v.isNaN || v.isInfinite) return 0.0;
final p = _pow10(decimals);
final p = math.pow(10, decimals).toDouble();
return (v * p).roundToDouble() / p;
}

double _pow10(int n) {
double p = 1;
for (int i = 0; i < n; i++) {
p *= 10;
}
return p;
}

String _hex(Uint8List b) {
final sb = StringBuffer();
for (final x in b) {
Expand Down Expand Up @@ -1414,8 +1407,46 @@ Decoded _decodeDataRecord(Uint8List inner,
return Decoded('data_record', {'rec_type': inner.length > 1 ? inner[1] : -1});
}
final recType = inner.length > 1 ? inner[1] : -1;
// Live R10 (HR + IMU) — surface HR for the live display. Checked before the
// generic small-packet branch below: a short/lite R10 record (parseR10Lite
// only requires 18 bytes) is still under that branch's 64-byte cutoff and
// would otherwise be swallowed there first, misread by the wrong offsets,
// and never reach this branch at all.
if (recType == Record.r10) {
final r = parseR10Lite(inner);
if (r != null) {
// rr_ms too: parseR10Lite already accepted these beats, and the short
// realtime-HR branch above emits them — dropping them here silently
// halved the beat supply of anything reading live R10 through
// decodeFrame rather than live.dart.
//
// Emit even when hr==0 — that's a legitimate off-wrist reading (see
// live.dart's `wristOn = hr > 0`), not an undecoded record. Falling
// through to 'data_record' below dropped the timestamp and wearing
// state for every wrist-off period.
return Decoded('realtime_hr', {
'rec_type': recType,
'ts_epoch': r.tsEpoch,
'hr': r.hr,
'rr_ms': r.rrIntervalsMs,
'wearing': r.hr > 0,
});
}
}
// Compact realtime stream (small packet).
if (inner.length < 64) {
if (recType == 2) {
final v2 = parseRealtimeHrV2(inner);
if (v2 != null) {
return Decoded('realtime_hr', {
'rec_type': recType,
'ts_epoch': v2.tsEpoch,
'hr': v2.hrBpm,
'wearing': !v2.isOffBody,
'location': v2.locationRaw,
});
}
}
final hr = parseRealtimeHr(inner);
if (hr != null) {
return Decoded('realtime_hr', {
Expand All @@ -1429,22 +1460,6 @@ Decoded _decodeDataRecord(Uint8List inner,
}
return Decoded('realtime_small', {'rec_type': recType});
}
// Live R10 (HR + IMU) — surface HR for the live display.
if (recType == Record.r10) {
final r = parseR10Lite(inner);
if (r != null && r.hr > 0) {
// rr_ms too: parseR10Lite already accepted these beats, and the short
// realtime-HR branch above emits them — dropping them here silently
// halved the beat supply of anything reading live R10 through
// decodeFrame rather than live.dart.
return Decoded('realtime_hr', {
'rec_type': recType,
'hr': r.hr,
'rr_ms': r.rrIntervalsMs,
'wearing': true,
});
}
}
// R24: delegate to the native Dart full-record decoder (Source 1).
if (recType == Record.r24) {
final r = parseR24(inner);
Expand Down
25 changes: 10 additions & 15 deletions lib/src/gen5_records.dart
Original file line number Diff line number Diff line change
Expand Up @@ -719,14 +719,14 @@ class Gen5OpticalBlock {

/// LED A drive current in units of 10 µA. @ sharedMeta[1:3] u16.
/// [ledACurrentMicroamps] is the same number in µA.
int get ledACurrentRaw => _u16(sharedMetaRaw, 1);
int get ledACurrentRaw => _view(sharedMetaRaw).getUint16(1, Endian.little);
int get ledACurrentMicroamps => ledACurrentRaw * 10;

/// Which driver output LED B is wired to. @ sharedMeta[3].
int get ledBDriverConnection => sharedMetaRaw[3];

/// LED B drive current in units of 10 µA. @ sharedMeta[4:6] u16.
int get ledBCurrentRaw => _u16(sharedMetaRaw, 4);
int get ledBCurrentRaw => _view(sharedMetaRaw).getUint16(4, Endian.little);
int get ledBCurrentMicroamps => ledBCurrentRaw * 10;

/// Which PHYSICAL photodiode (1..4) is routed into the TIA 1 path for this
Expand All @@ -736,7 +736,8 @@ class Gen5OpticalBlock {

/// TIA 1's ADC full-scale range, in µA. @ channel0Meta[1:5] u32
/// (descriptor relative 7).
int get channel0AdcRange => _u32(channel0MetaRaw, 1);
int get channel0AdcRange =>
_view(channel0MetaRaw).getUint32(1, Endian.little);

/// TIA 1's offset-current setting, raw wire value: **signed i16, 10 nA/LSB
/// (0.01 µA/LSB)**. @ channel0Meta[5:7] = descriptor relative 11.
Expand All @@ -747,7 +748,8 @@ class Gen5OpticalBlock {
/// labels the unscaled nA value with a "µA" suffix — that is a logging bug,
/// not an alternative unit. [tia1OffsetCurrentNanoamps] is the same number
/// in nA.
int get tia1OffsetCurrentRaw => _i16(channel0MetaRaw, 5);
int get tia1OffsetCurrentRaw =>
_view(channel0MetaRaw).getInt16(5, Endian.little);
int get tia1OffsetCurrentNanoamps => tia1OffsetCurrentRaw * 10;

@Deprecated(
Expand All @@ -760,11 +762,13 @@ class Gen5OpticalBlock {
/// TIA 2's, same three fields at the same relative offsets (descriptor
/// relative 13/14/18).
int get channel1Source => channel1MetaRaw[0];
int get channel1AdcRange => _u32(channel1MetaRaw, 1);
int get channel1AdcRange =>
_view(channel1MetaRaw).getUint32(1, Endian.little);

/// TIA 2's offset-current setting — see [tia1OffsetCurrentRaw] for the unit
/// and the quantization; @ channel1Meta[5:7] = descriptor relative 18.
int get tia2OffsetCurrentRaw => _i16(channel1MetaRaw, 5);
int get tia2OffsetCurrentRaw =>
_view(channel1MetaRaw).getInt16(5, Endian.little);
int get tia2OffsetCurrentNanoamps => tia2OffsetCurrentRaw * 10;

@Deprecated(
Expand All @@ -775,15 +779,6 @@ class Gen5OpticalBlock {
int get channel1AdcOffset => tia2OffsetCurrentRaw & 0xFFFF;
}

int _u16(Uint8List b, int i) => b[i] | (b[i + 1] << 8);
int _i16(Uint8List b, int i) {
final v = _u16(b, i);
return v >= 0x8000 ? v - 0x10000 : v;
}

int _u32(Uint8List b, int i) =>
b[i] | (b[i + 1] << 8) | (b[i + 2] << 16) | (b[i + 3] << 24);

/// The raw optical deep buffer. Layout is confirmed: the body starts at inner
/// 18, then 5 blocks of 422 bytes each; every block holds 50 sample-pair slots,
/// photodiode slot A at `block + 21` and slot B at `block + 221`, and the
Expand Down
Loading
Loading