mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-07 11:19:58 +02:00
Design for remembering devices after a transfer and sending to them without a new invitation. Grant-based contacts so consent and revocation are enforceable by the party being remembered. Local network discovery considered and deferred (Appendix A).
404 lines
19 KiB
Markdown
404 lines
19 KiB
Markdown
# Design — Device history and direct offers
|
||
|
||
Status: **draft for review**. No code written.
|
||
|
||
Lets a user send to a device they have already transferred with, without
|
||
creating and sharing a new invitation. Both sides opt in to being remembered,
|
||
and either side can end the relationship later and have that actually take
|
||
effect on the other device.
|
||
|
||
Local network discovery was considered and deliberately dropped. See
|
||
[Appendix A](#appendix-a--deferred-local-network-discovery).
|
||
|
||
---
|
||
|
||
## 1. Goals and non-goals
|
||
|
||
### Goals
|
||
|
||
- Send to a previously used device with no new invitation, QR code, or NFC tap.
|
||
- Let each side independently decide whether to be remembered after a transfer.
|
||
- Let either side revoke that relationship unilaterally, with real effect.
|
||
- Keep the receiving side's confirmation mandatory for every transfer that
|
||
arrives this way.
|
||
|
||
### Non-goals
|
||
|
||
- No automatic acceptance of transfers, under any configuration.
|
||
- No store-and-forward: an offer to an unreachable device fails; nothing is
|
||
queued and nothing is uploaded anywhere.
|
||
- No presence or "who is online" indicator. Knowing it requires probing, and
|
||
probing tells every contact when you opened your list. Reachability is
|
||
resolved lazily, at send time.
|
||
- No change to the invitation (QR / NFC / `.vnd`) flow, which remains how a
|
||
first contact is made and how an unpaired device is reached.
|
||
|
||
### Relationship to the existing flow
|
||
|
||
First contact is unchanged: an invitation, a transfer, a receiver confirmation.
|
||
This feature only removes the invitation step from the *second* and subsequent
|
||
transfers between the same two devices.
|
||
|
||
---
|
||
|
||
## 2. Threat model
|
||
|
||
Assume an attacker who can run a modified VniDrop client, choose any display
|
||
name, and reach the target over the network.
|
||
|
||
| Property | Mechanism |
|
||
|---|---|
|
||
| A stranger cannot send an unsolicited transfer prompt | The offer protocol requires a valid grant (§3) |
|
||
| A stranger cannot impersonate a known device | Identity is the iroh endpoint key; display names are untrusted data |
|
||
| Being remembered requires consent from the remembered party | Grants are minted by the party being remembered (§3.4) |
|
||
| A user can end a relationship unilaterally | A grant is validated only by its issuer (§3.3) |
|
||
| A revoked peer cannot quietly regain access | Revocation is local and immediate; no cooperation required |
|
||
|
||
Explicit non-property: we cannot erase data from a device we do not control. A
|
||
revoked peer's app may still hold a name string on disk. What is guaranteed is
|
||
that the entry stops **functioning** — see §3.3.
|
||
|
||
The app broadcasts nothing and advertises nothing. There is no passive network
|
||
surface introduced by this feature at all.
|
||
|
||
---
|
||
|
||
## 3. Grants: the core primitive
|
||
|
||
A history entry is **not** "I remember this device's endpoint ID". It is "this
|
||
device issued me a capability to reach it". This is what makes both consent and
|
||
revocation real rather than promised, and it is the reason a grant-based design
|
||
is worth the modest extra complexity over storing a public key.
|
||
|
||
### 3.1 Shape
|
||
|
||
A grant is directional. If Alice wants Bob to be able to reach her, *Alice*
|
||
mints the grant and gives it to Bob:
|
||
|
||
- `grant_id` — 128-bit random, opaque.
|
||
- `grant_secret` — 256-bit random.
|
||
- Bound to Bob's endpoint ID at issue time.
|
||
- `expires_at` — an **idle** expiry, renewed on use (§3.5).
|
||
|
||
Alice keeps `(grant_id, grant_secret, bob_endpoint_id, expires_at, revoked_at)`
|
||
in her **issued** table. Bob keeps `(grant_id, grant_secret, alice_endpoint_id,
|
||
display_name, …)` in his **held** table, which is what his history UI lists.
|
||
|
||
A mutual relationship is two independent grants. Either side can revoke its own
|
||
without affecting the other direction, which is the correct semantics: "you may
|
||
no longer reach me" is separable from "I may no longer reach you".
|
||
|
||
### 3.2 Proving a grant
|
||
|
||
iroh already provides a mutually authenticated, encrypted QUIC connection, so
|
||
both endpoint IDs are known and trustworthy at the transport layer. On top of
|
||
that, challenge–response proves possession of the grant without ever
|
||
transmitting it:
|
||
|
||
1. Alice (the accepting side) sends a 32-byte random `challenge`.
|
||
2. Bob replies with `grant_id` and
|
||
`HMAC(grant_secret, "vnidrop-grant-v1" ‖ challenge ‖ alice_endpoint_id ‖ bob_endpoint_id)`.
|
||
3. Alice looks up `grant_id`, checks it is neither revoked nor expired, checks
|
||
that the connection's remote endpoint ID equals the endpoint the grant was
|
||
issued to, and verifies the HMAC in constant time.
|
||
|
||
Binding to the issued-to endpoint means Bob cannot lend his grant to a third
|
||
party. Binding to the challenge means a captured proof cannot be replayed.
|
||
|
||
### 3.3 Revocation
|
||
|
||
Alice deletes (or tombstones) the grant in her issued table. That is the whole
|
||
mechanism, and it is sufficient: hers is the **only** device that can validate
|
||
it. Bob's next attempt presents an unknown `grant_id`, is refused, and his
|
||
client deletes the dead entry.
|
||
|
||
The refusal is **explicit**: ordinary revocation returns a distinct `Revoked`
|
||
status so Bob's client can remove the entry immediately and tell him the device
|
||
is no longer available. Silence would leave a zombie entry, and Bob can infer
|
||
what happened regardless, so the deniability is not worth the worse behavior.
|
||
|
||
The hard block list is the exception: a blocked endpoint receives a response
|
||
indistinguishable from an expired or unknown grant, so blocking cannot be
|
||
detected by probing.
|
||
|
||
Additionally, when Alice revokes while Bob is reachable, she sends a best-effort
|
||
`RevokeGrant { grant_id }` so his entry disappears promptly rather than at his
|
||
next attempt. Best-effort only — correctness never depends on it arriving.
|
||
|
||
Two things revocation deliberately is **not**:
|
||
|
||
- **Not retroactive.** Files already sent stay sent. UI copy must say so.
|
||
- **Not a block.** Bob can still reach Alice with a QR invitation like any
|
||
stranger. A separate hard block list refuses a given endpoint ID at the offer
|
||
and handshake layers.
|
||
|
||
### 3.4 Consent to be remembered
|
||
|
||
After a completed transfer, each side is asked independently whether to remember
|
||
the other. If Alice declines, no grant is minted, so Bob has nothing functional
|
||
to store and his UI must not offer to save the device. Bob cannot override
|
||
Alice's choice, because the useful half of the entry is hers to issue.
|
||
|
||
The prompt is per-transfer and must be dismissible without a choice, defaulting
|
||
to "no". A user who never engages with it is never added to anyone's history.
|
||
|
||
### 3.5 Grant lifetime
|
||
|
||
Grants expire on **idleness, not age**. Each successful offer renews the
|
||
issuer's `expires_at`, so a relationship in regular use never lapses, while one
|
||
that is forgotten cleans itself up.
|
||
|
||
Default idle lifetime: **90 days**, configurable per device in settings
|
||
(30 / 90 / 365 days / never) and applied at issue time. Changing the setting
|
||
affects newly minted grants; existing ones keep the lifetime they were issued
|
||
with until renewed.
|
||
|
||
Renewal is issuer-side only and needs no protocol message: Alice extends the
|
||
grant when she validates a proof from Bob. An expired grant behaves exactly like
|
||
a revoked one from Bob's side, except that the UI explains it as inactivity and
|
||
offers to pair again rather than presenting it as a deliberate removal.
|
||
|
||
This bounds the blast radius of a pairing the user has forgotten about, and it
|
||
softens the reinstall problem in §5.1: dead entries pointing at a regenerated
|
||
`iroh.secret` eventually disappear on their own.
|
||
|
||
---
|
||
|
||
## 4. The offer protocol
|
||
|
||
Today the protocol is strictly receiver-pull: the sender never initiates. An
|
||
offer inverts only the *delivery of the ticket*, not the transfer itself.
|
||
|
||
New ALPN: `/vnidrop/offer/1`.
|
||
|
||
1. Sender picks a contact from history.
|
||
2. Sender creates the share exactly as today (`share_files`). The share is
|
||
`ApprovalRequired`; an offer-created share may **never** be `Public`
|
||
(invariant, enforced in `access_policy`).
|
||
3. Sender pre-authorizes the target endpoint for that `transfer_id` via the
|
||
existing `AccessPolicy::approve_endpoint_until`, so the sender is not later
|
||
prompted to approve a transfer they themselves initiated.
|
||
4. Sender dials the target's offer ALPN, completes the grant challenge–response
|
||
(§3.2), and sends
|
||
`Offer { ticket, sender_display_name, file_count, total_bytes }`.
|
||
5. **The receiver is prompted.** This is the mandatory confirmation and it has
|
||
no bypass.
|
||
6. On accept, the receiver calls the existing `receive(ticket, output_dir,
|
||
receiver_name)` — completely unchanged. It dials the sender's existing
|
||
`/vnidrop/handshake/2`, where the pre-authorization from step 3 is already in
|
||
place, so exactly one human is prompted for the whole flow.
|
||
7. On decline, the sender receives `Declined` and stops the share.
|
||
|
||
The ticket must satisfy the receiver's relay profile, so the existing
|
||
`ticket_matches_relay_profile` check applies unchanged: a contact on a
|
||
strict-custom profile will refuse an offer whose ticket advertises public
|
||
relays, and the UI must explain that rather than failing opaquely.
|
||
|
||
### 4.1 Identity display
|
||
|
||
Display names are attacker-chosen data — the existing handshake already treats
|
||
`receiver_name` that way, and the same rule applies here. The endpoint ID is the
|
||
only real identity. Therefore:
|
||
|
||
- A contact's local label is set by the local user and is **never** silently
|
||
overwritten by a name the remote later claims. A changed remote name is shown
|
||
as a distinct, dismissible signal.
|
||
- A short fingerprint derived from the endpoint ID is available in the contact
|
||
detail view, for out-of-band verification.
|
||
|
||
---
|
||
|
||
## 5. Address resolution and reachability
|
||
|
||
A contact stores an endpoint ID, but iroh needs an address to dial. Without
|
||
local discovery, resolution depends on the relay profile:
|
||
|
||
| Relay mode | Resolution |
|
||
|---|---|
|
||
| `Automatic` | Public discovery resolves the endpoint ID anywhere |
|
||
| `StrictCustom` / `CustomWithDirectFallback` | Reachable through the configured relay, whose URL is stable |
|
||
| `LocalOnly` | Only while the cached direct address is still valid |
|
||
|
||
`presets::Minimal` deliberately leaves address lookup empty for the restricted
|
||
modes (see the comment at `runtime/mod.rs:154`), so those modes cannot fall back
|
||
to public resolution — by design.
|
||
|
||
**Mitigation: cache the peer's last-known `EndpointAddr` on the contact and
|
||
refresh it after every successful connection.** The repository already persists
|
||
sender addresses this way for receive rows —
|
||
`encode_persisted_sender_address` / `parse_persisted_sender_address` in
|
||
`ticket.rs:72` — so this reuses an established pattern rather than inventing
|
||
one.
|
||
|
||
This covers relay modes fully, and covers `LocalOnly` for as long as the peer's
|
||
address is unchanged. When it is not, the send fails and the user falls back to
|
||
a QR invitation: no regression against today's behavior, but the UI must say so
|
||
plainly rather than presenting an opaque failure. Local-only users in particular
|
||
should be told that contacts depend on a cached address.
|
||
|
||
Reachability is never polled in the background. It is determined when the user
|
||
actually sends.
|
||
|
||
### 5.1 Identity lifetime
|
||
|
||
Reinstalling the app regenerates `iroh.secret`, so every grant referencing the
|
||
old endpoint dies. The UI needs an explicit "this device is no longer
|
||
recognized, pair again" state rather than a silent failure.
|
||
|
||
---
|
||
|
||
## 6. Data model
|
||
|
||
New tables in the existing SQLite repository, with a schema migration:
|
||
|
||
| Table | Columns (sketch) |
|
||
|---|---|
|
||
| `contacts` | `id`, `endpoint_id` (unique), `local_label`, `remote_display_name`, `last_known_addr`, `created_at`, `last_transfer_at` |
|
||
| `grants_issued` | `grant_id`, `grant_secret`, `issued_to_endpoint_id`, `created_at`, `expires_at` (idle, renewed on use), `revoked_at` |
|
||
| `grants_held` | `grant_id`, `grant_secret`, `peer_endpoint_id`, `created_at`, `expires_at` (advisory copy) |
|
||
| `blocked_endpoints` | `endpoint_id`, `created_at` |
|
||
|
||
`grant_secret` is **key material**. It follows the same rule as tickets: never
|
||
in events, never in logs, never in bug reports, never in a UniFFI return value.
|
||
The existing "tickets are capabilities" discipline extends verbatim.
|
||
|
||
A contact list is itself a privacy artifact — it names the people someone
|
||
exchanges files with. It must be deletable per-entry and wholesale, and the
|
||
wholesale delete must be reachable from the same place as the existing
|
||
transfer-history and cache clearing actions.
|
||
|
||
Deleting a contact deletes both directions' grants for that peer and, for the
|
||
issued side, triggers the best-effort revoke message.
|
||
|
||
---
|
||
|
||
## 7. Abuse and resource limits
|
||
|
||
Extend `CoreLimits` rather than inventing a parallel mechanism:
|
||
|
||
- `max_contacts`.
|
||
- `max_pending_offers`, mirroring the existing `max_pending_approvals`.
|
||
- Per-endpoint offer rate limiting, with a cooldown after repeated declines.
|
||
- Blocked endpoints are refused at the offer ALPN before any user-visible
|
||
prompt.
|
||
|
||
Because an offer already requires a valid grant, the spam surface is limited to
|
||
devices the user deliberately chose to be reachable by, and the remedy — revoke
|
||
— is one tap.
|
||
|
||
---
|
||
|
||
## 8. Surfaces to build
|
||
|
||
- **Rust core:** offer ALPN and handler, grant minting/proof/revocation,
|
||
contacts and grants repository with migration, address caching, new limits,
|
||
block list.
|
||
- **UniFFI:** additive API — list/rename/delete contacts, send-to-contact,
|
||
revoke, block/unblock, respond to an incoming offer, plus the corresponding
|
||
events. Additive changes do not break existing Kotlin or Swift call sites, but
|
||
both must be updated to use them.
|
||
- **Compose (`shared/`)** and **SwiftUI (`apple/`)**: a contacts list and detail
|
||
view, the post-transfer "remember this device?" prompt, the incoming-offer
|
||
confirmation, a send-to-contact entry point in the send flow, and settings for
|
||
the feature toggle, the grant idle lifetime (30 / 90 / 365 days / never,
|
||
default 90), and blocked devices.
|
||
- **Localization:** all new strings go in `localization/strings.json` and are
|
||
generated; the platform catalogs are never hand-edited.
|
||
|
||
No new OS permissions, entitlements, or platform bridges are required.
|
||
|
||
---
|
||
|
||
## 9. Testing
|
||
|
||
- **Grant crypto:** fixed vectors for the HMAC proof; expiry, revocation,
|
||
wrong-endpoint binding, and replay rejection.
|
||
- **Grant lifetime:** a successful proof renews `expires_at`; an idle grant
|
||
lapses at the configured boundary; a renewed grant survives past its original
|
||
expiry. Assert the revoked and blocked responses are distinguishable from each
|
||
other and that blocked is indistinguishable from expired/unknown.
|
||
- **Offer protocol:** two in-process nodes using the existing
|
||
`crates/vnidrop/tests/support` harness — accept, decline, revoked grant,
|
||
expired grant, blocked endpoint, relay-profile mismatch, and the invariant
|
||
that an offer-created share is never `Public`.
|
||
- **Pre-authorization:** assert the sender is prompted exactly zero times and
|
||
the receiver exactly once, for a full offer → accept → transfer round trip.
|
||
- **Consent:** assert that declining to be remembered leaves the peer with no
|
||
usable grant, and that a subsequent offer from that peer is refused.
|
||
- **Address caching:** a contact whose cached address is stale falls back
|
||
cleanly and reports an actionable error, rather than hanging.
|
||
- **Persistence:** grants and contacts survive a core shutdown and reopen of the
|
||
same data dir, following the existing recovery-test pattern.
|
||
- Per `AGENTS.md`, any bug found gets a regression test at the lowest layer.
|
||
|
||
---
|
||
|
||
## 10. Settled decisions
|
||
|
||
Both previously open questions are decided and specified above; recorded here
|
||
with their rationale so the reasoning is not lost.
|
||
|
||
1. **Revocation is reported explicitly** (§3.3). A revoked peer's client
|
||
receives a distinct status and removes the dead entry immediately. The
|
||
alternative — silence — leaves a zombie entry, and the revocation is
|
||
inferable from the failure anyway, so the deniability is illusory.
|
||
Indistinguishable silence is reserved for the hard block list, where
|
||
undetectability is the point.
|
||
2. **Grants expire on idleness, renewed on use, defaulting to 90 days** (§3.5),
|
||
configurable to 30 / 90 / 365 days or never. Relationships in regular use
|
||
never lapse; forgotten ones clean themselves up, which bounds the blast
|
||
radius of a stale pairing and quietly disposes of entries orphaned by a
|
||
reinstall.
|
||
|
||
---
|
||
|
||
## Appendix A — Deferred: local network discovery
|
||
|
||
An earlier draft specified AirDrop-style discovery: three visibility tiers
|
||
(invisible / paired-only / a time-boxed pairing window), private per-grant mDNS
|
||
beacons using rotating per-epoch AEAD entries so only grant holders could
|
||
recognize a device, and a short-authentication-string pairing flow. It was
|
||
dropped, because once first contact requires a completed transfer anyway,
|
||
discovery adds far less than it costs.
|
||
|
||
**What it would have added:** camera-free pairing (QR pairing already works),
|
||
live presence (which requires probing, and probing leaks when a user opens their
|
||
contact list), and address resolution on a network with no public discovery —
|
||
the only substantive one, and largely handled by the address caching in §5.
|
||
|
||
**What dropping it avoids:**
|
||
|
||
- The `com.apple.developer.networking.multicast` entitlement risk. iroh's
|
||
local-network discovery uses raw multicast sockets rather than Bonjour, and
|
||
that entitlement requires a special request to Apple that is frequently
|
||
refused. This was the single largest threat to shipping.
|
||
- Local network permission prompts on iOS/macOS, an Android multicast lock and
|
||
`NEARBY_WIFI_DEVICES`, a Windows firewall prompt, and avahi coexistence on UDP
|
||
5353.
|
||
- A per-platform discovery bridge, including a native `NWBrowser`/`NWListener`
|
||
implementation in Swift.
|
||
- Beacon crypto, epoch/clock-skew handling, and a hard cap of roughly 24–28
|
||
advertised contacts imposed by the mDNS packet budget.
|
||
- A contradiction with the README's promise that the restricted relay modes
|
||
never use "public discovery".
|
||
- Visibility-tier settings, which are difficult to explain and easy to
|
||
misconfigure.
|
||
|
||
It also *improves* the privacy posture: the app broadcasts nothing at all, which
|
||
is a stronger and far more explainable claim than any beacon scheme, including
|
||
in an App Store review.
|
||
|
||
**Network-trust detection was rejected separately and stays rejected.** Deciding
|
||
what to expose based on whether a network looks "public" is unreliable — macOS
|
||
has no such concept, Android needs `ACCESS_FINE_LOCATION` to read an SSID, and
|
||
iOS cannot identify the current network at all without
|
||
`com.apple.developer.networking.wifi-info` plus location permission. It is also
|
||
spoofable, since an attacker can clone an SSID and choose a gateway MAC.
|
||
|
||
**If it is ever revisited**, the beacon scheme was deliberately keyed off grants,
|
||
so it layers onto the tables in §6 with no change to the offer protocol or the
|
||
data model. Nothing in this design forecloses it. One unrelated cleanup noted
|
||
along the way: `apple/VniDrop/Resources/Info.plist:78` declares
|
||
`NSBonjourServices` with a single empty-string entry, which is meaningless and
|
||
should be removed or given a real service type.
|