Skip to content

fix(contacts): pin a share link's bundle to the user it names - #3

Merged
koabula merged 1 commit into
mainfrom
fix/contact-share-pin
Nov 11, 2026
Merged

koabula merged 1 commit into
mainfrom
fix/contact-share-pin

Conversation

@koabula

@koabula koabula commented Nov 11, 2026

Copy link
Copy Markdown
Owner

Problem

A contact share link names the user who shared it: the link carries that user's user_id, which is the fingerprint of their root key. When the client imported from the link, it fetched the identity bundle from the host behind the link and checked only that the bundle was consistent with itself. So the host, or anyone serving that URL, could return its own validly signed bundle, and the client would pin that root key as the contact.

Change

  • contact_share::contact_share_url_user_id reads the user_id from both link forms:

    • the userId in a /v1/contact-share/{token} payload;
    • the {user_id} segment of /v1/shared-state/{user_id}/identity-bundle.

    It rejects malformed tokens and payloads, payloads for a service other than contact_share, and links that name no user. The token's HMAC is still checked only by the host.

  • contact_workflows::fetch_identity_bundle_from_url is the single path for every import from a link: the desktop and Tauri share-link commands, and accepting a message request. It now reads the user_id before sending any request, and rejects a bundle whose user_id differs from it.

  • Removed DesktopTransport::fetch_identity_bundle. It was unused, and it fetched a bundle with no URL rules and no verification.

Network behaviour is unchanged, except that a link naming no user is now rejected before any request. contracts/ and generated files are untouched.

Other ways a bundle arrives

I checked the other paths by which a bundle reaches the client; none needed a change:

  • Lane rotations and announced bundles must match the peer's user_id.
  • A refetch from identity_bundle_ref is checked against the requested user in the engine. The existing test fetched_identity_bundle_must_match_requested_user covers this.
  • Within one user_id, the root key cannot change without a second preimage of the 128-bit fingerprint.

Tests

New unit tests:

  • contact_share: user_id extraction from both link forms, plus the malformed, wrong-service and missing-user cases.
  • contact_workflows, three async tests against a local server with real signed bundles:
    • a valid bundle of a different user is rejected, for both link forms;
    • the bundle of the named user is accepted;
    • a link that names no user fails before any request.

These were not built locally; CI is the first build.

🤖 Generated with Claude Code

A contact share link carries the user_id of the person who shared it, but
the client only checked that the fetched identity bundle was consistent with
itself. The host behind the link could therefore serve any self-signed
bundle, and the client would pin that root key as the contact.

Every bundle fetched from a share or identity-bundle URL must now carry the
user_id the URL names: the userId in a /v1/contact-share token, or the
user_id segment of /v1/shared-state/{user_id}/identity-bundle. A link that
names no user is rejected before any request. The host's HMAC is still left
to the host.

Also remove an unused desktop transport method that fetched a bundle with
no URL rules and no verification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@koabula
koabula merged commit 8bb11c1 into main Nov 11, 2026
7 checks passed
koabula added a commit that referenced this pull request Nov 11, 2026
…esh (#4)

* fix(corpus): spread the replicate runs so a seconds clock reads as fresh

The leakage corpus ran its three replicate runs first and back to back. A
KeyPackage's createdAt, notBefore and expiresAt are whole seconds
(mls_adapter: now_seconds * 1000), so when those three runs fell inside one
second and a later run did not, the fields held still across the
replicates yet differed elsewhere, and the classifier read them as
carrying every secret. only_a_payloads_size_reaches_the_host then failed
on main after #3, though the same code had passed on the PR.

The replicates now sit at the first run, the last and one in the middle.
Runs execute in order, so a value read off a coarse clock either differs
between the two ends and is fresh, or is the same in every run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style(corpus): format matrix() as rustfmt does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: koala <koala@koaladeMacBook-Air.local>
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