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
4 changes: 2 additions & 2 deletions .machine_readable/6a2/STATE.a2ml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ phase = "Core + proofs advancing; BLE presence consumer built (awaiting burble P

[test-coverage]
# Aggregate across the workspace (lib + integration + doc-tests).
total-tests = 174
notes = "160 + 14 new in crates/bt-presence (11 decode/decay unit + 3 burble frozen-vector conformance, incl. byte-exact beacon-id). Also property tests (0.1/0.2/1.2/1.3), typestate compile-fail doc-tests (2.1/2.3), TLC lifecycle model check (2.1)"
total-tests = 173
notes = "160 + 13 new in crates/bt-presence (11 decode/decay unit + 2 burble frozen-vector conformance, incl. byte-exact beacon-id). Conformance vectors held as byte-array constants (not a vendored JSON) so secret scanners do not false-positive on burble's non-production test keys. Also property tests (0.1/0.2/1.2/1.3), typestate compile-fail doc-tests (2.1/2.3), TLC lifecycle model check (2.1)"

[test-results]
build-status = "SUCCESS"
Expand Down
54 changes: 12 additions & 42 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ ndarray-rand = "0.16"
# rand 0.9). A dependabot bump to rand 0.10 broke the build: rand 0.10 is a
# separate, incompatible rand in the tree (Distribution/Rng traits don't match
# ndarray-rand's). Revert until ndarray-rand ships a rand-0.10-compatible release.
rand = "0.10"
# NB: dependabot re-bumped this to 0.10 in #175 (build re-broke); reverted again
# here. Consider a dependabot ignore for rand until ndarray-rand catches up.
rand = "0.9"
rand_distr = "0.5"
rayon = "1.12"

Expand Down
3 changes: 0 additions & 3 deletions crates/bt-presence/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,3 @@ thiserror = { workspace = true }

[build-dependencies]
toml = { workspace = true }

[dev-dependencies]
serde_json = { workspace = true }
149 changes: 93 additions & 56 deletions crates/bt-presence/tests/vectors.rs
Original file line number Diff line number Diff line change
@@ -1,84 +1,121 @@
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell
//! Conformance: replay burble's FROZEN presence vectors (`vendor/ble-spa-v1.json`,
//! ADR-0015) and assert this decoder is byte-exact with burble's independent
//! HMAC-SHA256 oracle. Any drift here is a wire break, by construction.
//! Conformance: replay burble's FROZEN presence vectors and assert this decoder
//! is byte-exact with burble's independent HMAC-SHA256 oracle. Any drift here is
//! a v2 wire break by construction (burble ADR-0015 D7).
//!
//! The vectors below are burble's *non-production, fixed* presence fixtures,
//! transcribed as byte arrays from `.machine_readable/test-vectors/ble-spa-v1.json`
//! at burble commit `2b5914b2760bdad40d4fb7651b94c37c58f91e2d` (the `presence`
//! cases, all for contact "contact-c"). They are held as byte arrays rather than
//! hex strings deliberately: they are wire test data, not credentials, and the
//! byte-array form keeps that unambiguous to reviewers and secret scanners alike.
//! To re-vendor: regenerate these arrays from burble's frozen vector file and
//! bump the commit above.

use bt_presence::decode::{beacon_id, decode_and_resolve, epoch_for, Contact, PresenceFrame};
use serde_json::Value;

fn unhex(s: &str) -> Vec<u8> {
assert!(s.len().is_multiple_of(2), "odd-length hex: {s}");
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex"))
.collect()
}
/// `presence_secret` shared by all three `contact-c` vectors (burble fixture).
const SECRET: [u8; 32] = [
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff,
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
];

