Skip to main content

LeafDevice

Struct LeafDevice 

Source
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

Source

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.

Source

pub fn resume(store: SharedStore, app_id: &str) -> Result<Self>

Loads a device that was provisioned earlier.

Source

pub fn open(store: SharedStore, app_id: &str) -> Result<Self>

Loads a device, provisioning one on first boot.

Examples found in repository?
examples/wasi_host_shim.rs (line 128)
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}
Source

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?
examples/wasi_host_shim.rs (line 129)
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}
Source

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?
examples/wasi_host_shim.rs (line 138)
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}
Source

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?
examples/wasi_host_shim.rs (line 168)
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}
Source

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?
examples/wasi_host_shim.rs (line 151)
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}
Source

pub fn has_session(&self, peer: &str) -> Result<bool>

Whether a session with peer exists on flash.

Source

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.

Source

pub fn peer_env_versions(&self, peer: &str) -> Result<Vec<u8>>

What this device recorded about a peer’s capabilities.

Source

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§

Source§

impl Debug for LeafDevice

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T> Instrument for T

Source§

fn instrument(self, span: Span) -> Instrumented<Self> ⓘ

Instruments this type with the provided Span, returning an Instrumented wrapper. Read more
Source§

fn in_current_span(self) -> Instrumented<Self> ⓘ

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> IntoEither for T

Source§

fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ

Converts 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 more
Source§

fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
where F: FnOnce(&Self) -> bool,

Converts 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
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = !

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, !>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
Source§

impl<T> WithSubscriber for T

Source§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self> ⓘ
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a WithDispatch wrapper. Read more
Source§

fn with_current_subscriber(self) -> WithDispatch<Self> ⓘ

Attaches the current default Subscriber to this type, returning a WithDispatch wrapper. Read more