feat(core): reserved Signature slot on Observation

This commit is contained in:
2026-10-22 08:44:09 -07:00
parent e85b3704e0
commit 94c3a3c8ad
11 changed files with 521 additions and 3 deletions
+1
View File
@@ -108,6 +108,7 @@ mod tests {
topic: Topic::from_slice(b"door/3").unwrap(), topic: Topic::from_slice(b"door/3").unwrap(),
payload: Payload::from_slice(b"open").unwrap(), payload: Payload::from_slice(b"open").unwrap(),
dest: None, dest: None,
sig: None,
} }
} }
+11
View File
@@ -18,6 +18,11 @@ pub const MAX_TOPIC: usize = 64;
/// Maximum payload length in bytes. /// Maximum payload length in bytes.
pub const MAX_PAYLOAD: usize = 1024; pub const MAX_PAYLOAD: usize = 1024;
/// Maximum signature length in bytes. The 1.0 signature slot reserves this on
/// every observation (`[crate::observation::Signature]`); it is carried verbatim
/// and never signed or verified until 1.1.
pub const MAX_SIG: usize = 64;
/// Maximum number of peers a node tracks. /// Maximum number of peers a node tracks.
pub const MAX_PEERS: usize = 8; pub const MAX_PEERS: usize = 8;
@@ -36,6 +41,12 @@ pub const REASSEMBLY_TIMEOUT_MS: u64 = 5000;
/// Maximum number of frames buffered for outbound delivery. /// Maximum number of frames buffered for outbound delivery.
pub const SEND_QUEUE_CAP: usize = 256; pub const SEND_QUEUE_CAP: usize = 256;
/// Minimum usable UDP frame size in bytes (the BLE floor below which a
/// fragment header leaves no room for payload). [`crate::frag::fragment`] gives
/// up cleanly with `WireError::TooLarge` for any `mtu` below this rather than
/// emitting a truncated frame; the MTU sweep runs from here to [`MAX_FRAME`].
pub const MIN_MTU: usize = 23;
/// Maximum UDP frame size in bytes. /// Maximum UDP frame size in bytes.
pub const MAX_FRAME: usize = 1500; pub const MAX_FRAME: usize = 1500;
+1
View File
@@ -1292,6 +1292,7 @@ mod tests {
topic: Topic::from_slice(topic).unwrap(), topic: Topic::from_slice(topic).unwrap(),
payload: Payload::from_slice(payload).unwrap(), payload: Payload::from_slice(payload).unwrap(),
dest: None, dest: None,
sig: None,
} }
} }
+107
View File
@@ -218,6 +218,113 @@ mod tests {
topic: Topic::from_slice(b"door/3").unwrap(), topic: Topic::from_slice(b"door/3").unwrap(),
payload: Payload::from_slice(&std::vec![0u8; payload_len]).unwrap(), payload: Payload::from_slice(&std::vec![0u8; payload_len]).unwrap(),
dest: None, dest: None,
sig: None,
}
}
fn signed_max_obs(sig: crate::Signature) -> Observation {
let mut obs = obs_with_payload([1; 16], consts::MAX_PAYLOAD);
obs.sig = Some(sig);
obs
}
#[test]
fn reassembled_bad_signature_is_malformed() {
// A maximum-size signed obs carrying a malformed Ed25519 signature (wrong
// length) must be rejected on reassembly — never returned — exactly as a
// single-frame decode would reject it.
let obs = signed_max_obs(crate::Signature {
version: 1,
algo: crate::SigAlgo::Ed25519,
bytes: heapless::Vec::from_slice(&[0u8; 63]).unwrap(),
});
let mut frames: Vec<Vec<u8>> = Vec::new();
fragment([1; 16], &obs, 200, &mut |b| frames.push(b.to_vec()))
.expect("fragment signed max obs");
let mut r = Reassembler::new();
let mut last: Option<Observation> = None;
for f in frames.iter() {
if let Frame::Frag {
id,
index,
count,
data,
..
} = Frame::decode(f).unwrap()
{
last = r.feed(id, index, count, &data, 0);
}
}
assert!(
last.is_none(),
"a malformed signature on reassembly must be rejected, never returned"
);
}
#[test]
fn reassembled_unknown_algo_signature_roundtrips() {
// An unknown-algorithm signature is carried verbatim on reassembly too.
let obs = signed_max_obs(crate::Signature {
version: 2,
algo: crate::SigAlgo::Unknown(7),
bytes: heapless::Vec::from_slice(&[9u8; 10]).unwrap(),
});
let mut frames: Vec<Vec<u8>> = Vec::new();
fragment([1; 16], &obs, 200, &mut |b| frames.push(b.to_vec()))
.expect("fragment signed max obs");
let mut r = Reassembler::new();
let mut last: Option<Observation> = None;
for f in frames.iter() {
if let Frame::Frag {
id,
index,
count,
data,
..
} = Frame::decode(f).unwrap()
{
last = r.feed(id, index, count, &data, 0);
}
}
assert_eq!(last, Some(obs), "unknown-algo sig must reassemble verbatim");
}
#[test]
fn fragmentation_fits_mtu_sweep() {
// Review Focus #6: a full-size signed obs (MAX_PAYLOAD + 64-byte Ed25519
// sig) must fragment into <= MAX_FRAGMENTS frames each <= mtu, or — below
// the BLE floor — be given up cleanly as TooLarge, never emitted
// oversized, across every mtu from MIN_MTU to MAX_FRAME.
let obs = signed_max_obs(crate::Signature {
version: 1,
algo: crate::SigAlgo::Ed25519,
bytes: heapless::Vec::from_slice(&[0u8; 64]).unwrap(),
});
for mtu in consts::MIN_MTU..=consts::MAX_FRAME {
let mut frames: Vec<Vec<u8>> = Vec::new();
match fragment([1; 16], &obs, mtu, &mut |b| frames.push(b.to_vec())) {
Ok(count) => {
assert!(
count <= MAX_FRAGMENTS,
"frame count {count} exceeds MAX_FRAGMENTS {MAX_FRAGMENTS}"
);
assert_eq!(frames.len(), count, "emit count must match at mtu {mtu}");
for f in &frames {
assert!(
f.len() <= mtu,
"frame of {} bytes exceeds mtu {mtu}",
f.len()
);
}
}
Err(crate::wire::WireError::TooLarge) => {
assert!(
frames.is_empty(),
"TooLarge at mtu {mtu} must emit no frames"
);
}
Err(other) => panic!("unexpected error {other:?} at mtu {mtu}"),
}
} }
} }
+3 -1
View File
@@ -22,7 +22,9 @@ pub use bloom::Bloom;
pub use exchange::{DropReason, Exchange, NodeEvent, Peer}; pub use exchange::{DropReason, Exchange, NodeEvent, Peer};
pub use frag::{fragment, Reassembler}; pub use frag::{fragment, Reassembler};
pub use node::{Node, ObserveError}; pub use node::{Node, ObserveError};
pub use observation::{Dest, NodeId, Observation, ObservationId, Payload, Tick, Topic}; pub use observation::{
Dest, NodeId, Observation, ObservationId, Payload, SigAlgo, Signature, Tick, Topic,
};
pub use store::{Store, StoreError, TrimPolicy}; pub use store::{Store, StoreError, TrimPolicy};
pub use transport::{Event, FrameBuf, SendError, Transport}; pub use transport::{Event, FrameBuf, SendError, Transport};
pub use wire::{Frame, WireError}; pub use wire::{Frame, WireError};
+1
View File
@@ -215,6 +215,7 @@ mod tests {
topic: Topic::from_slice(topic).unwrap(), topic: Topic::from_slice(topic).unwrap(),
payload: Payload::from_slice(b"").unwrap(), payload: Payload::from_slice(b"").unwrap(),
dest: None, dest: None,
sig: None,
} }
} }
+2
View File
@@ -92,6 +92,7 @@ impl<S: Store, T: Transport> Node<S, T> {
topic, topic,
payload, payload,
dest: None, dest: None,
sig: None,
}; };
self.next_seq += 1; self.next_seq += 1;
let id = obs.id(); let id = obs.id();
@@ -128,6 +129,7 @@ impl<S: Store, T: Transport> Node<S, T> {
topic, topic,
payload, payload,
dest: Some(Dest { to, lifetime_ms }), dest: Some(Dest { to, lifetime_ms }),
sig: None,
}; };
self.next_seq += 1; self.next_seq += 1;
let id = obs.id(); let id = obs.id();
+185
View File
@@ -3,6 +3,7 @@
use crate::consts; use crate::consts;
use blake2::{Blake2s256, Digest}; use blake2::{Blake2s256, Digest};
use heapless::Vec; use heapless::Vec;
use serde::de::Deserializer;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// 16-byte node identifier. /// 16-byte node identifier.
@@ -55,6 +56,13 @@ pub struct Observation {
/// Addressing: `None` is a broadcast, `Some(dest)` is addressed to /// Addressing: `None` is a broadcast, `Some(dest)` is addressed to
/// `dest.to` and expires per `dest.lifetime_ms`. /// `dest.to` and expires per `dest.lifetime_ms`.
pub dest: Option<Dest>, pub dest: Option<Dest>,
/// The observer's signature over this observation, if any.
///
/// The 1.0 wire reserves this slot on every observation and carries a
/// signature verbatim (see [`Signature`] / [`SigAlgo`]), but 1.0 does not sign
/// or verify it. Signing and verification arrive in 1.1.
pub sig: Option<Signature>,
} }
impl Observation { impl Observation {
@@ -103,6 +111,7 @@ impl Observation {
topic: self.topic.clone(), topic: self.topic.clone(),
payload: self.payload.clone(), payload: self.payload.clone(),
dest: self.dest.clone(), dest: self.dest.clone(),
sig: self.sig.clone(),
} }
} }
@@ -118,6 +127,7 @@ impl Observation {
topic: Topic::from_slice(topic).expect("topic fits MAX_TOPIC"), topic: Topic::from_slice(topic).expect("topic fits MAX_TOPIC"),
payload: Payload::from_slice(payload).expect("payload fits MAX_PAYLOAD"), payload: Payload::from_slice(payload).expect("payload fits MAX_PAYLOAD"),
dest: None, dest: None,
sig: None,
} }
} }
@@ -141,6 +151,7 @@ impl Observation {
topic: Topic::from_slice(topic).expect("topic fits MAX_TOPIC"), topic: Topic::from_slice(topic).expect("topic fits MAX_TOPIC"),
payload: Payload::from_slice(payload).expect("payload fits MAX_PAYLOAD"), payload: Payload::from_slice(payload).expect("payload fits MAX_PAYLOAD"),
dest: Some(Dest { to, lifetime_ms }), dest: Some(Dest { to, lifetime_ms }),
sig: None,
} }
} }
@@ -152,6 +163,115 @@ impl Observation {
} }
} }
/// A signature scheme identifier, carried in [`Signature::algo`].
///
/// On the wire `algo` is a raw `u8` — the `#[repr(u8)]` discriminant, *not* a
/// postcard enum tag: [`SigAlgo::Ed25519`] is the byte `1`, and an algorithm
/// `tines-core` does not recognize is carried as [`SigAlgo::Unknown`] with its
/// raw id, never rejected. `0` is never valid here (it is `Malformed` on decode).
///
/// Signing and verification arrive in 1.1.
#[non_exhaustive]
#[repr(u8)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SigAlgo {
/// Ed25519: a 64-byte Ed25519 signature.
Ed25519 = 1,
/// An algorithm id `tines-core` does not recognize, carried verbatim.
/// Any `u8` other than `0` (the malformed sentinel) and `1` (`Ed25519`) is
/// preserved so a 1.0 node can forward it untouched to a 1.1 node.
Unknown(u8),
}
/// An observation's signature: the observer's over the whole observation.
///
/// The slot is reserved on the 1.0 wire but *unsigned* in 1.0: this type only
/// carries a signature verbatim, enforcing the shape rules below on decode.
/// Signing and verification arrive in 1.1.
#[non_exhaustive]
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Signature {
/// Signature format version. `0` is rejected as `Malformed` on decode;
/// every other value (including any version `tines-core` does not know) is
/// carried verbatim.
pub version: u8,
/// The algorithm the signature is over. Serialized as a raw `u8` on the
/// wire (its `#[repr(u8)]` discriminant, so [`SigAlgo::Ed25519`] is `1`);
/// unknown ids are preserved as [`SigAlgo::Unknown`] rather than rejected.
#[serde(with = "sig_algo_wire")]
pub algo: SigAlgo,
/// The signature bytes. For [`SigAlgo::Ed25519`] these MUST be exactly 64
/// bytes (a different length is `Malformed`); other algorithms carry any
/// length. Never rejected for being unparseable — carried as-is.
pub bytes: heapless::Vec<u8, { consts::MAX_SIG }>,
}
impl<'de> Deserialize<'de> for Signature {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct RawSignature {
version: u8,
#[serde(with = "sig_algo_wire")]
algo: SigAlgo,
bytes: heapless::Vec<u8, { consts::MAX_SIG }>,
}
let raw = RawSignature::deserialize(deserializer)?;
// A malformed signature (bad version, bad or wrong-length algorithm) is
// carried by no frame: it is a decode failure, never a panic.
if raw.version == 0 {
return Err(serde::de::Error::custom("signature version 0 is malformed"));
}
if matches!(raw.algo, SigAlgo::Ed25519) && raw.bytes.len() != 64 {
return Err(serde::de::Error::custom(
"Ed25519 signature must be exactly 64 bytes",
));
}
Ok(Signature {
version: raw.version,
algo: raw.algo,
bytes: raw.bytes,
})
}
}
/// Wire encoding of [`SigAlgo`]: a raw `u8` (its `#[repr(u8)]` discriminant),
/// not a postcard enum tag. Used via `#[serde(with = "sig_algo_wire")]` on the
/// [`Signature::algo`] field so an unknown id survives decode as [`SigAlgo::Unknown`].
mod sig_algo_wire {
use super::SigAlgo;
use serde::de::Deserializer;
use serde::ser::Serializer;
use serde::Deserialize;
pub fn serialize<S>(algo: &SigAlgo, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match algo {
// `Ed25519` is the byte `1`; any carried `Unknown(n)` is its raw id.
SigAlgo::Ed25519 => serializer.serialize_u8(1),
SigAlgo::Unknown(id) => serializer.serialize_u8(*id),
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<SigAlgo, D::Error>
where
D: Deserializer<'de>,
{
let raw = u8::deserialize(deserializer)?;
// `0` is the malformed sentinel on the wire; `1` is `Ed25519`; every
// other id is carried as `Unknown(n)` so it forwards untouched.
match raw {
0 => Err(serde::de::Error::custom("signature algo id 0 is malformed")),
1 => Ok(SigAlgo::Ed25519),
id => Ok(SigAlgo::Unknown(id)),
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -165,9 +285,31 @@ mod tests {
topic: Topic::from_slice(b"door/3").unwrap(), topic: Topic::from_slice(b"door/3").unwrap(),
payload: Payload::from_slice(b"open").unwrap(), payload: Payload::from_slice(b"open").unwrap(),
dest, dest,
sig: None,
} }
} }
fn sig(version: u8, algo: SigAlgo, bytes: &[u8]) -> Signature {
Signature {
version,
algo,
bytes: Vec::from_slice(bytes).unwrap(),
}
}
#[test]
fn sig_excluded_from_id() {
// A signature must not affect identity: a bare, a full Ed25519 and an
// arbitrary unknown-algorithm sig all share the observation's id.
let base = obs(5, 0, None, None);
let mut signed = obs(5, 0, None, None);
signed.sig = Some(sig(1, SigAlgo::Ed25519, &[0u8; 64]));
assert_eq!(base.id(), signed.id(), "sig must not affect id");
let mut other = obs(5, 0, None, None);
other.sig = Some(sig(2, SigAlgo::Unknown(9), &[1u8; 10]));
assert_eq!(base.id(), other.id());
}
#[test] #[test]
fn id_ignores_age_and_observed_at() { fn id_ignores_age_and_observed_at() {
assert_eq!(obs(1, 0, None, None).id(), obs(1, 999, Some(5), None).id()); assert_eq!(obs(1, 0, None, None).id(), obs(1, 999, Some(5), None).id());
@@ -360,4 +502,47 @@ mod tests {
] ]
); );
} }
#[test]
fn sig_algo_wire_roundtrip() {
// Review Focus #3: the raw-u8 wire encoding round-trips a known Ed25519
// and every unknown id verbatim, rejects the malformed sentinel, and
// never maps a wire byte of 1 to Unknown(1). Exercises the algo field
// through Signature, since SigAlgo serializes only via its serde module.
let make = |algo: SigAlgo| Signature {
version: 1,
algo,
bytes: heapless::Vec::<u8, { crate::consts::MAX_SIG }>::from_slice(&[0u8; 64]).unwrap(),
};
for (algo, wire_byte) in [
(SigAlgo::Ed25519, 1u8),
(SigAlgo::Unknown(7), 7u8),
(SigAlgo::Unknown(255), 255u8),
] {
let sig = make(algo);
let mut buf = [0u8; consts::MAX_SIG + 8];
let n = postcard::to_slice(&sig, &mut buf).unwrap();
assert_eq!(n[0], 1, "version serializes as the first byte");
assert_eq!(n[1], wire_byte, "algo is its raw repr discriminant");
let decoded: Signature = postcard::from_bytes(&n[..]).unwrap();
assert_eq!(decoded.algo, algo, "algo must round-trip verbatim");
}
// `0` is the malformed sentinel on the wire — it must fail to decode.
let sig = make(SigAlgo::Unknown(0));
let mut buf = [0u8; consts::MAX_SIG + 8];
let n = postcard::to_slice(&sig, &mut buf).unwrap();
assert_eq!(n[1], 0);
let bad: Result<Signature, _> = postcard::from_bytes::<Signature>(n);
assert!(bad.is_err(), "algo id 0 must be malformed");
// A wire byte of 1 is Ed25519, never Unknown(1).
let sig = make(SigAlgo::Ed25519);
let mut buf = [0u8; consts::MAX_SIG + 8];
let n = postcard::to_slice(&sig, &mut buf).unwrap();
assert_eq!(n[1], 1);
assert_eq!(
postcard::from_bytes::<Signature>(n).unwrap().algo,
SigAlgo::Ed25519,
"wire byte 1 must decode to Ed25519"
);
}
} }
+207 -2
View File
@@ -126,8 +126,7 @@ pub enum WireError {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::Payload; use crate::{Payload, SigAlgo, Signature, Topic};
use crate::Topic;
fn obs_frame() -> Frame { fn obs_frame() -> Frame {
Frame::Obs { Frame::Obs {
@@ -140,6 +139,7 @@ mod tests {
topic: Topic::from_slice(b"door/3").unwrap(), topic: Topic::from_slice(b"door/3").unwrap(),
payload: Payload::from_slice(b"open").unwrap(), payload: Payload::from_slice(b"open").unwrap(),
dest: None, dest: None,
sig: None,
}, },
} }
} }
@@ -155,6 +155,7 @@ mod tests {
topic: Topic::from_slice(b"t").unwrap(), topic: Topic::from_slice(b"t").unwrap(),
payload: Payload::from_slice(&[0u8; 1024]).unwrap(), payload: Payload::from_slice(&[0u8; 1024]).unwrap(),
dest: None, dest: None,
sig: None,
}, },
} }
} }
@@ -476,4 +477,208 @@ mod tests {
let mut buf = [0u8; 16]; let mut buf = [0u8; 16];
assert_eq!(f.encode(&mut buf).err(), Some(WireError::TooLarge)); assert_eq!(f.encode(&mut buf).err(), Some(WireError::TooLarge));
} }
fn sig(version: u8, algo: SigAlgo, bytes: &[u8]) -> Signature {
Signature {
version,
algo,
bytes: Vec::from_slice(bytes).unwrap(),
}
}
fn signed_obs(sig: Signature) -> Observation {
Observation {
observer: [7; 16],
seq: 42,
age_ms: 123,
observed_at: Some(999),
topic: Topic::from_slice(b"door/3").unwrap(),
payload: Payload::from_slice(b"open").unwrap(),
dest: None,
sig: Some(sig),
}
}
#[test]
fn obs_roundtrip_with_ed25519_signature() {
let f = Frame::Obs {
from: [7; 16],
obs: signed_obs(sig(1, SigAlgo::Ed25519, &[0u8; 64])),
};
let mut buf = [0u8; consts::MAX_FRAME];
let n = f.encode(&mut buf).unwrap();
let decoded = Frame::decode(&buf[..n]).unwrap();
assert_eq!(f, decoded);
}
#[test]
fn obs_roundtrip_with_unknown_algo() {
// An unknown algorithm id with short bytes is carried verbatim.
let f = Frame::Obs {
from: [7; 16],
obs: signed_obs(sig(2, SigAlgo::Unknown(7), &[9u8; 10])),
};
let mut buf = [0u8; consts::MAX_FRAME];
let n = f.encode(&mut buf).unwrap();
let decoded = Frame::decode(&buf[..n]).unwrap();
assert_eq!(f, decoded);
}
#[test]
fn algo_zero_is_malformed() {
let f = Frame::Obs {
from: [7; 16],
obs: signed_obs(sig(1, SigAlgo::Unknown(0), &[0u8; 64])),
};
let mut buf = [0u8; consts::MAX_FRAME];
let n = f.encode(&mut buf).unwrap();
assert_eq!(Frame::decode(&buf[..n]).err(), Some(WireError::Malformed));
}
#[test]
fn version_zero_is_malformed() {
let f = Frame::Obs {
from: [7; 16],
obs: signed_obs(sig(0, SigAlgo::Ed25519, &[0u8; 64])),
};
let mut buf = [0u8; consts::MAX_FRAME];
let n = f.encode(&mut buf).unwrap();
assert_eq!(Frame::decode(&buf[..n]).err(), Some(WireError::Malformed));
}
#[test]
fn ed25519_wrong_length_is_malformed() {
let f = Frame::Obs {
from: [7; 16],
obs: signed_obs(sig(1, SigAlgo::Ed25519, &[0u8; 63])),
};
let mut buf = [0u8; consts::MAX_FRAME];
let n = f.encode(&mut buf).unwrap();
assert_eq!(Frame::decode(&buf[..n]).err(), Some(WireError::Malformed));
}
#[test]
fn raw_one_decodes_to_ed25519() {
// A wire algo byte of 1 must decode to Ed25519, never Unknown(1).
let f = Frame::Obs {
from: [7; 16],
obs: signed_obs(sig(1, SigAlgo::Unknown(1), &[0u8; 64])),
};
let mut buf = [0u8; consts::MAX_FRAME];
let n = f.encode(&mut buf).unwrap();
let decoded = Frame::decode(&buf[..n]).unwrap();
match decoded {
Frame::Obs { obs, .. } => {
assert_eq!(obs.sig.as_ref().unwrap().algo, SigAlgo::Ed25519);
}
other => panic!("expected Obs, got {other:?}"),
}
}
// Pinned wire vector for a full-size (MAX_PAYLOAD + 64-byte Ed25519) signed
// Observation, exactly as a 1.0 node would emit it: the golden vector for
// review focus #5. Decoding it must yield the bare Observation with a
// SigAlgo::Ed25519 carrying 64 zeroed signature bytes.
const OBS_GOLDEN_VECTOR_ED25519: [u8; 1139] = [
0x04, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01,
0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01,
0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x06, 0x64, 0x6f, 0x6f, 0x72, 0x2f, 0x33, 0x80,
0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
#[test]
fn golden_vector_ed25519_decodes_to_bare_obs() {
// Review Focus #5: the pinned vector is the golden Obs vector — decoding
// it must yield the bare Observation with an Ed25519 signature carrying
// 64 zeroed bytes.
let frame = Frame::decode(&OBS_GOLDEN_VECTOR_ED25519).unwrap();
let Frame::Obs { from, obs } = frame else {
panic!("expected an Obs frame")
};
assert_eq!(from, [1; 16]);
assert_eq!(obs.observer, [1; 16]);
assert_eq!(obs.seq, 1);
assert_eq!(obs.age_ms, 0);
assert_eq!(obs.observed_at, None);
assert_eq!(&obs.topic[..], b"door/3");
assert_eq!(obs.payload.len(), consts::MAX_PAYLOAD);
assert_eq!(obs.dest, None);
let sig = obs.sig.expect("a signed obs carries a signature");
assert_eq!(sig.version, 1);
assert_eq!(sig.algo, SigAlgo::Ed25519);
assert_eq!(sig.bytes.len(), 64);
assert!(sig.bytes.iter().all(|b| *b == 0));
}
} }
+1
View File
@@ -1024,6 +1024,7 @@ mod tests {
topic: tines_core::Topic::from_slice(b"door/3").unwrap(), topic: tines_core::Topic::from_slice(b"door/3").unwrap(),
payload: tines_core::Payload::from_slice(b"open").unwrap(), payload: tines_core::Payload::from_slice(b"open").unwrap(),
dest: None, dest: None,
sig: None,
} }
} }
+2
View File
@@ -21,6 +21,7 @@ fn obs(observer: [u8; 16], seq: u64, age: u64, at: Option<u64>) -> Observation {
topic: Topic::from_slice(b"door/3").unwrap(), topic: Topic::from_slice(b"door/3").unwrap(),
payload: Payload::from_slice(b"open").unwrap(), payload: Payload::from_slice(b"open").unwrap(),
dest: None, dest: None,
sig: None,
} }
} }
@@ -346,6 +347,7 @@ mod fleet_fixes_f5 {
topic: Topic::from_slice(b"door/3").unwrap(), topic: Topic::from_slice(b"door/3").unwrap(),
payload: Payload::from_slice(b"open").unwrap(), payload: Payload::from_slice(b"open").unwrap(),
dest: None, dest: None,
sig: None,
} }
} }