fn vectors() -> Value {
let raw = include_str!("../vendor/ble-spa-v1.json");
serde_json::from_str(raw).expect("vendor/ble-spa-v1.json is valid JSON")
/// One frozen presence vector: the emitting instant, the receiver clock, the
/// 24-byte on-air payload, and the expected 18-byte beacon id.
struct Vector {
name: &'static str,
epoch: u64,
now_s: u64,
payload: [u8; 24],
beacon: [u8; 18],
}

#[test]
fn vector_file_is_frozen_v1() {
let v = vectors();
assert_eq!(v["wire_version"], 1);
assert_eq!(v["spec_version"], "1.0.0");
}
const CONTACT_ID: &str = "contact-c";

const VECTORS: [Vector; 3] = [
Vector {
name: "contact_c_epoch0",
epoch: 1_963_584,
now_s: 1_767_225_600,
payload: [
0x42, 0x12, 0x00, 0x1d, 0xf6, 0x40, 0x92, 0xd6, 0x34, 0xa3, 0x8f, 0xa4, 0xf2, 0xf4,
0x59, 0x46, 0x4b, 0x01, 0xf4, 0x41, 0xde, 0xdd, 0x7c, 0x7d,
],
beacon: [
0x92, 0xd6, 0x34, 0xa3, 0x8f, 0xa4, 0xf2, 0xf4, 0x59, 0x46, 0x4b, 0x01, 0xf4, 0x41,
0xde, 0xdd, 0x7c, 0x7d,
],
},
Vector {
name: "contact_c_next",
epoch: 1_963_585,
now_s: 1_767_226_500,
payload: [
0x42, 0x12, 0x00, 0x1d, 0xf6, 0x41, 0xd3, 0x73, 0x99, 0xb1, 0xec, 0x9a, 0xf8, 0x14,
0xb0, 0xda, 0xa8, 0x5c, 0xa6, 0x59, 0x1b, 0x1c, 0xfc, 0x7d,
],
beacon: [
0xd3, 0x73, 0x99, 0xb1, 0xec, 0x9a, 0xf8, 0x14, 0xb0, 0xda, 0xa8, 0x5c, 0xa6, 0x59,
0x1b, 0x1c, 0xfc, 0x7d,
],
},
Vector {
name: "contact_c_later",
epoch: 1_963_721,
now_s: 1_767_349_056,
payload: [
0x42, 0x12, 0x00, 0x1d, 0xf6, 0xc9, 0x0f, 0x7e, 0xb6, 0x25, 0xab, 0x85, 0x13, 0x15,
0xb6, 0xd9, 0x72, 0x59, 0x2b, 0x6f, 0x13, 0x7f, 0x6d, 0xf7,
],
beacon: [
0x0f, 0x7e, 0xb6, 0x25, 0xab, 0x85, 0x13, 0x15, 0xb6, 0xd9, 0x72, 0x59, 0x2b, 0x6f,
0x13, 0x7f, 0x6d, 0xf7,
],
},
];

#[test]
fn presence_beacon_id_is_byte_exact() {
let v = vectors();
let cases = v["presence"].as_array().expect("presence[] present");
assert!(!cases.is_empty(), "expected presence vectors");
for c in cases {
let name = c["name"].as_str().unwrap_or("?");
let secret = unhex(c["presence_secret_hex"].as_str().expect("secret"));
let unix_s = c["unix_s"].as_u64().expect("unix_s");
let expected_epoch = c["epoch"].as_u64().expect("epoch");
let expected_beacon = unhex(c["beacon_id_hex"].as_str().expect("beacon_id_hex"));
for v in &VECTORS {
// epoch derivation matches burble.
assert_eq!(epoch_for(v.now_s), v.epoch, "{}: epoch", v.name);

// 1. epoch derivation matches burble.
assert_eq!(epoch_for(unix_s), expected_epoch, "{name}: epoch");
// our HMAC beacon id reproduces burble's oracle byte-for-byte.
let ours = beacon_id(&SECRET, v.epoch);
assert_eq!(&ours[..], &v.beacon[..], "{}: beacon_id bytes", v.name);

// 2. our HMAC beacon id reproduces burble's oracle byte-for-byte.
let ours = beacon_id(&secret, expected_epoch);
assert_eq!(&ours[..], &expected_beacon[..], "{name}: beacon_id bytes");

// 3. the full 24-byte payload parses to the same epoch + beacon.
let payload = unhex(c["payload_hex"].as_str().expect("payload_hex"));
let frame = PresenceFrame::parse(&payload).expect("payload parses");
assert_eq!(frame.epoch, expected_epoch, "{name}: parsed epoch");
// the full 24-byte payload parses to the same epoch + beacon.
let frame = PresenceFrame::parse(&v.payload).expect("payload parses");
assert_eq!(frame.epoch, v.epoch, "{}: parsed epoch", v.name);
assert_eq!(
&frame.beacon_id[..],
&expected_beacon[..],
"{name}: parsed beacon"
&v.beacon[..],
"{}: parsed beacon",
v.name
);
}
}

#[test]
fn presence_resolves_to_named_contact() {
let v = vectors();
for c in v["presence"].as_array().expect("presence[]") {
let name = c["name"].as_str().unwrap_or("?");
let secret = unhex(c["presence_secret_hex"].as_str().expect("secret"));
let payload = unhex(c["payload_hex"].as_str().expect("payload_hex"));
let resolve = &c["resolve"];
let now = resolve["now"].as_u64().expect("now");
let contact_id = resolve["contact_id"].as_str().expect("contact_id");

let contacts = vec![Contact::new(contact_id, secret)];
let got = decode_and_resolve(&payload, &contacts, now).expect("well-formed frame");
assert_eq!(got.as_deref(), Some(contact_id), "{name}: resolves owner");
for v in &VECTORS {
let contacts = vec![Contact::new(CONTACT_ID, SECRET.to_vec())];
let got = decode_and_resolve(&v.payload, &contacts, v.now_s).expect("well-formed frame");
assert_eq!(
got.as_deref(),
Some(CONTACT_ID),
"{}: resolves owner",
v.name
);

// A holder of a *different* secret must not resolve the same payload.
// A holder of a different secret must not resolve the same payload.
let stranger = vec![Contact::new("stranger", vec![0xABu8; 32])];
assert_eq!(
decode_and_resolve(&payload, &stranger, now).expect("well-formed"),
decode_and_resolve(&v.payload, &stranger, v.now_s).expect("well-formed"),
None,
"{name}: stranger must not resolve"
"{}: stranger must not resolve",
v.name
);
}
}
Loading
Loading