Skip to content
Open
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
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,30 @@ jobs:
- name: Build the sealed layer standalone with default features
run: cargo build --package offline-protocol-sealed --locked

# The leaf node itself. `bare-metal-rng` selects getrandom's custom
# backend, which the target needs because getrandom has none for it and
# refuses to compile otherwise; the crate still registers no
# implementation, because that is the firmware's TRNG to wire up.
#
# This step is the only thing that catches an mls-rs error formatted with
# `{}`: mls-rs implements `Display` on its error only under `std`, so the
# host build accepts what the device build rejects.
- name: Build the leaf node for bare metal
run: >
cargo build --package offline-protocol-leaf
--no-default-features --features bare-metal-rng
--locked --target thumbv8m.main-none-eabihf

- name: Clippy the leaf node for bare metal
run: >
cargo clippy --package offline-protocol-leaf
--no-default-features --features bare-metal-rng
--locked --target thumbv8m.main-none-eabihf
-- -D warnings

- name: Build the leaf node standalone with default features
run: cargo build --package offline-protocol-leaf --locked

# `embedded-footprint` has its own workspace, so the repo's Clippy job
# does not reach it. The leaf configurations are linted individually
# because each is a separate link with a different feature set.
Expand Down
108 changes: 104 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@ archived by series under [docs/changelog/](docs/changelog/); see the
a monotonic clock (`LocalInstant`), entropy (`MessageId::new`, and
`Message::new` and `MessageBuilder` with it), and threads (the `sync`
module). Everything that parses, validates, re-encodes or compares is present
in both configurations, which is the half a leaf node uses, because it
receives frames rather than minting them. Build messages from wire-supplied
parts via `MessageId::from_bytes` and `Timestamp::from_millis` on that path.
in both configurations, which is the half a leaf node that only forwards
uses. A node that answers also mints, and `Message::from_parts` in this same
release is what lets it: build messages from wire-supplied parts, via
`MessageId::from_bytes` and `Timestamp::from_millis` on that path.

Two field types changed spelling to `MetadataMap`, which under `std` **is**
`HashMap<String, String>` exactly as before, and is a `BTreeMap` without it.
Expand Down Expand Up @@ -134,8 +135,107 @@ archived by series under [docs/changelog/](docs/changelog/); see the
One device, one key: a fleet sharing an identity key turns one extraction in
a laboratory into every unit's identity.

### Changed
- **`offline-protocol-leaf`: a constrained device that speaks this protocol as
a real peer.** A door lock or a sensor with a few hundred kilobytes of flash
and no operating system now runs RFC 9420 MLS through mls-rs and holds an
end-to-end encrypted conversation with a phone under the same guarantees a
phone gets. Same frames, same envelope, same trust gates, no second sealing
path and no reduced properties, which is the decision
[ADR 0021](./docs/adr/0021-a-leaf-node-speaks-mls.md) took and this crate
implements.

`LeafDevice` is a frame-level state machine rather than a bag of primitives:
an inbound message goes in, the frames to send and what happened come out. It
runs the **never-committing member** profile, so the phone creates the group
and issues every commit while the device joins, opens, answers and persists.
Per-commit cost on the device is two elliptic-curve operations and
per-message cost is symmetric only.

What it refuses is the point. Every control frame must carry a signature
whose key **derives to the address the frame claims**, and an identifier that
is not an address is the same refusal rather than a skip, because a claim
with no derivation to check is the bypass. A Welcome must name the peer that
signed it, name the group this pair would build, spend **the key package this
device minted for that peer**, and then actually join the group its body
claimed. That third one is what separates a peer from anyone who overheard
it: a key package rides in a frame that is signed but not encrypted, so a
copy taken off the air is as spendable as the original and satisfies every
other gate honestly. Checked before the join, because joining spends the init
key, and a package burned by a listener leaves the peer it was minted for
holding a Welcome that no longer opens. A commit must leave the group a pair,
re-read from the roster and derived rather than read, because a commit
changes the membership without changing the group id and every commit here is
the peer's to make. A sealed frame's MLS sender must be the peer the frame
came from, commits included. A confirmation probe is answered only by a
device that still holds a session, because a peer confirms on that answer and
flushes into it, and an inbound acknowledgement is never acted on at all,
because a leaf emits those and never probes, so every one that arrives is
unsolicited and treating it as proof of a session would let any keypair
holder assert one. A reset frame is acted on once, so a captured one is not a
repeatable session teardown. Underneath all of them, a frame addressed to
another node is ignored before a prefix is read: a signature covers the
recipient rather than checking it, and a sealed frame carries none at all, so
without that gate an overheard key package mints a private init key nobody
asked for and a captured frame with its recipient rewritten is still acted
on. Every one of these is covered against a real OpenMLS phone in the same
process, which is the only kind of test that catches a default in one library
the other refuses.

