mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-07 11:19:58 +02:00
docs(design): device history and direct offers
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).
This commit is contained in:
403
DESIGN-DEVICE-HISTORY.md
Normal file
403
DESIGN-DEVICE-HISTORY.md
Normal file
@@ -0,0 +1,403 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user