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
2 changes: 1 addition & 1 deletion lib/src/commands.dart
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ Uint8List cmdToggleHr(int seq, bool on) =>
///
/// NOTE: sending this with payload `[0x00]` (i.e. `cmdSendR10R11(seq, false)`)
/// is the REAL persistent raw-flood OFF-switch — the off state persists across
/// reconnects. STOP_RAW_DATA (0x52) does nothing. (PROTOCOL_FINDINGS.md:168-169)
/// reconnects. STOP_RAW_DATA (0x52) does nothing.
Uint8List cmdSendR10R11(int seq, bool on) =>
buildCommand(seq, Cmd.sendR10R11Realtime, [on ? 0x01 : 0x00]);
Uint8List cmdToggleImu(int seq, bool on) =>
Expand Down
57 changes: 40 additions & 17 deletions lib/src/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class PacketType {
class Cmd {
static const int linkValid = 0x01;
// Report the highest wire-protocol revision the strap understands. Response
// carries the max protocol version — used for firmware/feature gating.
// carries the max protocol version — used for feature gating.
static const int getMaxProtocolVersion = 0x02;
static const int toggleRealtimeHr = 0x03;
static const int reportVersionInfo = 0x07;
Expand All @@ -45,8 +45,10 @@ class Cmd {
static const int abortHistoricalTransmits = 0x14;
static const int sendHistoricalData = 0x16;
static const int historicalDataResult = 0x17; // the batch ACK
// DANGER — never send. Body is NOT empty: two LE i32 range args
// (full erase = both 0xFEFEFEFE). See PROTOCOL_FINDINGS.md.
// DANGER — never send. Body is NOT empty and is NOT a range/erase: it is an
// 8-byte `[u32 trim_page LE][u32 wrap_count LE]` — the
// same 8 bytes as the HISTORY_END trim token. It advances the strap's flash
// trim pointer, so a wrong value discards unsynced records.
static const int forceTrim = 0x19;
static const int getBatteryLevel = 0x1A;
static const int rebootStrap = 0x1D; // DANGER
Expand All @@ -56,12 +58,18 @@ class Cmd {
static const int setReadPointer = 0x21;
static const int getDataRange = 0x22;
static const int getHelloHarvard = 0x23;
// Firmware-load opcodes (Cmd opcode space — distinct from PacketType 0x24
// COMMAND_RESPONSE, which is inner[0], not a command opcode).
// DANGER — never send (per PROTOCOL_FINDINGS.md destructive list).
static const int startFirmwareLoad = 0x24; // DANGER
static const int loadFirmwareData = 0x25; // DANGER
static const int processFirmwareImage = 0x26; // DANGER
// Device-update trio (Cmd opcode space — distinct from PacketType
// 0x24 COMMAND_RESPONSE, which is inner[0], not a command opcode).
// DANGER — never send.
//
// ⚠ These 0x24-0x26 values are the GEN4-EMPIRICAL numbering and could not
// be confirmed on gen5. A gen5 strap does not act on them; the real gen5
// device-update trio is 0x8E/0x8F/0x90 (= 142/143/144), which
// [dangerousCmds] now also blocks. Keeping the gen4 entries because their
// numbering cannot be disproven, not because it is confirmed.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
static const int startUpdateLoad = 0x24; // DANGER (gen4-empirical)
static const int loadUpdateData = 0x25; // DANGER (gen4-empirical)
static const int processUpdateImage = 0x26; // DANGER (gen4-empirical)
static const int sendR10R11Realtime = 0x3F;
// On-device haptic alarm. SET carries a wall-clock epoch + a haptic waveform
// pattern (see cmdSetAlarm in commands.dart for the exact, hardware-verified
Expand Down Expand Up @@ -112,7 +120,7 @@ class Cmd {
/// Band-agnostic opcode safety classification, sourced from whoop-rs's
/// hardware-tested command surface (kept SEPARATE from [dangerousCmds] above,
/// which is OpenStrap's own, independently-curated gen4 list — the two do not
/// fully overlap, e.g. this list omits the firmware-load opcodes (0x24-0x26)
/// fully overlap, e.g. this list omits the device-update opcodes (0x24-0x26)
/// that [dangerousCmds] already blocks, and adds a few whoop-rs flags ours
/// didn't have, notably 120/SET_FF_VALUE — see the note on [forbidden] below).
///
Expand Down Expand Up @@ -146,24 +154,39 @@ class OpcodeSafety {
};

/// The subset of [forbidden] that is actively destructive (data loss /
/// bricking), not merely "don't auto-fire". Opcodes 142-144 have no named
/// meaning in either reference codebase — treat as permanently blocked,
/// unknown-but-dangerous.
/// bricking), not merely "don't auto-fire". Opcodes 142-144 (0x8E-0x90) are
/// the confirmed gen5 device-update trio — [dangerousCmds]
/// also lists them so they are actually REFUSED, because this set is
/// classification-only and self-enforces NOTHING (see the class doc). Treat
/// membership here as documentation; gate on [dangerousCmds] to block a send.
static const Set<int> destructive = {25, 45, 142, 143, 144};

static bool isForbidden(int opcode) => forbidden.contains(opcode);
static bool isDestructive(int opcode) => destructive.contains(opcode);
}

/// Commands that can brick the link / burn battery / brick flash. NEVER auto-fire.
/// Commands that can brick the link / burn battery / brick flash. NEVER
/// auto-fire. CALLER-ENFORCED: this package builds frames but has no transport
/// write path, so it does not itself block a send — a call site (edge, at the
/// point it writes a command) is expected to check membership here and refuse.
/// This is the set such a guard should gate on (contrast [OpcodeSafety], which
/// only sub-classifies and is likewise not self-enforcing).
const Set<int> dangerousCmds = {
Cmd.forceTrim,
Cmd.togglePersistentR21,
Cmd.rebootStrap,
Cmd.powerCycleStrap,
Cmd.startFirmwareLoad,
Cmd.loadFirmwareData,
Cmd.processFirmwareImage,
// gen4-empirical device-update numbering (unverified; inert on gen5).
Cmd.startUpdateLoad,
Cmd.loadUpdateData,
Cmd.processUpdateImage,
// CONFIRMED gen5 device-update trio (0x8E/0x8F/0x90 =
// 142/143/144). These are the opcodes a real gen5 strap actually acts on, so
// they must be in the enforced set — not merely classified in
// [OpcodeSafety.destructive]. No Cmd.* constant: we never construct these.
0x8E,
0x8F,
0x90,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

/// Historical-data record type (inner[1] of a 0x2F / data packet).
Expand Down
7 changes: 7 additions & 0 deletions lib/src/control.dart
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,13 @@ class Decoded {
/// scale, GET_HELLO opcode, historical-record version family); defaults to
/// gen4 so every existing caller is unchanged.
Decoded decodeFrame(Frame frame, {BandProfile profile = BandProfile.gen4}) {
// A frame that passed CRC but advertises a frame revision this decoder does
// not understand (see [Frame.frameRevOk]) must NOT be decoded with the rev-1
// `inner[0]/[1]/[2]` field offsets — surface it instead of silently handing
// back a body byte as the packet type / opcode.
if (!frame.frameRevOk) {
return Decoded('unsupported_frame_rev', {'packet_type': frame.packetType});
}
Comment on lines +829 to +835

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline lib/src/framing.dart --items all
rg -n -C 5 'class Frame\b|packetType|frameRevOk|inner\[' lib/src/framing.dart
rg -n -C 5 'unsupported_frame_rev|frame\.packetType' lib/src/control.dart

Repository: OpenStrap/protocol

Length of output: 3876


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the result shape and existing tests/usages to determine whether
# unsupported_frame_rev requires packet_type.
rg -n -C 6 'class Decoded\b|Decoded\(|unsupported_frame_rev|packet_type' lib test* 2>/dev/null || true

# Behavior probe from the getter definition: packetType reads inner[0].
python3 - <<'PY'
inner = bytes([0xA5, 0x10, 0x20, 0x99])
packet_type = inner[0] if len(inner) else -1
print({"unsupported_revision_inner": list(inner), "reported_packet_type": packet_type})
PY

Repository: OpenStrap/protocol

Length of output: 8004


Do not read revision-1 fields for unsupported revisions.

Frame.packetType reads inner[0]. Remove packet_type from the unsupported_frame_rev result.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/src/control.dart` around lines 829 - 835, Update the unsupported-revision
branch in the decoder to return only the unsupported_frame_rev status without
accessing Frame.packetType or including a packet_type field. Preserve the
existing handling for supported frame revisions.

final inner = frame.inner;
final pt = frame.packetType;
try {
Expand Down
28 changes: 26 additions & 2 deletions lib/src/framing.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,30 @@ class Frame {
final bool crc8Ok;
final bool crc32Ok;

Frame(this.inner, this.crc8Ok, this.crc32Ok);
/// Whether the frame header advertises the frame revision this decoder was
/// written for (rev-1). The `inner[0]/[1]/[2]` = packetType/seq/opcode field
/// offsets below assume rev-1 layout; a rev-2 frame can still pass both CRCs
/// yet shift those fields, so [opcode] would silently return a body byte.
///
/// gen5 carries an explicit revision byte at header[1] (0x01 on every real
/// strap frame — see [BandProfile.buildHeader]); a rev-2 frame stamps 0x02
/// there. gen4's 4-byte header has no revision byte, so it is always treated
/// as rev-1. Defaults true so directly-constructed frames and the entire
/// rev-1 path are unchanged; [parseFrame] sets it false for a gen5 frame
/// whose revision byte is not rev-1 — surfacing it instead of mis-decoding.
final bool frameRevOk;

Frame(this.inner, this.crc8Ok, this.crc32Ok, {this.frameRevOk = true});

/// Band-neutral alias for the header-integrity result.
bool get headerCrcOk => crc8Ok;

bool get valid => crc8Ok && crc32Ok;

/// Safe to read [packetType]/[seq]/[opcode] with the rev-1 field offsets:
/// CRCs pass AND the frame revision is one this decoder understands. Check
/// this (not just [valid]) before trusting [opcode] on an inbound frame.
bool get decodable => valid && frameRevOk;
Comment on lines +46 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect Frame parsing and validation consumers with surrounding context.
rg -n --type dart -C5 '\bparseFrame\s*\(|\.(valid|decodable)\b' lib test

Repository: OpenStrap/protocol

Length of output: 38092


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Enumerate production parsing, reassembly, and decoding paths, then inspect
# the relevant implementations and call sites.
printf '%s\n' '--- production parse/decode/reassembly references ---'
rg -n --type dart -C4 '\b(parseFrame|decodeFrame|FrameReassembler)\b|\.(valid|decodable|packetType|seq|opcode)\b' lib

printf '%s\n' '--- framing.dart structure and implementation ---'
ast-grep outline lib/src/framing.dart
sed -n '1,210p' lib/src/framing.dart

printf '%s\n' '--- decoder-related files ---'
rg -l --type dart '\bdecodeFrame\b|\bparseFrame\b|\bFrameReassembler\b' lib | sort

Repository: OpenStrap/protocol

Length of output: 16589


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- production parse/decode/reassembly references ---'
rg -n --type dart -C4 '\b(parseFrame|decodeFrame|FrameReassembler)\b|\.(valid|decodable|packetType|seq|opcode)\b' lib

printf '%s\n' '--- framing.dart structure and implementation ---'
ast-grep outline lib/src/framing.dart
sed -n '1,210p' lib/src/framing.dart

printf '%s\n' '--- decoder-related files ---'
rg -l --type dart '\bdecodeFrame\b|\bparseFrame\b|\bFrameReassembler\b' lib | sort

Repository: OpenStrap/protocol

Length of output: 16589


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- all repository call sites outside generated/build directories ---'
rg -n -g '!**/.git/**' -g '!**/build/**' -g '!**/dist/**' \
  '\b(parseFrame|decodeFrame|FrameReassembler)\s*(\(|<)|\.(valid|decodable)\b' .

printf '%s\n' '--- decodeFrame implementation and nearby parsers ---'
sed -n '430,520p' lib/src/control.dart
sed -n '800,900p' lib/src/control.dart

printf '%s\n' '--- BandProfile definitions and profile propagation ---'
rg -n -C8 '\b(class BandProfile|BandProfile\.gen[45]|profile\s*[:=])\b' lib test README.md

printf '%s\n' '--- public usage documentation ---'
rg -n -C5 '\b(parseFrame|decodeFrame|FrameReassembler|BandProfile)\b' README.md docs example tool 2>/dev/null || true

Repository: OpenStrap/protocol

Length of output: 50374


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

root = Path(".")
dart_files = [
    p for p in root.rglob("*.dart")
    if ".git" not in p.parts and "build" not in p.parts and "dist" not in p.parts
]

print("--- production parseFrame call classification ---")
for p in sorted(dart_files):
    if "test" in p.parts:
        continue
    for n, line in enumerate(p.read_text().splitlines(), 1):
        if re.search(r"\bparseFrame\s*\(", line):
            print(f"{p}:{n}: {line.strip()}")

print("--- production FrameReassembler construction classification ---")
for p in sorted(dart_files):
    if "test" in p.parts:
        continue
    for n, line in enumerate(p.read_text().splitlines(), 1):
        if "FrameReassembler(" in line:
            print(f"{p}:{n}: {line.strip()}")

print("--- decodeFrame dispatch ordering ---")
control = Path("lib/src/control.dart").read_text().splitlines()
start = next(i for i, s in enumerate(control) if s.startswith("Decoded decodeFrame("))
end = next(i for i in range(start + 1, len(control)) if control[i].startswith("Decoded _decodeDataRecord"))
body = control[start:end]
for i, s in enumerate(body, start + 1):
    if re.search(r"\b(frame\.decodable|frame\.valid|frame\.packetType|frame\.seq|frame\.opcode)\b", s):
        print(f"lib/src/control.dart:{i}: {s.strip()}")

packet = next(i for i, s in enumerate(body) if "frame.packetType" in s)
guard = next((i for i, s in enumerate(body) if "frame.decodable" in s or "frame.valid" in s), None)
print(f"packet_type_read_index={packet}")
print(f"decodability_guard_index={guard}")
print("decodeFrame_guards_decodability=",
      guard is not None and guard < packet)
PY

printf '%s\n' '--- focused test consumers that decode or dereference parsed frames ---'
rg -n -C2 --type dart \
  'parseFrame|decodeFrame|FrameReassembler|parsed\.inner|frame\.inner|\.packetType|\.seq|\.opcode' test \
  | rg -v '^\s*(//|test/[^:]+:[0-9]+-\s*(test|group|expect|final|late|return|}))' \
  | head -n 220

Repository: OpenStrap/protocol

Length of output: 15265


Guard decodeFrame against unsupported revisions

decodeFrame reads frame.packetType without checking frame.decodable and defaults to BandProfile.gen4. A CRC-valid gen5 rev-2 frame can therefore be decoded with rev-1 offsets and gen4 shapes. Reject or surface undecodable frames before reading frame fields, and pass the connected BandProfile through inbound reassembly and decoding. Add a regression test for this path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/src/framing.dart` around lines 46 - 49, The decodeFrame flow must reject
unsupported frame revisions before accessing frame.packetType or other rev-1
offsets; use frame.decodable rather than only valid, and propagate the connected
BandProfile through inbound reassembly and decoding instead of defaulting to
BandProfile.gen4. Add a regression test covering a CRC-valid rev-2/gen5 frame
and verify it is rejected or surfaced as undecodable.

int get packetType => inner.isNotEmpty ? inner[0] : -1;
int get seq => inner.length > 1 ? inner[1] : -1;
int get opcode => inner.length > 2 ? inner[2] : -1;
Expand Down Expand Up @@ -81,7 +99,13 @@ Frame? parseFrame(Uint8List raw, {BandProfile profile = BandProfile.gen4}) {
final storedBd =
raw.buffer.asByteData(raw.offsetInBytes + innerStart + declared - 4, 4);
final stored = storedBd.getUint32(0, Endian.little);
return Frame(Uint8List.fromList(inner), headerCrcOk, stored == crc32(inner));
// gen5 header[1] is the frame-revision byte (rev-1 = 0x01). A rev-2 frame can
// pass both CRCs but shifts the inner field offsets, so flag it rather than
// let the rev-1 getters return a body byte as the opcode. gen4 has no such
// byte and is always rev-1.
final frameRevOk = !profile.isGen5 || raw[1] == revision1;
return Frame(Uint8List.fromList(inner), headerCrcOk, stored == crc32(inner),
frameRevOk: frameRevOk);
}

/// Length-based reassembler. feed() returns every complete Frame it can carve
Expand Down
2 changes: 1 addition & 1 deletion lib/src/gen5_records.dart
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ abstract class Gen5HistoricalRecord {
/// per-field below — several fields have OPEN semantic disagreements between
/// the two reference implementations (whoop-rs vs noop) that could not be
/// resolved from bytes alone; those are called out explicitly rather than
/// silently picking a side. See PROTOCOL_FINDINGS / the multiband spec §1.7.
/// silently picking a side.
class Gen5HistorySample extends Gen5HistoricalRecord {
/// bpm. 0 is a legitimate reading (device warming up), not absence.
final int heartRate;
Expand Down
76 changes: 76 additions & 0 deletions test/framing_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,80 @@ void main() {
expect(frames.every((f) => f.valid), isTrue);
});
});

group('dangerousCmds (enforced never-send set)', () {
test('includes the confirmed gen5 brick trio 0x8E/0x8F/0x90', () {
expect(dangerousCmds.containsAll(<int>{0x8E, 0x8F, 0x90}), isTrue);
});

test('keeps the gen4-empirical device-update trio + destructive singles', () {
expect(
dangerousCmds,
containsAll(<int>[
Cmd.startUpdateLoad,
Cmd.loadUpdateData,
Cmd.processUpdateImage,
Cmd.forceTrim,
Cmd.rebootStrap,
Cmd.powerCycleStrap,
Cmd.togglePersistentR21,
]),
);
});

test('OpcodeSafety.destructive gen5 opcodes (142-144) are actually enforced', () {
final gen5Destructive =
OpcodeSafety.destructive.where((o) => o >= 142).toSet();
expect(gen5Destructive, {142, 143, 144});
expect(dangerousCmds.containsAll(gen5Destructive), isTrue,
reason: 'classification-only set must be blocked by the enforced set');
});
});

group('frame-rev-2 hardening', () {
test('gen5 rev-2 frame passes CRC but is flagged non-decodable', () {
final raw = Uint8List.fromList(
buildCommand(3, Cmd.getHello, const [0x01], BandProfile.gen5));

final rev1 = parseFrame(raw, profile: BandProfile.gen5)!;
expect(rev1.valid, isTrue);
expect(rev1.frameRevOk, isTrue);
expect(rev1.decodable, isTrue);

// Forge a rev-2 frame: bump the header revision byte (header[1]) and fix
// the header crc16 so the header-integrity check still passes — i.e. the
// frame stays .valid, exactly the latent case that used to mis-decode.
raw[1] = 0x02;
final c = crc16Modbus(raw.sublist(0, 6));
raw[6] = c & 0xFF;
raw[7] = (c >> 8) & 0xFF;

final rev2 = parseFrame(raw, profile: BandProfile.gen5)!;
expect(rev2.valid, isTrue, reason: 'both CRCs still pass');
expect(rev2.frameRevOk, isFalse, reason: 'revision byte 0x02 is not rev-1');
expect(rev2.decodable, isFalse,
reason: 'surfaced, not silently decoded as rev-1');
});

test('gen4 has no revision byte and is always treated as rev-1', () {
final f = parseFrame(buildCommand(0, Cmd.getHelloHarvard, const [0x00]))!;
expect(f.frameRevOk, isTrue);
expect(f.decodable, isTrue);
});

test('decodeFrame surfaces a rev-2 frame instead of decoding rev-1 fields', () {
final raw = Uint8List.fromList(
buildCommand(3, Cmd.getHello, const [0x01], BandProfile.gen5));
raw[1] = 0x02; // bump frame revision
final c = crc16Modbus(raw.sublist(0, 6)); // keep header CRC valid
raw[6] = c & 0xFF;
raw[7] = (c >> 8) & 0xFF;

final f = parseFrame(raw, profile: BandProfile.gen5)!;
expect(f.valid, isTrue);
final d = decodeFrame(f, profile: BandProfile.gen5);
expect(d.kind, 'unsupported_frame_rev',
reason: 'must not be decoded with rev-1 offsets');
});
});
}
8 changes: 4 additions & 4 deletions test/whoop_protocol_update_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,14 @@ void main() {
Cmd.rebootStrap,
Cmd.powerCycleStrap,
Cmd.togglePersistentR21,
Cmd.startFirmwareLoad,
Cmd.loadFirmwareData,
Cmd.processFirmwareImage,
Cmd.startUpdateLoad,
Cmd.loadUpdateData,
Cmd.processUpdateImage,
]),
);
// 0x24 here is a Cmd opcode; PacketType.commandResponse (0x24) is a
// separate namespace (inner[0], not inner[2]).
expect(Cmd.startFirmwareLoad, 0x24);
expect(Cmd.startUpdateLoad, 0x24);
expect(Cmd.powerCycleStrap, 0x20);
// 0x62 is GET_EXTENDED_BATTERY_INFO; there is no 0x63 command (the old
// "reset fuel gauge" opcode was a decode artifact and has been removed).
Expand Down
Loading