**What it keeps is bounded.** Prior-epoch records are trimmed to a window
rather than kept forever, which bounds both the flash they occupy and how far
back a stolen device reads; unpairing erases them along with the session, so
epoch secrets do not outlive the erasure an owner asked for. Peer records and
unspent key packages are bounded too, and a full peer table refuses a
stranger rather than evicting somebody the owner paired with, because
producing a frame that derives to its own address costs an attacker nothing.
Every operation that advances state takes `&mut self`, so two seals racing
into one AEAD nonce is a compile error rather than a rare one.

**Persist-before-emit is structural, not documented.** Every operation that
advances ratchet state writes through `LeafStore` and only then returns the
frame, so a store that fails produces an error and no frame at all. A device
that emitted first would come back from a power cut and reuse an AEAD nonce,
which is a confidentiality failure rather than a lost message. A test arms a
failing store and asserts both that nothing is emitted and that the write was
actually attempted, so it cannot pass by short-circuiting earlier.

The seam is atomic per entry rather than across a set, so what a cut lands
between is chosen rather than left to chance. Prior-epoch records go down
before the state, because a state with records it does not yet reference
costs nothing while the reverse loses the out-of-order tolerance a lossy
radio needs. The **epoch marker mls-rs sequences against travels inside the
state entry**, because ordering cannot make those two safe in either
direction: a marker that reached flash without its state refuses every commit
that follows, permanently, and the device goes deaf to its peer until that
peer drives a full reset. A separate high-water record survives the state and
bounds the erasure sweep on unpair, which is the one job the in-state marker
cannot do.

Four obligations stay with the integrator, and the API is shaped so none can
be forgotten silently: every entry point needing a clock takes
`now_unix_secs` (a device that lets an MLS library read a clock it does not
have stamps 1970 and is refused as expired, so it never pairs at all), the
crate registers no `getrandom` backend (firmware wires the part's hardware
entropy source, and key generation is exactly as strong as what it returns),
and `LeafStore` must be atomic per entry. The fourth is **authorization**: a
session proves who a peer is and never that the owner meant them, since any
address in radio range can complete a pairing, so firmware decides when the
radio accepts one and what a given peer's messages may actuate. A lock that
opens for whatever arrives on an established session opens for anyone patient
enough to pair with it. `LeafDevice::peers` is how firmware audits what a
device accumulated and `unpair` is how it removes one, and every route to a
session puts the peer on that list, because a session firmware cannot see is
one it can neither review nor revoke.

- **`Message::from_parts` in `offline-protocol-core`**, which is
`Message::new` with its two ambient inputs, the clock and the entropy, made
explicit. ADR 0020 made core build without `std` on the reading that a
constrained node "receives frames rather than minting them". That is true of
a node which only forwards and false the moment one answers, so without this
a bare-metal node could not produce a `Message` at all. `Message::new`
delegates to it, so there is one struct literal rather than two that drift.

