Files
vnidrop/DESIGN-DEVICE-HISTORY.md
cdricms 0f70663263 feat(apple): collect transfers held for this device
Adds the opt-in foreground check and an explicit Check now, the waiting-to-
be-delivered list on the sender side, and honest reporting when a send could
not be delivered: a closed app is a delay, not a success nobody received.

The setting is off by default and its footer states that checking reveals
app-open times to remembered devices, since that is the reason it is a
setting at all.

Records in the design doc that this shipped as one global toggle rather than
the per-contact opt-in originally specified.
2026-08-06 19:00:04 +02:00

484 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 server-side store-and-forward and no push infrastructure. An offer to an
unreachable device is held **on the sender's own device** or fails; nothing is
uploaded anywhere. See §11 for what this means in practice.
- 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, challengeresponse 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 challengeresponse
(§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 — and, for incoming offers, when the app next comes to the
foreground (§11).
### 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.
- **Sender-held offers (§11):** an offer to an unreachable contact is retained,
is cancellable, is collected on the receiver's next pull, and is not
double-delivered if the receiver pulls twice.
- 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.
---
## 11. Delivery when the recipient is not running
An offer is a live connection to a running app. This section states plainly what
that costs and how far it is mitigated.
### 11.1 The constraint
Notifying the user is not the problem — `LocalNotificationService` and the
existing `ApprovalCoordinator` already turn an incoming approval request into a
user-visible prompt, and an incoming offer reuses that path unchanged.
*Receiving* the request is the problem. `BackgroundActivityController` holds an
iOS background assertion only while there is active work and releases it as soon
as that drains, so a suspended app has no listening socket: the sender's dial
fails and there is nothing to notify about.
Waking a suspended iOS app from the network requires a remote push through APNs,
which means a server holding device tokens and observing who contacts whom. That
is infrastructure plus a metadata leak, both of which contradict the product's
no-cloud posture. **APNs is out of scope.** (This is also why AirDrop can do it
and a third-party app cannot: AirDrop is an OS daemon, not an app.)
### 11.2 Sender-held offers with a foreground pull
When the target is unreachable, the sender holds the offer **locally** — the
share stays on the sender's disk exactly as today, with no copy anywhere else —
and the receiver collects it when its app next comes to the foreground, raising
a local notification at that point.
Resulting coverage:
| Scenario | Result |
|---|---|
| Phone → always-on desktop | Immediate; the desktop is listening |
| Desktop → phone, app closed | Delivered on the phone's next launch |
| Phone → phone, both apps closed | **Not supported** |
Desktop platforms are unaffected by any of this and are always reachable while
the app runs.
### 11.3 The presence cost of pulling
Dialing contacts on launch tells them when the app was opened and reveals the
device's address to them — precisely the leak §1 avoids by refusing background
presence polling. The pull is therefore bounded rather than automatic:
- It is **off by default**, behind a single setting whose own footer states the
cost, plus an explicit "Check now" action that works regardless.
- It never runs in the background, only on an actual foreground transition.
- It is rate-limited per contact (5 minutes), so repeated app switching does not
turn into a presence beacon.
**Deviation from the original draft, as built.** This specified a *per-contact*
opt-in. What shipped is one global toggle, which is coarser: enabling it polls
every contact rather than a chosen few. Per-contact control needs a schema
column and a control on each device's detail screen, and the global switch with
an honest footer covers the same threat — the user still decides whether their
app-open times are revealed at all. Worth revisiting if anyone keeps contacts
they would rather not signal to.
### 11.4 What the sender sees
A held offer is listed on the sender's device with its target, and withdrawing
it is cancelling the transfer — stopping the share deletes the waiting ticket,
so a cancelled transfer can never be collected afterwards.
### 11.5 Scope statement for the UI
Mobile-to-mobile transfer with both apps closed is not supported and must not be
implied. The contact list distinguishes "reachable now" from "will be delivered
when they next open VniDrop", and an offer awaiting pickup is visible and
cancellable on the sender's side.
---
## 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 2428
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.