From efc2e85929ee9d3bca735b50e2bdbe0e00f9412c Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:36:27 +0530 Subject: [PATCH 1/2] oura + generic hrs wire format moved in from edge they were pure decode functions living in edge with no BLE/db deps of their own, so they belong here with the rest of the bytes-in-records-out code, not next to the session that drives them. two renames to avoid a barrel collision: parseFrame -> parseOuraFrame, parseEvent -> parseOuraEvent (whoop already owns the bare names). AES-128/ecb auth-response encryption stays in edge, this package has zero runtime deps on purpose. tests moved with the code they pin. --- lib/openstrap_protocol.dart | 16 +- lib/src/hrs.dart | 78 ++++++ lib/src/oura.dart | 399 +++++++++++++++++++++++++++++++ pubspec.yaml | 2 +- test/hrs_test.dart | 90 +++++++ test/oura_confirmation_test.dart | 213 +++++++++++++++++ test/oura_test.dart | 289 ++++++++++++++++++++++ 7 files changed, 1083 insertions(+), 4 deletions(-) create mode 100644 lib/src/hrs.dart create mode 100644 lib/src/oura.dart create mode 100644 test/hrs_test.dart create mode 100644 test/oura_confirmation_test.dart create mode 100644 test/oura_test.dart diff --git a/lib/openstrap_protocol.dart b/lib/openstrap_protocol.dart index 9aadde6..8c3b213 100644 --- a/lib/openstrap_protocol.dart +++ b/lib/openstrap_protocol.dart @@ -1,14 +1,24 @@ -/// openstrap_protocol — pure-Dart WHOOP 4.0 protocol library. +/// openstrap_protocol — pure-Dart, multi-band BLE protocol library. /// /// Combines the TS record decoders (parse_r24 / live decoders) with the edge /// framing / CRC / command / control-plane code into one bytes <-> records -/// library. No runtime dependencies; dart:typed_data / dart:convert / dart:math -/// only. +/// library, plus the wire format for every other band this project speaks to. +/// No runtime dependencies; dart:typed_data / dart:convert / dart:math only — +/// a band whose wire format needs a crypto primitive (Oura's AES-128/ECB auth +/// response) keeps that one piece with the session that drives it, one layer +/// up, rather than adding a dependency here. library openstrap_protocol; // Source 0 — multi-band wire-format profile (gen4 / gen5). export 'src/band.dart' show DeviceType, GattProfile, BandProfile; +// Source 0b — other bands' wire formats. Function names are prefixed +// (`parseOuraFrame`, not `parseFrame`) precisely because this library already +// has a `parseFrame`/`parseEvent` for WHOOP's framed envelope — two bands +// sharing one barrel must not share a bare verb. +export 'src/oura.dart'; +export 'src/hrs.dart'; + // Source 1 — record decoders. export 'src/records.dart' show diff --git a/lib/src/hrs.dart b/lib/src/hrs.dart new file mode 100644 index 0000000..16328cb --- /dev/null +++ b/lib/src/hrs.dart @@ -0,0 +1,78 @@ +// The Bluetooth SIG's Heart Rate Service (0x180D) — Heart Rate Measurement +// (0x2A37) — as a pure function. Any standard chest strap or optical armband +// that implements the SIG spec, not any one vendor's device. +// +// NOTHING HERE HAS MET HARDWARE. Nobody on this project owns a strap and +// `flutter_blue_plus` has no simulator path, so this is verified by the +// Bluetooth SIG's Heart Rate Service 1.0 layout and by the compiler. + +/// One Heart Rate Measurement notification. +class HrsSample { + /// Beats per minute as the sensor reported it. + final int hr; + + /// Beat-to-beat DURATIONS in milliseconds carried by this notification, in + /// the order the sensor sent them. Empty when the sensor does not report RR + /// (the flag is OPTIONAL in the SIG spec and plenty of straps send only a + /// bpm) — empty is "not reported", never "zero". + final List rrMs; + + /// The sensor's own contact claim: true/false when it reports one, null when + /// it does not support the field at all. Never inferred from HR. + final bool? contact; + + const HrsSample({required this.hr, required this.rrMs, this.contact}); +} + +/// Parse a Heart Rate Measurement (0x2A37) value. +/// +/// Layout (Bluetooth SIG, Heart Rate Service 1.0): +/// byte 0 flags +/// bit 0 HR format: 0 = uint8, 1 = uint16 little-endian +/// bits 1-2 sensor contact: 0b00/0b01 = not supported, 0b10 = no contact, +/// 0b11 = contact +/// bit 3 Energy Expended present (uint16, kJ) — skipped, we do not use it +/// bit 4 RR-Interval present (one or more uint16, units of 1/1024 s) +/// then HR, then energy expended if present, then RR intervals to the end. +/// +/// Returns null for a value that cannot be read as this characteristic (too +/// short, or a truncated field). A malformed notification is DROPPED, never +/// patched up into a plausible-looking beat. +HrsSample? parseHeartRateMeasurement(List value) { + if (value.length < 2) return null; + final flags = value[0]; + final wide = (flags & 0x01) != 0; + var i = 1; + final int hr; + if (wide) { + if (value.length < 3) return null; + hr = value[1] | (value[2] << 8); + i = 3; + } else { + hr = value[1]; + i = 2; + } + // 0 bpm is not a measurement. Sensors emit it while searching for a signal; + // storing it would put a real-looking zero into a heart-rate series. + if (hr <= 0 || hr > 300) return null; + + final contactBits = (flags >> 1) & 0x03; + final contact = contactBits < 2 ? null : contactBits == 3; + + if ((flags & 0x08) != 0) i += 2; // energy expended — present, not used + final rr = []; + if ((flags & 0x10) != 0) { + // Trailing RR intervals, uint16 LE, 1/1024 s each. A trailing odd byte is a + // malformed value: stop rather than reading past it. + while (i + 1 < value.length) { + final ticks = value[i] | (value[i + 1] << 8); + i += 2; + // 1024 ticks = 1 s. Round to the nearest millisecond. + final ms = (ticks * 1000 + 512) ~/ 1024; + // 250-3000 ms is 20-240 bpm. Outside that the value is not a beat + // interval, and a chest strap emits exactly this junk on a dropped beat. + if (ms >= 250 && ms <= 3000) rr.add(ms); + } + } + return HrsSample(hr: hr, rrMs: rr, contact: contact); +} diff --git a/lib/src/oura.dart b/lib/src/oura.dart new file mode 100644 index 0000000..0241dea --- /dev/null +++ b/lib/src/oura.dart @@ -0,0 +1,399 @@ +// The Oura ring's wire format, as pure functions. No BLE, no Flutter, no +// database — everything here takes bytes and returns values, so the whole of it +// is exercised by `test/oura_test.dart` against real captured records with no +// hardware in the room. +// +// AES-128/ECB auth-response encryption is NOT here: it needs a cipher +// implementation this package deliberately has none of (zero runtime deps). +// It lives with the session that drives this wire format, one layer up. +// +// WHAT IS PROVEN AND WHAT IS NOT. The distinction matters more here than +// anywhere else in this directory, because nobody on this project owns a ring +// (ASSUMPTIONS R6) and a decoder that is confidently wrong is the one failure +// this project treats as worse than an absent number. +// +// * PROVEN against a real 10,208-record capture: the frame header, the event +// envelope, the deciseconds timestamp unit, and every branch of +// [decodeDebugData] below. The fixture in the test file is that capture. +// * PROVEN by layout plus an independent physiological sanity check: the +// temperature decoders. centi-degrees Celsius, and a worn ring reads +// 33-35 C. +// * NOT DECODED AT ALL, on purpose: beat-to-beat intervals, SpO2, the +// hypnogram, steps, raw PPG. Their layouts are bit-packed and this project +// has not one byte of any of them. A guessed bit order produces a resting +// 50 bpm read as 100 that passes every plausibility bound it is shown, so +// those frames are ARCHIVED VERBATIM instead (owner rulings R1-R3: capture +// everything, decode when someone has the hardware). `raw_archive` is never +// pruned and `LocalDb.redrivableArchiveReasons` is how they get re-decoded +// in place later. See the report accompanying this change for the layouts. +// +// TIME IS THE HARD PART, and it is not solved here. An event's envelope carries +// a u32 of DECISECONDS on a clock whose epoch is not Unix and is not documented +// anywhere — 9,391,251 in the capture, which is ~10.9 days, so it is a device +// uptime, not a date. Turning it into a wall-clock second needs an ANCHOR, and +// anchoring is a session concern, so it lives in the adapter and not in here. + +import 'dart:typed_data'; + +/// One frame off the notify characteristic: `[tag u8][len u8][payload…]`. +/// +/// `len` counts payload bytes only, so a frame is 2 + len bytes and cannot +/// exceed 257. There is no CRC, no sequence number and no fragmentation: one +/// BLE notification carries exactly one frame, which is why this returns a +/// single frame rather than a list. +class OuraFrame { + final int tag; + final Uint8List payload; + const OuraFrame(this.tag, this.payload); +} + +/// Parse one notification. Null when it cannot be a frame at all. +/// +/// LENIENT IN ONE DIRECTION ONLY. The ring is known to append trailing bytes +/// past the declared length, so extra bytes are ignored. A declared length +/// LONGER than the buffer is the opposite case and it is a truncated frame — +/// this returns null rather than handing back a short payload that every +/// downstream length check would then treat as a real, complete record. +OuraFrame? parseOuraFrame(List value) { + if (value.length < 2) return null; + final len = value[1]; + if (value.length - 2 < len) return null; + return OuraFrame(value[0], Uint8List.fromList(value.sublist(2, 2 + len))); +} + +/// Tags at or above this are history-event frames; below it they are responses +/// to something the host wrote. +const int kOuraFirstEventTag = 0x41; + +/// One history event: an envelope timestamp and a type-specific body. +class OuraEvent { + final int tag; + + /// The ring's own clock, in units of 100 ms. NOT Unix time — see the header. + final int tsDs; + + final Uint8List body; + const OuraEvent(this.tag, this.tsDs, this.body); +} + +/// The history event carried by [f], or null when [f] is a command response or +/// is too short to carry the 4-byte envelope timestamp. +OuraEvent? parseOuraEvent(OuraFrame f) { + if (f.tag < kOuraFirstEventTag) return null; + if (f.payload.length < 4) return null; + final ts = f.payload.buffer + .asByteData(f.payload.offsetInBytes) + .getUint32(0, Endian.little); + return OuraEvent(f.tag, ts, Uint8List.sublistView(f.payload, 4)); +} + +// ── event tags this file has something to say about ──────────────────────── +/// Wall-clock the ring recorded when the host last set its RTC. The ONLY event +/// that pairs a Unix second with an envelope decisecond, which makes it the one +/// honest anchor between the two clocks. +const int kOuraEvtTimeSync = 0x42; + +/// An array of skin-temperature probes. +const int kOuraEvtTemp = 0x46; + +/// A single skin-temperature reading. +const int kOuraEvtTempPeriod = 0x69; + +/// Firmware diagnostics. Subtype-multiplexed; see [decodeDebugData]. +const int kOuraEvtDebugData = 0x61; + +/// The frame that terminates one history batch. +const int kOuraTagBatchSummary = 0x11; + +/// Unix seconds the ring recorded for an RTC set, or null when the body is not +/// the expected shape. +/// +/// UNLIKE EVERYTHING ELSE THIS FILE DECODES, this specific body layout has no +/// real captured [kOuraEvtTimeSync] frame behind it — the direct-u32-LE-Unix +/// reading is the simplest shape consistent with the envelope's own proven +/// 4-byte-LE convention, and the plausibility window below is what stops a +/// wrong reading from silently anchoring a whole sync in the wrong decade +/// rather than refusing outright. Treat a genuinely captured [kOuraEvtTimeSync] +/// body as the thing to check this against first, before trusting it for +/// anything beyond that gate. +int? decodeTimeSync(OuraEvent e) { + if (e.tag != kOuraEvtTimeSync || e.body.length < 4) return null; + final v = e.body.buffer + .asByteData(e.body.offsetInBytes) + .getUint32(0, Endian.little); + // A ring whose RTC was never set reports something that is not a date. The + // window is the same one `sync_policy` uses for the WHOOP: an absolute Unix + // second in this decade, and nothing else is an anchor. + return (v >= 1700000000 && v <= 4100000000) ? v : null; +} + +/// Skin temperature in degrees Celsius, one entry per probe. +/// +/// The wire carries signed 16-bit little-endian CENTI-degrees. Anything outside +/// the sensor part's own operating range is not a temperature and the WHOLE +/// array is refused — a single bad probe means the offsets are wrong, and half +/// a correct array is more dangerous than none. +/// +/// Which physical probe each array position is remains unknown, and one of them +/// may be an ambient reference rather than skin. A caller that needs "the" skin +/// temperature must therefore NOT average them. +List? decodeTemperatures(OuraEvent e) { + if (e.tag != kOuraEvtTemp && e.tag != kOuraEvtTempPeriod) return null; + if (e.body.length < 2 || e.body.length.isOdd) return null; + final d = e.body.buffer.asByteData(e.body.offsetInBytes); + final out = []; + for (var i = 0; i + 1 < e.body.length; i += 2) { + final c = d.getInt16(i, Endian.little) / 100.0; + if (c < -40 || c > 85) return null; + out.add(c); + } + return out; +} + +/// One `debug_data` (`0x61`) sub-record. +/// +/// Every field is null unless this subtype actually carries it. There is no +/// "unknown" fallback that invents a number: an unrecognised subtype comes back +/// with [subtype] set and everything else null, which is the signal to archive +/// the bytes rather than to interpret them. +class OuraDebugData { + /// The sub-record type — body byte 0. + final int subtype; + + /// A firmware diagnostic string, for [kOuraDebugText] only. + final String? text; + + /// State of charge, percent. + final int? batteryPct; + + /// Battery terminal voltage, millivolts. + final int? batteryMv; + + const OuraDebugData(this.subtype, {this.text, this.batteryPct, this.batteryMv}); +} + +/// Subtype `0x04` — a NUL-free ASCII diagnostic string in the rest of the body. +const int kOuraDebugText = 0x04; + +/// Subtype `0x14` — the fuel gauge's periodic sample. ~10 minutes. +const int kOuraDebugFuelGauge = 0x14; + +/// Subtype `0x24` — emitted when the state of charge changes. ~1 hour. +const int kOuraDebugBatteryLevel = 0x24; + +/// Decode one `debug_data` body. Null when it is not a sub-record at all. +/// +/// DISPATCH IS ON THE SUBTYPE BYTE, NOT ON WHETHER THE BODY LOOKS LIKE TEXT, +/// and that is a correction rather than a preference. Testing the body for +/// printability first gets BOTH halves wrong on the real capture: +/// +/// * every one of the 63 text records begins with subtype `0x04`, which is +/// itself not a printable byte — so a printability test over the whole body +/// never fires on them and they are lost; +/// * 127 records of subtypes `0x28` and `0x29` are entirely printable-or-NUL +/// binary — so a printability test DOES fire on them, and firmware counters +/// come back as a string of NULs. +/// +/// The subtype byte is unambiguous in both directions on that capture: all 63 +/// text records are `0x04`, and no non-`0x04` record has a printable NUL-free +/// tail. +OuraDebugData? decodeDebugData(List body) { + if (body.isEmpty) return null; + final subtype = body[0]; + switch (subtype) { + case kOuraDebugText: + // A diagnostic label with a counter after it, e.g. `ble_tx:full`. Refused + // outright if any byte is not printable ASCII: a mis-framed record read + // as text is how control bytes reach a log the user can export. + if (body.length < 2) return null; + for (var i = 1; i < body.length; i++) { + if (body[i] < 0x20 || body[i] > 0x7e) return null; + } + return OuraDebugData(subtype, + text: String.fromCharCodes(body, 1, body.length)); + + case kOuraDebugBatteryLevel: + // [subtype][u8 percent][u16 LE millivolts][optional flags] + if (body.length < 4) return null; + final pct = body[1]; + final mv = body[2] | (body[3] << 8); + if (pct > 100 || !_plausibleCellMv(mv)) return null; + return OuraDebugData(subtype, batteryPct: pct, batteryMv: mv); + + case kOuraDebugFuelGauge: + // [subtype][u16 LE charge counter][u16 LE millivolts][…]. The millivolts + // are the only field cross-checked against another record: this and + // `0x24` agree to within 3 mV wherever they land near each other in the + // capture. The remaining fields track charge and load and are left alone + // — there is no consumer for them and no second source to check them + // against. + if (body.length < 5) return null; + final mv = body[3] | (body[4] << 8); + if (!_plausibleCellMv(mv)) return null; + return OuraDebugData(subtype, batteryMv: mv); + + default: + // Recognised as a sub-record, deliberately not interpreted. The bytes are + // archived under this subtype; a future decoder finds them by it. + return OuraDebugData(subtype); + } +} + +/// A single lithium cell, in millivolts, anywhere between flat and full. +/// +/// A PHYSICAL bound and not an encoding one: it is true of the chemistry +/// whatever the field width turns out to be, so a decoder reading the wrong two +/// bytes fails it instead of sailing through (ADDING_A_DEVICE 6.3). +bool _plausibleCellMv(int mv) => mv >= 2500 && mv <= 4500; + +/// The frame the ring sends to close one history batch. +class OuraBatchSummary { + /// How many event frames this batch carried. + final int received; + + /// How many bytes of history the ring still holds. Zero means the drain is + /// complete — it is the ONLY completion signal on this path. + final int bytesLeft; + + const OuraBatchSummary(this.received, this.bytesLeft); +} + +/// The batch summary carried by [f], or null when [f] is something else. +OuraBatchSummary? parseBatchSummary(OuraFrame f) { + if (f.tag != kOuraTagBatchSummary || f.payload.length < 6) return null; + final d = f.payload.buffer.asByteData(f.payload.offsetInBytes); + // payload[1] is a sleep-analysis progress byte. Read and discarded on + // purpose: it is progress information and NOT a gate on the drain, and + // treating it as one stalls a sync that is working. + return OuraBatchSummary(f.payload[0], d.getUint32(2, Endian.little)); +} + +// ── outbound frames ──────────────────────────────────────────────────────── +// Every builder returns the complete frame including its two header bytes, so +// a caller can only ever hand `link.write` something well-formed. +// +// THE DESTRUCTIVE COMMANDS ARE ABSENT ON PURPOSE, and their absence is the only +// thing stopping them. Nothing at the session layer above this file inspects an +// unframed band's opcode the way the WHOOP dangerous-opcode gate does — this +// ring's frames carry no such gate at all — so this list of builders IS the +// whole defense. The ring has a factory reset, a firmware-update mode, a DFU +// state machine, a flight mode, a manufacturing-mode setter and a bulk-sampler +// channel with an erase operation. There is no builder for any of them here, +// the session that drives this wire format writes nothing it did not get from +// this file, and its own tests assert that no such tag ever reaches the link. +// Adding a builder for one re-opens the hole. + +/// Install this phone's 16-byte pairing key on a FACTORY-RESET ring. +/// +/// The key goes out in the clear and the command is not authenticated — it +/// cannot be, since it is what creates the credential the authentication +/// handshake then uses. So this is the FIRST thing written on a pairing +/// connection, before any nonce request, and it is the only command in this +/// file that is not preceded by one. +/// +/// The ring holds exactly one key and accepts a new one ONLY while it is +/// factory reset, which makes the reset a PRECONDITION of pairing rather than +/// a consequence of it: a ring that is currently onboarded elsewhere has to be +/// reset before this can succeed, and resetting is what frees it. There is no +/// state in which both work, and there is no way to read the installed key +/// back — losing ours costs another reset and nothing more. +/// +/// NOT DESTRUCTIVE, and worth saying because it sits next to a family of +/// commands that are. It writes a credential; it erases nothing. Putting the +/// ring INTO the state that accepts one is a separate command that has no +/// builder here and never will (see the block above). +List ouraCmdSetAuthKey(List key) { + if (key.length != 16) { + throw ArgumentError('the Oura pairing key is exactly 16 bytes'); + } + return [0x24, 0x10, ...key]; +} + +/// The status of a key install: 0 on success, non-zero for a refusal. Null when +/// [f] is not the reply to one. +/// +/// A ring that is NOT factory reset is the refusal that matters, and it does +/// not necessarily answer at all — so a caller must treat silence as a refusal +/// too, never as consent. There is no known way to tell the two apart, and +/// guessing that a quiet ring took the key is how a user spends a factory reset +/// and ends up with neither app working. +int? ouraSetAuthKeyResult(OuraFrame f) => + (f.tag == 0x25 && f.payload.isNotEmpty) ? f.payload[0] : null; + +/// Ask for a fresh authentication challenge. +List ouraCmdAuthNonce() => const [0x2f, 0x01, 0x2b]; + +/// Answer the challenge. [cipher] is the encrypted nonce, one AES block. +List ouraCmdAuthenticate(List cipher) => + [0x2f, 0x01 + cipher.length, 0x2d, ...cipher]; + +/// Turn the ring's asynchronous notifications on. `0x3f` is all six flags. +List ouraCmdSetNotifyFlags(int flags) => [0x1c, 0x01, flags & 0xff]; + +/// Set the ring's real-time clock: u64 LE Unix seconds, then a timezone in +/// half-hour steps. +/// +/// This is what later produces a [kOuraEvtTimeSync] event, and that event is the +/// only measured bridge between the ring's decisecond counter and a date — so +/// this write is not housekeeping, it is what makes the timestamps meaningful. +List ouraCmdSyncTime(int unixSeconds, {int tzHalfHours = 0}) { + final b = Uint8List(9); + b.buffer.asByteData().setUint64(0, unixSeconds, Endian.little); + b[8] = tzHalfHours & 0xff; + return [0x12, 0x09, ...b]; +} + +/// Request up to [maxEvents] history events at or after [startDs]. +/// +/// [startDs] is a cursor on the ring's own decisecond clock, not a record index +/// and not a byte offset. [flags] is a type filter passed through verbatim; -1 +/// asks for every type. +List ouraCmdGetEvents(int startDs, {int maxEvents = 255, int flags = -1}) { + final b = Uint8List(9); + final d = b.buffer.asByteData(); + d.setUint32(0, startDs, Endian.little); + b[4] = maxEvents.clamp(1, 255); + d.setInt32(5, flags, Endian.little); + return [0x10, 0x09, ...b]; +} + +/// The 15-byte challenge in an authentication-nonce reply, or null. +Uint8List? ouraAuthNonce(OuraFrame f) { + if (f.tag != 0x2f || f.payload.length < 16 || f.payload[0] != 0x2c) { + return null; + } + return Uint8List.sublistView(f.payload, 1, 16); +} + +/// The result of an authentication attempt. Null when [f] is not an +/// authentication reply at all. +/// +/// The codes, because the REMEDIES differ and a caller that collapses them to +/// "failed" tells the user the wrong thing: +/// +/// * `0` — success. +/// * [kOuraAuthWrongKey] — the ring holds a key and it is not ours. +/// Re-pairing means another factory reset. +/// * [kOuraAuthFactoryReset] — the ring holds NO key. It is waiting to be +/// given one, which is [ouraCmdSetAuthKey], not a re-pair of the same key. +/// * [kOuraAuthNotOnboarded] — a key matched but this is not the device the +/// ring was onboarded to. +int? ouraAuthResult(OuraFrame f) { + if (f.tag != 0x2f || f.payload.length < 2 || f.payload[0] != 0x2e) return null; + return f.payload[1]; +} + +/// The ring holds a key and the one presented is not it. +const int kOuraAuthWrongKey = 0x01; + +/// The ring holds no key at all — it is factory reset and waiting for one. +const int kOuraAuthFactoryReset = 0x02; + +/// Authenticated, but not as the device this ring was onboarded to. +const int kOuraAuthNotOnboarded = 0x03; + +/// True when [f] is the ring refusing a command because the session has not +/// authenticated. Distinguishing this from silence is what stops a drain loop +/// spinning against a ring that is simply waiting to be let in. +bool ouraIsAuthRequired(OuraFrame f) => + f.tag == 0x2f && f.payload.isNotEmpty && f.payload[0] == 0x2f; diff --git a/pubspec.yaml b/pubspec.yaml index 7abf2c2..817b046 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: openstrap_protocol -description: Pure-Dart WHOOP 4.0 protocol library — bytes <-> records/frames/commands. 1:1 port of the TS record decoders + edge framing/CRC/commands. +description: Pure-Dart, multi-band BLE protocol library — bytes <-> records/frames/commands for WHOOP 4/5, plus the Oura ring and generic Bluetooth HR sensors. version: 1.0.0 publish_to: none diff --git a/test/hrs_test.dart b/test/hrs_test.dart new file mode 100644 index 0000000..6956bbc --- /dev/null +++ b/test/hrs_test.dart @@ -0,0 +1,90 @@ +// The Bluetooth SIG Heart Rate Measurement (0x2A37) decoder — any standard +// chest strap or optical armband, not one vendor's device. +// +// NOTHING HERE HAS MET HARDWARE. Nobody on this project owns a strap, so these +// fixtures are built from the Bluetooth SIG's Heart Rate Service 1.0 +// characteristic layout, not captured off a device. They pin the decode; they +// do not prove any real strap behaves this way. + +import 'package:test/test.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +/// Frames as the three common flag shapes put them on the wire. +/// +/// The RR-Interval flag (bit 4) is OPTIONAL in the spec, and [kBpmOnly] is the +/// case that matters most: plenty of optical armbands never set it, and the +/// parser has to degrade to "HR, no beats" rather than assume beats are there. +const List kBpmOnly = [0x00, 61]; // flags 0x00 — RR bit CLEAR +const List kBpmOnlyWithContact = [0x06, 61]; // contact reported + +void main() { + test('uint8 HR, no RR', () { + final s = parseHeartRateMeasurement([0x00, 72])!; + expect(s.hr, 72); + expect(s.rrMs, isEmpty); + expect(s.contact, isNull); // contact not supported → absent, not "false" + }); + + test('the RR bit CLEAR is a strap with no beats, not a strap with zero', () { + final s = parseHeartRateMeasurement(kBpmOnly)!; + expect(s.hr, 61); + expect(s.rrMs, isEmpty); + expect(parseHeartRateMeasurement(kBpmOnlyWithContact)!.contact, isTrue); + }); + + test('uint16 HR reads little-endian', () { + final s = parseHeartRateMeasurement([0x01, 0x2C, 0x01])!; // 300 + expect(s.hr, 300); + }); + + test('RR intervals convert from 1/1024 s to ms', () { + // 1024 ticks = 1000 ms; 512 = 500 ms. + final s = parseHeartRateMeasurement([0x10, 60, 0x00, 0x04, 0x00, 0x02])!; + expect(s.rrMs, [1000, 500]); + }); + + test('energy-expended field is skipped, not read as an RR interval', () { + // flags 0x18 = RR present + energy expended present. The 2-byte energy + // field sits BETWEEN hr and the RR list; reading it as RR is the classic + // bug and would yield a bogus first interval. + final s = parseHeartRateMeasurement([ + 0x18, 60, // + 0xE8, 0x03, // energy expended = 1000 kJ + 0x00, 0x04, // RR = 1024 ticks = 1000 ms + ])!; + expect(s.rrMs, [1000]); + }); + + test('contact bits: reported false is distinguishable from unsupported', () { + expect(parseHeartRateMeasurement([0x04, 60])!.contact, isFalse); // 0b10 + expect(parseHeartRateMeasurement([0x06, 60])!.contact, isTrue); // 0b11 + expect(parseHeartRateMeasurement([0x02, 60])!.contact, isNull); // 0b01 + }); + + test('implausible beat intervals are dropped, not clamped', () { + // 8 ticks ≈ 8 ms and 4096 ticks = 4 s: neither is a beat. A clamped value + // would be a fabricated one. + final s = parseHeartRateMeasurement([ + 0x10, 60, // + 0x08, 0x00, // 8 ms + 0x00, 0x10, // 4000 ms + 0x00, 0x04, // 1000 ms — the only real one + ])!; + expect(s.rrMs, [1000]); + }); + + test('a searching sensor reporting 0 bpm is not a measurement', () { + expect(parseHeartRateMeasurement([0x00, 0]), isNull); + }); + + test('truncated values are dropped rather than patched up', () { + expect(parseHeartRateMeasurement([0x00]), isNull); + expect(parseHeartRateMeasurement([]), isNull); + expect(parseHeartRateMeasurement([0x01, 0x48]), isNull); // uint16, 1 byte + }); + + test('a trailing odd byte does not read past the buffer', () { + final s = parseHeartRateMeasurement([0x10, 60, 0x00, 0x04, 0x7F])!; + expect(s.rrMs, [1000]); + }); +} diff --git a/test/oura_confirmation_test.dart b/test/oura_confirmation_test.dart new file mode 100644 index 0000000..1b0d162 --- /dev/null +++ b/test/oura_confirmation_test.dart @@ -0,0 +1,213 @@ +// Independent confirmation of the Oura wire-format primitives that do NOT +// depend on a real ring's capture — the outbound command encodings and the +// batch-summary layout are fixed byte layouts with no sensor data in them, so +// they can be checked against values computed by hand from the layout, not +// just against a capture. +// +// WHY THIS FILE EXISTS SEPARATELY FROM `oura_test.dart`. That file's own +// header says plainly there is no independent oracle for this band, because +// its expectations are read off one real capture. That is true for the SENSOR +// decoders (temperature, battery, motion, …) — there is genuinely no second +// source for what a specific ring reported on a specific night. It is NOT +// true for the command builders and the batch-summary field layout: every one +// below is a fixed byte shape, added up by hand from the doc comments on the +// functions under test, not copied from this file's own encoder. +// +// The AES-128/ECB auth-proof cross-check lives with the session that drives +// this wire format, one layer up — this package has no cipher implementation. + +import 'dart:typed_data'; + +import 'package:test/test.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +Uint8List _hex(String s) => Uint8List.fromList([ + for (var i = 0; i + 1 < s.length; i += 2) + int.parse(s.substring(i, i + 2), radix: 16), + ]); + +String _toHex(List b) => + b.map((x) => x.toRadixString(16).padLeft(2, '0')).join(); + +void main() { + group('command encodings — field-by-field byte layout', () { + test('get-events: cursor 0, cap 8, "every type" filter', () { + // `[0x10][len 9][cursor u32 LE][cap u8][filter i32 LE]`. -1 as an i32 + // is 4 bytes of 0xff — the documented "every type" filter value. + final b = ouraCmdGetEvents(0, maxEvents: 8, flags: -1); + expect(_toHex(b), '10090000000008ffffffff'); + }); + + test('get-events: a non-zero cursor and cap round-trip byte for byte', + () { + // cursor 0x01020304 LE = 04 03 02 01, cap 0x2a, filter -1. + final b = ouraCmdGetEvents(0x01020304, maxEvents: 0x2a, flags: -1); + expect(_toHex(b), '1009040302012affffffff'); + }); + + test('auth-nonce request is the fixed 3-byte secure-session sub-op', () { + expect(_toHex(ouraCmdAuthNonce()), '2f012b'); + }); + + test('authenticate: sub-op 0x2d then the 16-byte proof, length = 17', () { + final proof = _hex('a38a8772d3acb6db5c2b516dd56987c8'); + final b = ouraCmdAuthenticate(proof); + expect(_toHex(b), '2f112d' 'a38a8772d3acb6db5c2b516dd56987c8'); + }); + + test('set-auth-key: opcode 0x24, length 16, the key verbatim', () { + final key = _hex('000102030405060708090a0b0c0d0e0f'); + final b = ouraCmdSetAuthKey(key); + expect(_toHex(b), '2410' '000102030405060708090a0b0c0d0e0f'); + }); + + test('sync-time: opcode 0x12, 8-byte LE unix seconds, then the tz byte', + () { + // unix 1 = u64 LE 01 00 00 00 00 00 00 00, timezone 2 half-hours (+1h). + final b = ouraCmdSyncTime(1, tzHalfHours: 2); + expect(_toHex(b), '1209' '0100000000000000' '02'); + }); + }); + + group('auth-result codes — the fixed status enum', () { + // `[0x2f][len][0x2e][status]`. The four codes and their meanings are a + // closed, documented set; wrong-key/factory-reset/not-onboarded have + // different remedies, so a decoder that collapsed them would be a + // regression even though every one of these frames "fails" the same way. + OuraFrame authReply(int status) => + OuraFrame(0x2f, Uint8List.fromList([0x2e, status])); + + test('0x00 is success', () => expect(ouraAuthResult(authReply(0x00)), 0)); + test('0x01 is a wrong key', + () => expect(ouraAuthResult(authReply(0x01)), kOuraAuthWrongKey)); + test('0x02 is factory-reset (no key installed yet)', + () => expect(ouraAuthResult(authReply(0x02)), kOuraAuthFactoryReset)); + test('0x03 is authenticated but not this phone', + () => expect(ouraAuthResult(authReply(0x03)), kOuraAuthNotOnboarded)); + }); + + group('nonce-response sub-op — exactly one value, nothing else', () { + // `2f 2c `: the outer frame's own length byte (16, one + // sub-op byte + the 15-byte nonce) sits BEFORE the payload this function + // reads — `f.payload` starts at the sub-op, so `f.payload[0]` is never the + // length. A decoder that also accepted the length byte's own value at + // this position would risk parsing an unrelated frame as a nonce reply. + final nonce = _hex('0102030405060708090a0b0c0d0e0f'); + + test('sub-op 0x2c is the nonce reply', () { + final f = OuraFrame(0x2f, Uint8List.fromList([0x2c, ...nonce])); + expect(ouraAuthNonce(f), nonce); + }); + + test('any other sub-op is not a nonce reply', () { + final f = OuraFrame(0x2f, Uint8List.fromList([0x11, ...nonce])); + expect(ouraAuthNonce(f), isNull); + }); + }); + + group('sensor decoders — algebraic identity, not a captured value', () { + // These don't need a ring either: the wire's own arithmetic (centi-degree + // integer / 100, little-endian byte order) is checkable by picking any + // in-range value and confirming the decoder recovers exactly it — no + // capture required, because the claim under test is "this decoder + // implements int16-LE/100.0", not "this ring read 33.56°C that night". + test('temperature: every representable centi-degree in range round-trips', + () { + for (final centi in [-4000, -100, 0, 100, 3356, 8500]) { + final le = Uint8List(2) + ..buffer.asByteData().setInt16(0, centi, Endian.little); + final ev = OuraEvent(kOuraEvtTempPeriod, 0, le); + final out = decodeTemperatures(ev); + expect(out, isNotNull, reason: 'centi=$centi'); + expect(out!.single, closeTo(centi / 100.0, 1e-9)); + } + }); + + test('temperature: one tick outside the sensor range refuses the array', + () { + final le = Uint8List(2) + ..buffer.asByteData().setInt16(0, 8501, Endian.little); // 85.01°C + expect(decodeTemperatures(OuraEvent(kOuraEvtTempPeriod, 0, le)), isNull); + }); + + test('debug-data battery: the percent/millivolt fields are independent', + () { + // subtype 0x24, then percent (u8) and millivolts (u16 LE) — sweep both + // fields across their real range and confirm each decodes on its own + // axis rather than one leaking into the other. + for (final pct in [0, 1, 50, 99, 100]) { + for (final mv in [2500, 3700, 4200, 4500]) { + final body = [0x24, pct, mv & 0xff, mv >> 8]; + final d = decodeDebugData(body); + expect(d, isNotNull, reason: 'pct=$pct mv=$mv'); + expect(d!.batteryPct, pct); + expect(d.batteryMv, mv); + } + } + }); + }); + + group('batch summary — the layout is a count + a byte total, not a status ' + 'code + a cursor', () { + // The 8-byte body after `[0x11][len]` has exactly one field a caller can + // safely treat as "the drain is done": whichever field is provably a + // COUNT that only ever reaches zero when nothing is left, never a status + // enum. The two candidate readings of this body disagree on which byte is + // which, so the fixed-point layout is worth pinning independently of the + // one real capture `oura_test.dart` reads it against. + OuraFrame batchFrame(int received, int progress, int bytesLeft) { + final b = Uint8List(6); + b[0] = received; + b[1] = progress; + b.buffer.asByteData().setUint32(2, bytesLeft, Endian.little); + return OuraFrame(0x11, b); + } + + test('byte 0 is a per-batch COUNT (0..255), never a binary status flag', + () { + // A status byte would only ever be one of a couple of fixed codes. This + // field takes every value across a full batch, which a status enum + // cannot — so byte 0 has to be counting something, not signalling one + // of a few states. + for (final n in [0, 1, 8, 100, 255]) { + final s = parseBatchSummary(batchFrame(n, 0, 0))!; + expect(s.received, n); + } + }); + + test('bytes 2-5 are the u32 LE byte total, independent of byte 0 or 1', + () { + for (final left in [0, 1, 3742, 65535, 0xFFFFFFFF]) { + final s = parseBatchSummary(batchFrame(200, 7, left))!; + expect(s.bytesLeft, left, reason: 'bytesLeft=$left'); + } + }); + + test( + 'completion is bytesLeft == 0 — an empty batch with bytesLeft > 0 ' + 'is NOT done', () { + // The bug this pins against: reading `received == 0` as "drain + // finished" stops a sync while the ring still holds undelivered + // history, because a batch can legitimately answer zero events for a + // stale cursor while the ring's flash still has bytes behind it. Only + // `bytesLeft == 0` may end the loop. + final stale = parseBatchSummary(batchFrame(0, 0, 3742))!; + expect(stale.received, 0); + expect(stale.bytesLeft, 3742, + reason: 'zero events this batch must not read as zero remaining'); + final done = parseBatchSummary(batchFrame(0, 0, 0))!; + expect(done.bytesLeft, 0); + }); + + test('a real captured frame decodes under the same field layout', () { + // `11 08 08 00 9e0e0000 0300`: received=8, progress=0 (discarded), + // bytesLeft = 0x00000e9e = 3742, trailing `0300` unused. Hand-built via + // the same helper above rather than re-typed as a literal, so this test + // and `oura_test.dart`'s real-capture assertion are two different + // constructions of the identical claim. + final s = parseBatchSummary(batchFrame(8, 0, 3742))!; + expect(s.received, 8); + expect(s.bytesLeft, 3742); + }); + }); +} diff --git a/test/oura_test.dart b/test/oura_test.dart new file mode 100644 index 0000000..1826634 --- /dev/null +++ b/test/oura_test.dart @@ -0,0 +1,289 @@ +// The Oura wire format, against real captured bytes. +// +// THE FIXTURE IS GROUND TRUTH, AND IT IS NARROW. Every `debug_data` body below +// is verbatim from a 10,208-record capture off a real ring, and the expectations +// are what an independent read of that capture supports — nothing here was +// copied from anyone's decoder. The battery numbers in particular are checked +// two ways: the ring reports its voltage in two unrelated sub-records at two +// different cadences, and where they land near each other in the capture they +// agree to within 3 mV. That agreement is the evidence; a single decoder +// agreeing with itself would not be. +// +// WHAT THIS CANNOT PROVE, said out loud because a fixture presented as a +// correctness credential is a liability (ADDING_A_DEVICE 6.2): it proves +// determinism, regression and physiological sanity. It does not prove +// correctness, because there is no independent oracle for this band — nobody +// on this project owns a ring. The decoders that are NOT here (beat intervals, +// SpO2, hypnogram, steps) are absent precisely because there are no bytes to +// build such a fixture from, and shipping a guess would have this file +// faithfully encoding the wrong answer. +// +// The NULL cases at the bottom are the load-bearing half: they are what proves +// the decoder REFUSES rather than always producing something. +// +// AES-128/ECB auth-response encryption is NOT exercised here — this package +// has no cipher implementation. See the session that drives this wire format +// for that half of the auth handshake. + +import 'package:test/test.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +List _hex(String s) => [ + for (var i = 0; i + 1 < s.length; i += 2) + int.parse(s.substring(i, i + 2), radix: 16), + ]; + +/// One `debug_data` body, exactly as captured, with the ring clock it carried. +const List<(String label, int ds, String bodyHex)> _kDebugData = [ + // Firmware diagnostic labels. Subtype 0x04, then a NUL-free ASCII string. + ('text Tsfs', 9408815, '04547366733b32'), + ('text EHRts', 9409977, '0445485274733b3633'), + ('text ble_tx', 9597025, '04626c655f74783a66756c6c'), + // State-of-charge changed. Subtype 0x24: percent, then millivolts. + ('battery 86%', 9391523, '2456c80f00'), + ('battery 85%', 9427525, '2455c10f00'), + ('battery 71%', 10093526, '24474a0f00'), + // The fuel gauge's own periodic sample. Subtype 0x14, millivolts at a + // different offset — this is the record the 0x24 voltage is checked against. + ('gauge 4040mV', 9395848, '14cf50c80fb2ffffffd53e0000da'), + ('gauge 3916mV', 10103853, '14fe424c0f7afcffffe033000065'), + // Recognised as sub-records, deliberately not interpreted. + ('afe stats', 9391258, '28000c030000000000000c030c03'), + ('sleep stats', 9391251, '0927e61e00922500005434000005'), + // The trap: binary, but every byte is printable-or-NUL. + ('afe stats, all-printable', 9410164, '2800000000000000000000000000'), + ('subtype 0x29, all-printable', 10098932, '2900000000000000'), +]; + +void main() { + group('framing', () { + test('a frame is tag, u8 length, then exactly that many payload bytes', () { + final f = parseOuraFrame(_hex('110808009e0e00000300')); + expect(f, isNotNull); + expect(f!.tag, 0x11); + expect(f.payload.length, 8); + }); + + test('trailing bytes past the declared length are ignored', () { + // The ring is known to append them. Eight declared, eleven delivered. + final f = parseOuraFrame(_hex('110808009e0e00000300') + _hex('aabbcc'))!; + expect(f.payload.length, 8); + expect(parseBatchSummary(f)!.bytesLeft, 3742); + }); + + test('a batch summary carries the count and the bytes still on the ring', + () { + // 8 events in this batch, 3742 bytes of history left to fetch. Zero is + // the ONLY completion signal on this path — there is no acknowledgement + // and nothing the host says makes the ring release anything. + final s = parseBatchSummary(parseOuraFrame(_hex('110808009e0e00000300'))!)!; + expect(s.received, 8); + expect(s.bytesLeft, 3742); + }); + + test('an event splits into a decisecond envelope stamp and a body', () { + // tag 0x61, length 5, ts = 9391523 ds, then a one-byte body. + final f = parseOuraFrame([0x61, 0x05, 0xa3, 0x4d, 0x8f, 0x00, 0x24])!; + final e = parseOuraEvent(f)!; + expect(e.tag, 0x61); + expect(e.tsDs, 9391523); + expect(e.body, [0x24]); + }); + + test('the envelope unit is deciseconds, proven by the capture cadence', () { + // The hourly battery record and the ten-minute fuel-gauge record sit at + // 36000 and 6000 ticks apart in the capture. Both only work at 10 ticks + // to the second, which is what makes every other timestamp readable. + expect(9427523 - 9391523, 36000); + expect(9401848 - 9395848, 6000); + }); + }); + + group('debug_data — dispatch is on the subtype byte, never on printability', + () { + test('every captured text record decodes to its string', () { + expect(decodeDebugData(_hex('04547366733b32'))!.text, 'Tsfs;2'); + expect(decodeDebugData(_hex('0445485274733b3633'))!.text, 'EHRts;63'); + expect(decodeDebugData(_hex('04626c655f74783a66756c6c'))!.text, + 'ble_tx:full'); + }); + + test('a text record does NOT begin with a printable byte', () { + // This is why a printability test over the whole body cannot find them: + // subtype 0x04 is itself a control byte, so the test fails on byte 0 and + // all 63 strings in the capture are lost. + expect(_hex('04547366733b32')[0], lessThan(0x20)); + }); + + test('an all-printable BINARY record is not mistaken for text', () { + // 127 records in the capture are entirely printable-or-NUL and are not + // strings — firmware counters padded with NULs, 113 of subtype 0x28 and + // 14 of 0x29. A printability test fires on exactly these and hands back + // '(' followed by thirteen NULs. Dispatching on the subtype cannot. + final b = _hex('2800000000000000000000000000'); + expect(b.every((x) => x == 0 || (x >= 0x20 && x <= 0x7e)), isTrue, + reason: 'the fixture must actually be all-printable to be the trap'); + final d = decodeDebugData(b)!; + expect(d.subtype, 0x28); + expect(d.text, isNull); + }); + + test('state of charge and voltage', () { + final a = decodeDebugData(_hex('2456c80f00'))!; + expect(a.batteryPct, 86); + expect(a.batteryMv, 4040); + final b = decodeDebugData(_hex('24474a0f00'))!; + expect(b.batteryPct, 71); + expect(b.batteryMv, 3914); + }); + + test('the fuel gauge reports the SAME voltage as the battery record', () { + // THE CROSS-CHECK, and it is the only independent evidence in this file. + // The two sub-records share no offset, no cadence and no length, and they + // are read here at both ends of the capture: 4325 deciseconds apart at + // the start, 10327 apart at the end. A wrong offset in either decoder + // could not agree with the other twice, 19 hours and 126 mV apart. + expect(decodeDebugData(_hex('2456c80f00'))!.batteryMv, 4040); + expect(decodeDebugData(_hex('14cf50c80fb2ffffffd53e0000da'))!.batteryMv, + 4040); + expect(decodeDebugData(_hex('24474a0f00'))!.batteryMv, 3914); + expect(decodeDebugData(_hex('14fe424c0f7afcffffe033000065'))!.batteryMv, + 3916); + }); + + test('the whole fixture decodes without throwing, and claims nothing extra', + () { + for (final (label, _, hex) in _kDebugData) { + final d = decodeDebugData(_hex(hex)); + expect(d, isNotNull, reason: label); + expect(d!.subtype, _hex(hex)[0], reason: label); + if (d.subtype != kOuraDebugText) { + expect(d.text, isNull, reason: '$label must not claim to be text'); + } + if (d.subtype != kOuraDebugBatteryLevel) { + expect(d.batteryPct, isNull, + reason: '$label must not claim a charge level'); + } + } + }); + }); + + group('the decoder REFUSES rather than always producing something', () { + test('an empty or truncated frame is null, never a short one', () { + expect(parseOuraFrame(const []), isNull); + expect(parseOuraFrame(const [0x61]), isNull); + // Declares 20 payload bytes, delivers 3. Handing back the 3 would make + // every length check downstream read a fragment as a complete record. + expect(parseOuraFrame(const [0x61, 20, 1, 2, 3]), isNull); + }); + + test('a command response is not an event, and a short envelope is not one', + () { + expect(parseOuraEvent(parseOuraFrame(_hex('0d03') + _hex('560100'))!), + isNull); + // Event tag, but three bytes where four are needed for the stamp. + expect(parseOuraEvent(parseOuraFrame(const [0x61, 3, 1, 2, 3])!), + isNull); + }); + + test('an empty debug_data body is null', () { + expect(decodeDebugData(const []), isNull); + }); + + test('a battery record too short to carry its voltage is null', () { + // Percent present, voltage cut off. The tempting failure is to return the + // percent alone; the byte that would be read as the low half of the + // voltage is simply not there, so nothing in the record is trustworthy. + expect(decodeDebugData(_hex('2456')), isNull); + expect(decodeDebugData(_hex('2456c8')), isNull); + }); + + test('a battery record with an impossible voltage is null', () { + // 0x1027 LE = 10000 mV. No lithium cell reads that, so the offsets are + // wrong and the percent beside them cannot be trusted either. The bound + // is chemistry, not encoding — a wrong-width decoder fails it too. + expect(decodeDebugData(_hex('2456102700')), isNull); + // 145 %, which is not a state of charge. + expect(decodeDebugData(_hex('2491c80f00')), isNull); + }); + + test('a text record containing a control byte is null', () { + // 0x07 is a bell. A mis-framed record read as text is how control bytes + // reach a log the user can export. + expect(decodeDebugData(_hex('0454070a')), isNull); + expect(decodeDebugData(_hex('04')), isNull); + }); + + test('a temperature outside the sensor part range refuses the WHOLE array', + () { + // 0x0d1c = 3356 -> 33.56 C, then 0x7530 = 30000 -> 300 C. Half a correct + // array is more dangerous than none: it would publish one real probe and + // silently hide that the offsets had moved. + final good = parseOuraEvent( + parseOuraFrame(_hex('4606') + _hex('01000000') + _hex('1c0d'))!)!; + expect(decodeTemperatures(good), [33.56]); + final bad = parseOuraEvent(parseOuraFrame( + _hex('4608') + _hex('01000000') + _hex('1c0d3075'))!)!; + expect(decodeTemperatures(bad), isNull); + }); + + test('an odd-length temperature body is null', () { + final e = parseOuraEvent( + parseOuraFrame(_hex('4607') + _hex('01000000') + _hex('1c0d30'))!)!; + expect(decodeTemperatures(e), isNull); + }); + + test('a clock reading that is not a date is refused', () { + final ok = parseOuraEvent( + parseOuraFrame(_hex('4208') + _hex('01000000') + _hex('4fd2376a'))!)!; + expect(decodeTimeSync(ok), 1782043215); + // A ring whose RTC was never set. Accepting it would anchor an entire + // sync's worth of records in 1970. + final unset = parseOuraEvent( + parseOuraFrame(_hex('4208') + _hex('01000000') + _hex('00000000'))!)!; + expect(decodeTimeSync(unset), isNull); + }); + }); + + group('outbound frames', () { + test('the history request carries a decisecond cursor, a cap and a filter', + () { + expect(ouraCmdGetEvents(0, maxEvents: 8), + _hex('10') + _hex('09') + _hex('0000000008ffffffff')); + // A resumed drain asks from the bookmark, not from the beginning. + expect(ouraCmdGetEvents(9391523).sublist(2, 6), _hex('a34d8f00')); + }); + + test('the authenticate frame declares its own length', () { + final f = ouraCmdAuthenticate(List.filled(16, 0xab)); + expect(f[0], 0x2f); + expect(f[1], 17, reason: 'one ext-tag byte plus one AES block'); + expect(f.length, 19); + }); + + test('the clock is set in Unix seconds', () { + expect(ouraCmdSyncTime(1782043215).sublist(2, 10), + _hex('4fd2376a00000000')); + }); + }); + + group('authentication (non-cryptographic half)', () { + test('the challenge is 15 bytes out of a 16-byte reply body', () { + final f = parseOuraFrame( + _hex('2f10') + _hex('2c0e2d6a0a08c99b4365f458e6e97382'))!; + expect(ouraAuthNonce(f), _hex('0e2d6a0a08c99b4365f458e6e97382')); + }); + + test('success and refusal are told apart, and silence is neither', () { + expect(ouraAuthResult(parseOuraFrame(_hex('2f022e00'))!), 0); + expect(ouraAuthResult(parseOuraFrame(_hex('2f022e01'))!), 1); + // The ring refusing a command because the session never authenticated. + // Distinguishing this from a timeout is what stops a drain loop spinning + // against a ring that is simply waiting to be let in. + final gate = parseOuraFrame(_hex('2f022f01'))!; + expect(ouraIsAuthRequired(gate), isTrue); + expect(ouraAuthResult(gate), isNull); + expect(ouraAuthNonce(gate), isNull); + }); + }); +} From 19d72919ecc0cbca518e0fdbbe2f6f9dc7ffe265 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:04:50 +0530 Subject: [PATCH 2/2] review fixes: truncated hrs fields, unbounded auth length, web-unsafe u64 parseHeartRateMeasurement accepted a notification that set the energy-expended or RR flag but didn't carry the field -- refuses now instead of silently walking past it, same for an odd-length RR remainder. ouraCmdAuthenticate had no length check on the cipher, so a wrong-size input either emitted a malformed frame or, past 254 bytes, overflowed the length byte. same guard ouraCmdSetAuthKey already has. ouraCmdSyncTime used ByteData.setUint64, which throws UnsupportedError on dart2js -- split into two little-endian setUint32 calls. plus one test that claimed to decode a real captured frame but actually just called the same encoder helper the decoder was being checked against, so it couldn't catch a field-offset bug. parses the literal bytes now. --- lib/src/hrs.dart | 16 +++++++++++++--- lib/src/oura.dart | 24 +++++++++++++++++++++--- test/hrs_test.dart | 16 +++++++++++++--- test/oura_confirmation_test.dart | 10 +++++----- test/oura_test.dart | 8 ++++++++ 5 files changed, 60 insertions(+), 14 deletions(-) diff --git a/lib/src/hrs.dart b/lib/src/hrs.dart index 16328cb..052086d 100644 --- a/lib/src/hrs.dart +++ b/lib/src/hrs.dart @@ -59,11 +59,21 @@ HrsSample? parseHeartRateMeasurement(List value) { final contactBits = (flags >> 1) & 0x03; final contact = contactBits < 2 ? null : contactBits == 3; - if ((flags & 0x08) != 0) i += 2; // energy expended — present, not used + // Energy Expended is a fixed 2-byte field ONCE the flag says it is present + // — a notification that sets the bit but doesn't carry both bytes is + // truncated, not "the field happens to be shorter here", so it is refused + // outright rather than silently walked past. + if ((flags & 0x08) != 0) { + if (i + 2 > value.length) return null; + i += 2; // energy expended — present, not used + } final rr = []; if ((flags & 0x10) != 0) { - // Trailing RR intervals, uint16 LE, 1/1024 s each. A trailing odd byte is a - // malformed value: stop rather than reading past it. + // Trailing RR intervals, uint16 LE, 1/1024 s each. An ODD remainder means + // the buffer ends mid-field — every earlier field's offset is only as + // trustworthy as the notification's own declared length, so a short tail + // refuses the whole notification instead of quietly keeping what parsed. + if ((value.length - i).isOdd) return null; while (i + 1 < value.length) { final ticks = value[i] | (value[i + 1] << 8); i += 2; diff --git a/lib/src/oura.dart b/lib/src/oura.dart index 0241dea..0b7a7a8 100644 --- a/lib/src/oura.dart +++ b/lib/src/oura.dart @@ -324,8 +324,18 @@ int? ouraSetAuthKeyResult(OuraFrame f) => List ouraCmdAuthNonce() => const [0x2f, 0x01, 0x2b]; /// Answer the challenge. [cipher] is the encrypted nonce, one AES block. -List ouraCmdAuthenticate(List cipher) => - [0x2f, 0x01 + cipher.length, 0x2d, ...cipher]; +/// +/// Refuses anything but exactly 16 bytes, the same guard [ouraCmdSetAuthKey] +/// applies to the key: the length byte here is `0x01 + cipher.length`, so a +/// wrong-size cipher either emits a malformed frame or — at 255 bytes and +/// above — overflows the length byte outright rather than throwing where the +/// mistake actually is. +List ouraCmdAuthenticate(List cipher) { + if (cipher.length != 16) { + throw ArgumentError('the Oura auth proof is exactly one 16-byte AES block'); + } + return [0x2f, 0x01 + cipher.length, 0x2d, ...cipher]; +} /// Turn the ring's asynchronous notifications on. `0x3f` is all six flags. List ouraCmdSetNotifyFlags(int flags) => [0x1c, 0x01, flags & 0xff]; @@ -338,7 +348,15 @@ List ouraCmdSetNotifyFlags(int flags) => [0x1c, 0x01, flags & 0xff]; /// this write is not housekeeping, it is what makes the timestamps meaningful. List ouraCmdSyncTime(int unixSeconds, {int tzHalfHours = 0}) { final b = Uint8List(9); - b.buffer.asByteData().setUint64(0, unixSeconds, Endian.little); + final d = b.buffer.asByteData(); + // Two 32-bit halves, not `setUint64`: Dart's web (dart2js) ByteData throws + // UnsupportedError on the 64-bit accessors — JS numbers have no native + // 64-bit integer, and the SDK does not emulate one here. A Unix second + // fits in the low word alone until the year 2106; the high word is written + // for correctness at the wire's own field width, not because this app + // expects it to ever be nonzero. + d.setUint32(0, unixSeconds & 0xffffffff, Endian.little); + d.setUint32(4, (unixSeconds >> 32) & 0xffffffff, Endian.little); b[8] = tzHalfHours & 0xff; return [0x12, 0x09, ...b]; } diff --git a/test/hrs_test.dart b/test/hrs_test.dart index 6956bbc..e0e1dd5 100644 --- a/test/hrs_test.dart +++ b/test/hrs_test.dart @@ -83,8 +83,18 @@ void main() { expect(parseHeartRateMeasurement([0x01, 0x48]), isNull); // uint16, 1 byte }); - test('a trailing odd byte does not read past the buffer', () { - final s = parseHeartRateMeasurement([0x10, 60, 0x00, 0x04, 0x7F])!; - expect(s.rrMs, [1000]); + test('a trailing odd byte refuses the notification, never reads past it', + () { + // The RR field is a run of uint16 LE ticks; an odd-length remainder means + // the buffer ends mid-field, which puts every earlier offset in doubt + // too. Refuse the whole notification rather than silently keep what + // parsed before the truncation. + expect(parseHeartRateMeasurement([0x10, 60, 0x00, 0x04, 0x7F]), isNull); + }); + + test('a truncated Energy Expended field refuses the notification', () { + // Flag bit 3 set (energy present) but only one byte follows HR — the + // field is declared 2 bytes wide unconditionally once the bit is set. + expect(parseHeartRateMeasurement([0x08, 60, 0x01]), isNull); }); } diff --git a/test/oura_confirmation_test.dart b/test/oura_confirmation_test.dart index 1b0d162..082ccac 100644 --- a/test/oura_confirmation_test.dart +++ b/test/oura_confirmation_test.dart @@ -201,11 +201,11 @@ void main() { test('a real captured frame decodes under the same field layout', () { // `11 08 08 00 9e0e0000 0300`: received=8, progress=0 (discarded), - // bytesLeft = 0x00000e9e = 3742, trailing `0300` unused. Hand-built via - // the same helper above rather than re-typed as a literal, so this test - // and `oura_test.dart`'s real-capture assertion are two different - // constructions of the identical claim. - final s = parseBatchSummary(batchFrame(8, 0, 3742))!; + // bytesLeft = 0x00000e9e = 3742, trailing `0300` unused. Parsed from + // the literal captured bytes — not built via [batchFrame] above, which + // encodes with the exact layout under test and so cannot catch a + // field-offset error here. + final s = parseBatchSummary(parseOuraFrame(_hex('110808009e0e00000300'))!)!; expect(s.received, 8); expect(s.bytesLeft, 3742); }); diff --git a/test/oura_test.dart b/test/oura_test.dart index 1826634..010b6cb 100644 --- a/test/oura_test.dart +++ b/test/oura_test.dart @@ -265,6 +265,14 @@ void main() { expect(ouraCmdSyncTime(1782043215).sublist(2, 10), _hex('4fd2376a00000000')); }); + + test('a value past the low 32 bits still encodes correctly', () { + // 0x1_00000001 = 4294967297. Low word 0x00000001, high word 0x00000001 + // — proves the two-setUint32 split actually carries the high half, + // not just that the common (high-word-zero) case happens to work. + expect(ouraCmdSyncTime(0x100000001).sublist(2, 10), + _hex('0100000001000000')); + }); }); group('authentication (non-cryptographic half)', () {