### Changed
- **The envelope codec and `GroupId::new` now return `SealedError`** rather
than `MlsError`, having moved into `offline-protocol-sealed`. Nothing else
changes: `From<SealedError>` exists for both `MlsError` and the engine's
Expand Down
21 changes: 15 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ that are versioned, reviewable, and readable by people who are not an agent.
| A decision that looks odd or over-engineered | [docs/adr/](docs/adr/README.md) |
| `offline-protocol-core`: adding an import, a dependency, or a constructor | ADR [0020](docs/adr/0020-core-compiles-without-std.md) (it is dual std/no_std) |
| `offline-protocol-sealed`: the envelope codec, `derive_address`, canonical signing payloads, ratchet constants, the 1:1 control-frame prefixes, `KeyPackagePayload` | ADR [0022](docs/adr/0022-one-sealed-layer-shared-with-the-leaf.md) (also dual std/no_std, and the one home for each) |
| `offline-protocol-leaf`: anything a device does at pairing, on a frame, or with its store | ADR [0021](docs/adr/0021-a-leaf-node-speaks-mls.md) and [docs/spec/leaf-provisioning.md](docs/spec/leaf-provisioning.md) (a time source, real entropy, durable-before-emit and authorization are obligations, not suggestions) |
| Replicated documents: the store, sync frames, attachments | [docs/spec/data-sync.md](docs/spec/data-sync.md), [the replication state machine](docs/state-machines/data-replication.md), ADR [0018](docs/adr/0018-data-layer-engine-and-storage-seams.md) and [0019](docs/adr/0019-remote-document-imports-are-contained-not-trusted.md) |
| Any binding: Swift, Kotlin, Python, TypeScript | [docs/bridges/](docs/bridges/README.md) |

Expand Down Expand Up @@ -60,15 +61,19 @@ RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps
# Benchmarks (Criterion)
cargo bench --package offline-protocol-bench

# Bare metal. `offline-protocol-core` and `offline-protocol-sealed` are dual
# std/no_std and CI gates both no_std halves; nothing else in the workspace
# compiles either without `std`, so a stray `use std::` in one of them only
# fails here.
# Bare metal. `offline-protocol-core`, `offline-protocol-sealed` and
# `offline-protocol-leaf` are dual std/no_std and CI gates all three no_std
# halves; nothing else in the workspace compiles without `std`, so a stray
# `use std::` in one of them only fails here. So does an mls-rs error
# formatted with `{}`: mls-rs implements Display only under std.
rustup target add thumbv8m.main-none-eabihf
for crate in offline-protocol-core offline-protocol-sealed; do
cargo clippy -p "$crate" --no-default-features \
--target thumbv8m.main-none-eabihf -- -D warnings
done
# The leaf crate needs a getrandom backend selected; the firmware registers one.
cargo clippy -p offline-protocol-leaf --no-default-features \
--features bare-metal-rng --target thumbv8m.main-none-eabihf -- -D warnings
./tools/embedded-footprint/measure.sh # flash/RAM cost of the protocol layer
```

Expand Down Expand Up @@ -117,6 +122,10 @@ offline-protocol Engine: OfflineProtocol, ProtocolConfig, Transpor
|
offline-protocol-uniffi UniFFI bindings (cdylib + staticlib)
offline-protocol-bench Criterion benchmarks

offline-protocol-leaf A constrained device as a never-committing MLS member.
Sits on core + sealed only, never on the engine
(dual std/no_std)
```

### Key extension points
Expand Down Expand Up @@ -205,8 +214,8 @@ Conventional Commits: `<type>(<scope>): <subject>`

Types: `feat`, `fix`, `docs`, `test`, `refactor`, `perf`, `chore`

Scopes: `core`, `sealed`, `transport`, `router`, `reliability`, `services`,
`protocol`, `uniffi`, `bindings`
Scopes: `core`, `sealed`, `leaf`, `transport`, `router`, `reliability`,
`services`, `protocol`, `uniffi`, `bindings`

## Code style (Rust)

Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ We use [Conventional Commits](https://www.conventionalcommits.org/):
**Scopes**:
- `core`: offline-protocol-core
- `sealed`: offline-protocol-sealed (envelope codec, address derivation, signing payloads)
- `leaf`: offline-protocol-leaf (a constrained device as a never-committing MLS member)
- `transport`: offline-protocol-transport
- `router`: offline-protocol-router (DORS)
- `reliability`: offline-protocol-reliability
Expand Down Expand Up @@ -117,6 +118,7 @@ offline-protocol-sdk/
├── crates/ # Rust crates (core logic)
│ ├── offline-protocol-core/
│ ├── offline-protocol-sealed/
│ ├── offline-protocol-leaf/
│ ├── offline-protocol-transport/
│ ├── offline-protocol-router/
│ ├── offline-protocol-reliability/
Expand Down
Loading
Loading