mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-10 20:59:57 +02:00
docs: redesign saved device foundation
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,6 +23,7 @@ config.override.mk
|
|||||||
|
|
||||||
# Local design export scratch
|
# Local design export scratch
|
||||||
output/
|
output/
|
||||||
|
.scratch/
|
||||||
.screenshots
|
.screenshots
|
||||||
apple/RELEASE-MACOS.md
|
apple/RELEASE-MACOS.md
|
||||||
apple/Generated/*.xcconfig
|
apple/Generated/*.xcconfig
|
||||||
|
|||||||
@@ -1,487 +1,524 @@
|
|||||||
# Design — Device history and direct offers
|
# Design — Saved devices and targeted transfers
|
||||||
|
|
||||||
Status: **implemented** in the Rust core and the SwiftUI app. The KMP/Compose
|
Status: **proposed for the experimental 0.3.x line**.
|
||||||
app has not been built yet; the UniFFI surface is additive, so `shared/` still
|
|
||||||
compiles untouched and the Compose string resources are already generated.
|
|
||||||
|
|
||||||
Where the build deviates from what was specified here, the section says so.
|
This document supersedes the previous device-history design. The implementation
|
||||||
|
currently on `feat/device-history` is an unreleased prototype. Its database and
|
||||||
|
wire formats are not compatibility commitments and may be replaced.
|
||||||
|
|
||||||
Lets a user send to a device they have already transferred with, without
|
The feature lets two VniDrop installations remember one another after a
|
||||||
creating and sharing a new invitation. Both sides opt in to being remembered,
|
successful transfer, with explicit consent on both devices. A saved device can
|
||||||
and either side can end the relationship later and have that actually take
|
then request a new transfer without another invitation, QR scan, or NFC tap.
|
||||||
effect on the other device.
|
The receiver must still approve every transfer.
|
||||||
|
|
||||||
Local network discovery was considered and deliberately dropped. See
|
The Rust core, protocol, persistence, credential-storage integration, and
|
||||||
[Appendix A](#appendix-a--deferred-local-network-discovery).
|
platform contracts are the first delivery scope. Product UI is intentionally
|
||||||
|
deferred to a separate design and implementation session.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Goals and non-goals
|
## 1. Vocabulary and invariants
|
||||||
|
|
||||||
|
### Saved device
|
||||||
|
|
||||||
|
A `SavedDevice` is a remote VniDrop **app-installation identity**. It is not a
|
||||||
|
person, account, address-book contact, or reliably identifiable piece of
|
||||||
|
physical hardware.
|
||||||
|
|
||||||
|
The identity is the remote iroh endpoint identity. A reinstall or unrecoverable
|
||||||
|
endpoint-key loss creates a new identity and requires a new successful transfer
|
||||||
|
and mutual consent. Display names, platform hints, IP addresses, and physical
|
||||||
|
device properties must never merge identities.
|
||||||
|
|
||||||
|
### Device relationship
|
||||||
|
|
||||||
|
A `DeviceRelationship` is a mutually acknowledged relationship between two
|
||||||
|
saved-device identities. It contains two directional grants: one issued in
|
||||||
|
each direction. The relationship is usable only after both grants have been
|
||||||
|
acknowledged.
|
||||||
|
|
||||||
|
### Targeted transfer
|
||||||
|
|
||||||
|
A `TargetedTransfer` is an immutable one-sender, one-receiver transfer. It is a
|
||||||
|
separate domain from the existing invitation-based `Share`, which may serve
|
||||||
|
multiple receivers.
|
||||||
|
|
||||||
|
The following invariants are mandatory:
|
||||||
|
|
||||||
|
- Saving a device requires a fully completed authenticated transfer and
|
||||||
|
explicit consent on both devices.
|
||||||
|
- Remembering a device never authorizes automatic receipt. Every targeted
|
||||||
|
transfer requires explicit receiver approval.
|
||||||
|
- A targeted transfer has exactly one sender identity, one receiver identity,
|
||||||
|
one transfer ID, and one immutable manifest.
|
||||||
|
- Authorization is bound to the selected receiver. A leaked capability or
|
||||||
|
ticket must not authorize any other identity.
|
||||||
|
- Relays may forward end-to-end encrypted traffic according to the active
|
||||||
|
network profile, but VniDrop has no intermediary file store, relationship
|
||||||
|
service, delivery queue, push service, or account system.
|
||||||
|
- Existing invitation-based transfers retain their current behavior and domain
|
||||||
|
model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Goals and non-goals
|
||||||
|
|
||||||
### Goals
|
### Goals
|
||||||
|
|
||||||
- Send to a previously used device with no new invitation, QR code, or NFC tap.
|
- Send to a previously saved device without exchanging another invitation.
|
||||||
- Let each side independently decide whether to be remembered after a transfer.
|
- Make mutual consent cryptographically enforceable rather than a UI promise.
|
||||||
- Let either side revoke that relationship unilaterally, with real effect.
|
- Keep receiver approval mandatory for each new transfer.
|
||||||
- Keep the receiving side's confirmation mandatory for every transfer that
|
- Give forget, revoke, block, cancellation, and deletion immediate local
|
||||||
arrives this way.
|
security effect even when the peer is offline.
|
||||||
|
- Persist accepted interrupted transfers so they can resume when both devices
|
||||||
|
are online again.
|
||||||
|
- Protect endpoint identity keys and relationship secrets with platform-backed
|
||||||
|
credential storage.
|
||||||
|
- Provide versioned, typed Rust and UniFFI contracts that every platform can
|
||||||
|
exercise before UI work begins.
|
||||||
|
|
||||||
### Non-goals
|
### Non-goals
|
||||||
|
|
||||||
- No automatic acceptance of transfers, under any configuration.
|
- Automatic acceptance or unattended writes to a receiver's device.
|
||||||
- No server-side store-and-forward and no push infrastructure. An offer to an
|
- Offline store-and-forward, automatic peer polling, background inboxes, or
|
||||||
unreachable device is held **on the sender's own device** or fails; nothing is
|
push notifications.
|
||||||
uploaded anywhere. See §11 for what this means in practice.
|
- Server-side device discovery, relationship storage, history synchronization,
|
||||||
- No presence or "who is online" indicator. Knowing it requires probing, and
|
backup, export, or restoration onto another installation.
|
||||||
probing tells every contact when you opened your list. Reachability is
|
- Presence indicators or a promise that a suspended mobile application is
|
||||||
resolved lazily, at send time.
|
reachable.
|
||||||
- No change to the invitation (QR / NFC / `.vnd`) flow, which remains how a
|
- Groups or a multi-recipient variant of `TargetedTransfer`.
|
||||||
first contact is made and how an unpaired device is reached.
|
- Associating several saved devices with a person or account.
|
||||||
|
- UI screens, navigation, wording, and presentation architecture in this phase.
|
||||||
### 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
|
## 3. Network and privacy model
|
||||||
|
|
||||||
Assume an attacker who can run a modified VniDrop client, choose any display
|
Saved-device operations use the same configured iroh network profile as
|
||||||
name, and reach the target over the network.
|
ordinary transfers:
|
||||||
|
|
||||||
| Property | Mechanism |
|
- `Automatic` may use configured/default relays and direct paths.
|
||||||
|
- Custom-relay modes remain restricted to their configured relays and fallback
|
||||||
|
policy.
|
||||||
|
- `LocalOnly` must not silently enable public discovery or a relay.
|
||||||
|
|
||||||
|
An endpoint ID authenticates a peer; it is not, by itself, a routable address.
|
||||||
|
Address discovery and file transport may use a relay. VniDrop and the endpoints
|
||||||
|
still provide end-to-end authentication and encryption, so the relay cannot
|
||||||
|
decrypt content or authorize a recipient. A relay may observe transport
|
||||||
|
metadata such as network addresses, timing, and volume. VniDrop must not claim
|
||||||
|
that relayed traffic is anonymous, metadata-free, or relay-free.
|
||||||
|
|
||||||
|
VniDrop does not upload a transfer for later delivery. The sender and receiver
|
||||||
|
cores must both be reachable while an offer is negotiated. A relay cannot wake
|
||||||
|
a terminated or suspended application. The first release therefore reports a
|
||||||
|
typed unavailable or timeout result when the receiver's core cannot answer.
|
||||||
|
|
||||||
|
Current direct address candidates may be exchanged over an authenticated
|
||||||
|
connection and cached for the connection or a short local lifetime. The app
|
||||||
|
must not accumulate a historical IP-address log.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Identity and credential custody
|
||||||
|
|
||||||
|
The endpoint private key and all relationship capability secrets are protected
|
||||||
|
by platform-backed credential storage:
|
||||||
|
|
||||||
|
| Platform | Required protection |
|
||||||
|---|---|
|
|---|---|
|
||||||
| A stranger cannot send an unsolicited transfer prompt | The offer protocol requires a valid grant (§3) |
|
| Apple | Keychain with a non-synchronizing, device-appropriate accessibility class |
|
||||||
| A stranger cannot impersonate a known device | Identity is the iroh endpoint key; display names are untrusted data |
|
| Android | Keystore-backed encryption; only ciphertext may live outside Keystore |
|
||||||
| Being remembered requires consent from the remembered party | Grants are minted by the party being remembered (§3.4) |
|
| Windows | DPAPI scoped to the current user |
|
||||||
| A user can end a relationship unilaterally | A grant is validated only by its issuer (§3.3) |
|
| Linux | Secret Service/libsecret |
|
||||||
| 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
|
There is no plaintext fallback.
|
||||||
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
|
Rust owns identity use, cryptographic operations, relationship state, and
|
||||||
surface introduced by this feature at all.
|
authorization. Platforms provide a narrow secure-secret-store adapter. Public
|
||||||
|
bindings exchange opaque handles and typed outcomes, never raw grants, pairing
|
||||||
|
tokens, or private keys.
|
||||||
|
|
||||||
|
If the endpoint identity key is temporarily unavailable, networking is
|
||||||
|
temporarily unavailable because VniDrop cannot authenticate as the same
|
||||||
|
endpoint. If the endpoint key is available but relationship grants are not,
|
||||||
|
ordinary invitation transfers remain available while saved-device operations
|
||||||
|
fail closed. Neither case may generate a replacement identity automatically.
|
||||||
|
|
||||||
|
### 4.1 Legacy endpoint-key migration
|
||||||
|
|
||||||
|
Migration of an existing endpoint key must be recoverable:
|
||||||
|
|
||||||
|
1. Read the legacy key.
|
||||||
|
2. Write it to protected storage.
|
||||||
|
3. Read it back and prove that it derives the same endpoint ID.
|
||||||
|
4. Commit a storage-version marker.
|
||||||
|
5. Only then remove the legacy copy.
|
||||||
|
|
||||||
|
A crash at any step must preserve at least one valid copy and must not change
|
||||||
|
the endpoint identity. Confirmed unrecoverable loss or an explicit identity
|
||||||
|
reset is required before replacement.
|
||||||
|
|
||||||
|
Secrets must not synchronize through platform cloud backup. Restored metadata
|
||||||
|
without its device-bound secrets reconciles to disabled relationships, never a
|
||||||
|
cloned identity.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Grants: the core primitive
|
## 5. Pairing eligibility
|
||||||
|
|
||||||
A history entry is **not** "I remember this device's endpoint ID". It is "this
|
Only a **fully completed authenticated transfer** creates pairing eligibility.
|
||||||
device issued me a capability to reach it". This is what makes both consent and
|
A handshake, partial download, failed export, cancellation, decline, or failed
|
||||||
revocation real rather than promised, and it is the reason a grant-based design
|
transfer does not qualify. Either the sender or receiver may initiate pairing
|
||||||
is worth the modest extra complexity over storing a public key.
|
after a qualifying transfer.
|
||||||
|
|
||||||
### 3.1 Shape
|
During the qualifying transfer, the peers establish a cryptographic,
|
||||||
|
single-use pairing eligibility capability bound to:
|
||||||
|
|
||||||
A grant is directional. If Alice wants Bob to be able to reach her, *Alice*
|
- Both endpoint identities.
|
||||||
mints the grant and gives it to Bob:
|
- The qualifying transfer/session.
|
||||||
|
- The saved-device protocol version.
|
||||||
|
- A 24-hour local expiry.
|
||||||
|
|
||||||
- `grant_id` — 128-bit random, opaque.
|
The capability becomes usable only after the transfer reaches its durable
|
||||||
- `grant_secret` — 256-bit random.
|
completed state. It is stored locally in encrypted form without filenames or a
|
||||||
- Bound to Bob's endpoint ID at issue time.
|
transfer-history record. It is deleted when consumed, declined, expired,
|
||||||
- `expires_at` — an **idle** expiry, renewed on use (§3.5).
|
forgotten, blocked, or reset.
|
||||||
|
|
||||||
Alice keeps `(grant_id, grant_secret, bob_endpoint_id, expires_at, revoked_at)`
|
Requests without valid eligibility are silently rejected. This prevents a
|
||||||
in her **issued** table. Bob keeps `(grant_id, grant_secret, alice_endpoint_id,
|
modified stranger from generating unsolicited pairing prompts.
|
||||||
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
|
## 6. Mutual-consent protocol
|
||||||
|
|
||||||
Today the protocol is strictly receiver-pull: the sender never initiates. An
|
The protocol uses explicit pending states rather than exposing partial contacts
|
||||||
offer inverts only the *delivery of the ticket*, not the transfer itself.
|
as usable saved devices:
|
||||||
|
|
||||||
New ALPN: `/vnidrop/offer/1`.
|
- `PendingOutgoing`
|
||||||
|
- `PendingIncoming`
|
||||||
|
- `Saved`
|
||||||
|
|
||||||
1. Sender picks a contact from history.
|
The normal exchange is:
|
||||||
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
|
1. Alice locally chooses to remember Bob after a qualifying transfer.
|
||||||
`ticket_matches_relay_profile` check applies unchanged: a contact on a
|
2. Alice sends a token-bound pairing request.
|
||||||
strict-custom profile will refuse an offer whose ticket advertises public
|
3. Bob explicitly consents.
|
||||||
relays, and the UI must explain that rather than failing opaquely.
|
4. Alice and Bob exchange fresh directional grants.
|
||||||
|
5. Alice acknowledges Bob's grant.
|
||||||
|
6. Both sides activate the relationship as `Saved` only after the mutual
|
||||||
|
exchange is acknowledged.
|
||||||
|
|
||||||
### 4.1 Identity display
|
Failure before activation remains a bounded pending operation and cannot be
|
||||||
|
used to initiate a transfer. Pending operations expire and are recoverable or
|
||||||
|
cleaned after crashes.
|
||||||
|
|
||||||
Display names are attacker-chosen data — the existing handshake already treats
|
If both devices initiate simultaneously, the protocol deterministically merges
|
||||||
`receiver_name` that way, and the same rule applies here. The endpoint ID is the
|
the attempts using the endpoint identities and the transfer-bound eligibility
|
||||||
only real identity. Therefore:
|
capability. It creates one relationship and one active grant per direction,
|
||||||
|
without duplicate prompts or rows.
|
||||||
|
|
||||||
- A contact's local label is set by the local user and is **never** silently
|
Declining consumes the eligibility for that qualifying transfer. It cannot
|
||||||
overwritten by a name the remote later claims. A changed remote name is shown
|
prompt again. A later completed transfer may establish new eligibility, but
|
||||||
as a distinct, dismissible signal.
|
another request still requires fresh local initiation.
|
||||||
- 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
|
## 7. Directional grants
|
||||||
|
|
||||||
A contact stores an endpoint ID, but iroh needs an address to dial. Without
|
Each direction has one active, high-entropy capability bound to:
|
||||||
local discovery, resolution depends on the relay profile:
|
|
||||||
|
|
||||||
| Relay mode | Resolution |
|
- Issuer endpoint identity.
|
||||||
|---|---|
|
- Holder endpoint identity.
|
||||||
| `Automatic` | Public discovery resolves the endpoint ID anywhere |
|
- Relationship generation.
|
||||||
| `StrictCustom` / `CustomWithDirectFallback` | Reachable through the configured relay, whose URL is stable |
|
- Minimum negotiated protocol generation.
|
||||||
| `LocalOnly` | Only while the cached direct address is still valid |
|
|
||||||
|
|
||||||
`presets::Minimal` deliberately leaves address lookup empty for the restricted
|
Proof uses the authenticated iroh channel plus established, domain-separated
|
||||||
modes (see the comment at `runtime/mod.rs:154`), so those modes cannot fall back
|
cryptographic primitives, challenge binding, and replay protection. Display
|
||||||
to public resolution — by design.
|
names, addresses, and transfer IDs alone are never authentication. The protocol
|
||||||
|
must have independent, reviewable test vectors.
|
||||||
|
|
||||||
**Mitigation: cache the peer's last-known `EndpointAddr` on the contact and
|
Relationships do not expire merely through inactivity. They remain until
|
||||||
refresh it after every successful connection.** The repository already persists
|
forget, block, explicit revocation, identity loss, or reset. Long-unseen devices
|
||||||
sender addresses this way for receive rows —
|
may later be represented as inactive by UI, but inactivity does not silently
|
||||||
`encode_persisted_sender_address` / `parse_persisted_sender_address` in
|
remove permission.
|
||||||
`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
|
Activating a replacement grant first makes the prior relationship generation
|
||||||
address is unchanged. When it is not, the send fails and the user falls back to
|
locally invalid. Exactly one generation is active per direction. Minimal
|
||||||
a QR invitation: no regression against today's behavior, but the UI must say so
|
non-secret revocation tombstones are retained for as long as an old generation
|
||||||
plainly rather than presenting an opaque failure. Local-only users in particular
|
could otherwise be replayed; tombstones contain no names, filenames, transfer
|
||||||
should be told that contacts depend on a cached address.
|
history, or capability material.
|
||||||
|
|
||||||
Reachability is never polled in the background. It is determined when the user
|
An established relationship records its minimum supported protocol generation
|
||||||
actually sends — and, for incoming offers, when the app next comes to the
|
and must never silently downgrade below it.
|
||||||
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
|
## 8. Forget, block, and identity replacement
|
||||||
|
|
||||||
New tables in the existing SQLite repository, with a schema migration:
|
### Forget
|
||||||
|
|
||||||
| Table | Columns (sketch) |
|
Forget makes the local relationship and its grants unusable immediately,
|
||||||
|---|---|
|
cancels active or resumable targeted transfers for that relationship, removes
|
||||||
| `contacts` | `id`, `endpoint_id` (unique), `local_label`, `remote_display_name`, `last_known_addr`, `created_at`, `last_transfer_at` |
|
relationship secrets and metadata, and sends a signed/bound best-effort remote
|
||||||
| `grants_issued` | `grant_id`, `grant_secret`, `issued_to_endpoint_id`, `created_at`, `expires_at` (idle, renewed on use), `revoked_at` |
|
revocation when possible. Correctness never depends on remote delivery.
|
||||||
| `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
|
An independently approved invitation transfer already in progress may continue
|
||||||
in events, never in logs, never in bug reports, never in a UniFFI return value.
|
because it belongs to the existing share domain.
|
||||||
The existing "tickets are capabilities" discipline extends verbatim.
|
|
||||||
|
|
||||||
A contact list is itself a privacy artifact — it names the people someone
|
### Block
|
||||||
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
|
Block is identity-wide and immediate. It rejects or cancels current and future
|
||||||
issued side, triggers the best-effort revoke message.
|
traffic from the blocked endpoint across:
|
||||||
|
|
||||||
|
- Pairing and grant operations.
|
||||||
|
- Targeted offers and transfers.
|
||||||
|
- Ordinary invitation handshakes and transfers.
|
||||||
|
- Revocation and probing endpoints, except for indistinguishable rejection
|
||||||
|
needed to avoid exposing block state.
|
||||||
|
|
||||||
|
Blocking deletes active relationship grants but retains the minimal identity
|
||||||
|
deny record and replay tombstones. Unblocking removes only the deny rule. It
|
||||||
|
does not restore grants, relationships, or cancelled transfers. Saving the
|
||||||
|
device again requires another qualifying transfer and fresh mutual consent.
|
||||||
|
|
||||||
|
A peer reinstall produces a new endpoint identity. It is never linked to the
|
||||||
|
old device by name, address, or platform. The old saved entry remains
|
||||||
|
unavailable until forgotten; the new identity follows the complete first-
|
||||||
|
transfer and consent flow.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Abuse and resource limits
|
## 9. Targeted-transfer model
|
||||||
|
|
||||||
Extend `CoreLimits` rather than inventing a parallel mechanism:
|
`TargetedTransfer` is not an access mode on an ordinary share. It has its own
|
||||||
|
protocol types, repository records, authorization rules, and public APIs.
|
||||||
|
Internal blob storage, import, hashing, streaming, and output-sink machinery may
|
||||||
|
be reused.
|
||||||
|
|
||||||
- `max_contacts`.
|
The following fields are immutable after creation:
|
||||||
- `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
|
- Transfer ID.
|
||||||
devices the user deliberately chose to be reachable by, and the remedy — revoke
|
- Sender endpoint identity.
|
||||||
— is one tap.
|
- Receiver endpoint identity.
|
||||||
|
- Manifest identity and content hashes.
|
||||||
|
- File count and total size.
|
||||||
|
|
||||||
|
Sending identical content to several saved devices creates independent
|
||||||
|
targeted transfers. Internal blobs may be deduplicated, but approval, progress,
|
||||||
|
cancellation, retry, authorization, and durable state remain independent.
|
||||||
|
|
||||||
|
The durable state machine is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Preparing -> Offering -> AwaitingApproval -> Approved -> Connecting
|
||||||
|
-> Transferring -> Completed
|
||||||
|
\-> Interrupted -> Connecting
|
||||||
|
|
||||||
|
Terminal alternatives: Declined, Cancelled, Failed, Deleted
|
||||||
|
```
|
||||||
|
|
||||||
|
Rust centrally validates transitions. Platform code invokes typed operations
|
||||||
|
and consumes snapshots/events; it cannot fabricate states.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Surfaces to build
|
## 10. Offer and approval protocol
|
||||||
|
|
||||||
- **Rust core:** offer ALPN and handler, grant minting/proof/revocation,
|
An offer is online-only and bounded:
|
||||||
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.
|
1. The sender creates an immutable targeted transfer for one saved-device
|
||||||
|
identity.
|
||||||
|
2. The peers authenticate the saved relationship and negotiate the targeted-
|
||||||
|
transfer protocol version.
|
||||||
|
3. The sender submits a bounded offer containing a stable transfer ID and an
|
||||||
|
authenticated manifest summary, but no reusable ordinary-share ticket.
|
||||||
|
4. The receiver validates all framing, limits, identity bindings, relay-policy
|
||||||
|
compatibility, and manifest claims before surfacing approval.
|
||||||
|
5. The receiver explicitly approves or declines.
|
||||||
|
6. On approval, the sender issues authorization bound to the exact transfer,
|
||||||
|
manifest, and receiver endpoint.
|
||||||
|
7. The receiver pulls the content through the existing safe streaming and
|
||||||
|
output-sink machinery.
|
||||||
|
|
||||||
|
The approved authorization covers the exact manifest, content hashes, sizes,
|
||||||
|
sender, receiver, transfer ID, and protocol generation. Any mismatch or content
|
||||||
|
mutation invalidates the transfer and requires a new transfer ID and approval.
|
||||||
|
A leaked capability must fail when presented by another endpoint.
|
||||||
|
|
||||||
|
Every operation is idempotent. Replaying the same pairing request, offer,
|
||||||
|
approval, acknowledgement, cancellation, or completion returns the existing
|
||||||
|
result and cannot create duplicate prompts, grants, authorizations, or rows.
|
||||||
|
|
||||||
|
Declining rejects only that transfer. It neither forgets nor blocks the sender.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Testing
|
## 11. Online, interruption, and deletion semantics
|
||||||
|
|
||||||
- **Grant crypto:** fixed vectors for the HMAC proof; expiry, revocation,
|
An unapproved offer exists only in a bounded live-session queue. Sender
|
||||||
wrong-endpoint binding, and replay rejection.
|
cancellation, decline, timeout, disconnect, or core restart removes it. There
|
||||||
- **Grant lifetime:** a successful proof renews `expires_at`; an idle grant
|
is no sender-held offline offer, receiver polling loop, background inbox, or
|
||||||
lapses at the configured boundary; a renewed grant survives past its original
|
automatic retry that can produce a later prompt.
|
||||||
expiry. Assert the revoked and blocked responses are distinguishable from each
|
|
||||||
other and that blocked is indistinguishable from expired/unknown.
|
After approval, the transfer and its recipient-scoped authorization are
|
||||||
- **Offer protocol:** two in-process nodes using the existing
|
durable. Interruption retains verified progress and may resume when both devices
|
||||||
`crates/vnidrop/tests/support` harness — accept, decline, revoked grant,
|
are online again. Resuming the same immutable transfer does not request another
|
||||||
expired grant, blocked endpoint, relay-profile mismatch, and the invariant
|
approval. Changed content or metadata requires a new transfer.
|
||||||
that an offer-created share is never `Public`.
|
|
||||||
- **Pre-authorization:** assert the sender is prompted exactly zero times and
|
Cancellation before approval withdraws the offer. Cancellation after approval
|
||||||
the receiver exactly once, for a full offer → accept → transfer round trip.
|
stops authorization and active streaming synchronously before asynchronous
|
||||||
- **Consent:** assert that declining to be remembered leaves the peer with no
|
cleanup. It affects only that transfer.
|
||||||
usable grant, and that a subsequent offer from that peer is refused.
|
|
||||||
- **Address caching:** a contact whose cached address is stale falls back
|
Deletion must make authorization unusable, stop content service for that
|
||||||
cleanly and reports an actionable error, rather than hanging.
|
transfer, remove resumable state, and clean related secrets. Remote cleanup is
|
||||||
- **Persistence:** grants and contacts survive a core shutdown and reopen of the
|
best-effort; immediate durable local denial is mandatory.
|
||||||
same data dir, following the existing recovery-test pattern.
|
|
||||||
- **Sender-held offers (§11):** an offer to an unreachable contact is retained,
|
Several separately approved targeted transfers may run concurrently between
|
||||||
is cancellable, is collected on the receiver's next pull, and is not
|
the same devices under existing global stream and resource limits.
|
||||||
double-delivered if the receiver pulls twice.
|
|
||||||
- Per `AGENTS.md`, any bug found gets a regression test at the lowest layer.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Settled decisions
|
## 12. Local data and consistency
|
||||||
|
|
||||||
Both previously open questions are decided and specified above; recorded here
|
The private application database may contain only the relationship and transfer
|
||||||
with their rationale so the reasoning is not lost.
|
metadata needed for the feature, including:
|
||||||
|
|
||||||
1. **Revocation is reported explicitly** (§3.3). A revoked peer's client
|
- Endpoint identity/public identifier.
|
||||||
receives a distinct status and removes the dead entry immediately. The
|
- User-owned local label and untrusted platform/name hints.
|
||||||
alternative — silence — leaves a zombie entry, and the revocation is
|
- Pending/saved/blocked/revoked state and state revision.
|
||||||
inferable from the failure anyway, so the deniability is illusory.
|
- Opaque secure-store handles.
|
||||||
Indistinguishable silence is reserved for the hard block list, where
|
- Protocol and relationship generation.
|
||||||
undetectability is the point.
|
- Last successful authenticated contact time.
|
||||||
2. **Grants expire on idleness, renewed on use, defaulting to 90 days** (§3.5),
|
- Minimal replay and revocation tombstones.
|
||||||
configurable to 30 / 90 / 365 days or never. Relationships in regular use
|
- Durable targeted-transfer state after approval.
|
||||||
never lapse; forgotten ones clean themselves up, which bounds the blast
|
|
||||||
radius of a stale pairing and quietly disposes of entries orphaned by a
|
It must not become a transfer-history log. Pairing does not justify retaining
|
||||||
reinstall.
|
filenames, previous IP addresses, or lists of past transfers.
|
||||||
|
|
||||||
|
Credential-store and SQLite updates cannot share a native transaction. Use
|
||||||
|
recoverable staged transitions:
|
||||||
|
|
||||||
|
1. Write secret material under a versioned opaque handle.
|
||||||
|
2. Verify the protected write.
|
||||||
|
3. Commit metadata referencing that handle in a non-active state.
|
||||||
|
4. Finalize activation.
|
||||||
|
|
||||||
|
Startup reconciliation removes orphaned secrets and disables metadata whose
|
||||||
|
required secrets are missing. Revocation becomes locally effective before any
|
||||||
|
network notification. Relationship mutations are serialized per remote
|
||||||
|
endpoint, while unrelated devices proceed concurrently. Database, relationship,
|
||||||
|
and credential-store guards must never be held across network awaits.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Delivery when the recipient is not running
|
## 13. Core and platform contract
|
||||||
|
|
||||||
An offer is a live connection to a running app. This section states plainly what
|
The Rust core exposes separate typed models and operations for:
|
||||||
that costs and how far it is mitigated.
|
|
||||||
|
|
||||||
### 11.1 The constraint
|
- Pairing eligibility and pending pairing requests.
|
||||||
|
- Listing, renaming, forgetting, blocking, and unblocking saved devices.
|
||||||
|
- Creating and submitting targeted transfers.
|
||||||
|
- Approving, declining, cancelling, resuming, and deleting transfers.
|
||||||
|
- Querying durable state and current capability availability.
|
||||||
|
- Subscribing to typed events carrying stable IDs and monotonic state revisions.
|
||||||
|
|
||||||
Notifying the user is not the problem — `LocalNotificationService` and the
|
Bindings must not expose raw secrets or generic state mutation. Events are
|
||||||
existing `ApprovalCoordinator` already turn an incoming approval request into a
|
wake-up notifications, not authoritative storage. They may be delivered at
|
||||||
user-visible prompt, and an incoming offer reuses that path unchanged.
|
least once; consumers deduplicate by stable ID and revision, then query current
|
||||||
|
state after reconnect or restart.
|
||||||
|
|
||||||
*Receiving* the request is the problem. `BackgroundActivityController` holds an
|
Failures remain typed where callers can act differently, including:
|
||||||
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,
|
- Device unavailable or offer timeout.
|
||||||
which means a server holding device tokens and observing who contacts whom. That
|
- Protocol incompatibility or forbidden downgrade.
|
||||||
is infrastructure plus a metadata leak, both of which contradict the product's
|
- Revoked or blocked relationship.
|
||||||
no-cloud posture. **APNs is out of scope.** (This is also why AirDrop can do it
|
- Relay-policy incompatibility.
|
||||||
and a third-party app cannot: AirDrop is an OS daemon, not an app.)
|
- Secure storage locked, unavailable, missing, or corrupted.
|
||||||
|
- Approval decline, cancellation, interruption, and invalid transition.
|
||||||
|
|
||||||
### 11.2 Sender-held offers with a foreground pull
|
Production errors and diagnostics must not expose endpoint IDs, direct
|
||||||
|
addresses, tickets, grants, pairing capabilities, filenames, or secret-store
|
||||||
When the target is unreachable, the sender holds the offer **locally** — the
|
payloads.
|
||||||
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
|
## 14. Limits and hostile-peer handling
|
||||||
|
|
||||||
An earlier draft specified AirDrop-style discovery: three visibility tiers
|
A saved relationship proves a remote app identity and permits it to request
|
||||||
(invisible / paired-only / a time-boxed pairing window), private per-grant mDNS
|
approval. It does not make remote metadata, filenames, paths, sizes, messages,
|
||||||
beacons using rotating per-epoch AEAD entries so only grant holders could
|
or content trusted.
|
||||||
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),
|
The feature reuses all existing filesystem safety, output-sink, no-overwrite,
|
||||||
live presence (which requires probing, and probing leaks when a user opens their
|
ticket validation, and resource-limit invariants. Before approval it also
|
||||||
contact list), and address resolution on a network with no public discovery —
|
enforces:
|
||||||
the only substantive one, and largely handled by the address caching in §5.
|
|
||||||
|
|
||||||
**What dropping it avoids:**
|
- One unresolved offer per sender identity.
|
||||||
|
- A bounded global pending-offer queue.
|
||||||
|
- Strict request, manifest, metadata, file-count, and size limits.
|
||||||
|
- Connection, pairing, offer, approval, and acknowledgement timeouts.
|
||||||
|
- Per-identity cooldown after repeated malformed traffic or declines.
|
||||||
|
- Silent rejection of unauthenticated, ineligible, blocked, or invalid traffic.
|
||||||
|
- A configurable `CoreLimits.max_saved_devices`, defaulting to 256.
|
||||||
|
|
||||||
- The `com.apple.developer.networking.multicast` entitlement risk. iroh's
|
These are control-plane and local-resource protections. They do not impose a
|
||||||
local-network discovery uses raw multicast sockets rather than Bonjour, and
|
quota on accepted transfers, files, bytes, or bandwidth.
|
||||||
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
|
VniDrop cannot protect against a compromised or unlocked endpoint, malicious
|
||||||
is a stronger and far more explainable claim than any beacon scheme, including
|
files the receiver knowingly accepts, operating-system credential compromise,
|
||||||
in an App Store review.
|
network traffic analysis, or a reinstalled peer appearing under a new identity.
|
||||||
|
|
||||||
**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,
|
## 15. Compatibility and release policy
|
||||||
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
|
Saved devices and targeted transfers use explicit, versioned protocol
|
||||||
along the way: `apple/VniDrop/Resources/Info.plist:78` declares
|
capabilities. A peer without compatible support cannot be paired or receive a
|
||||||
`NSBonjourServices` with a single empty-string entry, which is meaningless and
|
targeted transfer and falls back to the existing invitation flow. A targeted
|
||||||
should be removed or given a real service type.
|
transfer must never be reinterpreted as an ordinary share for compatibility.
|
||||||
|
|
||||||
|
The feature is gated as experimental in the 0.3.x line. The wire protocol is
|
||||||
|
versioned from its first merge. Removing the experimental gate requires:
|
||||||
|
|
||||||
|
- Stable migrations from every released database version.
|
||||||
|
- Compatible Apple, Android, Windows, and Linux credential-store adapters.
|
||||||
|
- Rust and platform contract coverage.
|
||||||
|
- Stable downgrade, revocation, recovery, and lifecycle behavior.
|
||||||
|
- No regression in invitation-based multi-recipient transfers.
|
||||||
|
|
||||||
|
The unreleased `feat/device-history` schema, held offers, polling behavior,
|
||||||
|
expiring grants, `Contact` terminology, Apple-only feature UI, and ordinary-
|
||||||
|
share offer authorization are prototype artifacts. They may be removed without
|
||||||
|
a migration. Useful low-level cryptographic, repository, protocol, and test
|
||||||
|
patterns may be retained only after they are checked against this design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Verification requirements
|
||||||
|
|
||||||
|
Rust tests must deterministically cover:
|
||||||
|
|
||||||
|
- Mutual consent, decline, simultaneous initiation, timeouts, and lost
|
||||||
|
acknowledgements.
|
||||||
|
- Pairing eligibility after completion and rejection after every non-completed
|
||||||
|
outcome.
|
||||||
|
- Replay, malformed input, spoofed identity, blocking, revocation, grant
|
||||||
|
rotation, and protocol downgrade.
|
||||||
|
- Recipient-bound authorization and rejection of leaked capabilities.
|
||||||
|
- Direct, relay, custom-relay, local-only, and incompatible-profile behavior.
|
||||||
|
- Restart and recovery at every durable state.
|
||||||
|
- Cancellation, deletion, forget, and block during active streaming.
|
||||||
|
- Credential-store failure and crash-point reconciliation.
|
||||||
|
- Concurrent independent targeted transfers.
|
||||||
|
- Existing invitation-based multi-recipient behavior remaining unchanged.
|
||||||
|
|
||||||
|
Each platform secure-storage adapter requires contract coverage for create,
|
||||||
|
read, update, delete, locked/unavailable behavior, migration, device-bound
|
||||||
|
persistence, orphan cleanup, and redaction. Platform harnesses must prove that
|
||||||
|
secrets do not appear in generated bindings, logs, diagnostics, or ordinary
|
||||||
|
database columns.
|
||||||
|
|
||||||
|
The core/platform foundation is complete only when these contracts are
|
||||||
|
implemented, documented, exposed through typed UniFFI APIs, and pass the
|
||||||
|
relevant Rust and platform checks. UI polish is not part of that completion
|
||||||
|
boundary.
|
||||||
|
|||||||
Reference in New Issue
Block a user