Skip to content

fix(direct): open items from the client's spec, and the KeyPackage pool count route - #5

Open
koabula wants to merge 4 commits into
mainfrom
fix/spec-open-items
Open

koabula wants to merge 4 commits into
mainfrom
fix/spec-open-items

Conversation

@koabula

@koabula koabula commented Nov 11, 2026

Copy link
Copy Markdown
Owner

Fixes found while writing the specification of the 1:1 client (Π_T) for the security proof. Three commits; each change has a test.

1. A requester is imported only once its device key signed the Welcome (c4603b6)

Accepting a message request fetched the sender's bundle from the address the Welcome names and imported it as an Available contact. The only check was that the bundle's user id equals the user the Welcome's credential claims, and that credential is a string the Welcome's maker chooses. Join checked the signer later, but by then the contact was stored. So a Welcome naming someone, with a reference to their real bundle, left them in the receiver's contacts, and the receiver could then start a conversation with them although they never shared a link.

Staging a Welcome already verifies its GroupInfo signature under the author's leaf key. inspect_welcome now returns that key and the preview carries it. Both accept flows (CLI and desktop) require it to equal the device key in the fetched bundle before importing anything.

2. A rebuild sends only its Welcome (e140569)

After a lost race, or a rebuild the user confirms, the commit that created the new group was sent ahead of the Welcome. Nobody can read it: everyone in the group joins from the Welcome. It went out under the new session's commit key for epoch 1, with an arbitration signature over base 0, and the peer quarantined it and then dropped it as superseded. A first contact already sends only its Welcome.

The emission ledger now also says why the winner sends ContactAccepted after joining a rebuild: it is the first record of the new session the loser processes, and that ends the loser's recovery.

3. Moves, races and quiet sessions (1c3f290)

  • KeyPackage pool count route. The count was requested at /v1/keypackage-pool/{dev}/count. The host serves it at /v1/keypackage-pool/{dev} and has no /count route, so every maintenance run was refused and the pool was never topped up. Claims fell back to the last-resort package.
  • Sync cursor on a move. A move carried the sync cursor to the new host, which numbers its records from 1. Nothing was fetched there until its head passed the old ack, and acks above its head were refused. A move now starts the new host's cursor afresh, drops the pending ack, and checks the new host's empty pool.
  • Lane notice after a race. A lane notice sent in the epoch a race took was never opened. The next refresh repeats it, but that may be days away, and after a move the peer kept writing to the host left behind. The loser now announces its lane after its rebuild, and the winner does so after joining it.
  • Lane registration retry. A failed lane registration was not retried, so every later append on that lane became a message request. A retryable failure is now retried, at the slowest pace, while the lane is still one this device reads.
  • Expiry in quiet sessions. Pending commits, the frames kept with them and fork witnesses expired only on a send or a receipt. A quiet or compromised session therefore kept their keys and KeyPackage secrets indefinitely. The daily upkeep now expires them too.

Not changed here

  • Envelopes the peer appends to the old host before it applies a move's lane notice are still lost.
  • A host's history floor still marks every session as needing recovery.
  • An ack is still sent after every batch, even when it has not moved.

Not verified locally

There is no Rust toolchain on this machine; CI is the first build.

🤖 Generated with Claude Code

koala and others added 3 commits November 11, 2026 16:34
… Welcome

Accepting a message request fetched the sender's bundle from the address the
Welcome names and imported it as an Available contact before anything was
authenticated: only the bundle's user id was compared with the user the
Welcome's credential claims, and that credential is a string the Welcome's
maker chooses. Join checked the signer later, but by then the contact was
stored. So a Welcome naming someone, with a reference to their real bundle,
left them in the receiver's contacts, and the receiver could then start a
conversation with them although they never shared a link.

Staging a Welcome already verifies its GroupInfo signature under the author's
leaf key. inspect_welcome now returns that key, the preview carries it, and
both accept flows (CLI and desktop) require it to equal the device key the
fetched bundle names before importing anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A rebuild after a lost race (and a rebuild the user confirms) enqueued the
commit that created the new group ahead of its Welcome. Everyone in that
group joins from the Welcome, so the commit had no reader: it went out under
the new session's commit key for epoch 1 with an arbitration signature over
base 0, sat in the winner's quarantine until the join, and was then dropped
as superseded. A first contact already sends only its Welcome.

The emission ledger now also says why the winner sends ContactAccepted after
joining a rebuild: it is the first record of the new session the loser
processes, and that is what ends the loser's recovery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Four fixes found while writing the spec of the client (proof/spec, §4.8),
and one found on the way:

- The KeyPackage pool count was requested at /v1/keypackage-pool/{dev}/count.
  The host serves it at /v1/keypackage-pool/{dev} and has no /count route, so
  every maintenance run was refused and the pool was never topped up: claims
  fell back to the last-resort package. The request now goes to the host's
  route.
- A move carried the sync cursor to the new host, which numbers its records
  from 1: nothing was fetched there until its head passed the old ack, and
  acks above its head were refused. A move now starts the new host's cursor
  afresh, drops the pending ack, and checks the new host's empty pool.
- A lane notice sent in the epoch a race took was never opened, and the next
  refresh that repeats it may be days away; after a move the peer kept
  writing to the host left behind. The loser now announces its lane after its
  rebuild, and the winner after joining it.
- A failed lane registration was not retried, so every later append on that
  lane became a message request. A retryable failure is now retried, at the
  slowest pace, while the lane is still one this device reads.
- Pending commits, the frames kept with them and fork witnesses expired only
  on a send or a receipt, so a quiet or compromised session kept their keys
  and KeyPackage secrets indefinitely. The daily upkeep now expires them too.

Each change has a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@koabula
koabula enabled auto-merge (squash) November 11, 2026 08:53
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant