pub struct LeafDevice { /* private fields */ }Expand description
A leaf node.
§The never-committing profile
The peer creates the group, adds this device, and issues every commit. The device joins, opens what arrives, answers, and persists. It never emits a Welcome, a commit or a proposal, and never any group, rich, document or relay frame. Per-commit cost here is two elliptic-curve operations; per-message cost is symmetric only.
Post-compromise security therefore arrives on the peer’s cadence rather
than this device’s, and a driven rekey reaches it as a key package with
session_reset set rather than as an unsolicited Welcome.
§Storage is the source of truth
No MLS state is cached in this value. Every operation loads the group from the store, does its work, writes back, and only then returns the frame to send. That costs a load per frame, which is the right trade for a device that handles a frame every few minutes and must survive losing power between any two instructions: there is no in-RAM state for a power cut to desynchronize from what is on flash.
§One writer at a time
Every operation that advances state takes &mut self, so the compiler
enforces what the ratchet requires. Two seals running at once would load
the same generation, encrypt under the same key and nonce, and emit both
frames: the AEAD nonce reuse this crate’s whole persist-before-emit rule
exists to prevent, arrived at without ever losing power. The same argument
covers the send counter, where a race mints one id twice and the peer’s
deduplicator swallows the second message.
A device is therefore one value, exclusively held. Two LeafDevices over
one LeafStore would each satisfy the borrow checker and race anyway;
construct one per store.
§What it does not defend against
Anyone pairing. Every gate here answers “is this peer the address it
claims to be”, and none of them answers “did the owner mean this peer”.
Producing a key that derives to its own address costs nothing, so an
unattended device admits whoever asks, up to the bound above. That is the
same position two phones are in, where the out-of-band artifact carries
first-contact trust; on a device it is firmware that decides when the radio
accepts a pairing at all, and firmware that decides what an opened message
may actuate. LeafDevice::peers is how it audits what accumulated.
A replayed control frame, as far as this device’s clock is honest. The signed payload states when the frame was made, so one older than two days is refused outright, and a reset-flagged key package (the destructive case, since it tears down a live session) is additionally refused unless it is newer than the last reset this device acted on. One capture is therefore worth one teardown, and only inside a two-day window.
What that leaves is the clock. Both checks are made against the time source firmware supplies, so a device that supplies a wrong one either refuses its peer or, if the clock is far enough behind, admits frames it should have refused. The obligation is stated with the others in the provisioning chapter, and it is why the time source is a parameter on every entry point here rather than something this crate reaches for.
Debug renders the device’s address and nothing else. Everything else it
holds is either secret or a handle to secrets, and a device that printed
its identity key into a log would undo the part of the threat model that
says the key never leaves storage.
Implementations§
Source§impl LeafDevice
impl LeafDevice
Sourcepub fn provision(store: SharedStore, app_id: &str) -> Result<Self>
pub fn provision(store: SharedStore, app_id: &str) -> Result<Self>
Generates an identity and returns a device that holds it.
Draws from the getrandom backend the firmware registered. Refuses if
the store already holds an identity, because replacing one changes the
device’s address and silently orphans every peer paired with it.
Sourcepub fn resume(store: SharedStore, app_id: &str) -> Result<Self>
pub fn resume(store: SharedStore, app_id: &str) -> Result<Self>
Loads a device that was provisioned earlier.
Sourcepub fn open(store: SharedStore, app_id: &str) -> Result<Self>
pub fn open(store: SharedStore, app_id: &str) -> Result<Self>
Loads a device, provisioning one on first boot.
Examples found in repository?
112fn run() -> Result<(), String> {
113 let args: Vec<String> = std::env::args().skip(1).collect();
114 let [app_id, peer, now] = args.as_slice() else {
115 return Err(USAGE.to_string());
116 };
117 let now_unix_secs: u64 = now
118 .parse()
119 .map_err(|_| format!("now_unix_secs must be a whole number of seconds, got {now:?}"))?;
120
121 // `shared_store` rather than `Arc::new`: it is the one constructor that
122 // builds on every target the crate supports, so it is the route to copy.
123 let store = shared_store(MemoryStore::new());
124
125 // Provisioning draws from the getrandom backend the target selects. On
126 // WASI that is the runtime's `random_get`; see the header.
127 let mut device =
128 LeafDevice::open(store, app_id).map_err(|error| format!("opening the device: {error}"))?;
129 eprintln!("address {}", device.address());
130
131 let stdout = io::stdout();
132 let mut out = stdout.lock();
133
134 // Pairing starts with the device's key package. The time is supplied, not
135 // read: with `now_unix_secs` the package's validity window is real, and
136 // without it the library would stamp 1970.
137 let advertisement = device
138 .key_package_frame(peer, now_unix_secs)
139 .map_err(|error| format!("minting the key package: {error}"))?;
140 emit(&mut out, &advertisement)?;
141
142 // Steady state: frames arrive, the device verifies each, opens it if it is
143 // sealed, persists, and only then hands back what it owes in reply.
144 for (index, line) in io::stdin().lock().lines().enumerate() {
145 let line = line.map_err(|error| format!("reading standard input: {error}"))?;
146 if line.trim().is_empty() {
147 continue;
148 }
149 let inbound = Message::from_json(&line)
150 .map_err(|error| format!("line {}: not a frame: {error}", index + 1))?;
151 match device.handle(&inbound, now_unix_secs) {
152 Ok(handled) => {
153 for event in &handled.events {
154 eprintln!("event {event:?}");
155 }
156 for frame in &handled.outbound {
157 emit(&mut out, frame)?;
158 }
159 }
160 Err(error) => eprintln!("line {}: refused: {error}", index + 1),
161 }
162 }
163
164 // Answering. The persist is inside `seal`, before it returns anything, so
165 // there is no ordering here to get wrong. Without the phone's Welcome
166 // there is no session, and the crate says so rather than sealing into a
167 // group that does not exist.
168 match device.seal(peer, GREETING, now_unix_secs) {
169 Ok(frame) => emit(&mut out, &frame),
170 Err(LeafError::NoSession(_)) => {
171 eprintln!(
172 "no session with {peer}: nothing sealed. A session exists only after the \
173 phone's Welcome, and no frame on standard input carried one."
174 );
175 Ok(())
176 }
177 Err(error) => Err(format!("sealing to {peer}: {error}")),
178 }
179}Sourcepub fn address(&self) -> &Address
pub fn address(&self) -> &Address
This device’s address. Self-certifying: it is the hash of the identity key, so a peer checks a claim rather than trusting a directory.
Examples found in repository?
112fn run() -> Result<(), String> {
113 let args: Vec<String> = std::env::args().skip(1).collect();
114 let [app_id, peer, now] = args.as_slice() else {
115 return Err(USAGE.to_string());
116 };
117 let now_unix_secs: u64 = now
118 .parse()
119 .map_err(|_| format!("now_unix_secs must be a whole number of seconds, got {now:?}"))?;
120
121 // `shared_store` rather than `Arc::new`: it is the one constructor that
122 // builds on every target the crate supports, so it is the route to copy.
123 let store = shared_store(MemoryStore::new());
124
125 // Provisioning draws from the getrandom backend the target selects. On
126 // WASI that is the runtime's `random_get`; see the header.
127 let mut device =
128 LeafDevice::open(store, app_id).map_err(|error| format!("opening the device: {error}"))?;
129 eprintln!("address {}", device.address());
130
131 let stdout = io::stdout();
132 let mut out = stdout.lock();
133
134 // Pairing starts with the device's key package. The time is supplied, not
135 // read: with `now_unix_secs` the package's validity window is real, and
136 // without it the library would stamp 1970.
137 let advertisement = device
138 .key_package_frame(peer, now_unix_secs)
139 .map_err(|error| format!("minting the key package: {error}"))?;
140 emit(&mut out, &advertisement)?;
141
142 // Steady state: frames arrive, the device verifies each, opens it if it is
143 // sealed, persists, and only then hands back what it owes in reply.
144 for (index, line) in io::stdin().lock().lines().enumerate() {
145 let line = line.map_err(|error| format!("reading standard input: {error}"))?;
146 if line.trim().is_empty() {
147 continue;
148 }
149 let inbound = Message::from_json(&line)
150 .map_err(|error| format!("line {}: not a frame: {error}", index + 1))?;
151 match device.handle(&inbound, now_unix_secs) {
152 Ok(handled) => {
153 for event in &handled.events {
154 eprintln!("event {event:?}");
155 }
156 for frame in &handled.outbound {
157 emit(&mut out, frame)?;
158 }
159 }
160 Err(error) => eprintln!("line {}: refused: {error}", index + 1),
161 }
162 }
163
164 // Answering. The persist is inside `seal`, before it returns anything, so
165 // there is no ordering here to get wrong. Without the phone's Welcome
166 // there is no session, and the crate says so rather than sealing into a
167 // group that does not exist.
168 match device.seal(peer, GREETING, now_unix_secs) {
169 Ok(frame) => emit(&mut out, &frame),
170 Err(LeafError::NoSession(_)) => {
171 eprintln!(
172 "no session with {peer}: nothing sealed. A session exists only after the \
173 phone's Welcome, and no frame on standard input carried one."
174 );
175 Ok(())
176 }
177 Err(error) => Err(format!("sealing to {peer}: {error}")),
178 }
179}Sourcepub fn key_package_frame(
&mut self,
peer: &str,
now_unix_secs: u64,
) -> Result<Message>
pub fn key_package_frame( &mut self, peer: &str, now_unix_secs: u64, ) -> Result<Message>
Mints a key package and wraps it in a signed frame for peer.
now_unix_secs is the pairing time source. It is a parameter because a
device has no clock, and passing something wrong here is the difference
between pairing and being refused as expired.
Examples found in repository?
112fn run() -> Result<(), String> {
113 let args: Vec<String> = std::env::args().skip(1).collect();
114 let [app_id, peer, now] = args.as_slice() else {
115 return Err(USAGE.to_string());
116 };
117 let now_unix_secs: u64 = now
118 .parse()
119 .map_err(|_| format!("now_unix_secs must be a whole number of seconds, got {now:?}"))?;
120
121 // `shared_store` rather than `Arc::new`: it is the one constructor that
122 // builds on every target the crate supports, so it is the route to copy.
123 let store = shared_store(MemoryStore::new());
124
125 // Provisioning draws from the getrandom backend the target selects. On
126 // WASI that is the runtime's `random_get`; see the header.
127 let mut device =
128 LeafDevice::open(store, app_id).map_err(|error| format!("opening the device: {error}"))?;
129 eprintln!("address {}", device.address());
130
131 let stdout = io::stdout();
132 let mut out = stdout.lock();
133
134 // Pairing starts with the device's key package. The time is supplied, not
135 // read: with `now_unix_secs` the package's validity window is real, and
136 // without it the library would stamp 1970.
137 let advertisement = device
138 .key_package_frame(peer, now_unix_secs)
139 .map_err(|error| format!("minting the key package: {error}"))?;
140 emit(&mut out, &advertisement)?;
141
142 // Steady state: frames arrive, the device verifies each, opens it if it is
143 // sealed, persists, and only then hands back what it owes in reply.
144 for (index, line) in io::stdin().lock().lines().enumerate() {
145 let line = line.map_err(|error| format!("reading standard input: {error}"))?;
146 if line.trim().is_empty() {
147 continue;
148 }
149 let inbound = Message::from_json(&line)
150 .map_err(|error| format!("line {}: not a frame: {error}", index + 1))?;
151 match device.handle(&inbound, now_unix_secs) {
152 Ok(handled) => {
153 for event in &handled.events {
154 eprintln!("event {event:?}");
155 }
156 for frame in &handled.outbound {
157 emit(&mut out, frame)?;
158 }
159 }
160 Err(error) => eprintln!("line {}: refused: {error}", index + 1),
161 }
162 }
163
164 // Answering. The persist is inside `seal`, before it returns anything, so
165 // there is no ordering here to get wrong. Without the phone's Welcome
166 // there is no session, and the crate says so rather than sealing into a
167 // group that does not exist.
168 match device.seal(peer, GREETING, now_unix_secs) {
169 Ok(frame) => emit(&mut out, &frame),
170 Err(LeafError::NoSession(_)) => {
171 eprintln!(
172 "no session with {peer}: nothing sealed. A session exists only after the \
173 phone's Welcome, and no frame on standard input carried one."
174 );
175 Ok(())
176 }
177 Err(error) => Err(format!("sealing to {peer}: {error}")),
178 }
179}Sourcepub fn seal(
&mut self,
peer: &str,
plaintext: &str,
now_unix_secs: u64,
) -> Result<Message>
pub fn seal( &mut self, peer: &str, plaintext: &str, now_unix_secs: u64, ) -> Result<Message>
Seals plaintext to peer.
The ratchet advances, the new state is persisted, and only then does the frame exist. A store that fails produces an error and no frame, which is the whole point: a device that emitted first and persisted second would, after a power cut, come back and reuse an AEAD nonce.
Examples found in repository?
112fn run() -> Result<(), String> {
113 let args: Vec<String> = std::env::args().skip(1).collect();
114 let [app_id, peer, now] = args.as_slice() else {
115 return Err(USAGE.to_string());
116 };
117 let now_unix_secs: u64 = now
118 .parse()
119 .map_err(|_| format!("now_unix_secs must be a whole number of seconds, got {now:?}"))?;
120
121 // `shared_store` rather than `Arc::new`: it is the one constructor that
122 // builds on every target the crate supports, so it is the route to copy.
123 let store = shared_store(MemoryStore::new());
124
125 // Provisioning draws from the getrandom backend the target selects. On
126 // WASI that is the runtime's `random_get`; see the header.
127 let mut device =
128 LeafDevice::open(store, app_id).map_err(|error| format!("opening the device: {error}"))?;
129 eprintln!("address {}", device.address());
130
131 let stdout = io::stdout();
132 let mut out = stdout.lock();
133
134 // Pairing starts with the device's key package. The time is supplied, not
135 // read: with `now_unix_secs` the package's validity window is real, and
136 // without it the library would stamp 1970.
137 let advertisement = device
138 .key_package_frame(peer, now_unix_secs)
139 .map_err(|error| format!("minting the key package: {error}"))?;
140 emit(&mut out, &advertisement)?;
141
142 // Steady state: frames arrive, the device verifies each, opens it if it is
143 // sealed, persists, and only then hands back what it owes in reply.
144 for (index, line) in io::stdin().lock().lines().enumerate() {
145 let line = line.map_err(|error| format!("reading standard input: {error}"))?;
146 if line.trim().is_empty() {
147 continue;
148 }
149 let inbound = Message::from_json(&line)
150 .map_err(|error| format!("line {}: not a frame: {error}", index + 1))?;
151 match device.handle(&inbound, now_unix_secs) {
152 Ok(handled) => {
153 for event in &handled.events {
154 eprintln!("event {event:?}");
155 }
156 for frame in &handled.outbound {
157 emit(&mut out, frame)?;
158 }
159 }
160 Err(error) => eprintln!("line {}: refused: {error}", index + 1),
161 }
162 }
163
164 // Answering. The persist is inside `seal`, before it returns anything, so
165 // there is no ordering here to get wrong. Without the phone's Welcome
166 // there is no session, and the crate says so rather than sealing into a
167 // group that does not exist.
168 match device.seal(peer, GREETING, now_unix_secs) {
169 Ok(frame) => emit(&mut out, &frame),
170 Err(LeafError::NoSession(_)) => {
171 eprintln!(
172 "no session with {peer}: nothing sealed. A session exists only after the \
173 phone's Welcome, and no frame on standard input carried one."
174 );
175 Ok(())
176 }
177 Err(error) => Err(format!("sealing to {peer}: {error}")),
178 }
179}Sourcepub fn handle(
&mut self,
message: &Message,
now_unix_secs: u64,
) -> Result<Handled>
pub fn handle( &mut self, message: &Message, now_unix_secs: u64, ) -> Result<Handled>
Handles one inbound frame.
Returns the frames to send and what happened. Everything in
Handled::outbound is already durable by the time it is returned.
§A frame addressed elsewhere is answered by nothing
A radio hears what it is not the recipient of, so this is the first question asked, before a signature is verified or a prefix is read. Nothing further down asks it again, and neither kind of frame answers it on its own: a control frame’s signature covers the recipient rather than checking it, so one honestly signed for somebody else verifies perfectly here, and a sealed frame carries no signature at all, so its recipient is whatever the last hand to touch it wrote there. Being able to open a frame is a different claim from having been sent it.
Three things follow from not asking. An overheard key package admits a peer, spends flash on a record, mints a private init key nobody asked this device for, and answers a phone that never addressed it. A sealed frame this device really can open is acted on after anyone who captured it rewrote the recipient, because that field is not inside the AEAD. And every other prefix arrives as an identity-binding failure, so ordinary traffic between two neighbours reaches firmware wearing the shape of an attack, on a device whose only account of itself is that error stream.
What the check does not do is keep anyone else’s ciphertext readable here: the group and credential gates below hold either way.
It is LeafEvent::Ignored rather than an error because overhearing is
what a shared radio does, and because firmware that carries frames for
its neighbours needs “not mine” to be a fact it can act on rather than a
failure it has to interpret.
Examples found in repository?
112fn run() -> Result<(), String> {
113 let args: Vec<String> = std::env::args().skip(1).collect();
114 let [app_id, peer, now] = args.as_slice() else {
115 return Err(USAGE.to_string());
116 };
117 let now_unix_secs: u64 = now
118 .parse()
119 .map_err(|_| format!("now_unix_secs must be a whole number of seconds, got {now:?}"))?;
120
121 // `shared_store` rather than `Arc::new`: it is the one constructor that
122 // builds on every target the crate supports, so it is the route to copy.
123 let store = shared_store(MemoryStore::new());
124
125 // Provisioning draws from the getrandom backend the target selects. On
126 // WASI that is the runtime's `random_get`; see the header.
127 let mut device =
128 LeafDevice::open(store, app_id).map_err(|error| format!("opening the device: {error}"))?;
129 eprintln!("address {}", device.address());
130
131 let stdout = io::stdout();
132 let mut out = stdout.lock();
133
134 // Pairing starts with the device's key package. The time is supplied, not
135 // read: with `now_unix_secs` the package's validity window is real, and
136 // without it the library would stamp 1970.
137 let advertisement = device
138 .key_package_frame(peer, now_unix_secs)
139 .map_err(|error| format!("minting the key package: {error}"))?;
140 emit(&mut out, &advertisement)?;
141
142 // Steady state: frames arrive, the device verifies each, opens it if it is
143 // sealed, persists, and only then hands back what it owes in reply.
144 for (index, line) in io::stdin().lock().lines().enumerate() {
145 let line = line.map_err(|error| format!("reading standard input: {error}"))?;
146 if line.trim().is_empty() {
147 continue;
148 }
149 let inbound = Message::from_json(&line)
150 .map_err(|error| format!("line {}: not a frame: {error}", index + 1))?;
151 match device.handle(&inbound, now_unix_secs) {
152 Ok(handled) => {
153 for event in &handled.events {
154 eprintln!("event {event:?}");
155 }
156 for frame in &handled.outbound {
157 emit(&mut out, frame)?;
158 }
159 }
160 Err(error) => eprintln!("line {}: refused: {error}", index + 1),
161 }
162 }
163
164 // Answering. The persist is inside `seal`, before it returns anything, so
165 // there is no ordering here to get wrong. Without the phone's Welcome
166 // there is no session, and the crate says so rather than sealing into a
167 // group that does not exist.
168 match device.seal(peer, GREETING, now_unix_secs) {
169 Ok(frame) => emit(&mut out, &frame),
170 Err(LeafError::NoSession(_)) => {
171 eprintln!(
172 "no session with {peer}: nothing sealed. A session exists only after the \
173 phone's Welcome, and no frame on standard input carried one."
174 );
175 Ok(())
176 }
177 Err(error) => Err(format!("sealing to {peer}: {error}")),
178 }
179}Sourcepub fn has_session(&self, peer: &str) -> Result<bool>
pub fn has_session(&self, peer: &str) -> Result<bool>
Whether a session with peer exists on flash.
Sourcepub fn peers(&self) -> Result<Vec<String>>
pub fn peers(&self) -> Result<Vec<String>>
The peers this device holds records for.
Firmware’s way to audit what a device accumulated. A session proves who
a peer is and never that the owner meant to have them, so a device that
has been in a hallway for a year may hold peers nobody chose; this is
how they are found, and LeafDevice::unpair is how they are removed.
Not every entry holds a session: a pairing that stopped after the first
frame leaves a record too, and those are the slots a new peer recycles.
Sourcepub fn peer_env_versions(&self, peer: &str) -> Result<Vec<u8>>
pub fn peer_env_versions(&self, peer: &str) -> Result<Vec<u8>>
What this device recorded about a peer’s capabilities.
Sourcepub fn unpair(&mut self, peer: &str) -> Result<()>
pub fn unpair(&mut self, peer: &str) -> Result<()>
Forgets a peer: its session, its prior epochs, the key package minted for it, and what it advertised.
Exposed because a device that is factory reset or unpaired must be able to forget, and because leaving MLS state behind for a peer the owner removed is the kind of residue that outlives the reason it existed.
The key package is part of that and is easy to miss, because the only pointer to it is the reference inside the record this call deletes. It holds an init key, which is single use and therefore never consumed if the pairing it was minted for never completed.
Also releases the slot it held, so unpairing is how an owner makes room on a device whose peer table is full.
Trait Implementations§
Auto Trait Implementations§
impl !RefUnwindSafe for LeafDevice
impl !UnwindSafe for LeafDevice
impl Freeze for LeafDevice
impl Send for LeafDevice
impl Sync for LeafDevice
impl Unpin for LeafDevice
impl UnsafeUnpin for LeafDevice
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
Source§impl<T> Instrument for T
impl<T> Instrument for T
Source§fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
Source§fn in_current_span(self) -> Instrumented<Self> ⓘ
fn in_current_span(self) -> Instrumented<Self> ⓘ
Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left is true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left(&self) returns true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read more