56 Commits

Author SHA1 Message Date
ebdff3df4b fix(desktop): unblock protected-core startup snackbars
Run Secret Service IO on spawn_blocking so Linux zbus cannot nest Tokio
runtimes during init, and wait for core initialize before experimental
saved-device coordinators refresh.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 21:40:17 +02:00
b9884b566a feat(shared): enable experimental saved-devices on desktop
Surface Settings → Experimental on Windows/Linux Compose, keep generic
Desktop hosts gated off, and assert path-based targeted receive when no
output sink is available.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 19:51:48 +02:00
37cc238888 fix(android): unblock protected-core startup on Android
Use flock for profile locks, hash secret record filenames under NAME_MAX,
and dismiss the starting overlay after the first init attempt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 19:46:14 +02:00
eabc2754a7 feat(shared): Android experimental saved-devices KMP UI
Gate pairing and targeted transfers behind Settings → Experimental, with
CoreGateway wake-ups, in-flow prompts, and one Android dogfood round-trip.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 18:09:37 +02:00
edf7426672 chore: drop design-section cross-refs from core comments
Keep module docs self-describing without pointing at DESIGN §N.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 17:03:42 +02:00
d91820b867 feat(core): id-centric targeted approve/receive with output sinks
Stop returning grant strings across UniFFI; approve yields typed outcomes and
pull/resume use transfer id plus path or ReceiveOutputSink. Document the
pairing/targeted event catalog and cover Android MediaStore-style sink contracts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 16:50:10 +02:00
3b7fd3d468 refactor(core): deepen domain stores and invitation-only persistence
Peel relationships, eligibility, and secrets off the shared pool into
AppDataStores adapters, split pairing service/protocol, and move the
invitation Repository into its own module so open_all owns schemas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 15:51:35 +02:00
5c66c2ca65 refactor(core): open AppDataStores instead of exporting SqlitePool
Introduce persistence::open_all so domain stores (invitation, targeted,
blocked) are constructed once. Extract TargetedTransferStore and own it on
CoreInner; soft-close raw pool access for new callers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 08:50:16 +02:00
a2385912c2 refactor(core): remove prototype contact paths for experimental saved devices
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 06:05:31 +02:00
0a6ecb5c3b merge: ticket 14 Apple saved-device core contract
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 05:39:40 +02:00
9c7697c90b feat(apple): exercise saved-device core contract harness
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 05:39:10 +02:00
039d3f942b merge: ticket 17 Linux saved-device core contract
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 05:13:00 +02:00
1e601e118a feat(linux): exercise saved-device core contract harness
Prove Secret Service-backed identity restart, the public saved-device
lifecycle, fault isolation, event recovery, and binding hygiene without
product UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 05:12:20 +02:00
e79aba7bcf merge: ticket 16 Windows saved-device core contract
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 05:11:51 +02:00
f603f5fb8d feat(windows): exercise saved-device core contract harness
Prove the DPAPI-backed Windows bridge can drive the full public saved-device
and targeted-transfer contract via an injectable API fake on non-Windows hosts
and real DPAPI under cfg(windows), without product UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 05:11:26 +02:00
0c317efe59 feat(android): exercise saved-device core contract harness
Prove Keystore-backed Android secret storage can drive the full public
saved-device and targeted-transfer contract without product UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 05:10:04 +02:00
8e8cab9b24 feat(core): add event revisions and saved-device local labels
Platform contract harnesses need monotonic event revisions for at-least-once
dedup and a typed rename API that survives listing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 04:47:28 +02:00
89c2206f0f feat(core): harden saved-device control plane against hostile peers
Bound pending offers, enforce pre-approval limits and max_saved_devices,
add per-identity cooldowns, silently reject invalid traffic, and redact
sensitive values from production events and errors without inventing
accepted-transfer quotas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 04:33:38 +02:00
a01d8328c9 merge: integrate resumable transfers with network profile compatibility
Combine ticket 11 resume/cancel/delete/idempotency with ticket 12 relay
profiles, protocol floors, and typed offer outcomes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 04:02:07 +02:00
8798edf0f2 feat(core): make targeted transfers resumable and idempotent
Preserve approved transfer auth and state across restart, resume without
re-approval, and keep cancel/delete from leaving usable orphan authorization.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 03:56:46 +02:00
4a83b18d42 feat(core): honor network profiles and protocol floors for saved devices
Targeted offers and pairing now validate relay-policy compatibility and
reject protocol downgrades with typed unavailable/timeout/incompatibility
errors, without falling back to ordinary shares.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 03:54:13 +02:00
e2cfad7fb3 fix(core): tear down shares on forget/block and fail-closed blocks
Cancel targeted protocol shares synchronously before await, and treat block
lookup errors as denied so store failures cannot admit blocked peers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 03:35:18 +02:00
22c842acb1 merge: integrate targeted transfers with revoke/forget/block
Combine ticket 09 lifecycle APIs with ticket 10 targeted-transfer protocol
and wire forget/block to real cancel_targeted_transfers_for_peer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 03:30:33 +02:00
11fae16391 feat(core): revoke, forget, block, and rotate device relationships
Give saved-device owners immediate local control over grants and identity-wide
denies, with minimal tombstones for replay rejection and a ticket-10 hook for
targeted-transfer cancellation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 03:27:28 +02:00
f4cec1415c feat(core): complete one approved targeted transfer between saved devices
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 03:22:38 +02:00
193fe7c757 feat(core): save devices through mutual consent and grants
Establish PendingOutgoing/PendingIncoming relationships over a token-bound
pairing protocol, exchange directional grants with challenge-response proofs
and a final ack before Saved, and merge simultaneous initiations without a
second prompt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 03:03:43 +02:00
04a31e8a24 feat(core): create pairing eligibility after completed transfers
Add the experimental eligibility control plane so either endpoint of a
fully completed authenticated invitation transfer can start one single-use
pairing attempt within 24 hours, with secrets held in custody and invalid
requests rejected silently.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 02:34:18 +02:00
e740942f63 chore: ignore bin directory in git repository 2026-08-09 21:24:25 +02:00
eefedd0cb0 fix(core): harden protected identity startup 2026-08-09 20:25:16 +02:00
429987785e feat(core): protect secrets on native platforms 2026-08-09 19:34:34 +02:00
931b297321 test(core): harden secret custody recovery 2026-08-09 18:44:58 +02:00
1cd09a2ec3 feat(core): add recoverable secret custody 2026-08-09 18:37:54 +02:00
b128457137 docs(core): clarify experimental domain versions 2026-08-09 18:07:03 +02:00
564b86c28c feat(core): establish saved device domain seam 2026-08-09 18:01:02 +02:00
0ec0daef2a docs: redesign saved device foundation 2026-08-09 18:00:48 +02:00
c852a68f28 fix(apple): correct the device id row on the detail screen
The fingerprint row reused the no-name placeholder as its label, so it read
"A nearby device — aaab1c58", and it duplicated the device id shown
directly beneath it. Now one selectable full id, which is what someone
comparing devices actually needs.
2026-08-07 10:59:00 +02:00
225ff9ad22 fix: stop the device picker hanging on an offer
Two causes. The connect step had no timeout, so an unreachable device was
retried indefinitely instead of falling through to hold-for-later; it now
gives up after 15s and holds the offer as designed.

The picker also waited on the whole exchange, which includes a person on the
other device deciding — up to two minutes. It now closes on tap and reports
the outcome as a message, and a decline or an unanswered offer is shown as
information rather than an error, since the offer did arrive.
2026-08-07 10:49:32 +02:00
677fc3c6d5 fix(apple): make the device detail screen reachable
The Settings stack has a typed path of [SettingsSection], so a
NavigationLink carrying a String could never push onto it: tapping a device
in the list did nothing. Contact detail is now a SettingsSection case, and
the path maps it to the two-level push the way the bug report screen
already does.
2026-08-07 10:33:10 +02:00
3441280599 feat(apple): send a transfer to a device from the share panel
Send to a device now sits alongside the QR code, NFC, and export actions,
since an offer is another way to deliver the same invitation. Picking a
device pushes the existing transfer rather than re-sharing the files.

The picker lists only devices holding a live grant, so nothing offered there
can fail on tap, and it distinguishes accepted from waiting for that device
to open the app.

Also fixes the deprecated SF Symbol and the two Sendable warnings introduced
with the contacts screen: the sections now talk to the model directly rather
than storing view callbacks that a Binding setter has to convert.
2026-08-07 10:24:05 +02:00
d8587851a3 feat(core): offer an existing share to a remembered device
Another way to deliver an invitation the user already created, alongside the
QR code, rather than a second share of the same files: the ticket handed over
is the stored one and the transfer id is unchanged.

Only an active share can be offered. A stopped one no longer serves its
content, so handing out its ticket would promise nothing.
2026-08-07 10:14:53 +02:00
cba51ad504 docs(design): mark device history as implemented 2026-08-06 19:00:24 +02:00
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
94a8ba2103 feat(core): hold undeliverable offers and collect them on demand
An unreachable device is a delay, not a failure: the share stays here and
the ticket waits in held_offers (schema 8 -> 9) until that device comes and
collects it. No server and no push, per the design.

Polling needs no grant proof. iroh has already authenticated the remote
endpoint key, and a device is only handed offers addressed to precisely that
endpoint, so a stranger polling learns nothing. Offers are consumed on
delivery, so polling twice does not re-deliver, and cancelling the transfer
withdraws the waiting ticket.

Polling is rate limited per device: it tells every contact the app was
opened, so it must never become a presence beacon.
2026-08-06 18:47:29 +02:00
268fbf161d feat(apple): send files to a remembered device
Adds the Send files action to the device detail, routing the picked
selection through sendToContact.

Rather than a second picker path, sharePickedFiles now takes a
ShareDestination, so the macOS security-scoped access handling covers both
routes. A contact destination carries no access policy, matching the core's
rule that an offer share is never public.
2026-08-06 18:21:44 +02:00
dc23e87c56 feat(apple): offer to remember a device after a transfer
Closes the loop: until now nothing in the UI could create a contact, so the
list stayed empty unless the peer initiated.

A completed receive names its sender and a completed delivery names its
receiver, so both sides get the suggestion. Declining is persisted, or every
later transfer with the same device would re-ask a question already
answered; pairing deliberately afterwards clears that.

The suggestion sheet ranks below the two other prompts, since nobody is
waiting on the answer.
2026-08-06 18:06:19 +02:00
1369df4578 feat(apple): contacts screen and consent prompts
Device list, detail, and block list under Settings, plus the two sheets:
an incoming offer and a device asking to be remembered. Both are
answer-only, since swiping away would leave the sender waiting.

Accepting an offer routes the released ticket into the ordinary receive
path, so the platform still chooses the destination. The prompt does not
ask a second time; it only falls back to the review sheet when the
destination is unusable.
2026-08-06 17:57:54 +02:00
256ff89423 feat(apple): add the contacts feature model
MVVM model for the device list, its detail, and the two consent prompts.
Accepting an offer is the only path that returns a ticket, matching the
core: a declined offer hands over nothing.

Grant lifetime lives in preferences because the core holds it in memory
only, so it is pushed back on every start rather than silently reverting to
the default.
2026-08-06 17:48:18 +02:00
3c89c34c6e feat(apple): expose device history through the core gateway
Adds contact, pairing, and offer models plus the gateway surface the feature
models will use. Contacts and offers are endpoint-scoped events with no
transfer id, so they get their own coalesced signals.

DeviceContact.displayName prefers the local label over the name the peer
claims, and carries a short endpoint fingerprint for telling apart devices
using the same name.
2026-08-06 17:40:38 +02:00
178def0629 i18n: add device history strings
Contacts list and detail, pairing consent prompts, incoming offer prompt,
and the grant lifetime setting, in all nine supported languages. Added to
strings.json and regenerated; targeted at both platforms so the Compose
resources exist when the KMP side is built.
2026-08-06 17:29:43 +02:00
5f5f7e0515 chore(apple): drop the unused SwiftPM manifest
Package.swift named its module VniDropApp while all 16 test files import
VniDrop, and it never declared the SFSafeSymbols dependency that project.yml
links, so neither swift build nor swift test worked. The Xcode project is
already the only functioning build definition for the UI and the tests.

Removes a second dependency list that had drifted, and corrects the README,
which documented the CLI path as if it worked.
2026-08-06 17:21:51 +02:00
e041fbeda2 feat(core): send transfers straight to a paired device
Adds SubmitOffer to the contacts ALPN: the sender creates an ordinary share
and pushes the ticket over an authenticated connection, replacing the QR
code without changing the transfer itself.

Only the receiving user is prompted. The sender pre-authorises the target
endpoint before offering, and the approval service now honours an existing
access session, so the handshake the receiver runs next does not ask the
sender to approve a transfer they initiated. An unsolicited ticket receive
still prompts as before.

The ticket leaves the core only when the user accepts; declining yields
nothing. Offer-created shares are never public, one prompt per device is
pending at a time, a decline starts a cooldown, and forgetting a device
clears any prompt it left on screen.
2026-08-06 16:56:52 +02:00
7aa99304b2 feat(core): add the contacts protocol and grant exchange
New /vnidrop/offer/1 ALPN carrying grant delivery and revocation, with a
per-connection challenge so a captured proof cannot be replayed onto another
connection. Unlike the transfer handshake, this serves nobody without a
grant, so an unpaired device cannot raise a prompt on the far side.

A delivered grant is never stored on arrival: it waits for the local user's
consent, so an unsolicited grant cannot create a contact. Forgetting a
contact revokes locally first and notifies the peer best effort. A blocked
endpoint is refused indistinguishably from any other refusal.

Adds the UniFFI surface for listing, pairing, forgetting, blocking, labels,
and grant lifetime.
2026-08-06 16:35:36 +02:00
9fbcf653e8 feat(core): persist contacts, grants, and the block list
Schema 7 -> 8 adds contacts, grants_issued, grants_held, and
blocked_endpoints. Kept in their own module so repository.rs does not grow
further; the tables migrate with the rest of the schema through the shared
pool.

Revocation tombstones rather than deletes, so a returning peer is answered
Revoked instead of Unknown and can drop its dead entry. Blocking revokes any
outstanding grant, and unblocking does not hand access back.
2026-08-06 16:12:31 +02:00
4cfee786fc feat(core): add grant primitives for device history
Grants are the capability a device issues so a known peer may reach it. The
issuer is the only party that can validate one, which is what makes consent
and revocation enforceable without the peer's cooperation.

Pure module: proof construction and constant-time verification bound to the
challenge and both endpoint ids, idle expiry renewed on use, and secrets
redacted in Debug output.
2026-08-06 16:01:20 +02:00
0388422318 docs(design): specify delivery when the recipient is not running
Sender-held offers with a bounded foreground pull instead of push
infrastructure. Records that APNs is out of scope and that mobile-to-mobile
with both apps closed is unsupported.
2026-08-06 15:51:00 +02:00
7afc7d0892 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).
2026-08-06 13:35:02 +02:00
177 changed files with 23124 additions and 250 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(swift test *)"
]
}
}

5
.gitignore vendored
View File

@@ -20,9 +20,14 @@ node_modules/
target/
.junie
config.override.mk
bin/
# Local design export scratch
output/
.scratch/
# Local ADRs (not tracked — agent/session decisions)
docs/adr/
.screenshots
apple/RELEASE-MACOS.md
apple/Generated/*.xcconfig

View File

@@ -139,7 +139,7 @@ crates/vnidrop/src/runtime/
provider.rs # provider events, per-connection send progress
```
Other core modules: `filesystem.rs`, `repository.rs`, `approval.rs`,
Other core modules: `filesystem.rs`, `invitation/`, `approval.rs`,
`handshake.rs`, `ticket.rs`, `access_policy.rs`, `event_hub.rs`, `api.rs`.
### Shared app

39
CONTEXT.md Normal file
View File

@@ -0,0 +1,39 @@
# VniDrop
Local peer-to-peer file transfer. This glossary is the product/core ubiquitous language — not an implementation guide.
## Transfers
**Invitation transfer**:
A share anyone with the ticket can request, subject to approval and access policy. Ordinary multi-recipient send/receive.
_Avoid_: contact send, held offer, reusable share offer
**Targeted transfer**:
A transfer bound to one saved-device relationship: immutable sender, receiver, manifest, and content identity; requires explicit approval before content.
_Avoid_: contact transfer, private share
**Saved device**:
A remote app identity this installation has mutually consented to remember, with directional grants at a relationship generation.
_Avoid_: contact, person, account
**Device relationship**:
The durable pairing state between this installation and a remote endpoint (pending, saved, forgotten/blocked lifecycle).
_Avoid_: contact record, friendship
## Persistence (core)
**Domain store**:
The module that owns schema and queries for one domain (invitation history, targeted transfers, blocked devices, relationship rows, pairing eligibility, secret metadata). Callers use store methods — never a raw SQL pool.
_Avoid_: repository-for-everything, DAO, database layer
**Invitation repository**:
The domain store for invitation-transfer history, artifacts, receiver requests, and related events. Module path `invitation`; todays type name may still be `Repository`.
_Avoid_: “the database”, AppDataStores
**AppDataStores**:
The bag of concrete domain stores opened together for one app-data profile (one SQLite pool, every schema applied once).
_Avoid_: Repository (for the bag), Persistence (as a type name), DbContext
**Persistence open**:
Creating the profiles SQLite pool, applying all domain schemas, and returning `AppDataStores`. The only place that may touch pool creation for app data.
_Avoid_: Repository::open as the global DB entry (once migrated), sqlite_pool export

283
Cargo.lock generated
View File

@@ -219,6 +219,18 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
@@ -244,6 +256,17 @@ dependencies = [
"tokio",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "async-trait"
version = "0.1.89"
@@ -421,6 +444,15 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.6.2"
@@ -483,6 +515,15 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.2.65"
@@ -864,7 +905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
dependencies = [
"data-encoding",
"syn 1.0.109",
"syn 2.0.118",
]
[[package]]
@@ -1049,6 +1090,12 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-assoc"
version = "1.3.0"
@@ -1060,6 +1107,27 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1930,6 +1998,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
@@ -2511,6 +2580,15 @@ version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -2811,6 +2889,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
@@ -2837,6 +2929,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -2863,6 +2964,17 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -3032,6 +3144,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]]
name = "papaya"
version = "0.2.4"
@@ -3866,6 +3988,25 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "secret-service"
version = "5.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14"
dependencies = [
"aes",
"cbc",
"futures-util",
"generic-array",
"getrandom 0.2.17",
"hkdf",
"num",
"once_cell",
"serde",
"sha2 0.10.9",
"zbus",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -3974,6 +4115,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_repr"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
@@ -4485,6 +4637,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn-mid"
version = "0.5.4"
@@ -4550,7 +4713,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -4684,6 +4847,7 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
"tracing",
"windows-sys 0.61.2",
]
@@ -4977,6 +5141,17 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uds_windows"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
]
[[package]]
name = "unicode-bidi"
version = "0.3.18"
@@ -5227,14 +5402,19 @@ dependencies = [
"data-encoding",
"futures",
"futures-lite",
"getrandom 0.3.4",
"iroh",
"iroh-blobs",
"iroh-relay",
"irpc",
"irpc-iroh",
"jni 0.21.1",
"libc",
"n0-future",
"ndk-context",
"num_cpus",
"secret-service",
"security-framework",
"serde",
"serde_json",
"sqlx",
@@ -5247,6 +5427,7 @@ dependencies = [
"uniffi",
"uuid",
"walkdir",
"windows-sys 0.61.2",
]
[[package]]
@@ -5442,7 +5623,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5916,6 +6097,62 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "5.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a"
dependencies = [
"async-broadcast",
"async-recursion",
"async-trait",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix",
"serde",
"serde_repr",
"tokio",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow 1.0.3",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "5.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.118",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
dependencies = [
"serde",
"winnow 1.0.3",
"zvariant",
]
[[package]]
name = "zerocopy"
version = "0.8.52"
@@ -6015,3 +6252,43 @@ name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zvariant"
version = "5.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 1.0.3",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.118",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.118",
"winnow 1.0.3",
]

551
DESIGN-DEVICE-HISTORY.md Normal file
View File

@@ -0,0 +1,551 @@
# Design — Saved devices and targeted transfers
Status: **experimental foundation for the 0.3.x line**.
The unreleased contact/held-offer/polling prototype has been removed. The
implementation on this branch is the versioned saved-device, device-relationship,
and targeted-transfer foundation described below. Its wire protocol is
experimental and versioned; product UI remains deferred.
The feature lets two VniDrop installations remember one another after a
successful transfer, with explicit consent on both devices. A saved device can
then request a new transfer without another invitation, QR scan, or NFC tap.
The receiver must still approve every transfer.
The Rust core, protocol, persistence, credential-storage integration, and
platform contracts are the first delivery scope. Product UI is intentionally
deferred to a separate design and implementation session.
---
## 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
- Send to a previously saved device without exchanging another invitation.
- Make mutual consent cryptographically enforceable rather than a UI promise.
- Keep receiver approval mandatory for each new transfer.
- Give forget, revoke, block, cancellation, and deletion immediate local
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
- Automatic acceptance or unattended writes to a receiver's device.
- Offline store-and-forward, automatic peer polling, background inboxes, or
push notifications.
- Server-side device discovery, relationship storage, history synchronization,
backup, export, or restoration onto another installation.
- Presence indicators or a promise that a suspended mobile application is
reachable.
- Groups or a multi-recipient variant of `TargetedTransfer`.
- Associating several saved devices with a person or account.
- UI screens, navigation, wording, and presentation architecture in this phase.
---
## 3. Network and privacy model
Saved-device operations use the same configured iroh network profile as
ordinary transfers:
- `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 |
|---|---|
| Apple | Keychain with a non-synchronizing, device-appropriate accessibility class |
| Android | Keystore-backed encryption; only ciphertext may live outside Keystore |
| Windows | DPAPI scoped to the current user |
| Linux | Secret Service/libsecret |
There is no plaintext fallback.
Rust owns identity use, cryptographic operations, relationship state, and
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.
---
## 5. Pairing eligibility
Only a **fully completed authenticated transfer** creates pairing eligibility.
A handshake, partial download, failed export, cancellation, decline, or failed
transfer does not qualify. Either the sender or receiver may initiate pairing
after a qualifying transfer.
During the qualifying transfer, the peers establish a cryptographic,
single-use pairing eligibility capability bound to:
- Both endpoint identities.
- The qualifying transfer/session.
- The saved-device protocol version.
- A 24-hour local expiry.
The capability becomes usable only after the transfer reaches its durable
completed state. It is stored locally in encrypted form without filenames or a
transfer-history record. It is deleted when consumed, declined, expired,
forgotten, blocked, or reset.
Requests without valid eligibility are silently rejected. This prevents a
modified stranger from generating unsolicited pairing prompts.
---
## 6. Mutual-consent protocol
The protocol uses explicit pending states rather than exposing partial contacts
as usable saved devices:
- `PendingOutgoing`
- `PendingIncoming`
- `Saved`
The normal exchange is:
1. Alice locally chooses to remember Bob after a qualifying transfer.
2. Alice sends a token-bound pairing request.
3. Bob explicitly consents.
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.
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.
If both devices initiate simultaneously, the protocol deterministically merges
the attempts using the endpoint identities and the transfer-bound eligibility
capability. It creates one relationship and one active grant per direction,
without duplicate prompts or rows.
Declining consumes the eligibility for that qualifying transfer. It cannot
prompt again. A later completed transfer may establish new eligibility, but
another request still requires fresh local initiation.
---
## 7. Directional grants
Each direction has one active, high-entropy capability bound to:
- Issuer endpoint identity.
- Holder endpoint identity.
- Relationship generation.
- Minimum negotiated protocol generation.
Proof uses the authenticated iroh channel plus established, domain-separated
cryptographic primitives, challenge binding, and replay protection. Display
names, addresses, and transfer IDs alone are never authentication. The protocol
must have independent, reviewable test vectors.
Relationships do not expire merely through inactivity. They remain until
forget, block, explicit revocation, identity loss, or reset. Long-unseen devices
may later be represented as inactive by UI, but inactivity does not silently
remove permission.
Activating a replacement grant first makes the prior relationship generation
locally invalid. Exactly one generation is active per direction. Minimal
non-secret revocation tombstones are retained for as long as an old generation
could otherwise be replayed; tombstones contain no names, filenames, transfer
history, or capability material.
An established relationship records its minimum supported protocol generation
and must never silently downgrade below it.
---
## 8. Forget, block, and identity replacement
### Forget
Forget makes the local relationship and its grants unusable immediately,
cancels active or resumable targeted transfers for that relationship, removes
relationship secrets and metadata, and sends a signed/bound best-effort remote
revocation when possible. Correctness never depends on remote delivery.
An independently approved invitation transfer already in progress may continue
because it belongs to the existing share domain.
### Block
Block is identity-wide and immediate. It rejects or cancels current and future
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.
---
## 9. Targeted-transfer model
`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.
The following fields are immutable after creation:
- Transfer ID.
- Sender endpoint identity.
- 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.
---
## 10. Offer and approval protocol
An offer is online-only and bounded:
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.
---
## 11. Online, interruption, and deletion semantics
An unapproved offer exists only in a bounded live-session queue. Sender
cancellation, decline, timeout, disconnect, or core restart removes it. There
is no sender-held offline offer, receiver polling loop, background inbox, or
automatic retry that can produce a later prompt.
After approval, the transfer and its recipient-scoped authorization are
durable. Interruption retains verified progress and may resume when both devices
are online again. Resuming the same immutable transfer does not request another
approval. Changed content or metadata requires a new transfer.
Cancellation before approval withdraws the offer. Cancellation after approval
stops authorization and active streaming synchronously before asynchronous
cleanup. It affects only that transfer.
Deletion must make authorization unusable, stop content service for that
transfer, remove resumable state, and clean related secrets. Remote cleanup is
best-effort; immediate durable local denial is mandatory.
Several separately approved targeted transfers may run concurrently between
the same devices under existing global stream and resource limits.
---
## 12. Local data and consistency
The private application database may contain only the relationship and transfer
metadata needed for the feature, including:
- Endpoint identity/public identifier.
- User-owned local label and untrusted platform/name hints.
- Pending/saved/blocked/revoked state and state revision.
- Opaque secure-store handles.
- Protocol and relationship generation.
- Last successful authenticated contact time.
- Minimal replay and revocation tombstones.
- Durable targeted-transfer state after approval.
It must not become a transfer-history log. Pairing does not justify retaining
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.
---
## 13. Core and platform contract
The Rust core exposes separate typed models and operations for:
- 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.
Bindings must not expose raw secrets or generic state mutation. Events are
wake-up notifications, not authoritative storage. They may be delivered at
least once; consumers deduplicate by stable ID and revision, then query current
state after reconnect or restart.
### 13.1 Pairing and targeted-transfer event catalog
Canonical kinds emitted on `CoreEvent` (phase → kind). Treat every event as a
wake-up: refresh durable state via list/get APIs. Mid-transfer progress polish
(live `verified_bytes` updates) may follow; this catalog is the readiness bar.
**`pairing`**
| Kind | Meaning |
|---|---|
| `eligibility-available` | Pairing eligibility exists for a peer after a completed authenticated invitation transfer. |
| `eligibility-removed` | Eligibility expired or was consumed/removed. |
| `relationship-changed` | Device-relationship state changed (pending, saved, revoked, blocked). Payload includes peer id and state. |
| `relationship-grant-rotated` | Local relationship grant generation advanced for a peer. |
| `saved-device-forgotten` | Local forget completed for a saved peer. |
| `device-blocked` | Peer was blocked locally. |
**`targeted_transfer`**
| Kind | Meaning |
|---|---|
| `offer-received` | A pre-approval offer is pending local approve/decline. |
| `offer-accepted` | Local approval completed; authorization is in core custody. |
| `offer-declined` | Local decline completed. |
Failures remain typed where callers can act differently, including:
- Device unavailable or offer timeout.
- Protocol incompatibility or forbidden downgrade.
- Revoked or blocked relationship.
- Relay-policy incompatibility.
- Secure storage locked, unavailable, missing, or corrupted.
- Approval decline, cancellation, interruption, and invalid transition.
Production errors and diagnostics must not expose endpoint IDs, direct
addresses, tickets, grants, pairing capabilities, filenames, or secret-store
payloads.
---
## 14. Limits and hostile-peer handling
A saved relationship proves a remote app identity and permits it to request
approval. It does not make remote metadata, filenames, paths, sizes, messages,
or content trusted.
The feature reuses all existing filesystem safety, output-sink, no-overwrite,
ticket validation, and resource-limit invariants. Before approval it also
enforces:
- 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.
These are control-plane and local-resource protections. They do not impose a
quota on accepted transfers, files, bytes, or bandwidth.
VniDrop cannot protect against a compromised or unlocked endpoint, malicious
files the receiver knowingly accepts, operating-system credential compromise,
network traffic analysis, or a reinstalled peer appearing under a new identity.
---
## 15. Compatibility and release policy
Saved devices and targeted transfers use explicit, versioned protocol
capabilities. A peer without compatible support cannot be paired or receive a
targeted transfer and falls back to the existing invitation flow. A targeted
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` contact schema, held offers, polling
behavior, expiring grants, `Contact` terminology, Apple-only feature UI, and
ordinary-share offer authorization were prototype artifacts and have been
removed without a compatibility migration. Useful low-level cryptographic,
repository, protocol, and test patterns were retained only after they were
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.

View File

@@ -7,6 +7,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.lifecycle.lifecycleScope
import com.vnidrop.app.core.initializeAndroidCoreRuntime
import com.vnidrop.app.feature.receive.ExternalInvitationController
import com.vnidrop.app.feature.receive.MaxVniDropInvitationBytes
import com.vnidrop.app.feature.receive.VniDropInvitationExtension
@@ -22,6 +23,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
initializeAndroidCoreRuntime(applicationContext)
setContent {
App(rememberAndroidAppDependencies(this, externalInvitations))
}

View File

@@ -1,43 +0,0 @@
// swift-tools-version:5.9
import PackageDescription
// Core/UI Swift sources built as a library so the shared logic can be typechecked
// and unit-tested from the command line (macOS). The iOS/macOS app target in the
// Xcode project links the same sources plus the app entry point.
let package = Package(
name: "VniDropApp",
defaultLocalization: "en",
platforms: [
.iOS(.v16),
.macOS(.v13),
],
products: [
.library(name: "VniDropApp", targets: ["VniDropApp"]),
],
dependencies: [
.package(path: "VnidropCore"),
],
targets: [
.target(
name: "VniDropApp",
dependencies: [.product(name: "VnidropCore", package: "VnidropCore")],
path: "VniDrop",
// The @main entry belongs to the Xcode app target only; excluding it
// keeps this library free of a conflicting `_main` symbol for tests.
exclude: ["Resources", "App/VniDropApp.swift"],
// The Rust core (iroh network stack) links these system libraries. The
// Xcode app target must add the same frameworks under "Link Binary With
// Libraries" (SystemConfiguration, Security, libresolv).
linkerSettings: [
.linkedFramework("SystemConfiguration"),
.linkedFramework("Security"),
.linkedLibrary("resolv"),
]
),
.testTarget(
name: "VniDropAppTests",
dependencies: ["VniDropApp"],
path: "Tests"
),
]
)

View File

@@ -18,9 +18,8 @@ apple/
UI/Theme|Components|Navigation|Feedback|Shell/
Platform/ # pickers, QR, NFC, share/export, per-OS file services
Resources/ # Localizable.xcstrings, Info.plist, entitlements, assets
Tests/ # XCTest (ported progress-derivation assertions)
Package.swift # builds VniDrop/ as a library for CLI build/test
project.yml # XcodeGen spec for the iOS/macOS app target
Tests/ # XCTest bundle (VniDropTests target)
project.yml # XcodeGen spec for the iOS/macOS app and test targets
```
## Build & run
@@ -72,19 +71,22 @@ opt in with `APPLE_CODE_SIGNING=YES`. For signed builds from Xcode, create the
ignored `apple/Local.xcconfig` and override the signing settings there, including
the development team.
## Command-line typecheck & tests
## Typecheck & tests
`Package.swift` builds the same sources as a library (minus the `@main` entry),
so the shared logic can be checked and unit-tested without Xcode:
The Xcode project is the only build definition: it owns the UI, its package
dependencies, and the `VniDropTests` bundle (module `VniDrop`, which is what the
tests import). Everything runs through `xcodebuild`:
```bash
cd apple
swift build # macOS
swift test # runs Tests/ (ported progress-derivation assertions)
# iOS typecheck:
swift build --triple arm64-apple-ios16.0-simulator --sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)"
make check-apple # iOS simulator unit tests
make build-apple-macos # unsigned macOS build (typecheck)
```
There is deliberately no SwiftPM manifest for the app. A second build definition
would duplicate the target's package dependencies, and the previous one had
already drifted out of sync with `project.yml` badly enough that neither
`swift build` nor `swift test` worked.
## Generated / ignored artifacts
`build-core.sh` produces build outputs that are gitignored (see `apple/.gitignore`):
@@ -106,8 +108,7 @@ Rust crate itself is never changed.
## System frameworks
The Rust core (iroh network stack) links `SystemConfiguration`, `Security`, and
`libresolv`. These are declared in both `Package.swift` (for CLI build/test) and
`project.yml` (for the app target).
`libresolv`. These are declared in `project.yml` for the app target.
## Parity & scope

View File

@@ -92,8 +92,16 @@ final class FakeFileSystemService: FileSystemService {
func defaultReceiveFolder() -> ReceiveFolder { folder }
func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus { .writable }
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, accessPolicy: ShareAccessPolicy) async -> Result<Share, Error> {
await repository.shareSources([], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy)
private(set) var shareDestinations: [ShareDestination] = []
func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, destination: ShareDestination) async -> Result<Share, Error> {
shareDestinations.append(destination)
guard case .invitation(let accessPolicy) = destination else {
return .failure(TestError.unimplemented)
}
return await repository.shareSources(
[], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
)
}
}

View File

@@ -70,7 +70,7 @@ final class ProgressDerivationTests: XCTestCase {
private func event(phase: String, kind: String, json: String) -> CoreEventModel {
CoreEventModel(
id: UUID().uuidString, timestamp: 0, scope: "transfer", transferId: 1,
id: UUID().uuidString, revision: 1, timestamp: 0, scope: "transfer", transferId: 1,
direction: "send", phase: phase, kind: kind, dataJson: json
)
}

View File

@@ -0,0 +1,167 @@
import Foundation
import XCTest
@preconcurrency import VnidropCore
@testable import VniDrop
/// Headless Apple harness for the saved-device core/platform contract (ticket 14).
///
/// Exercises protected identity restart, event revision recovery, and binding
/// hygiene against the generated UniFFI surface. The full two-node public-API
/// lifecycle (eligibility unblock) lives in
/// `crates/vnidrop/src/tests/platform_contract_apple.rs` so it can run without
/// the iOS simulator.
final class SavedDeviceCoreContractTests: XCTestCase {
private final class RecordingSink: CoreEventSink, @unchecked Sendable {
private let lock = NSLock()
private var events: [CoreEvent] = []
func onEvent(event: CoreEvent) {
lock.lock()
events.append(event)
lock.unlock()
}
func snapshot() -> [CoreEvent] {
lock.lock()
defer { lock.unlock() }
return events
}
}
func testExperimentalKeychainIdentitySurvivesRestart() throws {
let directory = try FileManager.default.url(
for: .itemReplacementDirectory,
in: .userDomainMask,
appropriateFor: FileManager.default.temporaryDirectory,
create: true
).appendingPathComponent("vnidrop-apple-contract-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let sink = RecordingSink()
let first = try VnidropCore.initializeWithExperimentalSavedDevices(
appDataDir: directory.path,
eventSink: sink,
limits: defaultCoreLimits(),
networkConfig: CoreNetworkConfig(mode: .automatic, relayUrls: [])
)
let endpointId = first.status().endpointId
XCTAssertFalse(endpointId.isEmpty)
XCTAssertFalse(
FileManager.default.fileExists(atPath: directory.appendingPathComponent("iroh.secret").path),
"protected identity must not fall back to plaintext"
)
first.shutdown()
let restarted = try VnidropCore.initializeWithExperimentalSavedDevices(
appDataDir: directory.path,
eventSink: RecordingSink(),
limits: defaultCoreLimits(),
networkConfig: CoreNetworkConfig(mode: .automatic, relayUrls: [])
)
defer { restarted.shutdown() }
XCTAssertEqual(restarted.status().endpointId, endpointId)
}
func testEventRevisionRecoveryUsesStableIdsThenListApis() throws {
let directory = try FileManager.default.url(
for: .itemReplacementDirectory,
in: .userDomainMask,
appropriateFor: FileManager.default.temporaryDirectory,
create: true
).appendingPathComponent("vnidrop-apple-events-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let sink = RecordingSink()
let core = try VnidropCore.initializeWithExperimentalSavedDevices(
appDataDir: directory.path,
eventSink: sink,
limits: defaultCoreLimits(),
networkConfig: CoreNetworkConfig(mode: .automatic, relayUrls: [])
)
defer { core.shutdown() }
// Force at least one durable event through the public surface.
XCTAssertThrowsError(
try core.receive(ticket: "not-a-ticket", outputDir: directory.path, receiverName: nil)
)
let listed = try core.listEvents(transferId: nil)
XCTAssertFalse(listed.isEmpty)
var seenIds = Set<String>()
var revisions = Set<UInt64>()
for event in listed {
XCTAssertTrue(seenIds.insert(event.id).inserted, "event ids must be stable/unique")
XCTAssertGreaterThanOrEqual(event.revision, 1)
XCTAssertTrue(revisions.insert(event.revision).inserted, "revisions must be distinct")
}
// Simulate duplicate delivery after a listener restart, then trust list APIs.
let duplicates = listed
var recoveredIds = Set<String>()
var maxRevision: UInt64 = 0
for event in listed + duplicates {
if recoveredIds.insert(event.id).inserted {
maxRevision = max(maxRevision, event.revision)
}
}
XCTAssertEqual(recoveredIds.count, listed.count)
XCTAssertGreaterThanOrEqual(maxRevision, 1)
XCTAssertEqual(try core.listSavedDevices().count, 0)
XCTAssertEqual(try core.listDeviceRelationships().count, 0)
XCTAssertEqual(try core.listBlockedDevices().count, 0)
}
func testGeneratedBindingsOmitRawSecretsAndGenericMutation() throws {
let candidates = [
Bundle(for: SavedDeviceCoreContractTests.self).bundleURL
.deletingLastPathComponent()
.appendingPathComponent("Vnidrop.swift"),
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("VnidropCore/Sources/VnidropCore/Vnidrop.swift"),
]
guard let bindingsURL = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) })
else {
// Source package path may be absent in a clean CI checkout before
// build-core; linking the typed APIs below still proves the
// regenerated surface is what the harness compiles against.
let _: (
(String, CoreEventSink, CoreLimits, CoreNetworkConfig) throws -> VnidropCore
) = VnidropCore.initializeWithExperimentalSavedDevices
XCTAssertNotNil(defaultCoreLimits().maxSavedDevices)
return
}
let source = try String(contentsOf: bindingsURL, encoding: .utf8)
let forbidden = [
"SecretMaterial",
"SecretHandle",
"SecureSecretStore",
"executeSql",
"executeSQL",
"mutateState",
"applyRawState",
"rawSecret",
"grantSecret",
"pairingCapabilityBytes",
"func setState(",
"func mutate(",
]
for needle in forbidden {
XCTAssertFalse(
source.contains(needle),
"generated bindings must not expose \(needle)"
)
}
XCTAssertTrue(source.contains("initializeWithExperimentalSavedDevices"))
XCTAssertTrue(source.contains("setSavedDeviceLabel"))
XCTAssertTrue(source.contains("listSavedDevices"))
XCTAssertTrue(source.contains("revision"))
}
}

View File

@@ -167,7 +167,8 @@ struct RootView: View {
switch destination {
case .send: SendScreen(model: sendModel, windowClass: windowClass)
case .receive: ReceiveScreen(model: receiveModel, windowClass: windowClass)
case .settings: SettingsScreen(model: settingsModel, windowClass: windowClass)
case .settings:
SettingsScreen(model: settingsModel, windowClass: windowClass)
}
}
@@ -283,3 +284,10 @@ import UIKit
#else
import AppKit
#endif
/// Hosts the device-history consent prompts, alongside `ApprovalLayer`.
///
/// Separate from the approval layer because the two never compete: an approval
/// belongs to a transfer this device is sending, and these belong to a device
/// asking to reach it. Both are suppressed while the other is up so the user is
/// never answering two modals at once.

View File

@@ -47,4 +47,3 @@ protocol CoreGateway: AnyObject {
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error>
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error>
func refresh() async -> Result<Void, Error>
}

View File

@@ -12,6 +12,7 @@ struct CoreStatus: Equatable, Sendable {
struct CoreEventModel: Equatable, Identifiable, Sendable {
let id: String
let revision: UInt64
let timestamp: Int64
let scope: String
let transferId: UInt64?
@@ -72,6 +73,11 @@ enum ShareAccessPolicy: Equatable, Sendable {
case anyoneWithTransfer
}
/// Where a picked selection is going.
enum ShareDestination: Equatable, Sendable {
case invitation(accessPolicy: ShareAccessPolicy)
}
enum TransferDirection: Equatable, Sendable {
case send
case receive

View File

@@ -53,9 +53,10 @@ struct NativeCoreBindingFactory: CoreBindingFactory {
case .localOnly:
nativeConfiguration = CoreNetworkConfig(mode: .localOnly, relayUrls: [])
}
return try VnidropCore.initializeWithNetworkConfig(
return try VnidropCore.initializeWithExperimentalSavedDevices(
appDataDir: appDataDir,
eventSink: eventSink,
limits: defaultCoreLimits(),
networkConfig: nativeConfiguration
)
}
@@ -292,7 +293,6 @@ final class CoreRepository: ObservableObject, CoreGateway {
if let snapshot { self.applySnapshot(snapshot) }
}
}
// MARK: - Event sink handling (ported from CoreRepository.sink)
private func handle(event: CoreEvent) {
@@ -396,7 +396,7 @@ private func withSecurityScopedAccess<T>(pathOrUrl: String, _ body: () throws ->
private extension CoreEvent {
func toModel() -> CoreEventModel {
CoreEventModel(
id: id, timestamp: timestamp, scope: scope, transferId: transferId,
id: id, revision: revision, timestamp: timestamp, scope: scope, transferId: transferId,
direction: direction, phase: phase, kind: kind, dataJson: dataJson
)
}
@@ -519,3 +519,8 @@ private extension ReceiverRequest {
}
}
}

View File

@@ -33,12 +33,15 @@ protocol FileSystemService {
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error>
/// Releases only app-owned picker copies; never deletes original user sources.
func discardPickedFiles(_ files: [PickedShareFile]) async
/// Imports a picked selection, either as an invitation or straight to a
/// remembered device. One entry point so the platform's security-scoped
/// access handling covers both.
func sharePickedFiles(
repository: CoreGateway,
files: [PickedShareFile],
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy
destination: ShareDestination
) async -> Result<Share, Error>
}

View File

@@ -111,7 +111,7 @@ final class TransferNotificationCoordinator: ObservableObject {
switch signal {
case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId):
Task { await self.syncReceivers(transferId: transferId) }
case .approvalChanged:
case .approvalChanged, .transfersChanged:
break
}
}

View File

@@ -6,6 +6,9 @@ enum ReceiveMethod {
case invitationFile
case qrCode
case nfc
/// Pushed by a remembered device and already accepted by the user, so no
/// invitation was acquired by hand.
case offer
}
enum ReceiveHistoryDeleteTarget: Equatable {
@@ -154,6 +157,40 @@ final class ReceiveModel: ObservableObject {
}
}
/// Receive a transfer the user has already accepted in the offer prompt.
///
/// The consent happened in that prompt, so this does not ask again: it
/// inspects the ticket and starts, falling back to the ordinary review sheet
/// only when the destination is not usable and the user has to fix it.
func receiveOffered(ticket: String) {
let trimmed = ticket.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return messages.error(.resource(L10n.Error.invitationEmpty)) }
state.ticket = trimmed
state.method = .offer
state.inspection = nil
state.isInspecting = true
Task {
switch await repository.inspectTicket(trimmed) {
case .success(let inspection):
state.inspection = inspection
state.isInspecting = false
if state.canReceive(coreInitialized: coreState.isInitialized) {
receive()
} else {
// Usually a missing or unwritable destination: show the review
// sheet so the user can point it somewhere valid.
state.isAcquisitionOpen = true
}
case .failure(let error):
state.ticket = ""
state.method = nil
state.inspection = nil
state.isInspecting = false
messages.error(error)
}
}
}
func receive() {
let current = state
guard let folder = current.receiveFolder else { return }

View File

@@ -351,9 +351,9 @@ final class SendModel: ObservableObject {
files: current.selectedFiles,
transferName: current.transferName.trimmingCharacters(in: .whitespacesAndNewlines),
senderName: current.senderName.trimmingCharacters(in: .whitespacesAndNewlines),
accessPolicy: current.accessPolicy
destination: .invitation(accessPolicy: current.accessPolicy)
)
switch result {
switch result.map(\.share) {
case .success(let share):
await fileSystemService.discardPickedFiles(current.selectedFiles)
if let thumb = current.selectedFiles.compactMap(\.thumbnailData).first {

View File

@@ -174,6 +174,7 @@ final class SettingsModel: ObservableObject {
loadDeviceInfo()
}
func selectSection(_ section: SettingsSection) {
state.selectedSection = section
if section == .about || section == .bugReport {

View File

@@ -80,6 +80,11 @@ struct SettingsScreen: View {
@ViewBuilder
private func sectionForm(_ section: SettingsSection) -> some View {
settingsSectionForm(section)
}
@ViewBuilder
private func settingsSectionForm(_ section: SettingsSection) -> some View {
let content = Form {
SettingsSectionContent(model: model, section: section)
}

View File

@@ -59,12 +59,15 @@ struct IosFileSystemService: FileSystemService {
files: [PickedShareFile],
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy
destination: ShareDestination
) async -> Result<Share, Error> {
guard !files.isEmpty else {
return .failure(InvitationError.shareEmpty)
}
let sources = files.map { $0.toIosShareSource() }
guard case .invitation(let accessPolicy) = destination else {
return .failure(InvitationError.unsupportedOperation)
}
return await repository.shareSources(
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
)

View File

@@ -39,7 +39,7 @@ struct MacFileSystemService: FileSystemService {
files: [PickedShareFile],
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy
destination: ShareDestination
) async -> Result<Share, Error> {
guard !files.isEmpty else {
return .failure(InvitationError.shareEmpty)
@@ -63,6 +63,9 @@ struct MacFileSystemService: FileSystemService {
let sources = files.map {
ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory)
}
guard case .invitation(let accessPolicy) = destination else {
return .failure(InvitationError.unsupportedOperation)
}
return await repository.shareSources(
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
)

View File

@@ -70,6 +70,9 @@ struct SendPickers: ViewModifier {
}
}
/// File picker for "send to this device", reusing the share picker's selection
/// handling so security-scoped bookmarks are captured the same way.
enum PickerSupport {
static func receiveFolder(from url: URL) -> ReceiveFolder {
#if os(iOS)
@@ -129,4 +132,5 @@ extension View {
func sendPickers(model: SendModel) -> some View {
modifier(SendPickers(model: model))
}
}

View File

@@ -3,6 +3,12 @@ import VnidropCore
/// Maps technical failures to stable, user-facing catalog keys. Ported from
/// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs.
/// How an offered transfer ended without being accepted.
enum OfferRefusal {
case declined
case noAnswer
}
extension Error {
func toUiText() -> UiText {
if let invitation = self as? InvitationError {
@@ -32,6 +38,12 @@ extension Error {
return .resource(L10n.Error.generic)
case .InvalidInput:
return .resource(L10n.Error.invalidInput)
case .InvalidTransition:
return .resource(L10n.Error.invalidInput)
case .SecureStorageLocked, .SecureStorageUnavailable:
return .resource(L10n.Error.startingUp)
case .SecureStorageMissing, .SecureStorageCorrupted:
return .resource(L10n.Error.generic)
case .Initialization(let reason):
return initializationUiText(reason)
case .Internal(let reason):
@@ -57,6 +69,19 @@ extension Error {
|| haystack.contains("user canceled")
}
/// The other device answered, and the answer was no.
///
/// Not a failure of this device: the offer was delivered and a person
/// declined it, so it is reported as information rather than an error.
var offerRefusal: OfferRefusal? {
let haystack = technicalDetail.lowercased()
if haystack.contains("receiver-declined") || haystack.contains("declined-recently") {
return .declined
}
if haystack.contains("no-response") { return .noAnswer }
return nil
}
/// Prefers a `VnidropError` reason; else the localized description.
var technicalDetail: String {
if let vni = self as? VnidropError {
@@ -64,7 +89,9 @@ extension Error {
case .Initialization(let r), .Ticket(let r), .Filesystem(let r), .FilesystemPermission(let r),
.DestinationExists(let r), .StorageFull(let r), .Network(let r),
.Transfer(let r), .Permission(let r), .Repository(let r), .Cancelled(let r),
.InvalidInput(let r), .Internal(let r):
.InvalidInput(let r), .InvalidTransition(let r), .SecureStorageLocked(let r),
.SecureStorageMissing(let r), .SecureStorageCorrupted(let r),
.SecureStorageUnavailable(let r), .Internal(let r):
return r
}
}

View File

@@ -54,8 +54,16 @@ src/
receive.rs # receive, download, export, OutputSinkFile
lifecycle.rs # cancel share, delete, status, access mode, shutdown
provider.rs # provider messages, per-peer transfer progress
saved_devices.rs # experimental saved-device pairing, forget, block
targeted.rs # saved-device targeted transfers
persistence.rs # AppDataStores / persistence open (domain stores)
invitation/ # invitation-transfer domain store (type name: Repository)
pairing_eligibility/ # eligibility service + store
device_relationship/ # store + service + protocol (ALPN pairing)
targeted_transfer/ # targeted protocol + store adapter
blocked_devices.rs
secure_secret/ # custody + platform credential adapters (+ metadata store)
filesystem.rs # collect sources, atomic publish, path rules
repository.rs # SQLite
approval.rs / handshake.rs / ticket.rs / access_policy.rs / event_hub.rs
api.rs # UniFFI records/enums
tests/ # crate-private unit tests
@@ -63,7 +71,8 @@ tests/ # public-API integration tests + support/
```
**Do not** reassemble a single huge `runtime.rs`. Prefer new focused modules if a
file approaches ~800 LoC of non-test code.
file approaches ~800 LoC of non-test code. Do not add new `SqlitePool` call sites —
open domain stores via `persistence::open_all`.
---
@@ -78,11 +87,14 @@ file approaches ~800 LoC of non-test code.
4. **Cancel:** signal active-transfer oneshot **synchronously** before async DB
work. Use existing `take_active_transfer` / facade cancel path. Do not reintroduce
nested exclusive `Runtime::block_on` deadlocks.
5. **No lock across await:** Clippy `await_holding_lock` fails CI.
6. **ReceiveOutputSink:** after successful `start_file`, exactly one of
5. **SecureSecretStore:** never call the sync store from an async task body.
Linux Secret Service / zbus blocking nests Tokio `block_on`; `SecretCustody`
must keep those calls on `spawn_blocking`.
6. **No lock across await:** Clippy `await_holding_lock` fails CI.
7. **ReceiveOutputSink:** after successful `start_file`, exactly one of
`finish_file` or `abort_file` (see `OutputSinkFile` Drop).
7. **No-overwrite publish** for path receives (temp + hard link / exclusive rename).
8. Integration tests must use the **public** API + `tests/support/` only.
8. **No-overwrite publish** for path receives (temp + hard link / exclusive rename).
9. Integration tests must use the **public** API + `tests/support/` only.
---

View File

@@ -41,11 +41,23 @@ bytes through Kotlin memory.
- Transfer statuses: `sharing`, `receiving`, `done`, `failed`, `cancelled`,
`stopped`.
- Main event phases: `endpoint`, `import`, `ticket`, `handshake`, `approval`,
`access`, `transfer`, `download`, `export`, `delivery`, `lifecycle`, `error`.
`access`, `transfer`, `download`, `export`, `delivery`, `lifecycle`, `error`,
plus experimental `pairing` and `targeted_transfer` (see catalog below).
- Events are sent to `CoreEventSink` immediately and persisted through the event
hub. `list_events` flushes queued persistence before reading SQLite.
- `shutdown()` is idempotent and flushes events before stopping the router.
### Pairing and targeted-transfer event catalog
Treat every event as a wake-up: refresh durable state via list/get APIs.
Mid-transfer progress polish may follow.
**`pairing`:** `eligibility-available`, `eligibility-removed`,
`relationship-changed`, `relationship-grant-rotated`, `saved-device-forgotten`,
`device-blocked`.
**`targeted_transfer`:** `offer-received`, `offer-accepted`, `offer-declined`.
## Platform File Rules
- Desktop uses normal filesystem paths.

View File

@@ -16,6 +16,7 @@ blake3 = "1.8.3"
data-encoding = "2.11.0"
futures = "0.3"
futures-lite = "2.6.1"
getrandom = "0.3.4"
iroh = "1.0.3"
iroh-blobs = "0.103.0"
irpc = "0.17.0"
@@ -35,6 +36,20 @@ uniffi = { version = "=0.29.4", features = ["tokio"] }
uuid = { version = "1.23.3", features = ["v4", "serde"] }
walkdir = "2.5.0"
[target.'cfg(target_os = "linux")'.dependencies]
secret-service = { version = "5.1.0", default-features = false, features = ["rt-tokio-crypto-rust"] }
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies]
security-framework = { version = "3.7.0", features = ["OSX_10_15"] }
[target.'cfg(target_os = "android")'.dependencies]
jni = "0.21.1"
ndk-context = "0.1.1"
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security_Cryptography", "Win32_Storage_FileSystem"] }
[dev-dependencies]
iroh-relay = { version = "1.0.3", features = ["server"] }
secret-service = { version = "5.1.0", default-features = false, features = ["rt-tokio-crypto-rust"] }
tempfile = "3.27.0"

View File

@@ -29,13 +29,6 @@ impl AccessPolicy {
self.modes.write().await.insert(transfer_id, mode);
}
pub(crate) async fn allows_without_approval(&self, transfer_id: u64) -> bool {
matches!(
self.modes.read().await.get(&transfer_id),
Some(TransferAccessMode::Public)
)
}
pub(crate) async fn remove_transfer(&self, transfer_id: u64) {
self.modes.write().await.remove(&transfer_id);
self.approved_sessions

View File

@@ -10,6 +10,124 @@ use crate::util::{non_empty, now_ms};
pub(crate) const MAX_CUSTOM_RELAYS: usize = 8;
pub(crate) const MAX_RELAY_URL_BYTES: usize = 2_048;
/// Versions the additive public domain seam and its two experimental wire protocols.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
pub struct ExperimentalSavedDeviceCapabilities {
pub domain_contract_version: u16,
pub relationship_protocol_version: u16,
pub targeted_transfer_protocol_version: u16,
}
#[uniffi::export]
pub fn experimental_saved_device_capabilities() -> ExperimentalSavedDeviceCapabilities {
ExperimentalSavedDeviceCapabilities {
domain_contract_version: 1,
relationship_protocol_version: 1,
targeted_transfer_protocol_version: 1,
}
}
/// Public view of a single-use pairing window after a completed transfer.
///
/// The eligibility capability itself never crosses this boundary.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
pub struct PairingEligibilitySummary {
pub peer_endpoint_id: String,
pub session_id: String,
pub protocol_version: u16,
pub created_at: i64,
pub expires_at: i64,
}
/// A remote VniDrop app-installation identity that completed mutual consent.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
pub struct SavedDevice {
pub endpoint_id: String,
pub local_label: Option<String>,
pub remote_display_name: Option<String>,
pub created_at: i64,
pub last_authenticated_at: Option<i64>,
}
/// Durable consent lifecycle for one remote app-installation identity.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
pub enum DeviceRelationshipState {
PendingOutgoing,
PendingIncoming,
Saved,
Revoked,
Blocked,
}
/// Public relationship state; directional grant material remains core-private.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
pub struct DeviceRelationship {
pub remote_endpoint_id: String,
pub state: DeviceRelationshipState,
pub generation: u64,
pub minimum_protocol_version: u16,
pub created_at: i64,
pub updated_at: i64,
}
/// Rust-owned lifecycle for an immutable one-sender, one-receiver transfer.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
pub enum TargetedTransferState {
Preparing,
Offering,
AwaitingApproval,
Approved,
Connecting,
Transferring,
Interrupted,
Completed,
Declined,
Cancelled,
Failed,
Deleted,
}
/// Immutable recipient-bound transfer snapshot, separate from an ordinary share.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
pub struct TargetedTransfer {
pub id: String,
pub sender_endpoint_id: String,
pub receiver_endpoint_id: String,
pub manifest_id: String,
pub file_count: u64,
pub total_size: u64,
/// Bytes verified so far; survives interruption for resume.
pub verified_bytes: u64,
pub state: TargetedTransferState,
pub created_at: i64,
pub updated_at: i64,
}
/// Pre-approval offer summary. Deliberately omits any reusable share ticket.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
pub struct PendingTargetedOffer {
pub transfer_id: String,
pub sender_endpoint_id: String,
pub receiver_endpoint_id: String,
pub manifest_id: String,
pub content_hash: String,
pub transfer_name: String,
pub file_count: u64,
pub total_size: u64,
pub protocol_version: u16,
pub received_at: i64,
}
/// Local approve/decline outcome for a pending targeted offer.
///
/// Authorization stays in core custody; callers only receive transfer ids.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
pub enum TargetedOfferResponse {
Approved { transfer_id: String },
Declined,
AlreadySettled { transfer_id: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
pub enum CoreRelayMode {
Automatic,
@@ -180,8 +298,22 @@ pub struct CoreLimits {
pub max_metadata_bytes: u64,
pub max_events: u64,
pub max_pending_approvals: u64,
/// Incoming pairing / targeted offers awaiting the local user's decision.
pub max_pending_offers: u64,
pub max_concurrent_transfers: u64,
pub event_queue_capacity: u64,
/// Cap on Saved + pending mutual-consent relationships.
pub max_saved_devices: u64,
/// Quiet period after a decline or repeated malformed control-plane traffic.
pub identity_cooldown_ms: u64,
/// Malformed control-plane messages from one identity before cooldown.
pub malformed_strike_limit: u64,
/// Pairing RPC / acknowledgement wait bound (milliseconds).
pub pairing_timeout_ms: u64,
/// Pre-approval offer decision wait bound (milliseconds).
pub offer_timeout_ms: u64,
/// Connection establishment bound for targeted transfers (milliseconds).
pub connection_timeout_ms: u64,
}
impl Default for CoreLimits {
@@ -198,8 +330,17 @@ impl Default for CoreLimits {
max_events: 500,
// Bound handshake spam / notification pressure on the sender.
max_pending_approvals: 64,
// A pairing prompt needs the user in front of the device, so this
// is far smaller than the handshake queue.
max_pending_offers: 16,
max_concurrent_transfers: 8,
event_queue_capacity: 1_024,
max_saved_devices: 256,
identity_cooldown_ms: 60_000,
malformed_strike_limit: 5,
pairing_timeout_ms: 15_000,
offer_timeout_ms: 120_000,
connection_timeout_ms: 30_000,
}
}
}
@@ -215,8 +356,15 @@ impl CoreLimits {
("max_metadata_bytes", self.max_metadata_bytes),
("max_events", self.max_events),
("max_pending_approvals", self.max_pending_approvals),
("max_pending_offers", self.max_pending_offers),
("max_concurrent_transfers", self.max_concurrent_transfers),
("event_queue_capacity", self.event_queue_capacity),
("max_saved_devices", self.max_saved_devices),
("identity_cooldown_ms", self.identity_cooldown_ms),
("malformed_strike_limit", self.malformed_strike_limit),
("pairing_timeout_ms", self.pairing_timeout_ms),
("offer_timeout_ms", self.offer_timeout_ms),
("connection_timeout_ms", self.connection_timeout_ms),
];
for (name, value) in positive {
if value == 0 {
@@ -225,8 +373,11 @@ impl CoreLimits {
}
for (name, value) in [
("max_pending_approvals", self.max_pending_approvals),
("max_pending_offers", self.max_pending_offers),
("max_concurrent_transfers", self.max_concurrent_transfers),
("event_queue_capacity", self.event_queue_capacity),
("max_saved_devices", self.max_saved_devices),
("malformed_strike_limit", self.malformed_strike_limit),
] {
usize::try_from(value)
.with_context(|| format!("core limit {name} exceeds platform capacity"))?;
@@ -263,6 +414,8 @@ pub fn default_core_limits() -> CoreLimits {
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct CoreEvent {
pub id: String,
/// Monotonic per-process revision for at-least-once delivery deduplication.
pub revision: u64,
pub timestamp: i64,
pub scope: String,
pub transfer_id: Option<u64>,

View File

@@ -6,13 +6,15 @@ use tokio::sync::{oneshot, Mutex};
use uuid::Uuid;
use crate::{
access_policy::{AccessPolicy, APPROVAL_SESSION_TTL_MS},
access_policy::{AccessDecision, AccessPolicy, APPROVAL_SESSION_TTL_MS},
blocked_devices::BlockStore,
event_hub::EventHub,
handshake::{
DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse,
RequestTransfer,
},
repository::{ReceiverRequestInsert, Repository},
invitation::{ReceiverRequestInsert, Repository},
pairing_eligibility::PairingEligibilityService,
transfer_state::ReceiverRequestStatus,
util::now_ms,
};
@@ -30,11 +32,13 @@ pub(crate) struct ApprovalDecision {
#[derive(Clone)]
pub(crate) struct ApprovalService {
repository: Repository,
blocked: BlockStore,
event_hub: Arc<EventHub>,
access_policy: Arc<AccessPolicy>,
pending: Arc<Mutex<HashMap<String, oneshot::Sender<ApprovalDecision>>>>,
max_pending: usize,
max_metadata_bytes: u64,
pairing_eligibility: Option<PairingEligibilityService>,
}
impl ApprovalService {
@@ -55,6 +59,18 @@ impl ApprovalService {
.await
{
Ok(()) => {
if let Some(eligibility) = &self.pairing_eligibility {
if let Err(error) = eligibility
.activate_after_completed_transfer(
&remote_endpoint_id,
&receipt.request_id,
&receipt.token,
)
.await
{
tracing::warn!(%error, "failed to activate pairing eligibility after delivery");
}
}
self.event_hub.emit_transfer(
receipt.transfer_id,
"send",
@@ -115,18 +131,22 @@ impl ApprovalService {
pub(crate) fn new(
repository: Repository,
blocked: BlockStore,
event_hub: Arc<EventHub>,
access_policy: Arc<AccessPolicy>,
max_pending: usize,
max_metadata_bytes: u64,
pairing_eligibility: Option<PairingEligibilityService>,
) -> Self {
Self {
repository,
blocked,
event_hub,
access_policy,
pending: Arc::new(Mutex::new(HashMap::new())),
max_pending,
max_metadata_bytes,
pairing_eligibility,
}
}
@@ -162,6 +182,17 @@ impl ApprovalService {
remote_endpoint_id: String,
request: RequestTransfer,
) -> HandshakeResponse {
if self
.blocked
.is_blocked(&remote_endpoint_id)
.await
.unwrap_or(true)
{
// Indistinguishable from other refusals so probing cannot detect blocks.
return self
.deny(request.transfer_id, remote_endpoint_id, "not-accepted")
.await;
}
let metadata_values = [
request.transfer_hash.as_str(),
request.transfer_name.as_str(),
@@ -198,10 +229,15 @@ impl ApprovalService {
.await
{
Ok(true) => {
// An existing access session means this endpoint was already
// authorised: either the share is public, or the sender pushed
// this transfer to them. Prompting again would ask the sender
// to approve a transfer they themselves initiated.
if self
.access_policy
.allows_without_approval(request.transfer_id)
.decide(request.transfer_id, Some(&remote_endpoint_id))
.await
== AccessDecision::Allow
{
self.allow_without_sender_decision(remote_endpoint_id, request)
.await

View File

@@ -0,0 +1,68 @@
//! Identity-wide deny list for saved-device and invitation traffic.
use anyhow::Result;
use sqlx::{Row, SqlitePool};
pub(crate) async fn ensure_schema(pool: &SqlitePool) -> Result<()> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS blocked_endpoints (
endpoint_id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL
);
"#,
)
.execute(pool)
.await?;
Ok(())
}
/// Durable deny records for one app-data profile.
#[derive(Debug, Clone)]
pub(crate) struct BlockStore {
pool: SqlitePool,
}
impl BlockStore {
pub(crate) fn new(pool: SqlitePool) -> Self {
Self { pool }
}
pub(crate) async fn block_endpoint(&self, endpoint_id: &str, now_ms: i64) -> Result<()> {
sqlx::query(
"INSERT INTO blocked_endpoints (endpoint_id, created_at) VALUES (?1, ?2)
ON CONFLICT(endpoint_id) DO NOTHING",
)
.bind(endpoint_id)
.bind(now_ms)
.execute(&self.pool)
.await?;
Ok(())
}
pub(crate) async fn unblock_endpoint(&self, endpoint_id: &str) -> Result<()> {
sqlx::query("DELETE FROM blocked_endpoints WHERE endpoint_id = ?1")
.bind(endpoint_id)
.execute(&self.pool)
.await?;
Ok(())
}
pub(crate) async fn is_blocked(&self, endpoint_id: &str) -> Result<bool> {
let row =
sqlx::query("SELECT EXISTS(SELECT 1 FROM blocked_endpoints WHERE endpoint_id = ?1)")
.bind(endpoint_id)
.fetch_one(&self.pool)
.await?;
Ok(row.get::<i64, _>(0) == 1)
}
pub(crate) async fn list_blocked(&self) -> Result<Vec<String>> {
let rows =
sqlx::query("SELECT endpoint_id FROM blocked_endpoints ORDER BY created_at DESC")
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(|row| row.get(0)).collect())
}
}

View File

@@ -0,0 +1,280 @@
//! Saved-device control-plane hardening.
//!
//! Bounds hostile / noisy peers without imposing quotas on transfers the
//! receiver has already accepted.
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use data_encoding::HEXLOWER;
use serde_json::{Map, Value};
use crate::util::now_ms;
/// Per-identity quiet period after declines or repeated malformed traffic.
#[derive(Clone)]
pub(crate) struct IdentityCooldown {
inner: Arc<Mutex<CooldownInner>>,
cooldown_ms: i64,
strike_limit: u32,
}
#[derive(Default)]
struct CooldownInner {
until: HashMap<String, i64>,
strikes: HashMap<String, u32>,
}
impl IdentityCooldown {
pub(crate) fn new(cooldown_ms: u64, strike_limit: u64) -> Self {
Self {
inner: Arc::new(Mutex::new(CooldownInner::default())),
cooldown_ms: cooldown_ms as i64,
strike_limit: strike_limit as u32,
}
}
pub(crate) fn is_cooling(&self, identity: &str) -> bool {
let now = now_ms();
let mut state = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.until.retain(|_, until| *until > now);
state.until.contains_key(identity)
}
pub(crate) fn record_decline(&self, identity: &str) {
let until = now_ms() + self.cooldown_ms;
let mut state = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.until.insert(identity.to_string(), until);
state.strikes.remove(identity);
}
/// Count a malformed / spoofed / ineligible control-plane message.
///
/// Trips cooldown once the strike limit is reached. Returns whether the
/// identity is now cooling (including an already-active cooldown).
pub(crate) fn record_malformed(&self, identity: &str) -> bool {
if self.is_cooling(identity) {
return true;
}
let mut state = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let strikes = state.strikes.entry(identity.to_string()).or_insert(0);
*strikes = strikes.saturating_add(1);
if *strikes >= self.strike_limit {
state
.until
.insert(identity.to_string(), now_ms() + self.cooldown_ms);
state.strikes.remove(identity);
return true;
}
false
}
pub(crate) fn clear_strikes(&self, identity: &str) {
let mut state = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.strikes.remove(identity);
}
}
const REDACTED: &str = "[redacted]";
/// Keys whose values must never appear in production events / diagnostics.
fn is_sensitive_key(key: &str) -> bool {
matches!(
key,
"endpoint_id"
| "peer_endpoint_id"
| "sender_endpoint_id"
| "receiver_endpoint_id"
| "from_endpoint_id"
| "remote_endpoint_id"
| "local_endpoint_id"
| "ticket"
| "blob_ticket"
| "authorization"
| "capability"
| "secret"
| "grant"
| "grant_id"
| "proof"
| "mac"
| "filename"
| "file_name"
| "path"
| "display_name"
| "transfer_name"
| "sender_display_name"
| "remote_display_name"
| "address"
| "addrs"
| "relay_url"
| "relay_urls"
)
}
/// Stable fingerprint so diagnostics can correlate without leaking raw values.
pub(crate) fn fingerprint(value: &str) -> String {
let digest = blake3::hash(value.as_bytes());
let hex = HEXLOWER.encode(digest.as_bytes());
format!("<redacted:{}>", &hex[..8])
}
pub(crate) fn redact_json(value: Value) -> Value {
match value {
Value::Object(map) => Value::Object(redact_object(map)),
Value::Array(items) => Value::Array(items.into_iter().map(redact_json).collect()),
other => other,
}
}
fn redact_object(map: Map<String, Value>) -> Map<String, Value> {
map.into_iter()
.map(|(key, value)| {
if is_sensitive_key(&key) {
let redacted = match value {
Value::String(raw) if !raw.is_empty() => Value::String(fingerprint(&raw)),
Value::Null => Value::Null,
_ => Value::String(REDACTED.to_string()),
};
(key, redacted)
} else {
(key, redact_json(value))
}
})
.collect()
}
/// Scrub ticket-like and long opaque blobs from free-form error / log text.
pub(crate) fn redact_text(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut rest = input;
while let Some(idx) = rest.find("vnd1:") {
out.push_str(&rest[..idx]);
out.push_str(REDACTED);
rest = &rest[idx + 5..];
// Skip the remainder of the ticket token (non-whitespace).
let end = rest
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'')
.unwrap_or(rest.len());
rest = &rest[end..];
}
out.push_str(rest);
// Collapse long hex runs that look like endpoint ids / grant material.
collapse_long_hex(&out)
}
fn collapse_long_hex(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i].is_ascii_hexdigit() {
let start = i;
while i < bytes.len() && bytes[i].is_ascii_hexdigit() {
i += 1;
}
let len = i - start;
if len >= 32 {
out.push_str(REDACTED);
} else {
out.push_str(&input[start..i]);
}
} else {
out.push(bytes[i] as char);
i += 1;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// Documented non-enforcement: accepted transfers have no per-device quota.
const ACCEPTED_TRANSFER_QUOTA_FIELDS: &[&str] = &[
"max_per_device_files",
"max_per_device_bytes",
"max_per_device_bandwidth",
"max_per_device_transfers",
];
#[test]
fn cooldown_trips_after_strike_limit_and_isolates_identities() {
let guard = IdentityCooldown::new(60_000, 3);
assert!(!guard.record_malformed("a"));
assert!(!guard.record_malformed("a"));
assert!(guard.record_malformed("a"));
assert!(guard.is_cooling("a"));
assert!(!guard.is_cooling("b"));
guard.record_decline("b");
assert!(guard.is_cooling("b"));
assert!(guard.is_cooling("a"));
}
#[test]
fn redaction_scrubs_sensitive_event_fields() {
let raw = json!({
"transfer_id": "ok-to-keep",
"sender_endpoint_id": "abc123endpointid000000000000000000000000000000000000000000000000",
"transfer_name": "secret.pdf",
"ticket": "vnd1:deadbeef",
"file_count": 2,
"nested": { "capability": [1, 2, 3], "state": "saved" }
});
let redacted = redact_json(raw);
let obj = redacted.as_object().unwrap();
assert_eq!(obj.get("transfer_id").unwrap(), "ok-to-keep");
assert_eq!(obj.get("file_count").unwrap(), 2);
let sender = obj.get("sender_endpoint_id").unwrap().as_str().unwrap();
assert!(sender.starts_with("<redacted:"));
assert!(!sender.contains("abc123"));
let name = obj.get("transfer_name").unwrap().as_str().unwrap();
assert!(name.starts_with("<redacted:"));
assert!(!name.contains("secret"));
assert!(obj
.get("ticket")
.unwrap()
.as_str()
.unwrap()
.starts_with("<redacted:"));
let nested = obj.get("nested").unwrap().as_object().unwrap();
assert_eq!(nested.get("capability").unwrap(), REDACTED);
assert_eq!(nested.get("state").unwrap(), "saved");
}
#[test]
fn redact_text_strips_tickets_and_long_hex() {
let text = "ticket vnd1:abcDEF123 and id 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let scrubbed = redact_text(text);
assert!(!scrubbed.contains("vnd1:"));
assert!(!scrubbed.contains("0123456789abcdef"));
assert!(scrubbed.contains(REDACTED));
}
#[test]
fn accepted_transfer_quota_fields_are_not_core_limits() {
// Control-plane hardening must not invent per-device accepted-transfer quotas.
let encoded = serde_json::to_string(&crate::api::CoreLimits::default()).unwrap();
for field in ACCEPTED_TRANSFER_QUOTA_FIELDS {
assert!(
!encoded.contains(field),
"CoreLimits must not enforce {field}"
);
}
}
}

View File

@@ -0,0 +1,134 @@
//! Relationship-grant possession proofs.
use crate::{
error::VnidropError,
grant::{Challenge, GrantId, GrantProof, GrantSecret},
secure_secret::SecretMaterial,
};
const RELATIONSHIP_GRANT_CONTEXT: &[u8] = b"vnidrop-relationship-grant-v1";
pub(super) fn encode_relationship_grant_secret(
secret: &GrantSecret,
) -> Result<SecretMaterial, VnidropError> {
// Custody stores only the 32-byte secret; issuer/holder/generation/protocol
// bindings live in the relationship row and are enforced at prove/verify time.
SecretMaterial::new(secret.as_bytes().to_vec())
}
pub(super) fn secret_from_material(material: &SecretMaterial) -> Result<GrantSecret, VnidropError> {
let bytes: [u8; 32] =
material
.to_vec()
.try_into()
.map_err(|_| VnidropError::SecureStorageCorrupted {
reason: "relationship grant secret has invalid length".to_string(),
})?;
Ok(GrantSecret::from_bytes(bytes))
}
pub(super) fn prove_relationship_grant(
grant_id: GrantId,
secret: &GrantSecret,
challenge: &Challenge,
issuer: &str,
holder: &str,
generation: u64,
protocol_version: u16,
) -> GrantProof {
let mac = relationship_mac(
secret,
challenge,
issuer,
holder,
generation,
protocol_version,
);
GrantProof::from_parts(grant_id, mac)
}
pub(super) fn verify_relationship_grant(
secret: &GrantSecret,
proof: &GrantProof,
challenge: &Challenge,
issuer: &str,
holder: &str,
generation: u64,
protocol_version: u16,
) -> Result<(), &'static str> {
let expected = relationship_mac(
secret,
challenge,
issuer,
holder,
generation,
protocol_version,
);
if blake3::Hash::from_bytes(expected) != blake3::Hash::from_bytes(*proof.mac()) {
return Err("bad relationship grant proof");
}
Ok(())
}
fn relationship_mac(
secret: &GrantSecret,
challenge: &Challenge,
issuer: &str,
holder: &str,
generation: u64,
protocol_version: u16,
) -> [u8; 32] {
let mut hasher = blake3::Hasher::new_keyed(secret.as_bytes());
hasher.update(RELATIONSHIP_GRANT_CONTEXT);
hasher.update(challenge.as_bytes());
hasher.update(&(issuer.len() as u64).to_le_bytes());
hasher.update(issuer.as_bytes());
hasher.update(&(holder.len() as u64).to_le_bytes());
hasher.update(holder.as_bytes());
hasher.update(&generation.to_le_bytes());
hasher.update(&protocol_version.to_le_bytes());
*hasher.finalize().as_bytes()
}
#[cfg(test)]
mod grant_vectors {
use super::*;
use crate::api::experimental_saved_device_capabilities;
use data_encoding::HEXLOWER;
#[test]
fn relationship_grant_proof_vectors_are_stable() {
let secret =
GrantSecret::decode("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
.unwrap();
let grant_id = GrantId::decode("0123456789abcdef0123456789abcdef").unwrap();
let challenge = Challenge::from_bytes([9u8; 32]);
let protocol = experimental_saved_device_capabilities().relationship_protocol_version;
let proof = prove_relationship_grant(
grant_id, &secret, &challenge, "issuer", "holder", 1, protocol,
);
// Binding and replay resistance: wrong holder or challenge must fail.
verify_relationship_grant(&secret, &proof, &challenge, "issuer", "holder", 1, protocol)
.unwrap();
let mac_hex = HEXLOWER.encode(proof.mac());
assert_eq!(
mac_hex,
"e6cc2641183b84fae9e3805761961d69e09d25a1f8ceeaeede952774ddd95d6b"
);
assert!(verify_relationship_grant(
&secret, &proof, &challenge, "issuer", "other", 1, protocol,
)
.is_err());
let other_challenge = Challenge::from_bytes([8u8; 32]);
assert!(verify_relationship_grant(
&secret,
&proof,
&other_challenge,
"issuer",
"holder",
1,
protocol,
)
.is_err());
}
}

View File

@@ -0,0 +1,243 @@
//! Forget, block, grant rotation, and minimal revocation tombstones.
use serde_json::json;
use super::{store::RelationshipRow, DeviceRelationshipService};
use crate::{
api::DeviceRelationshipState, error::VnidropError, grant::GrantRejection,
secure_secret::SecretHandle,
};
#[derive(Debug, Clone)]
pub(crate) struct ForgetOutcome {
pub(crate) had_relationship: bool,
pub(crate) generation: Option<u64>,
pub(crate) issued_grant_id: Option<String>,
}
impl DeviceRelationshipService {
/// Forget a saved (or pending) device: revoke locally first, clean secrets,
/// then the caller sends a best-effort remote notice. Invitation-domain
/// transfers are untouched.
pub(crate) async fn forget(
&self,
peer_endpoint_id: String,
) -> Result<ForgetOutcome, VnidropError> {
let peer_lock = self.lock_peer(&peer_endpoint_id).await;
let _guard = peer_lock.lock().await;
let Some(row) = self.find_row(&peer_endpoint_id).await? else {
self.eligibility.remove_for_peer(&peer_endpoint_id).await?;
return Ok(ForgetOutcome {
had_relationship: false,
generation: None,
issued_grant_id: None,
});
};
let issued_grant_id = row.issued_grant_id.clone();
let generation = row.generation;
self.tombstone_generation(&peer_endpoint_id, &row).await?;
self.delete_relationship(&peer_endpoint_id).await?;
self.eligibility.remove_for_peer(&peer_endpoint_id).await?;
self.emit_changed(&peer_endpoint_id, DeviceRelationshipState::Revoked);
drop(_guard);
Ok(ForgetOutcome {
had_relationship: true,
generation: Some(generation),
issued_grant_id,
})
}
/// Identity-wide block: revoke relationship grants, keep deny + tombstones.
/// Caller owns the durable deny record (`blocked_endpoints`).
pub(crate) async fn revoke_for_block(
&self,
peer_endpoint_id: &str,
) -> Result<(), VnidropError> {
let peer_lock = self.lock_peer(peer_endpoint_id).await;
let _guard = peer_lock.lock().await;
if let Some(row) = self.find_row(peer_endpoint_id).await? {
self.tombstone_generation(peer_endpoint_id, &row).await?;
self.delete_relationship(peer_endpoint_id).await?;
}
self.eligibility.remove_for_peer(peer_endpoint_id).await?;
self.emit_changed(peer_endpoint_id, DeviceRelationshipState::Blocked);
Ok(())
}
/// Activate a replacement grant: invalidate the prior generation first, then
/// mint exactly one new active generation for the issued direction.
pub(crate) async fn rotate_relationship_grant(
&self,
peer_endpoint_id: String,
) -> Result<u64, VnidropError> {
let peer_lock = self.lock_peer(&peer_endpoint_id).await;
let _guard = peer_lock.lock().await;
let Some(row) = self.find_row(&peer_endpoint_id).await? else {
return Err(VnidropError::invalid_input(anyhow::anyhow!(
"no relationship to rotate"
)));
};
if row.state != DeviceRelationshipState::Saved {
return Err(VnidropError::invalid_input(anyhow::anyhow!(
"only saved relationships can rotate grants"
)));
}
// Invalidate first: tombstone + secret removal before the new generation
// becomes active, so a concurrent presenter cannot race past revocation.
self.tombstone_generation(&peer_endpoint_id, &row).await?;
self.clear_grant_secrets(&row).await?;
let new_generation = row.generation.saturating_add(1);
self.store
.begin_grant_rotation(&peer_endpoint_id, new_generation)
.await?;
let _wire = self
.mint_and_store_issued_grant(
&peer_endpoint_id,
new_generation,
row.minimum_protocol_version,
)
.await?;
self.event_hub.emit_endpoint(
"pairing",
"relationship-grant-rotated",
json!({
"peer_endpoint_id": peer_endpoint_id,
"generation": new_generation,
}),
);
Ok(new_generation)
}
/// Reject a presented generation when it is tombstoned or not the active one.
pub(crate) async fn reject_replayed_generation(
&self,
peer_endpoint_id: &str,
generation: u64,
_grant_id: Option<&str>,
) -> Result<(), GrantRejection> {
if self
.find_tombstone(peer_endpoint_id, generation)
.await
.map_err(|_| GrantRejection::Unknown)?
.is_some()
{
return Err(GrantRejection::Revoked);
}
let Some(row) = self
.find_row(peer_endpoint_id)
.await
.map_err(|_| GrantRejection::Unknown)?
else {
return Err(GrantRejection::Unknown);
};
// Pending pairing and Saved both use the active row generation; only a
// mismatch (or tombstone above) means the presenter is replaying.
match row.state {
DeviceRelationshipState::PendingOutgoing
| DeviceRelationshipState::PendingIncoming
| DeviceRelationshipState::Saved => {
if row.generation != generation {
return Err(GrantRejection::Unknown);
}
Ok(())
}
DeviceRelationshipState::Revoked | DeviceRelationshipState::Blocked => {
Err(GrantRejection::Unknown)
}
}
}
#[cfg(test)]
pub(crate) async fn list_tombstones(
&self,
peer_endpoint_id: &str,
) -> Result<Vec<super::store::GenerationTombstone>, VnidropError> {
self.store.list_tombstones(peer_endpoint_id).await
}
#[cfg(test)]
pub(crate) async fn issued_grant_snapshot(
&self,
peer_endpoint_id: &str,
) -> Result<Option<(u64, String)>, VnidropError> {
let Some(row) = self.find_row(peer_endpoint_id).await? else {
return Ok(None);
};
let Some(grant_id) = row.issued_grant_id else {
return Ok(None);
};
Ok(Some((row.generation, grant_id)))
}
async fn tombstone_generation(
&self,
peer_endpoint_id: &str,
row: &RelationshipRow,
) -> Result<(), VnidropError> {
self.store.insert_tombstone(peer_endpoint_id, row).await
}
async fn find_tombstone(
&self,
peer_endpoint_id: &str,
generation: u64,
) -> Result<Option<super::store::GenerationTombstone>, VnidropError> {
self.store
.find_tombstone(peer_endpoint_id, generation)
.await
}
async fn clear_grant_secrets(&self, row: &RelationshipRow) -> Result<(), VnidropError> {
let Some(custody) = &self.custody else {
return Ok(());
};
for handle in [&row.issued_grant_handle, &row.held_grant_handle]
.into_iter()
.flatten()
{
let _ = custody
.remove(&SecretHandle::from_stored(handle.clone()))
.await;
}
Ok(())
}
/// Apply a best-effort remote revocation notice from a peer.
pub(crate) async fn handle_remote_revoke(
&self,
remote_endpoint_id: String,
generation: u64,
) -> bool {
let peer_lock = self.lock_peer(&remote_endpoint_id).await;
let _guard = peer_lock.lock().await;
let Ok(Some(row)) = self.find_row(&remote_endpoint_id).await else {
return true;
};
if row.generation != generation
&& generation != 0
&& self
.find_tombstone(&remote_endpoint_id, generation)
.await
.ok()
.flatten()
.is_some()
{
return true;
}
let _ = self.tombstone_generation(&remote_endpoint_id, &row).await;
let _ = self.delete_relationship(&remote_endpoint_id).await;
let _ = self.eligibility.remove_for_peer(&remote_endpoint_id).await;
self.emit_changed(&remote_endpoint_id, DeviceRelationshipState::Revoked);
true
}
}

View File

@@ -0,0 +1,16 @@
//! Experimental saved-device mutual-consent relationships.
//!
//! Pending outgoing/incoming states, directional grants bound to relationship
//! generation, and Saved only after mutual acknowledgement.
mod crypto;
mod lifecycle;
mod protocol;
mod service;
mod store;
pub(crate) use protocol::{RelationshipProtocol, WireProof};
pub(crate) use service::DeviceRelationshipService;
pub(crate) use store::DeviceRelationshipStore;
#[cfg(test)]
pub(crate) use store::GenerationTombstone;

View File

@@ -0,0 +1,226 @@
//! Iroh ALPN handler and client for mutual-consent pairing.
//!
//! Wire messages and transport live here; durable state and grant custody stay on
//! [`super::service::DeviceRelationshipService`].
use std::{fmt, sync::Arc};
use iroh::{
endpoint::Connection,
protocol::{AcceptError, ProtocolHandler},
Endpoint, EndpointAddr,
};
use irpc::{channel::oneshot, rpc_requests, Client, WithChannels};
use irpc_iroh::{read_request, IrohLazyRemoteConnection};
use serde::{Deserialize, Serialize};
use super::DeviceRelationshipService;
#[derive(Clone)]
pub(crate) struct RelationshipProtocol {
relationships: Arc<DeviceRelationshipService>,
}
impl RelationshipProtocol {
pub(crate) const ALPN: &'static [u8] = b"/vnidrop/relationship/1";
pub(crate) fn new(relationships: Arc<DeviceRelationshipService>) -> Self {
Self { relationships }
}
}
impl fmt::Debug for RelationshipProtocol {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("RelationshipProtocol")
}
}
impl ProtocolHandler for RelationshipProtocol {
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
let remote_endpoint_id = connection.remote_id().to_string();
while let Some(message) = read_request::<RelationshipMessages>(&connection).await? {
match message {
RelationshipMessage::PairingRequest(message) => {
let WithChannels { inner, tx, .. } = message;
let response = self
.relationships
.handle_pairing_request(remote_endpoint_id.clone(), inner)
.await;
let _ = tx.send(response).await;
}
RelationshipMessage::PairingConsent(message) => {
let WithChannels { inner, tx, .. } = message;
let response = self
.relationships
.handle_pairing_consent(remote_endpoint_id.clone(), inner)
.await;
let _ = tx.send(response).await;
}
RelationshipMessage::PairingAck(message) => {
let WithChannels { inner, tx, .. } = message;
let response = self
.relationships
.handle_pairing_ack(remote_endpoint_id.clone(), inner)
.await;
let _ = tx.send(response).await;
}
RelationshipMessage::RevokeNotice(message) => {
let WithChannels { inner, tx, .. } = message;
let acknowledged = self
.relationships
.handle_remote_revoke(remote_endpoint_id.clone(), inner.generation)
.await;
let response = if acknowledged {
RevokeNoticeResponse::Acknowledged
} else {
RevokeNoticeResponse::Rejected
};
let _ = tx.send(response).await;
}
}
}
connection.closed().await;
Ok(())
}
}
pub(super) struct RelationshipClient {
inner: Client<RelationshipMessages>,
}
impl RelationshipClient {
pub(super) fn connect(endpoint: Endpoint, addr: EndpointAddr) -> Self {
Self {
inner: Client::boxed(IrohLazyRemoteConnection::new(
endpoint,
addr,
RelationshipProtocol::ALPN.to_vec(),
)),
}
}
pub(super) async fn pairing_request(
&self,
request: PairingRequest,
) -> Result<PairingRequestResponse, irpc::Error> {
self.inner.rpc(request).await
}
pub(super) async fn pairing_consent(
&self,
consent: PairingConsent,
) -> Result<PairingConsentResponse, irpc::Error> {
self.inner.rpc(consent).await
}
pub(super) async fn pairing_ack(
&self,
ack: PairingAck,
) -> Result<PairingAckResponse, irpc::Error> {
self.inner.rpc(ack).await
}
pub(super) async fn revoke_notice(
&self,
notice: RevokeNotice,
) -> Result<RevokeNoticeResponse, irpc::Error> {
self.inner.rpc(notice).await
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PairingRequest {
pub(super) session_id: String,
pub(super) capability: Vec<u8>,
pub(super) protocol_version: u16,
pub(super) generation: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) enum PairingRequestResponse {
AwaitingConsent,
Merged,
AlreadySaved,
Rejected,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PairingConsent {
pub(super) accepted: bool,
pub(super) grant: Option<WireGrant>,
pub(super) challenge: Option<String>,
pub(super) generation: u64,
pub(super) protocol_version: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) enum PairingConsentResponse {
Completed {
grant: Box<WireGrant>,
possession_proof: WireProof,
ack_challenge: String,
},
AlreadySaved,
Rejected,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PairingAck {
pub(super) possession_proof: WireProof,
pub(super) challenge: String,
pub(super) generation: u64,
pub(super) protocol_version: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) enum PairingAckResponse {
Acknowledged,
AlreadySaved,
Rejected,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct WireGrant {
pub(super) grant_id: String,
pub(super) secret: String,
pub(super) issuer_endpoint_id: String,
pub(super) holder_endpoint_id: String,
pub(super) generation: u64,
pub(super) protocol_version: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct WireProof {
pub(crate) grant_id: String,
pub(crate) mac: String,
pub(crate) challenge: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct RevokeNotice {
pub(super) generation: u64,
pub(super) issued_grant_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) enum RevokeNoticeResponse {
Acknowledged,
Rejected,
}
#[rpc_requests(message = RelationshipMessage)]
#[derive(Debug, Serialize, Deserialize)]
#[allow(
clippy::enum_variant_names,
reason = "Pairing* names mirror the wire RPC surface"
)]
enum RelationshipMessages {
#[rpc(tx = oneshot::Sender<PairingRequestResponse>)]
PairingRequest(PairingRequest),
#[rpc(tx = oneshot::Sender<PairingConsentResponse>)]
PairingConsent(PairingConsent),
#[rpc(tx = oneshot::Sender<PairingAckResponse>)]
PairingAck(PairingAck),
#[rpc(tx = oneshot::Sender<RevokeNoticeResponse>)]
RevokeNotice(RevokeNotice),
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,574 @@
//! Durable device-relationship rows (schema + queries).
//!
//! Orchestration (custody, pairing RPC, events) stays on
//! [`super::DeviceRelationshipService`]; this store is the domain adapter held
//! in [`crate::persistence::AppDataStores`].
use sqlx::{Row, SqlitePool};
use crate::{
api::{DeviceRelationship, DeviceRelationshipState, SavedDevice},
error::VnidropError,
util::now_ms,
};
/// Minimal non-secret tombstone for a revoked relationship generation.
///
/// Retains only what is needed to reject replay: peer identity, generation,
/// opaque grant ids, and revocation time. No names, filenames, history, or
/// capability material.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct GenerationTombstone {
pub(crate) remote_endpoint_id: String,
pub(crate) generation: u64,
pub(crate) issued_grant_id: Option<String>,
pub(crate) held_grant_id: Option<String>,
pub(crate) revoked_at: i64,
}
#[derive(Debug, Clone)]
pub(super) struct RelationshipRow {
pub(super) state: DeviceRelationshipState,
pub(super) generation: u64,
pub(super) minimum_protocol_version: u16,
pub(super) session_id: Option<String>,
pub(super) issued_grant_handle: Option<String>,
pub(super) held_grant_handle: Option<String>,
pub(super) issued_grant_id: Option<String>,
pub(super) held_grant_id: Option<String>,
pub(super) created_at: i64,
}
/// Compact projection used by grant-secret reconcile.
#[derive(Debug, Clone)]
pub(super) struct ReconcileRow {
pub(super) remote_endpoint_id: String,
pub(super) state: DeviceRelationshipState,
pub(super) issued_grant_handle: Option<String>,
pub(super) held_grant_handle: Option<String>,
}
pub(super) struct RelationshipUpsert<'a> {
pub(super) remote_endpoint_id: &'a str,
pub(super) state: DeviceRelationshipState,
pub(super) generation: u64,
pub(super) minimum_protocol_version: u16,
pub(super) session_id: Option<&'a str>,
pub(super) issued_grant_handle: Option<&'a str>,
pub(super) held_grant_handle: Option<&'a str>,
pub(super) issued_grant_id: Option<&'a str>,
pub(super) held_grant_id: Option<&'a str>,
pub(super) peer_ack: bool,
pub(super) local_ack: bool,
pub(super) created_at: i64,
pub(super) updated_at: i64,
}
/// Domain store for `device_relationships` (+ generation tombstones).
#[derive(Clone)]
pub(crate) struct DeviceRelationshipStore {
pool: SqlitePool,
}
impl DeviceRelationshipStore {
pub(crate) fn new(pool: SqlitePool) -> Self {
Self { pool }
}
pub(crate) async fn ensure_schema(pool: &SqlitePool) -> anyhow::Result<()> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS device_relationships (
remote_endpoint_id TEXT PRIMARY KEY,
state TEXT NOT NULL,
generation INTEGER NOT NULL,
minimum_protocol_version INTEGER NOT NULL,
session_id TEXT,
issued_grant_handle TEXT,
held_grant_handle TEXT,
issued_grant_id TEXT,
held_grant_id TEXT,
peer_ack INTEGER NOT NULL DEFAULT 0,
local_ack INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
"#,
)
.execute(pool)
.await?;
let columns = sqlx::query("PRAGMA table_info(device_relationships)")
.fetch_all(pool)
.await?;
let has = |name: &str| columns.iter().any(|row| row.get::<String, _>(1) == name);
if !has("issued_grant_id") {
sqlx::query("ALTER TABLE device_relationships ADD COLUMN issued_grant_id TEXT")
.execute(pool)
.await?;
}
if !has("held_grant_id") {
sqlx::query("ALTER TABLE device_relationships ADD COLUMN held_grant_id TEXT")
.execute(pool)
.await?;
}
if !has("local_label") {
sqlx::query("ALTER TABLE device_relationships ADD COLUMN local_label TEXT")
.execute(pool)
.await?;
}
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS relationship_generation_tombstones (
remote_endpoint_id TEXT NOT NULL,
generation INTEGER NOT NULL,
issued_grant_id TEXT,
held_grant_id TEXT,
revoked_at INTEGER NOT NULL,
PRIMARY KEY (remote_endpoint_id, generation)
);
"#,
)
.execute(pool)
.await?;
Ok(())
}
pub(super) async fn count_active_slots(&self) -> Result<u64, VnidropError> {
let row = sqlx::query(
r#"
SELECT COUNT(*) AS n FROM device_relationships
WHERE state IN ('saved', 'pending_outgoing', 'pending_incoming')
"#,
)
.fetch_one(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(row.get::<i64, _>("n") as u64)
}
pub(super) async fn list_reconcile_rows(&self) -> Result<Vec<ReconcileRow>, VnidropError> {
let rows = sqlx::query(
r#"
SELECT remote_endpoint_id, issued_grant_handle, held_grant_handle, state
FROM device_relationships
"#,
)
.fetch_all(&self.pool)
.await
.map_err(VnidropError::repository)?;
rows.into_iter()
.map(|row| {
Ok(ReconcileRow {
remote_endpoint_id: row.get("remote_endpoint_id"),
state: parse_state(&row.get::<String, _>("state"))?,
issued_grant_handle: row.get("issued_grant_handle"),
held_grant_handle: row.get("held_grant_handle"),
})
})
.collect()
}
pub(super) async fn list(&self) -> Result<Vec<DeviceRelationship>, VnidropError> {
let rows = sqlx::query(
r#"
SELECT remote_endpoint_id, state, generation, minimum_protocol_version, created_at, updated_at
FROM device_relationships
ORDER BY updated_at DESC
"#,
)
.fetch_all(&self.pool)
.await
.map_err(VnidropError::repository)?;
rows.into_iter().map(row_to_relationship).collect()
}
pub(super) async fn list_saved_devices(&self) -> Result<Vec<SavedDevice>, VnidropError> {
let rows = sqlx::query(
r#"
SELECT remote_endpoint_id, local_label, created_at, updated_at
FROM device_relationships
WHERE state = 'saved'
ORDER BY updated_at DESC
"#,
)
.fetch_all(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(rows
.into_iter()
.map(|row| SavedDevice {
endpoint_id: row.get("remote_endpoint_id"),
local_label: row.get("local_label"),
remote_display_name: None,
created_at: row.get("created_at"),
last_authenticated_at: Some(row.get("updated_at")),
})
.collect())
}
pub(super) async fn set_saved_device_label(
&self,
peer_endpoint_id: &str,
label: Option<String>,
) -> Result<bool, VnidropError> {
let result = sqlx::query(
r#"
UPDATE device_relationships
SET local_label = ?2, updated_at = ?3
WHERE remote_endpoint_id = ?1 AND state = 'saved'
"#,
)
.bind(peer_endpoint_id)
.bind(label)
.bind(now_ms())
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(result.rows_affected() > 0)
}
pub(super) async fn set_issued_grant(
&self,
peer_endpoint_id: &str,
handle: &str,
grant_id: &str,
) -> Result<(), VnidropError> {
sqlx::query(
r#"
UPDATE device_relationships
SET issued_grant_handle = ?2, issued_grant_id = ?3, updated_at = ?4
WHERE remote_endpoint_id = ?1
"#,
)
.bind(peer_endpoint_id)
.bind(handle)
.bind(grant_id)
.bind(now_ms())
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(())
}
pub(super) async fn set_held_grant(
&self,
peer_endpoint_id: &str,
handle: &str,
grant_id: &str,
) -> Result<(), VnidropError> {
sqlx::query(
r#"
UPDATE device_relationships
SET held_grant_handle = ?2, held_grant_id = ?3, updated_at = ?4
WHERE remote_endpoint_id = ?1
"#,
)
.bind(peer_endpoint_id)
.bind(handle)
.bind(grant_id)
.bind(now_ms())
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(())
}
#[cfg(test)]
pub(super) async fn set_minimum_protocol_version(
&self,
peer_endpoint_id: &str,
minimum_protocol_version: u16,
) -> Result<(), VnidropError> {
sqlx::query(
"UPDATE device_relationships SET minimum_protocol_version = ?2, updated_at = ?3 WHERE remote_endpoint_id = ?1",
)
.bind(peer_endpoint_id)
.bind(i64::from(minimum_protocol_version))
.bind(now_ms())
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(())
}
pub(super) async fn set_acks(
&self,
peer_endpoint_id: &str,
local_ack: bool,
peer_ack: bool,
) -> Result<(), VnidropError> {
sqlx::query(
"UPDATE device_relationships SET local_ack = ?2, peer_ack = ?3, updated_at = ?4 WHERE remote_endpoint_id = ?1",
)
.bind(peer_endpoint_id)
.bind(i64::from(local_ack))
.bind(i64::from(peer_ack))
.bind(now_ms())
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(())
}
pub(super) async fn set_state(
&self,
peer_endpoint_id: &str,
state: DeviceRelationshipState,
) -> Result<(), VnidropError> {
sqlx::query(
"UPDATE device_relationships SET state = ?2, updated_at = ?3 WHERE remote_endpoint_id = ?1",
)
.bind(peer_endpoint_id)
.bind(state_as_str(state))
.bind(now_ms())
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(())
}
pub(super) async fn list_expired_pending_peers(
&self,
cutoff_ms: i64,
) -> Result<Vec<String>, VnidropError> {
let rows = sqlx::query(
r#"
SELECT remote_endpoint_id FROM device_relationships
WHERE state IN ('pending_outgoing', 'pending_incoming') AND updated_at < ?1
"#,
)
.bind(cutoff_ms)
.fetch_all(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(rows.into_iter().map(|row| row.get(0)).collect())
}
pub(super) async fn delete(&self, peer_endpoint_id: &str) -> Result<(), VnidropError> {
sqlx::query("DELETE FROM device_relationships WHERE remote_endpoint_id = ?1")
.bind(peer_endpoint_id)
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(())
}
pub(super) async fn find_row(
&self,
peer_endpoint_id: &str,
) -> Result<Option<RelationshipRow>, VnidropError> {
let row = sqlx::query(
r#"
SELECT remote_endpoint_id, state, generation, minimum_protocol_version, session_id,
issued_grant_handle, held_grant_handle, issued_grant_id, held_grant_id,
peer_ack, local_ack, created_at, updated_at
FROM device_relationships WHERE remote_endpoint_id = ?1
"#,
)
.bind(peer_endpoint_id)
.fetch_optional(&self.pool)
.await
.map_err(VnidropError::repository)?;
row.map(relationship_row_from_sql).transpose()
}
pub(super) async fn upsert(&self, entry: RelationshipUpsert<'_>) -> Result<(), VnidropError> {
sqlx::query(
r#"
INSERT INTO device_relationships (
remote_endpoint_id, state, generation, minimum_protocol_version, session_id,
issued_grant_handle, held_grant_handle, issued_grant_id, held_grant_id,
peer_ack, local_ack, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
ON CONFLICT(remote_endpoint_id) DO UPDATE SET
state = excluded.state,
generation = excluded.generation,
minimum_protocol_version = excluded.minimum_protocol_version,
session_id = excluded.session_id,
issued_grant_handle = COALESCE(excluded.issued_grant_handle, device_relationships.issued_grant_handle),
held_grant_handle = COALESCE(excluded.held_grant_handle, device_relationships.held_grant_handle),
issued_grant_id = COALESCE(excluded.issued_grant_id, device_relationships.issued_grant_id),
held_grant_id = COALESCE(excluded.held_grant_id, device_relationships.held_grant_id),
peer_ack = excluded.peer_ack,
local_ack = excluded.local_ack,
updated_at = excluded.updated_at
"#,
)
.bind(entry.remote_endpoint_id)
.bind(state_as_str(entry.state))
.bind(entry.generation as i64)
.bind(i64::from(entry.minimum_protocol_version))
.bind(entry.session_id)
.bind(entry.issued_grant_handle)
.bind(entry.held_grant_handle)
.bind(entry.issued_grant_id)
.bind(entry.held_grant_id)
.bind(i64::from(entry.peer_ack))
.bind(i64::from(entry.local_ack))
.bind(entry.created_at)
.bind(entry.updated_at)
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(())
}
/// Bump generation and clear grant columns after a prior generation was tombstoned.
pub(super) async fn begin_grant_rotation(
&self,
peer_endpoint_id: &str,
new_generation: u64,
) -> Result<(), VnidropError> {
sqlx::query(
r#"
UPDATE device_relationships
SET generation = ?2,
issued_grant_handle = NULL,
held_grant_handle = NULL,
issued_grant_id = NULL,
held_grant_id = NULL,
updated_at = ?3
WHERE remote_endpoint_id = ?1
"#,
)
.bind(peer_endpoint_id)
.bind(new_generation as i64)
.bind(now_ms())
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(())
}
pub(super) async fn insert_tombstone(
&self,
peer_endpoint_id: &str,
row: &RelationshipRow,
) -> Result<(), VnidropError> {
sqlx::query(
r#"
INSERT INTO relationship_generation_tombstones (
remote_endpoint_id, generation, issued_grant_id, held_grant_id, revoked_at
) VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(remote_endpoint_id, generation) DO UPDATE SET
issued_grant_id = COALESCE(excluded.issued_grant_id, relationship_generation_tombstones.issued_grant_id),
held_grant_id = COALESCE(excluded.held_grant_id, relationship_generation_tombstones.held_grant_id),
revoked_at = excluded.revoked_at
"#,
)
.bind(peer_endpoint_id)
.bind(row.generation as i64)
.bind(row.issued_grant_id.as_deref())
.bind(row.held_grant_id.as_deref())
.bind(now_ms())
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(())
}
pub(super) async fn find_tombstone(
&self,
peer_endpoint_id: &str,
generation: u64,
) -> Result<Option<GenerationTombstone>, VnidropError> {
let row = sqlx::query(
r#"
SELECT remote_endpoint_id, generation, issued_grant_id, held_grant_id, revoked_at
FROM relationship_generation_tombstones
WHERE remote_endpoint_id = ?1 AND generation = ?2
"#,
)
.bind(peer_endpoint_id)
.bind(generation as i64)
.fetch_optional(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(row.map(|row| GenerationTombstone {
remote_endpoint_id: row.get("remote_endpoint_id"),
generation: row.get::<i64, _>("generation") as u64,
issued_grant_id: row.get("issued_grant_id"),
held_grant_id: row.get("held_grant_id"),
revoked_at: row.get("revoked_at"),
}))
}
#[cfg(test)]
pub(crate) async fn list_tombstones(
&self,
peer_endpoint_id: &str,
) -> Result<Vec<GenerationTombstone>, VnidropError> {
let rows = sqlx::query(
r#"
SELECT remote_endpoint_id, generation, issued_grant_id, held_grant_id, revoked_at
FROM relationship_generation_tombstones
WHERE remote_endpoint_id = ?1
ORDER BY generation ASC
"#,
)
.bind(peer_endpoint_id)
.fetch_all(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(rows
.into_iter()
.map(|row| GenerationTombstone {
remote_endpoint_id: row.get("remote_endpoint_id"),
generation: row.get::<i64, _>("generation") as u64,
issued_grant_id: row.get("issued_grant_id"),
held_grant_id: row.get("held_grant_id"),
revoked_at: row.get("revoked_at"),
})
.collect())
}
}
pub(super) fn state_as_str(state: DeviceRelationshipState) -> &'static str {
match state {
DeviceRelationshipState::PendingOutgoing => "pending_outgoing",
DeviceRelationshipState::PendingIncoming => "pending_incoming",
DeviceRelationshipState::Saved => "saved",
DeviceRelationshipState::Revoked => "revoked",
DeviceRelationshipState::Blocked => "blocked",
}
}
fn parse_state(value: &str) -> Result<DeviceRelationshipState, VnidropError> {
match value {
"pending_outgoing" => Ok(DeviceRelationshipState::PendingOutgoing),
"pending_incoming" => Ok(DeviceRelationshipState::PendingIncoming),
"saved" => Ok(DeviceRelationshipState::Saved),
"revoked" => Ok(DeviceRelationshipState::Revoked),
"blocked" => Ok(DeviceRelationshipState::Blocked),
_ => Err(VnidropError::Internal {
reason: "unknown device relationship state".to_string(),
}),
}
}
fn row_to_relationship(row: sqlx::sqlite::SqliteRow) -> Result<DeviceRelationship, VnidropError> {
Ok(DeviceRelationship {
remote_endpoint_id: row.get("remote_endpoint_id"),
state: parse_state(&row.get::<String, _>("state"))?,
generation: row.get::<i64, _>("generation") as u64,
minimum_protocol_version: row.get::<i64, _>("minimum_protocol_version") as u16,
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
})
}
fn relationship_row_from_sql(
row: sqlx::sqlite::SqliteRow,
) -> Result<RelationshipRow, VnidropError> {
Ok(RelationshipRow {
state: parse_state(&row.get::<String, _>("state"))?,
generation: row.get::<i64, _>("generation") as u64,
minimum_protocol_version: row.get::<i64, _>("minimum_protocol_version") as u16,
session_id: row.get("session_id"),
issued_grant_handle: row.get("issued_grant_handle"),
held_grant_handle: row.get("held_grant_handle"),
issued_grant_id: row.get("issued_grant_id"),
held_grant_id: row.get("held_grant_id"),
created_at: row.get("created_at"),
})
}

View File

@@ -16,6 +16,14 @@ pub enum VnidropError {
StorageFull { reason: String },
#[error("network error: {reason}")]
Network { reason: String },
#[error("device unavailable: {reason}")]
DeviceUnavailable { reason: String },
#[error("offer timed out: {reason}")]
OfferTimeout { reason: String },
#[error("relay policy incompatible: {reason}")]
RelayPolicyIncompatible { reason: String },
#[error("protocol incompatible: {reason}")]
ProtocolIncompatible { reason: String },
#[error("transfer error: {reason}")]
Transfer { reason: String },
#[error("permission error: {reason}")]
@@ -26,20 +34,28 @@ pub enum VnidropError {
Cancelled { reason: String },
#[error("invalid input: {reason}")]
InvalidInput { reason: String },
#[error("invalid targeted transfer transition: {reason}")]
InvalidTransition { reason: String },
#[error("secure storage is locked: {reason}")]
SecureStorageLocked { reason: String },
#[error("secure storage item is missing: {reason}")]
SecureStorageMissing { reason: String },
#[error("secure storage item is corrupted: {reason}")]
SecureStorageCorrupted { reason: String },
#[error("secure storage is unavailable: {reason}")]
SecureStorageUnavailable { reason: String },
#[error("internal error: {reason}")]
Internal { reason: String },
}
impl VnidropError {
pub(crate) fn initialization(error: impl Into<anyhow::Error>) -> Self {
Self::Initialization {
reason: error.into().to_string(),
}
Self::from_error(error.into(), |reason| Self::Initialization { reason })
}
pub(crate) fn ticket(error: impl Into<anyhow::Error>) -> Self {
Self::Ticket {
reason: error.into().to_string(),
reason: crate::control_plane::redact_text(&error.into().to_string()),
}
}
@@ -52,6 +68,24 @@ impl VnidropError {
Self::from_error(error.into(), |reason| Self::Network { reason })
}
pub(crate) fn device_unavailable(error: impl Into<anyhow::Error>) -> Self {
Self::from_error(error.into(), |reason| Self::DeviceUnavailable { reason })
}
pub(crate) fn offer_timeout(error: impl Into<anyhow::Error>) -> Self {
Self::from_error(error.into(), |reason| Self::OfferTimeout { reason })
}
pub(crate) fn relay_policy_incompatible(error: impl Into<anyhow::Error>) -> Self {
Self::from_error(error.into(), |reason| Self::RelayPolicyIncompatible {
reason,
})
}
pub(crate) fn protocol_incompatible(error: impl Into<anyhow::Error>) -> Self {
Self::from_error(error.into(), |reason| Self::ProtocolIncompatible { reason })
}
pub(crate) fn transfer(error: impl Into<anyhow::Error>) -> Self {
let error = error.into();
Self::classify(error, |reason| Self::Transfer { reason })
@@ -88,11 +122,20 @@ impl VnidropError {
Self::DestinationExists { .. } => "destination_exists",
Self::StorageFull { .. } => "storage_full",
Self::Network { .. } => "network",
Self::DeviceUnavailable { .. } => "device_unavailable",
Self::OfferTimeout { .. } => "offer_timeout",
Self::RelayPolicyIncompatible { .. } => "relay_policy_incompatible",
Self::ProtocolIncompatible { .. } => "protocol_incompatible",
Self::Transfer { .. } => "transfer",
Self::Permission { .. } => "permission_denied",
Self::Repository { .. } => "repository",
Self::Cancelled { .. } => "cancelled",
Self::InvalidInput { .. } => "invalid_input",
Self::InvalidTransition { .. } => "invalid_transition",
Self::SecureStorageLocked { .. } => "secure_storage_locked",
Self::SecureStorageMissing { .. } => "secure_storage_missing",
Self::SecureStorageCorrupted { .. } => "secure_storage_corrupted",
Self::SecureStorageUnavailable { .. } => "secure_storage_unavailable",
Self::Internal { .. } => "internal",
}
}
@@ -106,17 +149,26 @@ impl VnidropError {
| Self::DestinationExists { reason }
| Self::StorageFull { reason }
| Self::Network { reason }
| Self::DeviceUnavailable { reason }
| Self::OfferTimeout { reason }
| Self::RelayPolicyIncompatible { reason }
| Self::ProtocolIncompatible { reason }
| Self::Transfer { reason }
| Self::Permission { reason }
| Self::Repository { reason }
| Self::Cancelled { reason }
| Self::InvalidInput { reason }
| Self::InvalidTransition { reason }
| Self::SecureStorageLocked { reason }
| Self::SecureStorageMissing { reason }
| Self::SecureStorageCorrupted { reason }
| Self::SecureStorageUnavailable { reason }
| Self::Internal { reason } => reason,
}
}
fn classify(error: anyhow::Error, fallback: impl FnOnce(String) -> Self) -> Self {
let reason = error.to_string();
let reason = crate::control_plane::redact_text(&error.to_string());
if let Some(existing) = error.chain().find_map(|cause| cause.downcast_ref::<Self>()) {
return existing.with_reason(reason);
}
@@ -141,7 +193,7 @@ impl VnidropError {
}
fn from_error(error: anyhow::Error, fallback: impl FnOnce(String) -> Self) -> Self {
let reason = error.to_string();
let reason = crate::control_plane::redact_text(&error.to_string());
if let Some(existing) = error.chain().find_map(|cause| cause.downcast_ref::<Self>()) {
existing.with_reason(reason)
} else {
@@ -158,11 +210,20 @@ impl VnidropError {
Self::DestinationExists { .. } => Self::DestinationExists { reason },
Self::StorageFull { .. } => Self::StorageFull { reason },
Self::Network { .. } => Self::Network { reason },
Self::DeviceUnavailable { .. } => Self::DeviceUnavailable { reason },
Self::OfferTimeout { .. } => Self::OfferTimeout { reason },
Self::RelayPolicyIncompatible { .. } => Self::RelayPolicyIncompatible { reason },
Self::ProtocolIncompatible { .. } => Self::ProtocolIncompatible { reason },
Self::Transfer { .. } => Self::Transfer { reason },
Self::Permission { .. } => Self::Permission { reason },
Self::Repository { .. } => Self::Repository { reason },
Self::Cancelled { .. } => Self::Cancelled { reason },
Self::InvalidInput { .. } => Self::InvalidInput { reason },
Self::InvalidTransition { .. } => Self::InvalidTransition { reason },
Self::SecureStorageLocked { .. } => Self::SecureStorageLocked { reason },
Self::SecureStorageMissing { .. } => Self::SecureStorageMissing { reason },
Self::SecureStorageCorrupted { .. } => Self::SecureStorageCorrupted { reason },
Self::SecureStorageUnavailable { .. } => Self::SecureStorageUnavailable { reason },
Self::Internal { .. } => Self::Internal { reason },
}
}

View File

@@ -8,7 +8,8 @@ use tokio::{
use crate::{
api::{CoreEvent, CoreEventSink},
repository::Repository,
control_plane::redact_json,
invitation::Repository,
transfer_state::TransferDirection,
util::now_ms,
};
@@ -47,6 +48,10 @@ enum EventPhase {
Transfer,
/// Delivery receipts from receivers (completed download acknowledgements).
Delivery,
/// Saved-device pairing eligibility and consent prompts.
Pairing,
/// Saved-device targeted-transfer pre-approval prompts.
TargetedTransfer,
}
impl EventPhase {
@@ -68,6 +73,8 @@ impl EventPhase {
"approval" => Some(Self::Approval),
"transfer" => Some(Self::Transfer),
"delivery" => Some(Self::Delivery),
"pairing" => Some(Self::Pairing),
"targeted_transfer" => Some(Self::TargetedTransfer),
_ => None,
}
}
@@ -90,6 +97,8 @@ impl EventPhase {
Self::Approval => "approval",
Self::Transfer => "transfer",
Self::Delivery => "delivery",
Self::Pairing => "pairing",
Self::TargetedTransfer => "targeted_transfer",
}
}
}
@@ -230,22 +239,26 @@ impl EventHub {
.sequence
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let id = format!("{timestamp}-{}", *sequence);
*sequence += 1;
let revision = *sequence;
let id = format!("{timestamp}-{revision}");
*sequence = sequence.saturating_add(1);
drop(sequence);
// Compose observes this event synchronously, while SQLite persistence is
// serialized through the queue. That keeps the UI responsive without
// losing the ability to flush persisted history during shutdown/tests.
// Production diagnostics redact endpoint ids, tickets, grants, and paths;
// typed UniFFI list APIs still expose the values the UI needs.
let event = CoreEvent {
id,
revision,
timestamp,
scope: scope.as_str().to_string(),
transfer_id,
direction: direction.map(|direction| direction.as_str().to_string()),
phase: phase.as_str().to_string(),
kind: kind.0,
data_json: data.to_string(),
data_json: redact_json(data).to_string(),
};
if let Err(error) = self.tx.try_send(EventCommand::Persist(event.clone())) {
tracing::warn!(event_id = %event.id, %error, "event persistence queue dropped event");

181
crates/vnidrop/src/grant.rs Normal file
View File

@@ -0,0 +1,181 @@
//! Grant identity and possession-proof primitives for saved-device relationships.
//!
//! The issuer is the only party that can validate a grant, which is what makes
//! both consent and revocation enforceable. This module is pure: no storage and
//! no network. Relationship-bound MACs live in `device_relationship::crypto`.
use std::fmt;
use anyhow::{Context, Result};
use data_encoding::HEXLOWER;
use serde::{Deserialize, Serialize};
const GRANT_ID_LEN: usize = 16;
const GRANT_SECRET_LEN: usize = 32;
const CHALLENGE_LEN: usize = 32;
const PROOF_LEN: usize = 32;
/// Opaque public identifier for a grant. Safe to send in the clear.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub(crate) struct GrantId([u8; GRANT_ID_LEN]);
impl GrantId {
pub(crate) fn generate() -> Self {
Self(random_bytes())
}
pub(crate) fn encode(&self) -> String {
HEXLOWER.encode(&self.0)
}
pub(crate) fn decode(value: &str) -> Result<Self> {
let bytes = HEXLOWER
.decode(value.as_bytes())
.context("invalid grant id encoding")?;
let bytes: [u8; GRANT_ID_LEN] = bytes
.try_into()
.map_err(|_| anyhow::anyhow!("invalid grant id length"))?;
Ok(Self(bytes))
}
}
impl fmt::Debug for GrantId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "GrantId({})", self.encode())
}
}
/// Key material. Never logged, never emitted in an event, never returned across
/// the UniFFI boundary.
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct GrantSecret([u8; GRANT_SECRET_LEN]);
impl GrantSecret {
pub(crate) fn generate() -> Self {
Self(random_bytes())
}
pub(crate) fn as_bytes(&self) -> &[u8; GRANT_SECRET_LEN] {
&self.0
}
pub(crate) fn from_bytes(bytes: [u8; GRANT_SECRET_LEN]) -> Self {
Self(bytes)
}
pub(crate) fn encode(&self) -> String {
HEXLOWER.encode(&self.0)
}
pub(crate) fn decode(value: &str) -> Result<Self> {
let bytes = HEXLOWER
.decode(value.as_bytes())
.context("invalid grant secret encoding")?;
let bytes: [u8; GRANT_SECRET_LEN] = bytes
.try_into()
.map_err(|_| anyhow::anyhow!("invalid grant secret length"))?;
Ok(Self(bytes))
}
}
// Redacted on purpose: a secret must not reach a log line through a derived
// Debug on some enclosing struct.
impl fmt::Debug for GrantSecret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("GrantSecret(redacted)")
}
}
/// Random challenge sent by the issuer to bind a proof to one connection.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct Challenge([u8; CHALLENGE_LEN]);
impl Challenge {
pub(crate) fn generate() -> Self {
Self(random_bytes())
}
pub(crate) fn as_bytes(&self) -> &[u8; CHALLENGE_LEN] {
&self.0
}
#[cfg(test)]
pub(crate) fn from_bytes(bytes: [u8; CHALLENGE_LEN]) -> Self {
Self(bytes)
}
pub(crate) fn encode(&self) -> String {
HEXLOWER.encode(&self.0)
}
pub(crate) fn decode(value: &str) -> Result<Self> {
let bytes = HEXLOWER
.decode(value.as_bytes())
.context("invalid challenge encoding")?;
let bytes: [u8; CHALLENGE_LEN] = bytes
.try_into()
.map_err(|_| anyhow::anyhow!("invalid challenge length"))?;
Ok(Self(bytes))
}
}
impl fmt::Debug for Challenge {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Challenge(..)")
}
}
/// Proof that the sender holds the secret behind `grant_id`.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct GrantProof {
pub(crate) grant_id: GrantId,
mac: [u8; PROOF_LEN],
}
impl GrantProof {
pub(crate) fn from_parts(grant_id: GrantId, mac: [u8; PROOF_LEN]) -> Self {
Self { grant_id, mac }
}
pub(crate) fn mac(&self) -> &[u8; PROOF_LEN] {
&self.mac
}
}
impl fmt::Debug for GrantProof {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("GrantProof")
.field("grant_id", &self.grant_id)
.finish_non_exhaustive()
}
}
/// Why a presented proof was not accepted.
///
/// `Revoked` is reported to the peer so its client can drop the dead entry.
/// `Unknown` is deliberately also used for blocked endpoints, so blocking
/// cannot be detected by probing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum GrantRejection {
Unknown,
Revoked,
}
impl GrantRejection {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Unknown => "unknown",
Self::Revoked => "revoked",
}
}
}
/// Cryptographically secure random bytes.
///
/// Panics if the OS entropy source fails. That is unrecoverable and must never
/// degrade into a weak grant, so it is not surfaced as a fallible API.
fn random_bytes<const N: usize>() -> [u8; N] {
let mut bytes = [0u8; N];
getrandom::fill(&mut bytes).expect("OS entropy source unavailable");
bytes
}

View File

@@ -1,4 +1,11 @@
use std::{path::Path, str::FromStr};
//! Invitation-transfer domain store (history, approvals, delivery receipts).
//!
//! This is the invitation half of [`crate::persistence::AppDataStores`]. It owns
//! only invitation-transfer tables — not relationships, eligibility, blocks, or
//! secret metadata (those have their own domain stores).
#[cfg(test)]
use std::path::Path;
#[cfg(test)]
use std::sync::{
@@ -7,10 +14,7 @@ use std::sync::{
};
use anyhow::{Context, Result};
use sqlx::{
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
Row, SqlitePool,
};
use sqlx::{Row, SqlitePool};
use uuid::Uuid;
use crate::{
@@ -20,8 +24,11 @@ use crate::{
util::now_ms,
};
const SCHEMA_VERSION: i64 = 7;
const SCHEMA_VERSION: i64 = 13;
/// Invitation-transfer durable store (history, receiver requests, receipts, events).
///
/// Type name kept for call-site stability; module path is [`crate::invitation`].
#[derive(Debug, Clone)]
pub(crate) struct Repository {
pool: SqlitePool,
@@ -99,29 +106,26 @@ pub(crate) struct PendingDeliveryReceiptInsert<'a> {
}
impl Repository {
pub(crate) async fn open(app_data_dir: &Path) -> Result<Self> {
let db_path = app_data_dir.join("vnidrop.sqlite3");
let options = SqliteConnectOptions::from_str("sqlite://")?
.filename(db_path)
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(4)
.connect_with(options)
.await?;
let repository = Self {
pub(crate) fn from_pool(pool: SqlitePool) -> Self {
Self {
pool,
#[cfg(test)]
fail_next_write: Arc::new(AtomicBool::new(false)),
#[cfg(test)]
fail_receive_history_after_dependants: Arc::new(AtomicBool::new(false)),
};
repository.ensure_schema().await?;
Ok(repository)
}
}
async fn ensure_schema(&self) -> Result<()> {
// The app owns this SQLite file. Keep migrations explicit so future
// desktop/mobile releases can move user history forward in place.
/// Test/helper entry: opens [`AppDataStores`] and returns the invitation store.
#[cfg(test)]
pub(crate) async fn open(app_data_dir: &Path) -> Result<Self> {
Ok(crate::persistence::open_all(app_data_dir).await?.invitation)
}
pub(crate) async fn ensure_schema(&self) -> Result<()> {
// Invitation-transfer tables only. Other domains apply schema from
// [`crate::persistence::open_all`]. Keep migrations explicit so releases
// can move invitation history forward in place.
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS transfers (
@@ -221,6 +225,7 @@ impl Repository {
r#"
CREATE TABLE IF NOT EXISTS transfer_events (
id TEXT PRIMARY KEY,
revision INTEGER NOT NULL DEFAULT 0,
timestamp INTEGER NOT NULL,
scope TEXT NOT NULL,
transfer_id INTEGER,
@@ -234,6 +239,20 @@ impl Repository {
.execute(&self.pool)
.await?;
let event_columns = sqlx::query("PRAGMA table_info(transfer_events)")
.fetch_all(&self.pool)
.await?;
if !event_columns
.iter()
.any(|row| row.get::<String, _>(1) == "revision")
{
sqlx::query(
"ALTER TABLE transfer_events ADD COLUMN revision INTEGER NOT NULL DEFAULT 0",
)
.execute(&self.pool)
.await?;
}
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_transfer_events_transfer_id ON transfer_events(transfer_id, timestamp);",
)
@@ -786,12 +805,13 @@ impl Repository {
sqlx::query(
r#"
INSERT OR REPLACE INTO transfer_events (
id, timestamp, scope, transfer_id, direction, phase, kind, data_json
id, revision, timestamp, scope, transfer_id, direction, phase, kind, data_json
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9);
"#,
)
.bind(&event.id)
.bind(to_db_id(event.revision)?)
.bind(event.timestamp)
.bind(&event.scope)
.bind(event.transfer_id.map(to_db_id).transpose()?)
@@ -1135,10 +1155,10 @@ impl Repository {
let rows = if let Some(transfer_id) = transfer_id {
sqlx::query(
r#"
SELECT id, timestamp, scope, transfer_id, direction, phase, kind, data_json
SELECT id, revision, timestamp, scope, transfer_id, direction, phase, kind, data_json
FROM transfer_events
WHERE transfer_id = ?1
ORDER BY timestamp ASC
ORDER BY timestamp ASC, revision ASC
LIMIT ?2
"#,
)
@@ -1149,9 +1169,9 @@ impl Repository {
} else {
sqlx::query(
r#"
SELECT id, timestamp, scope, transfer_id, direction, phase, kind, data_json
SELECT id, revision, timestamp, scope, transfer_id, direction, phase, kind, data_json
FROM transfer_events
ORDER BY timestamp DESC
ORDER BY timestamp DESC, revision DESC
LIMIT ?1
"#,
)
@@ -1218,6 +1238,7 @@ fn row_to_transfer(row: sqlx::sqlite::SqliteRow) -> Result<StoredTransfer> {
fn row_to_event(row: sqlx::sqlite::SqliteRow) -> CoreEvent {
CoreEvent {
id: row.get("id"),
revision: row.get::<i64, _>("revision") as u64,
timestamp: row.get("timestamp"),
scope: row.get("scope"),
transfer_id: row

View File

@@ -1,24 +1,40 @@
mod access_policy;
mod api;
mod approval;
mod blocked_devices;
mod control_plane;
mod device_relationship;
mod error;
mod event_hub;
mod filesystem;
mod grant;
mod handshake;
mod invitation;
mod logging;
mod repository;
mod pairing_eligibility;
mod persistence;
mod runtime;
mod secret;
#[allow(
dead_code,
reason = "the private custody seam is activated by platform credential adapters"
)]
mod secure_secret;
mod targeted_transfer;
mod ticket;
mod transfer_state;
mod util;
pub use api::{
clear_inactive_transfer_cache, default_core_limits, default_core_network_config, CoreEvent,
CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, PublishedOutput,
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest,
RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer,
TicketInspection, TransferAccessMode, TransferMetadata,
clear_inactive_transfer_cache, default_core_limits, default_core_network_config,
experimental_saved_device_capabilities, CoreEvent, CoreEventSink, CoreLimits,
CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, DeviceRelationship,
DeviceRelationshipState, ExperimentalSavedDeviceCapabilities, PairingEligibilitySummary,
PendingTargetedOffer, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2,
ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus, SavedDevice,
ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer,
TargetedOfferResponse, TargetedTransfer, TargetedTransferState, TicketInspection,
TransferAccessMode, TransferMetadata,
};
pub use error::VnidropError;
pub use runtime::VnidropCore;

View File

@@ -0,0 +1,375 @@
//! Pairing eligibility after completed authenticated invitation transfers.
//!
//! The capability is derived from the shared approval session token and becomes
//! usable only after the transfer reaches a durable completed state. Public APIs
//! expose eligibility state, never the capability bytes.
use std::collections::HashSet;
use std::sync::Arc;
use serde_json::json;
mod store;
pub(crate) use store::PairingEligibilityStore;
use crate::{
api::{experimental_saved_device_capabilities, PairingEligibilitySummary},
error::VnidropError,
event_hub::EventHub,
secure_secret::{SecretCustody, SecretHandle, SecretKind, SecretMaterial},
util::now_ms,
};
const ELIGIBILITY_TTL_MS: i64 = 24 * 60 * 60 * 1_000;
const CAPABILITY_CONTEXT: &str = "vnidrop-pairing-eligibility-v1";
#[derive(Clone)]
pub(crate) struct PairingEligibilityService {
store: PairingEligibilityStore,
custody: Option<Arc<SecretCustody>>,
event_hub: Arc<EventHub>,
local_endpoint_id: String,
}
impl PairingEligibilityService {
pub(crate) fn new(
store: PairingEligibilityStore,
custody: Option<Arc<SecretCustody>>,
event_hub: Arc<EventHub>,
local_endpoint_id: String,
) -> Self {
Self {
store,
custody,
event_hub,
local_endpoint_id,
}
}
/// Removes orphaned eligibility secrets and rows whose secrets are missing.
pub(crate) async fn reconcile(&self) -> Result<(), VnidropError> {
let records = self.store.list_records().await?;
let mut referenced = HashSet::new();
for entry in records {
referenced.insert(entry.secret_handle.clone());
let Some(custody) = &self.custody else {
continue;
};
let handle = SecretHandle::from_stored(entry.secret_handle.clone());
if custody.load(&handle).await.is_err() {
self.delete_entry_silent(&entry).await?;
}
}
if let Some(custody) = &self.custody {
for handle in custody
.list_active_handles(SecretKind::PairingEligibility)
.await?
{
if !referenced.contains(handle.as_str()) {
let _ = custody.remove(&handle).await;
}
}
}
self.expire_due(true).await
}
pub(crate) async fn list(&self) -> Result<Vec<PairingEligibilitySummary>, VnidropError> {
self.expire_due(true).await?;
self.store.list_summaries().await
}
/// Activates eligibility after a durable completed authenticated transfer.
pub(crate) async fn activate_after_completed_transfer(
&self,
peer_endpoint_id: &str,
session_id: &str,
approval_token: &str,
) -> Result<(), VnidropError> {
let Some(custody) = &self.custody else {
return Ok(());
};
if peer_endpoint_id.is_empty() || session_id.is_empty() || approval_token.is_empty() {
return Ok(());
}
if self.store.find_by_session(session_id).await?.is_some() {
return Ok(());
}
let protocol_version =
experimental_saved_device_capabilities().relationship_protocol_version;
let capability = derive_capability(
approval_token,
&self.local_endpoint_id,
peer_endpoint_id,
session_id,
protocol_version,
)?;
// Credential custody already stages then activates the secret. Domain
// metadata is written only after that verify; a crash leaves an orphan
// secret that reconcile() removes on the next start.
let handle = custody
.protect(SecretKind::PairingEligibility, capability, None)
.await?;
let created_at = now_ms();
let expires_at = created_at + ELIGIBILITY_TTL_MS;
if let Err(error) = self
.store
.insert(PairingEligibilityInsert {
peer_endpoint_id,
session_id,
protocol_version,
secret_handle: handle.as_str(),
created_at,
expires_at,
})
.await
{
let _ = custody.remove(&handle).await;
return Err(error);
}
self.event_hub.emit_endpoint(
"pairing",
"eligibility-available",
json!({
"peer_endpoint_id": peer_endpoint_id,
"session_id": session_id,
"protocol_version": protocol_version,
"expires_at": expires_at,
}),
);
Ok(())
}
/// Starts a local pairing attempt when eligibility exists.
///
/// Returns `false` when eligibility is missing/expired (silent reject). A
/// successful start consumes the single-use eligibility for that session.
#[allow(
dead_code,
reason = "retained for eligibility-only callers; mutual consent uses take_eligibility"
)]
pub(crate) async fn request_pairing(
&self,
peer_endpoint_id: &str,
) -> Result<bool, VnidropError> {
Ok(self.take_eligibility(peer_endpoint_id).await?.is_some())
}
/// Takes and consumes eligibility for a peer, returning the capability material.
pub(crate) async fn take_eligibility(
&self,
peer_endpoint_id: &str,
) -> Result<Option<TakenEligibility>, VnidropError> {
self.expire_due(true).await?;
let entries = self.store.list_for_peer(peer_endpoint_id).await?;
let Some(entry) = entries.into_iter().next() else {
return Ok(None);
};
if entry.expires_at <= now_ms() {
self.delete_entry_silent(&entry).await?;
return Ok(None);
}
let Some(custody) = &self.custody else {
self.delete_entry_silent(&entry).await?;
return Ok(None);
};
let capability = match custody
.load(&SecretHandle::from_stored(entry.secret_handle.clone()))
.await
{
Ok(material) => material,
Err(_) => {
self.delete_entry_silent(&entry).await?;
return Ok(None);
}
};
self.delete_entry(&entry).await?;
Ok(Some(TakenEligibility {
session_id: entry.session_id,
protocol_version: entry.protocol_version,
capability,
}))
}
/// Validates an inbound eligibility presentation without prompts or events on failure.
#[allow(
dead_code,
reason = "inbound pairing wire acceptance lands with mutual-consent ticket 08"
)]
pub(crate) async fn accept_presented_eligibility(
&self,
peer_endpoint_id: &str,
session_id: &str,
capability: &SecretMaterial,
) -> Result<bool, VnidropError> {
let Some(entry) = self
.validate_presented_capability(peer_endpoint_id, session_id, capability)
.await?
else {
return Ok(false);
};
self.delete_entry(&entry).await?;
Ok(true)
}
/// Consumes eligibility for one session without requiring the capability bytes.
pub(crate) async fn consume_session(
&self,
peer_endpoint_id: &str,
session_id: &str,
) -> Result<(), VnidropError> {
if let Some(entry) = self.store.find_by_session(session_id).await? {
if entry.peer_endpoint_id == peer_endpoint_id {
self.delete_entry(&entry).await?;
}
}
Ok(())
}
pub(crate) async fn decline(&self, peer_endpoint_id: &str) -> Result<(), VnidropError> {
self.remove_for_peer(peer_endpoint_id).await
}
pub(crate) async fn remove_for_peer(&self, peer_endpoint_id: &str) -> Result<(), VnidropError> {
let entries = self.store.list_for_peer(peer_endpoint_id).await?;
for entry in entries {
self.delete_entry(&entry).await?;
}
Ok(())
}
/// Returns the matching record when the capability is valid; otherwise `None`
/// without emitting prompts or eligibility-removed events for the reject path.
#[allow(
dead_code,
reason = "inbound pairing wire acceptance lands with mutual-consent ticket 08"
)]
pub(crate) async fn validate_presented_capability(
&self,
peer_endpoint_id: &str,
session_id: &str,
capability: &SecretMaterial,
) -> Result<Option<PairingEligibilityRecord>, VnidropError> {
self.expire_due(false).await?;
let Some(custody) = &self.custody else {
return Ok(None);
};
let Some(entry) = self.store.find_by_session(session_id).await? else {
return Ok(None);
};
if entry.peer_endpoint_id != peer_endpoint_id || entry.expires_at <= now_ms() {
return Ok(None);
}
let stored = match custody
.load(&SecretHandle::from_stored(entry.secret_handle.clone()))
.await
{
Ok(material) => material,
Err(_) => return Ok(None),
};
if stored == *capability {
Ok(Some(entry))
} else {
Ok(None)
}
}
async fn expire_due(&self, emit_events: bool) -> Result<(), VnidropError> {
let now = now_ms();
let expired = self.store.list_expired(now).await?;
for entry in expired {
if emit_events {
self.delete_entry(&entry).await?;
} else {
self.delete_entry_silent(&entry).await?;
}
}
Ok(())
}
async fn delete_entry(&self, entry: &PairingEligibilityRecord) -> Result<(), VnidropError> {
self.delete_entry_silent(entry).await?;
self.event_hub.emit_endpoint(
"pairing",
"eligibility-removed",
json!({
"peer_endpoint_id": entry.peer_endpoint_id,
"session_id": entry.session_id,
}),
);
Ok(())
}
#[cfg(test)]
pub(crate) async fn force_expiry_for_test(
&self,
session_id: &str,
expires_at: i64,
) -> Result<(), VnidropError> {
self.store
.force_expiry_for_test(session_id, expires_at)
.await
}
async fn delete_entry_silent(
&self,
entry: &PairingEligibilityRecord,
) -> Result<(), VnidropError> {
if let Some(custody) = &self.custody {
let handle = SecretHandle::from_stored(entry.secret_handle.clone());
let _ = custody.remove(&handle).await;
}
self.store.delete(&entry.session_id).await
}
}
pub(crate) struct PairingEligibilityInsert<'a> {
pub(crate) peer_endpoint_id: &'a str,
pub(crate) session_id: &'a str,
pub(crate) protocol_version: u16,
pub(crate) secret_handle: &'a str,
pub(crate) created_at: i64,
pub(crate) expires_at: i64,
}
#[derive(Debug, Clone)]
pub(crate) struct TakenEligibility {
pub(crate) session_id: String,
pub(crate) protocol_version: u16,
pub(crate) capability: SecretMaterial,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PairingEligibilityRecord {
pub(crate) peer_endpoint_id: String,
pub(crate) session_id: String,
pub(crate) protocol_version: u16,
pub(crate) secret_handle: String,
pub(crate) created_at: i64,
pub(crate) expires_at: i64,
}
fn derive_capability(
approval_token: &str,
local_endpoint_id: &str,
peer_endpoint_id: &str,
session_id: &str,
protocol_version: u16,
) -> Result<SecretMaterial, VnidropError> {
let mut endpoints = [local_endpoint_id, peer_endpoint_id];
endpoints.sort_unstable();
let mut hasher = blake3::Hasher::new_derive_key(CAPABILITY_CONTEXT);
hasher.update(approval_token.as_bytes());
hasher.update(&[0]);
hasher.update(endpoints[0].as_bytes());
hasher.update(&[0]);
hasher.update(endpoints[1].as_bytes());
hasher.update(&[0]);
hasher.update(session_id.as_bytes());
hasher.update(&[0]);
hasher.update(&protocol_version.to_le_bytes());
let bytes = *hasher.finalize().as_bytes();
SecretMaterial::new(bytes.to_vec())
}

View File

@@ -0,0 +1,195 @@
//! Durable pairing-eligibility rows (schema + queries).
use sqlx::{Row, SqlitePool};
use crate::{api::PairingEligibilitySummary, error::VnidropError};
use super::{PairingEligibilityInsert, PairingEligibilityRecord};
/// Domain store for `pairing_eligibilities`.
#[derive(Clone)]
pub(crate) struct PairingEligibilityStore {
pool: SqlitePool,
}
impl PairingEligibilityStore {
pub(crate) fn new(pool: SqlitePool) -> Self {
Self { pool }
}
pub(crate) async fn ensure_schema(pool: &SqlitePool) -> anyhow::Result<()> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS pairing_eligibilities (
session_id TEXT PRIMARY KEY,
peer_endpoint_id TEXT NOT NULL,
protocol_version INTEGER NOT NULL,
secret_handle TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE INDEX IF NOT EXISTS pairing_eligibilities_peer
ON pairing_eligibilities(peer_endpoint_id);
"#,
)
.execute(pool)
.await?;
Ok(())
}
pub(crate) async fn insert(
&self,
entry: PairingEligibilityInsert<'_>,
) -> Result<(), VnidropError> {
sqlx::query(
r#"
INSERT INTO pairing_eligibilities (
session_id, peer_endpoint_id, protocol_version, secret_handle, created_at, expires_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
"#,
)
.bind(entry.session_id)
.bind(entry.peer_endpoint_id)
.bind(i64::from(entry.protocol_version))
.bind(entry.secret_handle)
.bind(entry.created_at)
.bind(entry.expires_at)
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(())
}
pub(crate) async fn list_summaries(
&self,
) -> Result<Vec<PairingEligibilitySummary>, VnidropError> {
let rows = sqlx::query(
r#"
SELECT peer_endpoint_id, session_id, protocol_version, created_at, expires_at
FROM pairing_eligibilities
ORDER BY created_at DESC
"#,
)
.fetch_all(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(rows
.into_iter()
.map(|row| PairingEligibilitySummary {
peer_endpoint_id: row.get("peer_endpoint_id"),
session_id: row.get("session_id"),
protocol_version: row.get::<i64, _>("protocol_version") as u16,
created_at: row.get("created_at"),
expires_at: row.get("expires_at"),
})
.collect())
}
pub(crate) async fn list_records(&self) -> Result<Vec<PairingEligibilityRecord>, VnidropError> {
let rows = sqlx::query(
r#"
SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at
FROM pairing_eligibilities
"#,
)
.fetch_all(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(rows.into_iter().map(row_to_record).collect())
}
pub(crate) async fn list_for_peer(
&self,
peer_endpoint_id: &str,
) -> Result<Vec<PairingEligibilityRecord>, VnidropError> {
let rows = sqlx::query(
r#"
SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at
FROM pairing_eligibilities
WHERE peer_endpoint_id = ?1
"#,
)
.bind(peer_endpoint_id)
.fetch_all(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(rows.into_iter().map(row_to_record).collect())
}
pub(crate) async fn list_expired(
&self,
now_ms: i64,
) -> Result<Vec<PairingEligibilityRecord>, VnidropError> {
let rows = sqlx::query(
r#"
SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at
FROM pairing_eligibilities
WHERE expires_at <= ?1
"#,
)
.bind(now_ms)
.fetch_all(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(rows.into_iter().map(row_to_record).collect())
}
pub(crate) async fn find_by_session(
&self,
session_id: &str,
) -> Result<Option<PairingEligibilityRecord>, VnidropError> {
let row = sqlx::query(
r#"
SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at
FROM pairing_eligibilities
WHERE session_id = ?1
"#,
)
.bind(session_id)
.fetch_optional(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(row.map(row_to_record))
}
pub(crate) async fn delete(&self, session_id: &str) -> Result<(), VnidropError> {
sqlx::query("DELETE FROM pairing_eligibilities WHERE session_id = ?1")
.bind(session_id)
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(())
}
#[cfg(test)]
pub(crate) async fn force_expiry_for_test(
&self,
session_id: &str,
expires_at: i64,
) -> Result<(), VnidropError> {
sqlx::query("UPDATE pairing_eligibilities SET expires_at = ?2 WHERE session_id = ?1")
.bind(session_id)
.bind(expires_at)
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(())
}
}
fn row_to_record(row: sqlx::sqlite::SqliteRow) -> PairingEligibilityRecord {
PairingEligibilityRecord {
peer_endpoint_id: row.get("peer_endpoint_id"),
session_id: row.get("session_id"),
protocol_version: row.get::<i64, _>("protocol_version") as u16,
secret_handle: row.get("secret_handle"),
created_at: row.get("created_at"),
expires_at: row.get("expires_at"),
}
}

View File

@@ -0,0 +1,72 @@
//! Persistence open: one SQLite pool, every domain schema, [`AppDataStores`].
//!
//! Runtime talks to domain stores — not a raw pool. Schema application for each
//! domain is owned here (not orchestrated from the invitation store).
use std::{path::Path, str::FromStr};
use anyhow::{Context, Result};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use crate::{
blocked_devices::{self, BlockStore},
device_relationship::DeviceRelationshipStore,
invitation::Repository,
pairing_eligibility::PairingEligibilityStore,
secure_secret::{self, SecretMetadataStore},
targeted_transfer::{self, TargetedTransferStore},
};
/// Concrete domain stores for one app-data profile.
#[derive(Clone)]
pub(crate) struct AppDataStores {
/// Invitation-transfer history and related invitation tables.
pub(crate) invitation: Repository,
/// Targeted-transfer durable rows.
pub(crate) targeted: TargetedTransferStore,
/// Mutual-consent device relationships (+ generation tombstones).
pub(crate) relationships: DeviceRelationshipStore,
/// Post-transfer pairing eligibility rows.
pub(crate) eligibility: PairingEligibilityStore,
/// Non-secret metadata for protected credential handles.
pub(crate) secrets: SecretMetadataStore,
/// Identity-wide deny list.
pub(crate) blocked: BlockStore,
}
/// Create the profile pool, apply all domain schemas, return [`AppDataStores`].
pub(crate) async fn open_all(app_data_dir: &Path) -> Result<AppDataStores> {
let db_path = app_data_dir.join("vnidrop.sqlite3");
let options = SqliteConnectOptions::from_str("sqlite://")?
.filename(db_path)
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(4)
.connect_with(options)
.await
.context("failed to open app data sqlite")?;
// Unreleased device-history prototype tables — no migration path.
for table in ["held_offers", "grants_held", "grants_issued", "contacts"] {
sqlx::query(&format!("DROP TABLE IF EXISTS {table}"))
.execute(&pool)
.await?;
}
let invitation = Repository::from_pool(pool.clone());
invitation.ensure_schema().await?;
blocked_devices::ensure_schema(&pool).await?;
secure_secret::ensure_schema(&pool).await?;
DeviceRelationshipStore::ensure_schema(&pool).await?;
targeted_transfer::ensure_schema(&pool).await?;
PairingEligibilityStore::ensure_schema(&pool).await?;
Ok(AppDataStores {
targeted: TargetedTransferStore::new(pool.clone()),
relationships: DeviceRelationshipStore::new(pool.clone()),
eligibility: PairingEligibilityStore::new(pool.clone()),
secrets: SecretMetadataStore::new(pool.clone()),
blocked: BlockStore::new(pool),
invitation,
})
}

View File

@@ -7,7 +7,7 @@ use crate::{
handshake::{
DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeService,
},
repository::PendingDeliveryReceipt,
invitation::PendingDeliveryReceipt,
ticket::parse_persisted_sender_address,
};

View File

@@ -3,20 +3,24 @@ use std::{future::Future, path::PathBuf, sync::Arc};
use anyhow::Context;
use serde_json::json;
use super::CoreInner;
use super::{CoreInner, IdentityMode};
use crate::{
api::{
CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreStorageUsage,
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceiverRequest, RuntimeStatus,
ShareMetadataInput, ShareResult, ShareSource, StoredTransfer, TicketInspection,
TransferAccessMode,
PairingEligibilitySummary, ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact,
ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource,
StoredTransfer, TicketInspection, TransferAccessMode,
},
error::VnidropError,
filesystem::platform_path,
secure_secret::{lock_profile, platform_secret_store},
ticket::parse_transfer_ticket_with_limits,
transfer_state::{TransferDirection, TransferStatus},
};
#[cfg(test)]
use crate::secure_secret::unlocked_profile_for_test;
#[derive(uniffi::Object)]
pub struct VnidropCore {
runtime: tokio::runtime::Runtime,
@@ -34,6 +38,171 @@ impl VnidropCore {
fn block_on<F: Future>(&self, future: F) -> F::Output {
self.runtime.handle().block_on(future)
}
fn initialize_with_identity_mode(
app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>,
limits: CoreLimits,
network_config: CoreNetworkConfig,
identity_mode: IdentityMode,
) -> Result<Arc<Self>, VnidropError> {
limits.validate().map_err(VnidropError::initialization)?;
let relay_urls = network_config
.validated_relay_urls()
.map_err(VnidropError::initialization)?;
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.thread_name("vnidrop")
.build()?;
let inner = runtime
.block_on(CoreInner::start(
PathBuf::from(app_data_dir),
event_sink,
limits,
network_config.mode,
relay_urls,
identity_mode,
))
.map_err(VnidropError::initialization)?;
Ok(Arc::new(Self { runtime, inner }))
}
}
#[cfg(test)]
impl VnidropCore {
/// Test-only protected identity with an injected secret store.
pub(crate) fn initialize_with_test_secret_store(
app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>,
store: Arc<dyn crate::secure_secret::SecureSecretStore>,
) -> Result<Arc<Self>, VnidropError> {
Self::initialize_with_test_secret_store_and_network(
app_data_dir,
event_sink,
store,
CoreNetworkConfig::default(),
)
}
pub(crate) fn initialize_with_test_secret_store_and_network(
app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>,
store: Arc<dyn crate::secure_secret::SecureSecretStore>,
network_config: CoreNetworkConfig,
) -> Result<Arc<Self>, VnidropError> {
Self::initialize_with_test_secret_store_limits_and_network(
app_data_dir,
event_sink,
store,
CoreLimits::default(),
network_config,
)
}
pub(crate) fn initialize_with_test_secret_store_limits_and_network(
app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>,
store: Arc<dyn crate::secure_secret::SecureSecretStore>,
limits: CoreLimits,
network_config: CoreNetworkConfig,
) -> Result<Arc<Self>, VnidropError> {
let app_data_path = PathBuf::from(&app_data_dir);
std::fs::create_dir_all(&app_data_path).map_err(VnidropError::filesystem)?;
// In-process restart tests reopen the same directory immediately after
// drop; skip exclusive locking and rely on the injected store instead.
let profile_lock = unlocked_profile_for_test(&app_data_path)?;
Self::initialize_with_identity_mode(
app_data_dir,
event_sink,
limits,
network_config,
IdentityMode::Protected {
store,
profile_lock,
},
)
}
pub(crate) fn force_pairing_eligibility_expiry_for_test(
&self,
session_id: String,
expires_at: i64,
) -> Result<(), VnidropError> {
self.block_on(
self.inner
.pairing_eligibility
.force_expiry_for_test(&session_id, expires_at),
)
}
pub(crate) fn submit_pairing_eligibility_for_test(
&self,
peer_endpoint_id: String,
session_id: String,
capability: Vec<u8>,
) -> Result<bool, VnidropError> {
self.block_on(self.inner.submit_pairing_eligibility_for_test(
peer_endpoint_id,
session_id,
capability,
))
}
pub(crate) fn relationship_issued_grant_for_test(
&self,
peer_endpoint_id: String,
) -> Result<Option<(u64, String)>, VnidropError> {
self.block_on(
self.inner
.device_relationships
.issued_grant_snapshot(&peer_endpoint_id),
)
}
pub(crate) fn relationship_tombstones_for_test(
&self,
peer_endpoint_id: String,
) -> Result<Vec<crate::device_relationship::GenerationTombstone>, VnidropError> {
self.block_on(
self.inner
.device_relationships
.list_tombstones(&peer_endpoint_id),
)
}
pub(crate) fn reject_relationship_generation_for_test(
&self,
peer_endpoint_id: String,
generation: u64,
grant_id: Option<String>,
) -> Result<(), String> {
self.block_on(async {
self.inner
.device_relationships
.reject_replayed_generation(&peer_endpoint_id, generation, grant_id.as_deref())
.await
.map_err(|rejection| rejection.as_str().to_string())
})
}
pub(crate) fn targeted_cancel_log_for_test(&self) -> Vec<String> {
self.inner.targeted_cancel_log_for_test()
}
pub(crate) fn force_relationship_protocol_floor_for_test(
&self,
peer_endpoint_id: String,
minimum_protocol_version: u16,
) -> Result<(), VnidropError> {
self.block_on(
self.inner
.device_relationships
.force_minimum_protocol_version_for_test(
&peer_endpoint_id,
minimum_protocol_version,
),
)
}
}
#[uniffi::export]
@@ -86,25 +255,39 @@ impl VnidropCore {
limits: CoreLimits,
network_config: CoreNetworkConfig,
) -> Result<Arc<Self>, VnidropError> {
limits.validate().map_err(VnidropError::initialization)?;
let relay_urls = network_config
.validated_relay_urls()
.map_err(VnidropError::initialization)?;
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.thread_name("vnidrop")
.build()?;
let app_data_dir = PathBuf::from(app_data_dir);
let inner = runtime
.block_on(CoreInner::start(
Self::initialize_with_identity_mode(
app_data_dir,
event_sink,
limits,
network_config.mode,
relay_urls,
))
.map_err(VnidropError::initialization)?;
Ok(Arc::new(Self { runtime, inner }))
network_config,
IdentityMode::Legacy,
)
}
/// Starts the experimental saved-device core with a platform-protected identity.
#[uniffi::constructor]
pub fn initialize_with_experimental_saved_devices(
app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>,
limits: CoreLimits,
network_config: CoreNetworkConfig,
) -> Result<Arc<Self>, VnidropError> {
let app_data_path = PathBuf::from(app_data_dir);
std::fs::create_dir_all(&app_data_path).map_err(VnidropError::filesystem)?;
let app_data_path =
std::fs::canonicalize(app_data_path).map_err(VnidropError::filesystem)?;
let profile_lock = lock_profile(&app_data_path)?;
let store = platform_secret_store(&app_data_path)?;
Self::initialize_with_identity_mode(
app_data_path.to_string_lossy().into_owned(),
event_sink,
limits,
network_config,
IdentityMode::Protected {
store,
profile_lock,
},
)
}
pub fn status(&self) -> RuntimeStatus {
@@ -271,6 +454,230 @@ impl VnidropCore {
.map_err(VnidropError::permission)
}
/// Single-use pairing windows created by completed authenticated transfers.
///
/// Returns eligibility state only — never the capability material.
pub fn list_pairing_eligibilities(
&self,
) -> Result<Vec<PairingEligibilitySummary>, VnidropError> {
self.block_on(self.inner.list_pairing_eligibilities())
}
/// Declines and removes pairing eligibility for a peer. Idempotent.
pub fn decline_pairing_eligibility(
&self,
peer_endpoint_id: String,
) -> Result<(), VnidropError> {
self.block_on(self.inner.decline_pairing_eligibility(peer_endpoint_id))
}
/// Initiates saved-device pairing when local eligibility exists.
///
/// Returns `false` when eligibility is missing or already consumed. Invalid
/// attempts produce no pairing prompt.
pub fn request_saved_device_pairing(
&self,
peer_endpoint_id: String,
) -> Result<bool, VnidropError> {
self.block_on(self.inner.request_saved_device_pairing(peer_endpoint_id))
}
pub fn list_device_relationships(
&self,
) -> Result<Vec<crate::api::DeviceRelationship>, VnidropError> {
self.block_on(self.inner.list_device_relationships())
}
pub fn list_saved_devices(&self) -> Result<Vec<crate::api::SavedDevice>, VnidropError> {
self.block_on(self.inner.list_saved_devices())
}
/// Sets the user-owned local label for a Saved device.
pub fn set_saved_device_label(
&self,
peer_endpoint_id: String,
label: Option<String>,
) -> Result<(), VnidropError> {
self.block_on(
self.inner
.device_relationships
.set_saved_device_label(peer_endpoint_id, label),
)
}
pub fn respond_to_device_pairing(
&self,
peer_endpoint_id: String,
accepted: bool,
) -> Result<bool, VnidropError> {
self.block_on(
self.inner
.respond_to_device_pairing(peer_endpoint_id, accepted),
)
}
/// Forget a saved device: revoke locally, clean secrets, cancel that
/// relationship's targeted transfers, and best-effort notify the peer.
pub fn forget_saved_device(&self, peer_endpoint_id: String) -> Result<(), VnidropError> {
self.block_on(self.inner.forget_saved_device(peer_endpoint_id))
}
/// Identity-wide deny across pairing, targeted transfer, invitation, and handshake.
pub fn block_device(&self, peer_endpoint_id: String) -> Result<(), VnidropError> {
self.block_on(self.inner.block_device(peer_endpoint_id))
}
/// Remove only the deny rule; does not restore grants or relationships.
pub fn unblock_device(&self, peer_endpoint_id: String) -> Result<(), VnidropError> {
self.block_on(self.inner.unblock_device(peer_endpoint_id))
}
pub fn list_blocked_devices(&self) -> Result<Vec<String>, VnidropError> {
self.block_on(self.inner.list_blocked_devices())
}
/// Invalidate the prior relationship generation, then activate a replacement grant.
pub fn rotate_relationship_grant(&self, peer_endpoint_id: String) -> Result<u64, VnidropError> {
self.block_on(self.inner.rotate_relationship_grant(peer_endpoint_id))
}
/// Create an immutable one-receiver transfer and submit its pre-approval offer.
///
/// Blocks until the saved receiver approves or declines. On approval the
/// receiver stores bound authorization locally via
/// [`Self::respond_to_targeted_offer`].
pub fn create_targeted_transfer(
&self,
receiver_endpoint_id: String,
sources: Vec<ShareSource>,
transfer_name: Option<String>,
) -> Result<crate::api::TargetedTransfer, VnidropError> {
self.block_on(self.inner.create_targeted_transfer(
receiver_endpoint_id,
sources,
transfer_name,
))
}
/// Offline-only pending offers awaiting explicit local approval.
pub fn list_pending_targeted_offers(&self) -> Vec<crate::api::PendingTargetedOffer> {
self.block_on(self.inner.list_pending_targeted_offers())
}
/// Approve or decline a pending targeted offer.
///
/// On approval, authorization stays in core custody; callers pull content
/// with [`Self::receive_targeted_transfer`] using the transfer id.
pub fn respond_to_targeted_offer(
&self,
transfer_id: String,
accepted: bool,
) -> Result<crate::api::TargetedOfferResponse, VnidropError> {
self.block_on(self.inner.respond_to_targeted_offer(transfer_id, accepted))
}
/// Pull an approved targeted transfer through existing output-sink machinery.
pub fn receive_targeted_transfer(
&self,
transfer_id: String,
output_dir: String,
) -> Result<(), VnidropError> {
self.block_on(
self.inner
.receive_targeted_transfer(transfer_id, output_dir),
)
}
pub fn receive_targeted_transfer_with_output_sink(
&self,
transfer_id: String,
output_sink: Arc<dyn ReceiveOutputSink>,
) -> Result<(), VnidropError> {
self.block_on(
self.inner
.receive_targeted_transfer_with_output_sink(transfer_id, output_sink),
)
}
pub fn receive_targeted_transfer_with_output_sink_v2(
&self,
transfer_id: String,
output_sink: Arc<dyn ReceiveOutputSinkV2>,
) -> Result<(), VnidropError> {
self.block_on(
self.inner
.receive_targeted_transfer_with_output_sink_v2(transfer_id, output_sink),
)
}
pub fn get_targeted_transfer(
&self,
id: String,
) -> Result<Option<crate::api::TargetedTransfer>, VnidropError> {
self.block_on(self.inner.get_targeted_transfer(id))
}
pub fn list_targeted_transfers(
&self,
) -> Result<Vec<crate::api::TargetedTransfer>, VnidropError> {
self.block_on(self.inner.list_targeted_transfers())
}
/// Withdraw an offer or revoke an approved transfer.
///
/// Stops active streaming synchronously before asynchronous cleanup.
pub fn cancel_targeted_transfer(&self, id: String) -> Result<(), VnidropError> {
if let Ok(Some(row)) = self.block_on(self.inner.targeted_store().get_row(&id)) {
let _ = self
.inner
.signal_targeted_transfer_cancel(row.protocol_transfer_id);
}
self.block_on(self.inner.cancel_targeted_transfer(id))
}
/// Durably remove authorization, resumable state, and content service.
///
/// Local denial is mandatory even when remote cleanup fails.
pub fn delete_targeted_transfer(&self, id: String) -> Result<(), VnidropError> {
if let Ok(Some(row)) = self.block_on(self.inner.targeted_store().get_row(&id)) {
let _ = self
.inner
.signal_targeted_transfer_cancel(row.protocol_transfer_id);
}
self.block_on(self.inner.delete_targeted_transfer(id))
}
/// Resume an approved/interrupted transfer without another approval.
pub fn resume_targeted_transfer(
&self,
id: String,
output_dir: String,
) -> Result<(), VnidropError> {
self.block_on(self.inner.resume_targeted_transfer(id, output_dir))
}
pub fn resume_targeted_transfer_with_output_sink(
&self,
id: String,
output_sink: Arc<dyn ReceiveOutputSink>,
) -> Result<(), VnidropError> {
self.block_on(
self.inner
.resume_targeted_transfer_with_output_sink(id, output_sink),
)
}
pub fn resume_targeted_transfer_with_output_sink_v2(
&self,
id: String,
output_sink: Arc<dyn ReceiveOutputSinkV2>,
) -> Result<(), VnidropError> {
self.block_on(
self.inner
.resume_targeted_transfer_with_output_sink_v2(id, output_sink),
)
}
pub fn list_transfers(&self) -> Result<Vec<StoredTransfer>, VnidropError> {
self.block_on(self.inner.repository.list_transfers())
.map_err(VnidropError::repository)

View File

@@ -179,7 +179,7 @@ impl CoreInner {
Ok(())
}
pub(super) async fn shutdown(&self) {
pub(crate) async fn shutdown(&self) {
if self.shutdown_started.swap(true, Ordering::SeqCst) {
return;
}

View File

@@ -6,14 +6,18 @@
//! - [`receive`] — ticket receive, download, export
//! - [`lifecycle`] — cancel/delete/shutdown/status/access
//! - [`provider`] — blob provider events and per-connection send progress
//! - [`saved_devices`] — experimental saved-device pairing, forget, block
//! - [`targeted`] — saved-device targeted transfers
mod delivery;
mod facade;
mod lifecycle;
mod provider;
mod receive;
mod saved_devices;
mod share;
mod storage;
mod targeted;
pub use facade::VnidropCore;
#[cfg(test)]
@@ -52,11 +56,15 @@ use crate::{
access_policy::{mode_from_storage, AccessPolicy},
api::{CoreEvent, CoreEventSink, CoreLimits, CoreRelayMode},
approval::ApprovalService,
device_relationship::{DeviceRelationshipService, RelationshipProtocol},
event_hub::EventHub,
handshake::HandshakeService,
invitation::Repository,
logging::init_logging,
repository::Repository,
pairing_eligibility::PairingEligibilityService,
secret::load_or_create_secret,
secure_secret::{start_endpoint_identity, ProfileLock, SecureSecretStore},
targeted_transfer::{TargetedOfferInbox, TargetedTransferProtocol},
ticket::ticket_matches_relay_profile,
transfer_state::{TransferDirection, TransferStatus},
};
@@ -88,8 +96,15 @@ pub(super) struct CoreInner {
pub(super) router: Router,
pub(super) store: FsStore,
pub(super) repository: Repository,
pub(super) targeted_transfers: crate::targeted_transfer::TargetedTransferStore,
pub(super) blocked_devices: crate::blocked_devices::BlockStore,
_profile_lock: Option<ProfileLock>,
pub(super) secret_custody: Option<Arc<crate::secure_secret::SecretCustody>>,
pub(super) event_hub: Arc<EventHub>,
pub(super) approval: ApprovalService,
pub(super) pairing_eligibility: PairingEligibilityService,
pub(super) device_relationships: Arc<DeviceRelationshipService>,
pub(super) targeted_offers: TargetedOfferInbox,
pub(super) limits: CoreLimits,
pub(super) relay_mode: CoreRelayMode,
pub(super) custom_relay_urls: Vec<RelayUrl>,
@@ -108,6 +123,9 @@ pub(super) struct CoreInner {
pub(super) delivery_receipt_notify: Notify,
pub(super) delivery_receipt_task: TokioMutex<Option<JoinHandle<()>>>,
pub(super) shutdown_started: AtomicBool,
/// Test-only log of peers passed to [`Self::cancel_targeted_transfers_for_peer`].
#[cfg(test)]
targeted_cancel_log: std::sync::Mutex<Vec<String>>,
}
pub(super) struct ActiveTransfer {
@@ -115,6 +133,14 @@ pub(super) struct ActiveTransfer {
pub(super) cancel: oneshot::Sender<()>,
}
pub(super) enum IdentityMode {
Legacy,
Protected {
store: Arc<dyn SecureSecretStore>,
profile_lock: ProfileLock,
},
}
impl CoreInner {
pub(super) async fn start(
app_data_dir: PathBuf,
@@ -122,11 +148,29 @@ impl CoreInner {
limits: CoreLimits,
relay_mode: CoreRelayMode,
relay_urls: Vec<RelayUrl>,
identity_mode: IdentityMode,
) -> Result<Arc<Self>> {
tokio::fs::create_dir_all(&app_data_dir).await?;
init_logging(&app_data_dir)?;
let secret_key = load_or_create_secret(&app_data_dir).await?;
let repository = Repository::open(&app_data_dir).await?;
let stores = crate::persistence::open_all(&app_data_dir).await?;
let repository = stores.invitation.clone();
let targeted_transfers = stores.targeted.clone();
let blocked_devices = stores.blocked.clone();
let (secret_key, secret_custody, profile_lock) = match identity_mode {
IdentityMode::Legacy => (load_or_create_secret(&app_data_dir).await?, None, None),
IdentityMode::Protected {
store,
profile_lock,
} => {
let (secret_key, custody) = start_endpoint_identity(
stores.secrets.clone(),
store,
&app_data_dir.join("iroh.secret"),
)
.await?;
(secret_key, Some(Arc::new(custody)), Some(profile_lock))
}
};
let store_root = app_data_dir.join("blobs");
let mut store_options = FsStoreOptions::new(&store_root);
store_options.gc = Some(GcConfig {
@@ -313,17 +357,64 @@ impl CoreInner {
store.tags().delete(name).await?;
}
}
let pairing_eligibility = PairingEligibilityService::new(
stores.eligibility.clone(),
secret_custody.clone(),
event_hub.clone(),
endpoint.id().to_string(),
);
let approval = ApprovalService::new(
repository.clone(),
blocked_devices.clone(),
event_hub.clone(),
access_policy.clone(),
limits.max_pending_approvals as usize,
limits.max_metadata_bytes,
Some(pairing_eligibility.clone()),
);
let handshake = HandshakeService::new(approval.clone());
let identity_cooldown = crate::control_plane::IdentityCooldown::new(
limits.identity_cooldown_ms,
limits.malformed_strike_limit,
);
let targeted_offers = TargetedOfferInbox::new(
event_hub.clone(),
limits.max_pending_offers as usize,
identity_cooldown,
limits.offer_timeout_ms,
);
let device_relationships = Arc::new(DeviceRelationshipService::new(
stores.relationships.clone(),
stores.blocked.clone(),
secret_custody.clone(),
pairing_eligibility.clone(),
event_hub.clone(),
endpoint.id().to_string(),
endpoint.clone(),
relay_mode,
relay_urls.clone(),
limits.max_saved_devices,
limits.pairing_timeout_ms,
));
let router = Router::builder(endpoint.clone())
.accept(iroh_blobs::ALPN, blobs)
.accept(HandshakeService::ALPN, handshake)
.accept(
RelationshipProtocol::ALPN,
RelationshipProtocol::new(device_relationships.clone()),
)
.accept(
TargetedTransferProtocol::ALPN,
TargetedTransferProtocol::new(
device_relationships.clone(),
targeted_offers.clone(),
targeted_transfers.clone(),
limits.clone(),
endpoint.id().to_string(),
relay_mode,
relay_urls.clone(),
),
)
.spawn();
let inner = Arc::new(Self {
@@ -332,8 +423,15 @@ impl CoreInner {
router,
store,
repository,
targeted_transfers,
blocked_devices,
_profile_lock: profile_lock,
secret_custody: secret_custody.clone(),
event_hub,
approval,
pairing_eligibility,
device_relationships,
targeted_offers,
relay_mode,
custom_relay_urls: relay_urls,
transfer_slots: Semaphore::new(limits.max_concurrent_transfers as usize),
@@ -347,8 +445,19 @@ impl CoreInner {
delivery_receipt_notify: Notify::new(),
delivery_receipt_task: TokioMutex::new(None),
shutdown_started: AtomicBool::new(false),
#[cfg(test)]
targeted_cancel_log: std::sync::Mutex::new(Vec::new()),
});
// In-flight connecting/transferring transfers become Interrupted across restart.
if let Err(error) = inner.targeted_store().mark_interrupted_in_flight().await {
tracing::warn!(%error, "failed to mark in-flight targeted transfers interrupted");
}
// Restore permanent receiver ACLs for approved targeted shares.
if let Err(error) = inner.restore_targeted_transfer_access().await {
tracing::warn!(%error, "failed to restore targeted transfer access");
}
inner.emit_endpoint(
"startup",
"endpoint-online",
@@ -360,6 +469,12 @@ impl CoreInner {
);
inner.spawn_provider_event_task(event_rx).await;
inner.spawn_delivery_receipt_task().await;
if let Err(error) = inner.pairing_eligibility.reconcile().await {
tracing::warn!(%error, "failed to reconcile pairing eligibility");
}
if let Err(error) = inner.device_relationships.reconcile().await {
tracing::warn!(%error, "failed to reconcile device relationships");
}
Ok(inner)
}
@@ -411,38 +526,7 @@ pub(crate) fn filter_peer_addr_for_relay_mode(
relay_mode: CoreRelayMode,
custom_relay_urls: &[RelayUrl],
) -> Result<EndpointAddr> {
match relay_mode {
CoreRelayMode::Automatic => Ok(addr.clone()),
CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => {
let mut filtered = EndpointAddr::new(addr.id);
for ip_addr in addr.ip_addrs().copied() {
filtered = filtered.with_ip_addr(ip_addr);
}
for relay_url in addr
.relay_urls()
.filter(|relay_url| custom_relay_urls.contains(relay_url))
.cloned()
{
filtered = filtered.with_relay_url(relay_url);
}
if filtered.is_empty() {
anyhow::bail!(
"invitation has no direct address or relay allowed by strict custom relay mode"
);
}
Ok(filtered)
}
CoreRelayMode::LocalOnly => {
let mut filtered = EndpointAddr::new(addr.id);
for ip_addr in addr.ip_addrs().copied() {
filtered = filtered.with_ip_addr(ip_addr);
}
if filtered.is_empty() {
anyhow::bail!("invitation has no direct address allowed by local-only mode");
}
Ok(filtered)
}
}
crate::ticket::filter_peer_addr_for_relay_mode(addr, relay_mode, custom_relay_urls)
}
pub(crate) async fn wait_for_relay(

View File

@@ -27,7 +27,7 @@ use crate::{
AtomicOutputFile,
},
handshake::{DeliveryReceipt, HandshakeResponse, HandshakeService},
repository::{PendingDeliveryReceiptInsert, ReceivedArtifactInsert, TransferUpsert},
invitation::{PendingDeliveryReceiptInsert, ReceivedArtifactInsert, TransferUpsert},
ticket::{
encode_persisted_sender_address, parse_transfer_ticket_with_limits, ParsedTransferTicket,
},
@@ -419,6 +419,18 @@ impl CoreInner {
})
.await
.map_err(VnidropError::repository)?;
let peer_endpoint_id = sender_addr.id.to_string();
if let Err(error) = self
.pairing_eligibility
.activate_after_completed_transfer(
&peer_endpoint_id,
&delivery_receipt.request_id,
&delivery_receipt.token,
)
.await
{
tracing::warn!(%error, "failed to activate pairing eligibility after receive");
}
pending_delivery_receipt
.lock()
.expect("pending_delivery_receipt")

View File

@@ -0,0 +1,167 @@
//! Runtime operations for experimental saved devices and device relationships.
use std::sync::Arc;
use anyhow::Result;
use serde_json::json;
use super::CoreInner;
use crate::{error::VnidropError, util::now_ms};
impl CoreInner {
pub(super) async fn list_pairing_eligibilities(
&self,
) -> Result<Vec<crate::api::PairingEligibilitySummary>, crate::error::VnidropError> {
self.pairing_eligibility.list().await
}
pub(super) async fn decline_pairing_eligibility(
&self,
peer_endpoint_id: String,
) -> Result<(), crate::error::VnidropError> {
self.pairing_eligibility.decline(&peer_endpoint_id).await
}
pub(super) async fn request_saved_device_pairing(
&self,
peer_endpoint_id: String,
) -> Result<bool, crate::error::VnidropError> {
self.device_relationships
.request_pairing(peer_endpoint_id)
.await
}
pub(super) async fn list_device_relationships(
&self,
) -> Result<Vec<crate::api::DeviceRelationship>, crate::error::VnidropError> {
self.device_relationships.list().await
}
pub(super) async fn list_saved_devices(
&self,
) -> Result<Vec<crate::api::SavedDevice>, crate::error::VnidropError> {
self.device_relationships.list_saved_devices().await
}
pub(super) async fn respond_to_device_pairing(
self: &Arc<Self>,
peer_endpoint_id: String,
accepted: bool,
) -> Result<bool, crate::error::VnidropError> {
if self
.blocked_devices
.is_blocked(&peer_endpoint_id)
.await
.unwrap_or(true)
{
return Ok(false);
}
self.device_relationships
.respond_to_pairing(peer_endpoint_id, accepted)
.await
}
pub(super) async fn forget_saved_device(
self: &Arc<Self>,
peer_endpoint_id: String,
) -> Result<(), crate::error::VnidropError> {
let outcome = self
.device_relationships
.forget(peer_endpoint_id.clone())
.await?;
// Targeted transfers for this relationship only.
// Invitation-domain shares are deliberately not cancelled here.
self.cancel_targeted_transfers_for_peer(&peer_endpoint_id)
.await?;
self.emit_endpoint(
"pairing",
"saved-device-forgotten",
json!({
"peer_endpoint_id": peer_endpoint_id,
"had_relationship": outcome.had_relationship,
}),
);
if outcome.had_relationship {
if let Some(generation) = outcome.generation {
self.device_relationships
.notify_remote_revoke(&peer_endpoint_id, generation, outcome.issued_grant_id)
.await;
}
}
Ok(())
}
pub(super) async fn block_device(
self: &Arc<Self>,
peer_endpoint_id: String,
) -> Result<(), crate::error::VnidropError> {
let now = now_ms();
self.blocked_devices
.block_endpoint(&peer_endpoint_id, now)
.await
.map_err(VnidropError::repository)?;
self.device_relationships
.revoke_for_block(&peer_endpoint_id)
.await?;
self.cancel_targeted_transfers_for_peer(&peer_endpoint_id)
.await?;
self.emit_endpoint(
"pairing",
"device-blocked",
json!({ "peer_endpoint_id": peer_endpoint_id }),
);
// Silence: blocked peers are not notified.
Ok(())
}
pub(super) async fn unblock_device(
&self,
peer_endpoint_id: String,
) -> Result<(), crate::error::VnidropError> {
self.blocked_devices
.unblock_endpoint(&peer_endpoint_id)
.await
.map_err(VnidropError::repository)?;
// Unblock removes only the deny rule; grants/relationships stay gone.
Ok(())
}
pub(super) async fn list_blocked_devices(
&self,
) -> Result<Vec<String>, crate::error::VnidropError> {
self.blocked_devices
.list_blocked()
.await
.map_err(VnidropError::repository)
}
pub(super) async fn rotate_relationship_grant(
&self,
peer_endpoint_id: String,
) -> Result<u64, crate::error::VnidropError> {
self.device_relationships
.rotate_relationship_grant(peer_endpoint_id)
.await
}
#[cfg(test)]
pub(super) fn targeted_cancel_log_for_test(&self) -> Vec<String> {
self.targeted_cancel_log
.lock()
.expect("targeted cancel log")
.clone()
}
#[cfg(test)]
pub(super) async fn submit_pairing_eligibility_for_test(
&self,
peer_endpoint_id: String,
session_id: String,
capability: Vec<u8>,
) -> Result<bool, crate::error::VnidropError> {
let material = crate::secure_secret::SecretMaterial::new(capability)?;
self.pairing_eligibility
.accept_presented_eligibility(&peer_endpoint_id, &session_id, &material)
.await
}
}

View File

@@ -22,7 +22,7 @@ use crate::{
collect_import_files_with_limits, default_collection_name,
read_stream_from_blocking_reader, TransferImport,
},
repository::TransferUpsert,
invitation::TransferUpsert,
ticket::VnidropTicket,
transfer_state::{TransferDirection, TransferStatus},
util::non_empty,

View File

@@ -0,0 +1,832 @@
//! Create, offer, approve, resume, cancel, and delete targeted transfers.
use std::sync::Arc;
use anyhow::{Context, Result};
use iroh_blobs::{ticket::BlobTicket, BlobFormat};
use uuid::Uuid;
use super::{receive::ReceiveTarget, CoreInner};
use crate::{
api::{
experimental_saved_device_capabilities, PendingTargetedOffer, ShareMetadataInput,
ShareSource, TargetedOfferResponse, TargetedTransfer, TargetedTransferState,
TransferAccessMode, TransferMetadata,
},
error::VnidropError,
secure_secret::{SecretHandle, SecretKind},
targeted_transfer::{
auth_secret_material,
protocol::{
map_offer_refuse_reason, CancelTargetedOffer, DeliverTargetedAuthorization,
SubmitTargetedOffer, TargetedTransferProtocol, WireOfferResponse,
},
reconstruct_authorization, TargetedAuthorization, TargetedAuthorizationDraft,
TargetedTransferRole, TargetedTransferRow,
},
ticket::VnidropTicket,
util::{non_empty, now_ms},
};
impl CoreInner {
fn connection_timeout(&self) -> std::time::Duration {
std::time::Duration::from_millis(self.limits.connection_timeout_ms)
}
fn offer_wait_timeout(&self) -> std::time::Duration {
std::time::Duration::from_millis(self.limits.offer_timeout_ms)
}
pub(super) fn targeted_store(&self) -> crate::targeted_transfer::TargetedTransferStore {
self.targeted_transfers.clone()
}
pub(super) async fn list_pending_targeted_offers(&self) -> Vec<PendingTargetedOffer> {
self.targeted_offers.list().await
}
pub(super) async fn get_targeted_transfer(
&self,
id: String,
) -> Result<Option<TargetedTransfer>, VnidropError> {
self.targeted_store().get(&id).await
}
pub(super) async fn list_targeted_transfers(
&self,
) -> Result<Vec<TargetedTransfer>, VnidropError> {
self.targeted_store().list().await
}
pub(crate) async fn restore_targeted_transfer_access(&self) -> Result<(), VnidropError> {
for row in self.targeted_store().list_resumable_sender_rows().await? {
self.access_policy
.approve_endpoint_until(row.protocol_transfer_id, row.receiver_endpoint_id, None)
.await;
}
Ok(())
}
/// Cancel in-flight targeted transfers involving `peer` (for forget/block).
pub(crate) async fn cancel_targeted_transfers_for_peer(
&self,
peer_endpoint_id: &str,
) -> Result<u64, VnidropError> {
#[cfg(test)]
{
self.targeted_cancel_log
.lock()
.expect("targeted cancel log")
.push(peer_endpoint_id.to_string());
}
self.targeted_offers.discard_from(peer_endpoint_id).await;
let protocol_ids = self
.targeted_store()
.protocol_ids_for_peer(peer_endpoint_id)
.await?;
// Signal active transfers synchronously before awaiting share teardown.
for protocol_transfer_id in &protocol_ids {
let _ = self.take_active_transfer(*protocol_transfer_id);
}
for protocol_transfer_id in &protocol_ids {
let _ = self.cancel_idle_or_share(*protocol_transfer_id).await;
}
self.targeted_store().cancel_by_peer(peer_endpoint_id).await
}
/// Synchronously stop streaming for one transfer (facade calls this first).
pub(super) fn signal_targeted_transfer_cancel(&self, protocol_transfer_id: u64) -> bool {
self.take_active_transfer(protocol_transfer_id).is_some()
}
pub(super) async fn cancel_targeted_transfer(&self, id: String) -> Result<(), VnidropError> {
let store = self.targeted_store();
let Some(row) = store.get_row(&id).await? else {
// Still drop any live-session offer under this id.
self.targeted_offers.discard(&id).await;
return Ok(());
};
let _ = self.take_active_transfer(row.protocol_transfer_id);
self.targeted_offers.discard(&id).await;
self.access_policy
.remove_transfer(row.protocol_transfer_id)
.await;
let _ = self.cancel_idle_or_share(row.protocol_transfer_id).await;
if !matches!(
row.state,
TargetedTransferState::Completed
| TargetedTransferState::Declined
| TargetedTransferState::Cancelled
| TargetedTransferState::Failed
| TargetedTransferState::Deleted
) {
let _ = store
.set_state_from_any(&id, TargetedTransferState::Cancelled)
.await;
}
// Best-effort remote withdraw of an unapproved live offer.
if row.role == TargetedTransferRole::Sender
&& matches!(
row.state,
TargetedTransferState::Offering | TargetedTransferState::AwaitingApproval
)
{
if let Ok(addr) = self
.device_relationships
.peer_addr(&row.receiver_endpoint_id)
.await
{
let client = TargetedTransferProtocol::client(self.endpoint.clone(), addr);
let _ = tokio::time::timeout(
self.connection_timeout(),
client.cancel_offer(CancelTargetedOffer {
transfer_id: id.clone(),
}),
)
.await;
}
}
Ok(())
}
pub(super) async fn delete_targeted_transfer(
self: &Arc<Self>,
id: String,
) -> Result<(), VnidropError> {
let store = self.targeted_store();
let Some(row) = store.get_row(&id).await? else {
self.targeted_offers.discard(&id).await;
return Ok(());
};
// Durable local denial first — remote cleanup is best-effort.
let _ = self.take_active_transfer(row.protocol_transfer_id);
self.targeted_offers.discard(&id).await;
self.access_policy
.remove_transfer(row.protocol_transfer_id)
.await;
let _ = self.cancel_idle_or_share(row.protocol_transfer_id).await;
if let Some(handle) = &row.authorization_secret_handle {
if let Some(custody) = &self.secret_custody {
let _ = custody
.remove(&SecretHandle::from_stored(handle.clone()))
.await;
}
}
store.clear_authorization(&id).await?;
if row.state != TargetedTransferState::Deleted {
if !matches!(
row.state,
TargetedTransferState::Completed
| TargetedTransferState::Declined
| TargetedTransferState::Cancelled
| TargetedTransferState::Failed
) {
let _ = store
.set_state_from_any(&id, TargetedTransferState::Cancelled)
.await;
}
let _ = store
.set_state_from_any(&id, TargetedTransferState::Deleted)
.await;
}
Ok(())
}
pub(super) async fn respond_to_targeted_offer(
self: &Arc<Self>,
transfer_id: String,
accepted: bool,
) -> Result<TargetedOfferResponse, VnidropError> {
if self.targeted_offers.is_settled(&transfer_id).await {
return Ok(TargetedOfferResponse::AlreadySettled { transfer_id });
}
if let Ok(Some(row)) = self.targeted_store().get_row(&transfer_id).await {
if self.load_stored_authorization(&row).await?.is_some()
|| matches!(
row.state,
TargetedTransferState::Approved
| TargetedTransferState::Connecting
| TargetedTransferState::Transferring
| TargetedTransferState::Interrupted
| TargetedTransferState::Completed
| TargetedTransferState::Declined
| TargetedTransferState::Cancelled
| TargetedTransferState::Failed
| TargetedTransferState::Deleted
)
{
return Ok(TargetedOfferResponse::AlreadySettled { transfer_id });
}
}
match self.targeted_offers.respond(&transfer_id, accepted).await {
Ok(Some(auth)) => {
self.persist_receiver_authorization(&auth).await?;
Ok(TargetedOfferResponse::Approved { transfer_id })
}
Ok(None) => Ok(TargetedOfferResponse::Declined),
Err(crate::targeted_transfer::RespondError::Unknown) => Err(
VnidropError::invalid_input(anyhow::anyhow!("unknown targeted offer")),
),
Err(crate::targeted_transfer::RespondError::SenderGone) => {
Err(VnidropError::device_unavailable(anyhow::anyhow!(
"sender disconnected before approval completed"
)))
}
Err(crate::targeted_transfer::RespondError::AuthorizationTimeout) => {
Err(VnidropError::offer_timeout(anyhow::anyhow!(
"authorization was not delivered in time"
)))
}
}
}
pub(super) async fn create_targeted_transfer(
self: &Arc<Self>,
receiver_endpoint_id: String,
sources: Vec<ShareSource>,
transfer_name: Option<String>,
) -> Result<TargetedTransfer, VnidropError> {
self.device_relationships
.require_saved(&receiver_endpoint_id)
.await?;
let transfer_uuid = Uuid::new_v4().to_string();
let protocol_transfer_id = allocate_protocol_transfer_id(&transfer_uuid);
let sender_endpoint_id = self.endpoint.id().to_string();
let now = now_ms();
let share = self
.share_files(
sources,
ShareMetadataInput {
transfer_id: protocol_transfer_id,
transfer_name: transfer_name.clone(),
sender_name: None,
access_mode: TransferAccessMode::ApprovalRequired,
},
)
.await
.map_err(VnidropError::transfer)?;
let store = self.targeted_store();
let row = TargetedTransferRow {
id: transfer_uuid.clone(),
protocol_transfer_id,
sender_endpoint_id: sender_endpoint_id.clone(),
receiver_endpoint_id: receiver_endpoint_id.clone(),
manifest_id: share.hash.clone(),
content_hash: share.hash.clone(),
transfer_name: share.transfer_name.clone(),
file_count: share.file_count,
total_size: share.total_size,
verified_bytes: 0,
blob_ticket: None,
authorization_secret_handle: None,
role: TargetedTransferRole::Sender,
state: TargetedTransferState::Preparing,
created_at: now,
updated_at: now,
};
store.insert(&row).await?;
store
.set_state(
&transfer_uuid,
TargetedTransferState::Preparing,
TargetedTransferState::Offering,
)
.await?;
let addr = self
.device_relationships
.peer_addr(&receiver_endpoint_id)
.await?;
let client = TargetedTransferProtocol::client(self.endpoint.clone(), addr);
let challenge =
match tokio::time::timeout(self.connection_timeout(), client.request_challenge()).await
{
Ok(Ok(challenge)) => challenge,
Ok(Err(error)) => {
let _ = store
.set_state(
&transfer_uuid,
TargetedTransferState::Offering,
TargetedTransferState::Failed,
)
.await;
let _ = self.cancel_idle_or_share(protocol_transfer_id).await;
return Err(map_connect_failure(error));
}
Err(_) => {
let _ = store
.set_state(
&transfer_uuid,
TargetedTransferState::Offering,
TargetedTransferState::Failed,
)
.await;
let _ = self.cancel_idle_or_share(protocol_transfer_id).await;
return Err(VnidropError::device_unavailable(anyhow::anyhow!(
"device did not answer in time"
)));
}
};
let (proof, generation, relationship_protocol_version) = self
.device_relationships
.prove_saved_possession(&receiver_endpoint_id, &challenge)
.await?;
let protocol_version =
experimental_saved_device_capabilities().targeted_transfer_protocol_version;
store
.set_state(
&transfer_uuid,
TargetedTransferState::Offering,
TargetedTransferState::AwaitingApproval,
)
.await?;
let response = match tokio::time::timeout(
self.connection_timeout() + self.offer_wait_timeout(),
client.submit_offer(SubmitTargetedOffer {
proof,
generation,
relationship_protocol_version,
protocol_version,
transfer_id: transfer_uuid.clone(),
sender_endpoint_id: sender_endpoint_id.clone(),
receiver_endpoint_id: receiver_endpoint_id.clone(),
manifest_id: share.hash.clone(),
content_hash: share.hash.clone(),
transfer_name: share.transfer_name.clone(),
file_count: share.file_count,
total_size: share.total_size,
relay_mode: self.relay_mode,
relay_urls: self
.custom_relay_urls
.iter()
.map(ToString::to_string)
.collect(),
}),
)
.await
{
Ok(Ok(response)) => response,
Ok(Err(error)) => {
let _ = store
.set_state(
&transfer_uuid,
TargetedTransferState::AwaitingApproval,
TargetedTransferState::Failed,
)
.await;
let _ = self.cancel_idle_or_share(protocol_transfer_id).await;
return Err(map_connect_failure(error));
}
Err(_) => {
let _ = store
.set_state(
&transfer_uuid,
TargetedTransferState::AwaitingApproval,
TargetedTransferState::Failed,
)
.await;
let _ = self.cancel_idle_or_share(protocol_transfer_id).await;
return Err(VnidropError::offer_timeout(anyhow::anyhow!(
"offer timed out"
)));
}
};
match response {
WireOfferResponse::Accepted => {}
WireOfferResponse::Declined { reason } => {
let _ = store
.set_state(
&transfer_uuid,
TargetedTransferState::AwaitingApproval,
TargetedTransferState::Declined,
)
.await;
let _ = self.cancel_idle_or_share(protocol_transfer_id).await;
return Err(VnidropError::permission(anyhow::anyhow!(
"targeted offer declined: {reason}"
)));
}
WireOfferResponse::Refused { reason } => {
let _ = store
.set_state(
&transfer_uuid,
TargetedTransferState::AwaitingApproval,
TargetedTransferState::Failed,
)
.await;
let _ = self.cancel_idle_or_share(protocol_transfer_id).await;
return Err(map_offer_refuse_reason(&reason));
}
}
// Permanent until cancel/delete — approved targeted transfers must resume.
self.access_policy
.approve_endpoint_until(protocol_transfer_id, receiver_endpoint_id.clone(), None)
.await;
let parsed = crate::ticket::parse_transfer_ticket_with_limits(&share.ticket, &self.limits)
.map_err(VnidropError::ticket)?;
let blob_ticket = BlobTicket::new(
parsed.blob_ticket.addr().clone(),
parsed.blob_ticket.hash(),
BlobFormat::HashSeq,
);
let authorization = TargetedAuthorization::issue(TargetedAuthorizationDraft {
transfer_id: transfer_uuid.clone(),
protocol_transfer_id,
sender_endpoint_id,
receiver_endpoint_id,
manifest_id: share.hash.clone(),
content_hash: share.hash.clone(),
file_count: share.file_count,
total_size: share.total_size,
protocol_version,
transfer_name: share.transfer_name.clone(),
blob_ticket: blob_ticket.to_string(),
})?;
self.persist_authorization_secret(&transfer_uuid, &authorization)
.await?;
let encoded = authorization.encode()?;
let deliver = client
.deliver_authorization(DeliverTargetedAuthorization {
transfer_id: transfer_uuid.clone(),
authorization: encoded,
})
.await
.context("failed to deliver targeted authorization")
.map_err(VnidropError::network)?;
if deliver != crate::targeted_transfer::protocol::DeliverAuthorizationResponse::Stored {
let _ = store
.set_state(
&transfer_uuid,
TargetedTransferState::AwaitingApproval,
TargetedTransferState::Failed,
)
.await;
return Err(VnidropError::network(anyhow::anyhow!(
"receiver rejected authorization delivery"
)));
}
store
.set_state(
&transfer_uuid,
TargetedTransferState::AwaitingApproval,
TargetedTransferState::Approved,
)
.await?;
store
.get(&transfer_uuid)
.await?
.ok_or_else(|| VnidropError::internal(anyhow::anyhow!("targeted transfer missing")))
}
pub(super) async fn receive_targeted_transfer(
self: &Arc<Self>,
transfer_id: String,
output_dir: String,
) -> Result<(), VnidropError> {
self.receive_targeted_to_target(
transfer_id,
ReceiveTarget::Directory(std::path::PathBuf::from(output_dir)),
)
.await
}
pub(super) async fn receive_targeted_transfer_with_output_sink(
self: &Arc<Self>,
transfer_id: String,
output_sink: Arc<dyn crate::ReceiveOutputSink>,
) -> Result<(), VnidropError> {
self.receive_targeted_to_target(transfer_id, ReceiveTarget::OutputSink(output_sink))
.await
}
pub(super) async fn receive_targeted_transfer_with_output_sink_v2(
self: &Arc<Self>,
transfer_id: String,
output_sink: Arc<dyn crate::ReceiveOutputSinkV2>,
) -> Result<(), VnidropError> {
self.receive_targeted_to_target(transfer_id, ReceiveTarget::OutputSinkV2(output_sink))
.await
}
pub(super) async fn resume_targeted_transfer(
self: &Arc<Self>,
id: String,
output_dir: String,
) -> Result<(), VnidropError> {
self.resume_targeted_to_target(
id,
ReceiveTarget::Directory(std::path::PathBuf::from(output_dir)),
)
.await
}
pub(super) async fn resume_targeted_transfer_with_output_sink(
self: &Arc<Self>,
id: String,
output_sink: Arc<dyn crate::ReceiveOutputSink>,
) -> Result<(), VnidropError> {
self.resume_targeted_to_target(id, ReceiveTarget::OutputSink(output_sink))
.await
}
pub(super) async fn resume_targeted_transfer_with_output_sink_v2(
self: &Arc<Self>,
id: String,
output_sink: Arc<dyn crate::ReceiveOutputSinkV2>,
) -> Result<(), VnidropError> {
self.resume_targeted_to_target(id, ReceiveTarget::OutputSinkV2(output_sink))
.await
}
async fn receive_targeted_to_target(
self: &Arc<Self>,
transfer_id: String,
target: ReceiveTarget,
) -> Result<(), VnidropError> {
let auth = self.load_receiver_authorization(&transfer_id).await?;
self.run_targeted_receive(&auth, target).await
}
async fn resume_targeted_to_target(
self: &Arc<Self>,
id: String,
target: ReceiveTarget,
) -> Result<(), VnidropError> {
let store = self.targeted_store();
let row = store.get_row(&id).await?.ok_or_else(|| {
VnidropError::invalid_input(anyhow::anyhow!("unknown targeted transfer"))
})?;
if !matches!(
row.state,
TargetedTransferState::Approved
| TargetedTransferState::Connecting
| TargetedTransferState::Transferring
| TargetedTransferState::Interrupted
) {
return Err(VnidropError::InvalidTransition {
reason: format!(
"cannot resume from {}",
crate::targeted_transfer::state_as_str(row.state)
),
});
}
let auth = self.load_receiver_authorization(&id).await?;
self.run_targeted_receive(&auth, target).await
}
async fn load_receiver_authorization(
&self,
transfer_id: &str,
) -> Result<TargetedAuthorization, VnidropError> {
let store = self.targeted_store();
let row = store.get_row(transfer_id).await?.ok_or_else(|| {
VnidropError::invalid_input(anyhow::anyhow!("unknown targeted transfer"))
})?;
let encoded = self.load_stored_authorization(&row).await?.ok_or_else(|| {
VnidropError::invalid_input(anyhow::anyhow!(
"targeted transfer has no durable authorization"
))
})?;
let auth = TargetedAuthorization::decode(&encoded)?;
auth.verify_for_receiver(&self.endpoint.id().to_string())?;
Ok(auth)
}
async fn run_targeted_receive(
self: &Arc<Self>,
auth: &TargetedAuthorization,
target: ReceiveTarget,
) -> Result<(), VnidropError> {
let store = self.targeted_store();
if let Ok(Some(row)) = store.get_row(&auth.transfer_id).await {
match row.state {
TargetedTransferState::Approved | TargetedTransferState::Interrupted => {
store
.set_state(
&auth.transfer_id,
row.state,
TargetedTransferState::Connecting,
)
.await?;
store
.set_state(
&auth.transfer_id,
TargetedTransferState::Connecting,
TargetedTransferState::Transferring,
)
.await?;
}
TargetedTransferState::Connecting => {
store
.set_state(
&auth.transfer_id,
TargetedTransferState::Connecting,
TargetedTransferState::Transferring,
)
.await?;
}
TargetedTransferState::Transferring => {}
other => {
return Err(VnidropError::InvalidTransition {
reason: format!(
"cannot receive from {}",
crate::targeted_transfer::state_as_str(other)
),
});
}
}
}
let blob_ticket = BlobTicket::from_str_compat(&auth.blob_ticket)
.map_err(|error| VnidropError::ticket(anyhow::anyhow!(error)))?;
let metadata = TransferMetadata::new(
auth.protocol_transfer_id,
non_empty(auth.transfer_name.clone()).unwrap_or_else(|| "transfer".to_string()),
None,
blob_ticket.hash(),
auth.file_count,
auth.total_size,
);
let ticket =
VnidropTicket::new_with_relay_urls(blob_ticket, metadata, &self.custom_relay_urls)
.encode()
.map_err(VnidropError::ticket)?;
let receive_result = self.receive_to_target(ticket, target, None).await;
match receive_result {
Ok(()) => {
if let Ok(Some(row)) = store.get_row(&auth.transfer_id).await {
let _ = store
.set_verified_bytes(&auth.transfer_id, row.total_size)
.await;
let _ = store
.set_state(
&auth.transfer_id,
TargetedTransferState::Transferring,
TargetedTransferState::Completed,
)
.await;
}
Ok(())
}
Err(error) => {
if let Ok(Some(row)) = store.get_row(&auth.transfer_id).await {
if matches!(
row.state,
TargetedTransferState::Connecting | TargetedTransferState::Transferring
) {
let _ = store
.set_state_from_any(
&auth.transfer_id,
TargetedTransferState::Interrupted,
)
.await;
}
}
Err(VnidropError::transfer(error))
}
}
}
async fn persist_authorization_secret(
&self,
transfer_id: &str,
authorization: &TargetedAuthorization,
) -> Result<(), VnidropError> {
let custody =
self.secret_custody
.as_ref()
.ok_or_else(|| VnidropError::SecureStorageUnavailable {
reason: "targeted authorization requires protected custody".to_string(),
})?;
let material = auth_secret_material(authorization)?;
let handle = custody
.protect(SecretKind::TargetedAuthorization, material, None)
.await?;
self.targeted_store()
.store_authorization(transfer_id, &authorization.blob_ticket, handle.as_str())
.await
}
async fn persist_receiver_authorization(&self, encoded: &str) -> Result<(), VnidropError> {
let auth = TargetedAuthorization::decode(encoded)?;
let store = self.targeted_store();
if store.get_row(&auth.transfer_id).await?.is_none() {
let now = now_ms();
store
.insert(&TargetedTransferRow {
id: auth.transfer_id.clone(),
protocol_transfer_id: auth.protocol_transfer_id,
sender_endpoint_id: auth.sender_endpoint_id.clone(),
receiver_endpoint_id: auth.receiver_endpoint_id.clone(),
manifest_id: auth.manifest_id.clone(),
content_hash: auth.content_hash.clone(),
transfer_name: auth.transfer_name.clone(),
file_count: auth.file_count,
total_size: auth.total_size,
verified_bytes: 0,
blob_ticket: Some(auth.blob_ticket.clone()),
authorization_secret_handle: None,
role: TargetedTransferRole::Receiver,
state: TargetedTransferState::Approved,
created_at: now,
updated_at: now,
})
.await?;
}
self.persist_authorization_secret(&auth.transfer_id, &auth)
.await
}
async fn load_stored_authorization(
&self,
row: &TargetedTransferRow,
) -> Result<Option<String>, VnidropError> {
let (Some(handle), Some(blob_ticket)) = (
row.authorization_secret_handle.as_ref(),
row.blob_ticket.as_ref(),
) else {
return Ok(None);
};
let custody =
self.secret_custody
.as_ref()
.ok_or_else(|| VnidropError::SecureStorageUnavailable {
reason: "targeted authorization requires protected custody".to_string(),
})?;
let material = custody
.load(&SecretHandle::from_stored(handle.clone()))
.await?;
let auth = reconstruct_authorization(
TargetedAuthorizationDraft {
transfer_id: row.id.clone(),
protocol_transfer_id: row.protocol_transfer_id,
sender_endpoint_id: row.sender_endpoint_id.clone(),
receiver_endpoint_id: row.receiver_endpoint_id.clone(),
manifest_id: row.manifest_id.clone(),
content_hash: row.content_hash.clone(),
file_count: row.file_count,
total_size: row.total_size,
protocol_version: experimental_saved_device_capabilities()
.targeted_transfer_protocol_version,
transfer_name: row.transfer_name.clone(),
blob_ticket: blob_ticket.clone(),
},
&material,
)?;
Ok(Some(auth.encode()?))
}
}
fn allocate_protocol_transfer_id(transfer_uuid: &str) -> u64 {
let hash = blake3::hash(transfer_uuid.as_bytes());
let mut bytes = [0u8; 8];
bytes.copy_from_slice(&hash.as_bytes()[..8]);
// SQLite transfer ids are signed; keep within i64::MAX.
let value = u64::from_le_bytes(bytes) & (i64::MAX as u64);
if value == 0 {
1
} else {
value
}
}
fn map_connect_failure(error: irpc::Error) -> VnidropError {
let rendered = error.to_string();
// ALPN / protocol negotiation failures are distinguishable from offline peers.
if rendered.contains("ALPN")
|| rendered.contains("alpn")
|| rendered.contains("protocol")
|| rendered.contains("unsupported")
{
return VnidropError::protocol_incompatible(anyhow::anyhow!(
"peer does not support saved-device targeted transfers"
));
}
VnidropError::device_unavailable(anyhow::anyhow!("device is not reachable: {rendered}"))
}
trait BlobTicketParse {
fn from_str_compat(value: &str) -> Result<BlobTicket, String>;
}
impl BlobTicketParse for BlobTicket {
fn from_str_compat(value: &str) -> Result<BlobTicket, String> {
use std::str::FromStr;
BlobTicket::from_str(value).map_err(|error| error.to_string())
}
}

View File

@@ -0,0 +1,850 @@
use std::{collections::HashSet, fmt, io, path::Path, sync::Arc, time::Duration};
#[cfg(test)]
use std::{collections::HashMap, sync::Mutex};
use data_encoding::HEXLOWER;
use iroh::SecretKey;
use sqlx::{Row, SqlitePool};
use uuid::Uuid;
use crate::{error::VnidropError, util::now_ms};
#[cfg(any(test, target_os = "android"))]
pub(crate) mod android;
#[cfg(any(target_os = "macos", target_os = "ios"))]
pub(crate) mod apple;
#[cfg(any(test, target_os = "linux"))]
pub(crate) mod linux;
mod platform;
#[cfg(any(test, target_os = "windows"))]
pub(crate) mod windows;
#[cfg(test)]
pub(crate) use platform::scope_store;
#[cfg(test)]
pub(crate) use platform::unlocked_profile_for_test;
pub(crate) use platform::{lock_profile, platform_secret_store, ProfileLock};
const SECRET_BYTES: usize = 32;
const HANDLE_NAMESPACE: &str = "vnidrop";
const HANDLE_VERSION: &str = "v1";
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct SecretMaterial(Vec<u8>);
impl SecretMaterial {
pub(crate) fn new(bytes: Vec<u8>) -> Result<Self, VnidropError> {
if bytes.len() != SECRET_BYTES || bytes.iter().all(|byte| *byte == 0) {
return Err(VnidropError::SecureStorageCorrupted {
reason: "protected secret has invalid key material".to_string(),
});
}
Ok(Self(bytes))
}
fn endpoint_id(&self) -> String {
let bytes: [u8; SECRET_BYTES] = self.0.as_slice().try_into().expect("validated length");
SecretKey::from_bytes(&bytes).public().to_string()
}
fn into_secret_key(self) -> SecretKey {
let bytes: [u8; SECRET_BYTES] = self.0.try_into().expect("validated length");
SecretKey::from_bytes(&bytes)
}
pub(crate) fn as_bytes(&self) -> &[u8] {
&self.0
}
pub(crate) fn to_vec(&self) -> Vec<u8> {
self.0.clone()
}
}
impl fmt::Debug for SecretMaterial {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("SecretMaterial(redacted)")
}
}
#[derive(Clone, PartialEq, Eq, Hash)]
pub(crate) struct SecretHandle(String);
impl SecretHandle {
fn generate(kind: SecretKind) -> Self {
Self(format!(
"{HANDLE_NAMESPACE}/{HANDLE_VERSION}/{}/{}",
kind.as_str(),
Uuid::new_v4()
))
}
pub(crate) fn from_stored(value: String) -> Self {
Self(value)
}
pub(crate) fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Debug for SecretHandle {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_tuple("SecretHandle")
.field(&self.0)
.finish()
}
}
#[cfg(test)]
pub(crate) fn secret_handle_for_test(value: String) -> SecretHandle {
SecretHandle(value)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SecretKind {
EndpointIdentity,
RelationshipGrant,
PairingEligibility,
TargetedAuthorization,
}
impl SecretKind {
fn as_str(self) -> &'static str {
match self {
Self::EndpointIdentity => "endpoint-identity",
Self::RelationshipGrant => "relationship-grant",
Self::PairingEligibility => "pairing-eligibility",
Self::TargetedAuthorization => "targeted-authorization",
}
}
fn parse(value: &str) -> Result<Self, VnidropError> {
match value {
"endpoint-identity" => Ok(Self::EndpointIdentity),
"relationship-grant" => Ok(Self::RelationshipGrant),
"pairing-eligibility" => Ok(Self::PairingEligibility),
"targeted-authorization" => Ok(Self::TargetedAuthorization),
_ => Err(VnidropError::SecureStorageCorrupted {
reason: "protected secret has an unknown kind".to_string(),
}),
}
}
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum SecureSecretStoreError {
#[error("credential store is locked")]
Locked,
#[error("credential is missing")]
Missing,
#[error("credential is corrupted")]
Corrupted,
#[error("credential store is unavailable")]
Unavailable,
}
/// Opaque credential-store boundary implemented by each supported platform.
///
/// Implementations must persist material outside ordinary application storage and
/// must never include material in errors or diagnostics.
pub(crate) trait SecureSecretStore: Send + Sync {
fn put(
&self,
handle: &SecretHandle,
material: SecretMaterial,
) -> Result<(), SecureSecretStoreError>;
fn get(&self, handle: &SecretHandle) -> Result<SecretMaterial, SecureSecretStoreError>;
fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError>;
fn list_handles(&self) -> Result<Vec<SecretHandle>, SecureSecretStoreError>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SecretMetadataState {
Staged,
Active,
Disabled,
}
impl SecretMetadataState {
fn as_str(self) -> &'static str {
match self {
Self::Staged => "staged",
Self::Active => "active",
Self::Disabled => "disabled",
}
}
fn parse(value: &str) -> Result<Self, VnidropError> {
match value {
"staged" => Ok(Self::Staged),
"active" => Ok(Self::Active),
"disabled" => Ok(Self::Disabled),
_ => Err(VnidropError::SecureStorageCorrupted {
reason: "protected secret has an unknown metadata state".to_string(),
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SecretMetadata {
handle: SecretHandle,
kind: SecretKind,
state: SecretMetadataState,
expected_identity: Option<String>,
}
pub(crate) async fn ensure_schema(pool: &SqlitePool) -> anyhow::Result<()> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS protected_secret_refs (
handle TEXT PRIMARY KEY,
kind TEXT NOT NULL,
state TEXT NOT NULL,
expected_identity TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE UNIQUE INDEX IF NOT EXISTS protected_secret_one_endpoint_identity
ON protected_secret_refs(kind)
WHERE kind = 'endpoint-identity' AND state != 'disabled'
"#,
)
.execute(pool)
.await?;
Ok(())
}
#[derive(Debug, Clone)]
pub(crate) struct SecretMetadataStore {
pool: SqlitePool,
}
impl SecretMetadataStore {
pub(crate) fn new(pool: SqlitePool) -> Self {
Self { pool }
}
async fn stage(
&self,
handle: &SecretHandle,
kind: SecretKind,
expected_identity: Option<&str>,
) -> Result<(), VnidropError> {
let now = now_ms();
sqlx::query(
r#"
INSERT INTO protected_secret_refs
(handle, kind, state, expected_identity, created_at, updated_at)
VALUES (?1, ?2, 'staged', ?3, ?4, ?4)
"#,
)
.bind(handle.as_str())
.bind(kind.as_str())
.bind(expected_identity)
.bind(now)
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(())
}
async fn activate(&self, handle: &SecretHandle) -> Result<(), VnidropError> {
self.set_state(handle, SecretMetadataState::Active).await
}
async fn disable(&self, handle: &SecretHandle) -> Result<(), VnidropError> {
self.set_state(handle, SecretMetadataState::Disabled).await
}
async fn set_state(
&self,
handle: &SecretHandle,
state: SecretMetadataState,
) -> Result<(), VnidropError> {
sqlx::query(
"UPDATE protected_secret_refs SET state = ?2, updated_at = ?3 WHERE handle = ?1",
)
.bind(handle.as_str())
.bind(state.as_str())
.bind(now_ms())
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(())
}
async fn find(&self, handle: &SecretHandle) -> Result<Option<SecretMetadata>, VnidropError> {
let row = sqlx::query(
"SELECT handle, kind, state, expected_identity FROM protected_secret_refs WHERE handle = ?1",
)
.bind(handle.as_str())
.fetch_optional(&self.pool)
.await
.map_err(VnidropError::repository)?;
row.map(row_to_metadata).transpose()
}
async fn list(&self) -> Result<Vec<SecretMetadata>, VnidropError> {
let rows = sqlx::query(
"SELECT handle, kind, state, expected_identity FROM protected_secret_refs ORDER BY handle",
)
.fetch_all(&self.pool)
.await
.map_err(VnidropError::repository)?;
rows.into_iter().map(row_to_metadata).collect()
}
async fn find_active_kind(
&self,
kind: SecretKind,
) -> Result<Option<SecretMetadata>, VnidropError> {
let row = sqlx::query(
r#"
SELECT handle, kind, state, expected_identity
FROM protected_secret_refs
WHERE kind = ?1 AND state = 'active'
ORDER BY created_at ASC
LIMIT 1
"#,
)
.bind(kind.as_str())
.fetch_optional(&self.pool)
.await
.map_err(VnidropError::repository)?;
row.map(row_to_metadata).transpose()
}
async fn contains_kind(&self, kind: SecretKind) -> Result<bool, VnidropError> {
let row = sqlx::query("SELECT 1 FROM protected_secret_refs WHERE kind = ?1 LIMIT 1")
.bind(kind.as_str())
.fetch_optional(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(row.is_some())
}
}
fn row_to_metadata(row: sqlx::sqlite::SqliteRow) -> Result<SecretMetadata, VnidropError> {
Ok(SecretMetadata {
handle: SecretHandle(row.get(0)),
kind: SecretKind::parse(row.get::<String, _>(1).as_str())?,
state: SecretMetadataState::parse(row.get::<String, _>(2).as_str())?,
expected_identity: row.get(3),
})
}
pub(crate) struct SecretCustody {
metadata: SecretMetadataStore,
store: Arc<dyn SecureSecretStore>,
#[cfg(test)]
crash_point: Mutex<Option<CustodyCrashPoint>>,
}
pub(crate) async fn start_endpoint_identity(
metadata: SecretMetadataStore,
store: Arc<dyn SecureSecretStore>,
legacy_path: &Path,
) -> Result<(SecretKey, SecretCustody), VnidropError> {
let (custody, _) = SecretCustody::start(metadata, store).await?;
let secret_key = custody
.initialize_endpoint_identity(legacy_path)
.await?
.into_secret_key();
Ok((secret_key, custody))
}
impl SecretCustody {
pub(crate) async fn start(
metadata: SecretMetadataStore,
store: Arc<dyn SecureSecretStore>,
) -> Result<(Self, ReconciliationSummary), VnidropError> {
let custody = Self::from_parts(metadata, store);
let summary = custody.reconcile().await?;
Ok((custody, summary))
}
#[cfg(test)]
pub(crate) fn new(metadata: SecretMetadataStore, store: Arc<dyn SecureSecretStore>) -> Self {
Self::from_parts(metadata, store)
}
fn from_parts(metadata: SecretMetadataStore, store: Arc<dyn SecureSecretStore>) -> Self {
Self {
metadata,
store,
#[cfg(test)]
crash_point: Mutex::new(None),
}
}
pub(crate) async fn protect(
&self,
kind: SecretKind,
material: SecretMaterial,
expected_identity: Option<&str>,
) -> Result<SecretHandle, VnidropError> {
validate_material(kind, &material, expected_identity)?;
let handle = SecretHandle::generate(kind);
self.store_put(handle.clone(), material.clone()).await?;
#[cfg(test)]
self.maybe_crash(CustodyCrashPoint::StoreWrite)?;
let stored = self.store_get(handle.clone()).await?;
if stored != material {
return Err(VnidropError::SecureStorageCorrupted {
reason: "credential store did not preserve protected material".to_string(),
});
}
validate_material(kind, &stored, expected_identity)?;
if let Err(error) = self.metadata.stage(&handle, kind, expected_identity).await {
self.delete_if_present(&handle).await?;
return Err(error);
}
#[cfg(test)]
self.maybe_crash(CustodyCrashPoint::MetadataStage)?;
self.metadata.activate(&handle).await?;
#[cfg(test)]
self.maybe_crash(CustodyCrashPoint::MetadataActivation)?;
Ok(handle)
}
pub(crate) async fn load(&self, handle: &SecretHandle) -> Result<SecretMaterial, VnidropError> {
let metadata = self.metadata.find(handle).await?.ok_or_else(|| {
VnidropError::SecureStorageMissing {
reason: "protected secret metadata is missing".to_string(),
}
})?;
if metadata.state != SecretMetadataState::Active {
return Err(VnidropError::SecureStorageUnavailable {
reason: "protected secret is not active".to_string(),
});
}
let material = self.store_get(handle.clone()).await?;
validate_material(
metadata.kind,
&material,
metadata.expected_identity.as_deref(),
)?;
Ok(material)
}
/// Removes protected material and disables its metadata. Idempotent.
pub(crate) async fn remove(&self, handle: &SecretHandle) -> Result<(), VnidropError> {
if self.metadata.find(handle).await?.is_some() {
self.metadata.disable(handle).await?;
}
self.delete_if_present(handle).await
}
pub(crate) async fn list_active_handles(
&self,
kind: SecretKind,
) -> Result<Vec<SecretHandle>, VnidropError> {
Ok(self
.metadata
.list()
.await?
.into_iter()
.filter(|entry| entry.kind == kind && entry.state == SecretMetadataState::Active)
.map(|entry| entry.handle)
.collect())
}
pub(crate) async fn migrate_legacy_endpoint_identity(
&self,
legacy_path: &Path,
) -> Result<SecretHandle, VnidropError> {
if let Some(active) = self
.metadata
.find_active_kind(SecretKind::EndpointIdentity)
.await?
{
let protected = self.load(&active.handle).await?;
match read_legacy_endpoint_identity(legacy_path).await {
Ok(legacy) => {
if legacy.endpoint_id() != protected.endpoint_id() {
return Err(VnidropError::SecureStorageCorrupted {
reason: "legacy endpoint key does not match protected identity"
.to_string(),
});
}
tokio::fs::remove_file(legacy_path)
.await
.map_err(VnidropError::filesystem)?;
}
Err(VnidropError::SecureStorageMissing { .. }) => {}
Err(error) => return Err(error),
}
return Ok(active.handle);
}
let legacy = read_legacy_endpoint_identity(legacy_path).await?;
let endpoint_id = legacy.endpoint_id();
let handle = self
.protect(
SecretKind::EndpointIdentity,
legacy,
Some(endpoint_id.as_str()),
)
.await?;
tokio::fs::remove_file(legacy_path)
.await
.map_err(VnidropError::filesystem)?;
Ok(handle)
}
pub(crate) async fn initialize_endpoint_identity(
&self,
legacy_path: &Path,
) -> Result<SecretMaterial, VnidropError> {
if self
.metadata
.find_active_kind(SecretKind::EndpointIdentity)
.await?
.is_some()
{
let handle = self.migrate_legacy_endpoint_identity(legacy_path).await?;
return self.load(&handle).await;
}
if self
.metadata
.contains_kind(SecretKind::EndpointIdentity)
.await?
{
// Concurrent first-start may have staged (not yet active) metadata.
// Wait for activation before treating leftover rows as disabled.
return match self.wait_for_active_endpoint_identity().await {
Ok(handle) => self.load(&handle).await,
Err(_) => Err(VnidropError::SecureStorageUnavailable {
reason: "protected endpoint identity is disabled".to_string(),
}),
};
}
match tokio::fs::try_exists(legacy_path).await {
Ok(true) => {
let handle = self.migrate_legacy_endpoint_identity(legacy_path).await?;
self.load(&handle).await
}
Ok(false) => {
let secret = SecretKey::generate();
let material = SecretMaterial::new(secret.to_bytes().to_vec())?;
let endpoint_id = material.endpoint_id();
match self
.protect(
SecretKind::EndpointIdentity,
material,
Some(endpoint_id.as_str()),
)
.await
{
Ok(handle) => self.load(&handle).await,
Err(error) => match self.wait_for_active_endpoint_identity().await {
Ok(handle) => self.load(&handle).await,
Err(_) => Err(error),
},
}
}
Err(error) => Err(VnidropError::filesystem(error)),
}
}
async fn wait_for_active_endpoint_identity(&self) -> Result<SecretHandle, VnidropError> {
tokio::time::timeout(Duration::from_secs(1), async {
loop {
if let Some(active) = self
.metadata
.find_active_kind(SecretKind::EndpointIdentity)
.await?
{
return Ok(active.handle);
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.map_err(|_| VnidropError::SecureStorageUnavailable {
reason: "timed out waiting for protected endpoint identity".to_string(),
})?
}
pub(crate) async fn reconcile(&self) -> Result<ReconciliationSummary, VnidropError> {
let metadata = self.metadata.list().await?;
let stored_handles = self.store_list_handles().await?;
let known_handles = metadata
.iter()
.map(|entry| entry.handle.clone())
.collect::<HashSet<_>>();
let mut summary = ReconciliationSummary::default();
for entry in metadata {
if entry.state == SecretMetadataState::Disabled {
self.delete_if_present(&entry.handle).await?;
continue;
}
match self.store_get_raw(entry.handle.clone()).await? {
Ok(material) => {
if validate_material(entry.kind, &material, entry.expected_identity.as_deref())
.is_err()
{
self.metadata.disable(&entry.handle).await?;
self.delete_if_present(&entry.handle).await?;
summary.disabled += 1;
} else if entry.state == SecretMetadataState::Staged {
self.metadata.activate(&entry.handle).await?;
summary.staged_activated += 1;
}
}
Err(SecureSecretStoreError::Missing | SecureSecretStoreError::Corrupted) => {
self.metadata.disable(&entry.handle).await?;
self.delete_if_present(&entry.handle).await?;
summary.disabled += 1;
}
Err(error) => return Err(map_store_error(error)),
}
}
for handle in stored_handles {
if !known_handles.contains(&handle) {
self.store_delete(handle).await?;
summary.orphans_deleted += 1;
}
}
Ok(summary)
}
async fn delete_if_present(&self, handle: &SecretHandle) -> Result<(), VnidropError> {
match self.store_delete_raw(handle.clone()).await? {
Ok(()) | Err(SecureSecretStoreError::Missing) => Ok(()),
Err(error) => Err(map_store_error(error)),
}
}
// Platform credential stores (especially Linux Secret Service via zbus
// blocking) nest their own Tokio `block_on`. Calling them on a worker
// already inside Vnidrop's runtime panics with "Cannot start a runtime
// from within a runtime" and breaks protected-core desktop startup.
async fn store_put(
&self,
handle: SecretHandle,
material: SecretMaterial,
) -> Result<(), VnidropError> {
let store = Arc::clone(&self.store);
tokio::task::spawn_blocking(move || store.put(&handle, material))
.await
.map_err(VnidropError::internal)?
.map_err(map_store_error)
}
async fn store_get(&self, handle: SecretHandle) -> Result<SecretMaterial, VnidropError> {
self.store_get_raw(handle).await?.map_err(map_store_error)
}
async fn store_get_raw(
&self,
handle: SecretHandle,
) -> Result<Result<SecretMaterial, SecureSecretStoreError>, VnidropError> {
let store = Arc::clone(&self.store);
tokio::task::spawn_blocking(move || store.get(&handle))
.await
.map_err(VnidropError::internal)
}
async fn store_delete(&self, handle: SecretHandle) -> Result<(), VnidropError> {
self.store_delete_raw(handle)
.await?
.map_err(map_store_error)
}
async fn store_delete_raw(
&self,
handle: SecretHandle,
) -> Result<Result<(), SecureSecretStoreError>, VnidropError> {
let store = Arc::clone(&self.store);
tokio::task::spawn_blocking(move || store.delete(&handle))
.await
.map_err(VnidropError::internal)
}
async fn store_list_handles(&self) -> Result<Vec<SecretHandle>, VnidropError> {
let store = Arc::clone(&self.store);
tokio::task::spawn_blocking(move || store.list_handles())
.await
.map_err(VnidropError::internal)?
.map_err(map_store_error)
}
#[cfg(test)]
pub(crate) fn crash_once_at(&self, point: CustodyCrashPoint) {
*self.crash_point.lock().unwrap() = Some(point);
}
#[cfg(test)]
fn maybe_crash(&self, point: CustodyCrashPoint) -> Result<(), VnidropError> {
let mut crash_point = self.crash_point.lock().unwrap();
if *crash_point == Some(point) {
*crash_point = None;
return Err(VnidropError::Internal {
reason: format!("simulated custody crash at {point:?}"),
});
}
Ok(())
}
}
async fn read_legacy_endpoint_identity(path: &Path) -> Result<SecretMaterial, VnidropError> {
let encoded = match tokio::fs::read_to_string(path).await {
Ok(encoded) => encoded,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return Err(VnidropError::SecureStorageMissing {
reason: "no protected or legacy endpoint identity exists".to_string(),
});
}
Err(error) => return Err(VnidropError::filesystem(error)),
};
let bytes = HEXLOWER.decode(encoded.trim().as_bytes()).map_err(|_| {
VnidropError::SecureStorageCorrupted {
reason: "legacy endpoint key encoding is invalid".to_string(),
}
})?;
SecretMaterial::new(bytes)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct ReconciliationSummary {
pub(crate) orphans_deleted: u64,
pub(crate) staged_activated: u64,
pub(crate) disabled: u64,
}
#[cfg(test)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CustodyCrashPoint {
StoreWrite,
MetadataStage,
MetadataActivation,
}
fn validate_material(
kind: SecretKind,
material: &SecretMaterial,
expected_identity: Option<&str>,
) -> Result<(), VnidropError> {
if kind == SecretKind::EndpointIdentity {
let expected_identity =
expected_identity.ok_or_else(|| VnidropError::SecureStorageCorrupted {
reason: "endpoint identity metadata lacks its expected endpoint id".to_string(),
})?;
if material.endpoint_id() != expected_identity {
return Err(VnidropError::SecureStorageCorrupted {
reason: "protected endpoint identity does not match its endpoint id".to_string(),
});
}
}
Ok(())
}
fn map_store_error(error: SecureSecretStoreError) -> VnidropError {
let reason = error.to_string();
match error {
SecureSecretStoreError::Locked => VnidropError::SecureStorageLocked { reason },
SecureSecretStoreError::Missing => VnidropError::SecureStorageMissing { reason },
SecureSecretStoreError::Corrupted => VnidropError::SecureStorageCorrupted { reason },
SecureSecretStoreError::Unavailable => VnidropError::SecureStorageUnavailable { reason },
}
}
#[cfg(test)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReferenceStoreFailure {
Locked,
Unavailable,
}
#[cfg(test)]
#[derive(Default)]
pub(crate) struct FaultInjectingSecretStore {
values: Mutex<HashMap<SecretHandle, SecretMaterial>>,
failure: Mutex<Option<ReferenceStoreFailure>>,
corrupted: Mutex<Vec<SecretHandle>>,
}
#[cfg(test)]
impl FaultInjectingSecretStore {
pub(crate) fn fail_with(&self, failure: Option<ReferenceStoreFailure>) {
*self.failure.lock().unwrap() = failure;
}
pub(crate) fn remove_for_test(&self, handle: &SecretHandle) {
self.values.lock().unwrap().remove(handle);
}
pub(crate) fn corrupt_for_test(&self, handle: &SecretHandle) {
self.corrupted.lock().unwrap().push(handle.clone());
}
pub(crate) fn only_handle_for_test(&self) -> SecretHandle {
let handles = self
.values
.lock()
.unwrap()
.keys()
.cloned()
.collect::<Vec<_>>();
assert_eq!(handles.len(), 1, "expected exactly one protected secret");
handles.into_iter().next().unwrap()
}
fn check_available(&self) -> Result<(), SecureSecretStoreError> {
match *self.failure.lock().unwrap() {
Some(ReferenceStoreFailure::Locked) => Err(SecureSecretStoreError::Locked),
Some(ReferenceStoreFailure::Unavailable) => Err(SecureSecretStoreError::Unavailable),
None => Ok(()),
}
}
}
#[cfg(test)]
impl SecureSecretStore for FaultInjectingSecretStore {
fn put(
&self,
handle: &SecretHandle,
material: SecretMaterial,
) -> Result<(), SecureSecretStoreError> {
self.check_available()?;
self.values.lock().unwrap().insert(handle.clone(), material);
Ok(())
}
fn get(&self, handle: &SecretHandle) -> Result<SecretMaterial, SecureSecretStoreError> {
self.check_available()?;
if self.corrupted.lock().unwrap().contains(handle) {
return Err(SecureSecretStoreError::Corrupted);
}
self.values
.lock()
.unwrap()
.get(handle)
.cloned()
.ok_or(SecureSecretStoreError::Missing)
}
fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> {
self.check_available()?;
self.values.lock().unwrap().remove(handle);
Ok(())
}
fn list_handles(&self) -> Result<Vec<SecretHandle>, SecureSecretStoreError> {
self.check_available()?;
Ok(self.values.lock().unwrap().keys().cloned().collect())
}
}

View File

@@ -0,0 +1,335 @@
use std::{
fs::{self, File, OpenOptions},
io::{Read, Write},
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use data_encoding::HEXLOWER;
use super::{SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError};
const RECORD_MAGIC: &[u8; 8] = b"VNDASK01";
const RECORD_STAGED: u8 = 0;
const RECORD_SEALED: u8 = 1;
const RECORD_EXTENSION: &str = "vns";
const KEY_ALIAS_PREFIX: &str = "vnidrop.secret.v1.";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AndroidSealedValue {
pub(crate) nonce: Vec<u8>,
pub(crate) ciphertext: Vec<u8>,
}
/// Performs AES-GCM operations with a non-exportable key held by Android Keystore.
///
/// Implementations create one key per alias, let Keystore generate the encryption
/// nonce, and never return key material to Rust.
pub(crate) trait AndroidKeystore: Send + Sync {
fn seal(
&self,
alias: &str,
plaintext: &[u8],
) -> Result<AndroidSealedValue, SecureSecretStoreError>;
fn open(
&self,
alias: &str,
sealed: &AndroidSealedValue,
) -> Result<Vec<u8>, SecureSecretStoreError>;
fn delete(&self, alias: &str) -> Result<(), SecureSecretStoreError>;
}
/// Android secret-store adapter whose ordinary storage contains authenticated
/// ciphertext only.
///
/// `no_backup_dir` must be the directory returned by Android
/// `Context.getNoBackupFilesDir()`. The Android host owns acquiring that Context;
/// secret values never cross that initialization boundary.
pub(crate) struct AndroidSecureSecretStore {
records_dir: PathBuf,
keystore: Arc<dyn AndroidKeystore>,
mutation_lock: Mutex<()>,
}
impl AndroidSecureSecretStore {
pub(crate) fn new(
no_backup_dir: &Path,
keystore: Arc<dyn AndroidKeystore>,
) -> Result<Self, SecureSecretStoreError> {
if !no_backup_dir.is_absolute() {
return Err(SecureSecretStoreError::Unavailable);
}
let records_dir = no_backup_dir.join("vnidrop-protected-secrets-v1");
fs::create_dir_all(&records_dir).map_err(map_io_error)?;
set_private_directory_permissions(&records_dir)?;
Ok(Self {
records_dir,
keystore,
mutation_lock: Mutex::new(()),
})
}
fn record_path(&self, handle: &SecretHandle) -> PathBuf {
// Hash the handle for the on-disk name. Scoped handles are longer than
// Linux/Android NAME_MAX when hex-encoded, and the handle is already
// authenticated inside the record body.
let digest = blake3::hash(handle.as_str().as_bytes());
let encoded = HEXLOWER.encode(digest.as_bytes());
self.records_dir
.join(format!("{encoded}.{RECORD_EXTENSION}"))
}
fn alias(handle: &SecretHandle) -> String {
let digest = blake3::hash(handle.as_str().as_bytes());
format!("{KEY_ALIAS_PREFIX}{}", HEXLOWER.encode(digest.as_bytes()))
}
fn write_record(
&self,
handle: &SecretHandle,
state: u8,
sealed: Option<&AndroidSealedValue>,
) -> Result<(), SecureSecretStoreError> {
let bytes = encode_record(handle, state, sealed)?;
let path = self.record_path(handle);
let temporary = path.with_extension(format!("{RECORD_EXTENSION}.tmp"));
let mut file = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&temporary)
.map_err(map_io_error)?;
set_private_file_permissions(&temporary)?;
file.write_all(&bytes).map_err(map_io_error)?;
file.sync_all().map_err(map_io_error)?;
fs::rename(&temporary, &path).map_err(map_io_error)?;
sync_directory(&self.records_dir)?;
Ok(())
}
fn read_record(
&self,
handle: &SecretHandle,
) -> Result<AndroidSealedValue, SecureSecretStoreError> {
let mut bytes = Vec::new();
File::open(self.record_path(handle))
.map_err(map_io_error)?
.read_to_end(&mut bytes)
.map_err(map_io_error)?;
decode_record(&bytes, handle)
}
#[cfg(test)]
pub(crate) fn record_path_for_test(&self, handle: &SecretHandle) -> PathBuf {
self.record_path(handle)
}
#[cfg(test)]
pub(crate) fn stage_for_test(
&self,
handle: &SecretHandle,
) -> Result<(), SecureSecretStoreError> {
self.write_record(handle, RECORD_STAGED, None)
}
}
#[cfg(test)]
pub(crate) fn secret_handle_for_test(value: &str) -> SecretHandle {
SecretHandle(value.to_string())
}
impl SecureSecretStore for AndroidSecureSecretStore {
fn put(
&self,
handle: &SecretHandle,
material: SecretMaterial,
) -> Result<(), SecureSecretStoreError> {
let _mutation = self
.mutation_lock
.lock()
.map_err(|_| SecureSecretStoreError::Unavailable)?;
let record_exists = match fs::metadata(self.record_path(handle)) {
Ok(_) => true,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
Err(error) => return Err(map_io_error(error)),
};
if !record_exists {
self.write_record(handle, RECORD_STAGED, None)?;
}
let alias = Self::alias(handle);
let sealed = self.keystore.seal(&alias, &material.0)?;
if sealed.nonce.is_empty() || sealed.ciphertext.is_empty() {
return Err(SecureSecretStoreError::Corrupted);
}
self.write_record(handle, RECORD_SEALED, Some(&sealed))
}
fn get(&self, handle: &SecretHandle) -> Result<SecretMaterial, SecureSecretStoreError> {
let sealed = self.read_record(handle)?;
let plaintext = self.keystore.open(&Self::alias(handle), &sealed)?;
SecretMaterial::new(plaintext).map_err(|_| SecureSecretStoreError::Corrupted)
}
fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> {
let _mutation = self
.mutation_lock
.lock()
.map_err(|_| SecureSecretStoreError::Unavailable)?;
match self.keystore.delete(&Self::alias(handle)) {
Ok(()) | Err(SecureSecretStoreError::Missing) => {}
Err(error) => return Err(error),
}
let path = self.record_path(handle);
match fs::remove_file(path) {
Ok(()) => sync_directory(&self.records_dir)?,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(map_io_error(error)),
}
Ok(())
}
fn list_handles(&self) -> Result<Vec<SecretHandle>, SecureSecretStoreError> {
let mut handles = Vec::new();
for entry in fs::read_dir(&self.records_dir).map_err(map_io_error)? {
let entry = entry.map_err(map_io_error)?;
let path = entry.path();
if path.extension().and_then(|value| value.to_str()) != Some(RECORD_EXTENSION) {
continue;
}
let mut bytes = Vec::new();
File::open(&path)
.map_err(map_io_error)?
.read_to_end(&mut bytes)
.map_err(map_io_error)?;
handles.push(decode_handle_from_record(&bytes)?);
}
handles.sort_by(|left, right| left.as_str().cmp(right.as_str()));
Ok(handles)
}
}
fn encode_record(
handle: &SecretHandle,
state: u8,
sealed: Option<&AndroidSealedValue>,
) -> Result<Vec<u8>, SecureSecretStoreError> {
let handle_bytes = handle.as_str().as_bytes();
let handle_len =
u16::try_from(handle_bytes.len()).map_err(|_| SecureSecretStoreError::Corrupted)?;
let (nonce, ciphertext) = match (state, sealed) {
(RECORD_STAGED, None) => (&[][..], &[][..]),
(RECORD_SEALED, Some(value)) => (value.nonce.as_slice(), value.ciphertext.as_slice()),
_ => return Err(SecureSecretStoreError::Corrupted),
};
let nonce_len = u16::try_from(nonce.len()).map_err(|_| SecureSecretStoreError::Corrupted)?;
let ciphertext_len =
u32::try_from(ciphertext.len()).map_err(|_| SecureSecretStoreError::Corrupted)?;
let mut record = Vec::with_capacity(
RECORD_MAGIC.len() + 1 + 2 + 2 + 4 + handle_bytes.len() + nonce.len() + ciphertext.len(),
);
record.extend_from_slice(RECORD_MAGIC);
record.push(state);
record.extend_from_slice(&handle_len.to_be_bytes());
record.extend_from_slice(&nonce_len.to_be_bytes());
record.extend_from_slice(&ciphertext_len.to_be_bytes());
record.extend_from_slice(handle_bytes);
record.extend_from_slice(nonce);
record.extend_from_slice(ciphertext);
Ok(record)
}
fn decode_record(
bytes: &[u8],
expected_handle: &SecretHandle,
) -> Result<AndroidSealedValue, SecureSecretStoreError> {
let (handle, state, nonce, ciphertext) = parse_record(bytes)?;
if handle.as_str() != expected_handle.as_str() || state != RECORD_SEALED {
return Err(SecureSecretStoreError::Corrupted);
}
if nonce.is_empty() || ciphertext.is_empty() {
return Err(SecureSecretStoreError::Corrupted);
}
Ok(AndroidSealedValue { nonce, ciphertext })
}
fn decode_handle_from_record(bytes: &[u8]) -> Result<SecretHandle, SecureSecretStoreError> {
let (handle, _state, _nonce, _ciphertext) = parse_record(bytes)?;
Ok(handle)
}
fn parse_record(
bytes: &[u8],
) -> Result<(SecretHandle, u8, Vec<u8>, Vec<u8>), SecureSecretStoreError> {
const HEADER_LEN: usize = 8 + 1 + 2 + 2 + 4;
if bytes.len() < HEADER_LEN || &bytes[..8] != RECORD_MAGIC {
return Err(SecureSecretStoreError::Corrupted);
}
let state = bytes[8];
if state != RECORD_STAGED && state != RECORD_SEALED {
return Err(SecureSecretStoreError::Corrupted);
}
let handle_len = usize::from(u16::from_be_bytes([bytes[9], bytes[10]]));
let nonce_len = usize::from(u16::from_be_bytes([bytes[11], bytes[12]]));
let ciphertext_len = usize::try_from(u32::from_be_bytes([
bytes[13], bytes[14], bytes[15], bytes[16],
]))
.map_err(|_| SecureSecretStoreError::Corrupted)?;
let expected_len = HEADER_LEN
.checked_add(handle_len)
.and_then(|value| value.checked_add(nonce_len))
.and_then(|value| value.checked_add(ciphertext_len))
.ok_or(SecureSecretStoreError::Corrupted)?;
if bytes.len() != expected_len {
return Err(SecureSecretStoreError::Corrupted);
}
let handle_end = HEADER_LEN + handle_len;
let handle = std::str::from_utf8(&bytes[HEADER_LEN..handle_end])
.map_err(|_| SecureSecretStoreError::Corrupted)?;
let nonce_end = handle_end + nonce_len;
Ok((
SecretHandle(handle.to_string()),
state,
bytes[handle_end..nonce_end].to_vec(),
bytes[nonce_end..].to_vec(),
))
}
fn map_io_error(error: std::io::Error) -> SecureSecretStoreError {
match error.kind() {
std::io::ErrorKind::NotFound => SecureSecretStoreError::Missing,
std::io::ErrorKind::InvalidData => SecureSecretStoreError::Corrupted,
_ => SecureSecretStoreError::Unavailable,
}
}
fn sync_directory(path: &Path) -> Result<(), SecureSecretStoreError> {
File::open(path)
.and_then(|directory| directory.sync_all())
.map_err(map_io_error)
}
#[cfg(unix)]
fn set_private_directory_permissions(path: &Path) -> Result<(), SecureSecretStoreError> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(map_io_error)
}
#[cfg(not(unix))]
fn set_private_directory_permissions(_path: &Path) -> Result<(), SecureSecretStoreError> {
Ok(())
}
#[cfg(unix)]
fn set_private_file_permissions(path: &Path) -> Result<(), SecureSecretStoreError> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(map_io_error)
}
#[cfg(not(unix))]
fn set_private_file_permissions(_path: &Path) -> Result<(), SecureSecretStoreError> {
Ok(())
}
#[cfg(target_os = "android")]
#[path = "android_native.rs"]
pub(crate) mod native;

View File

@@ -0,0 +1,494 @@
use jni::{
errors::Error as JniError,
objects::{GlobalRef, JByteArray, JObject, JString, JValue},
sys::jboolean,
JNIEnv, JavaVM,
};
use std::{panic::AssertUnwindSafe, sync::Mutex};
use super::*;
const ANDROID_KEYSTORE: &str = "AndroidKeyStore";
const AES: &str = "AES";
const TRANSFORMATION: &str = "AES/GCM/NoPadding";
static ANDROID_APPLICATION_CONTEXT: Mutex<Option<GlobalRef>> = Mutex::new(None);
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_vnidrop_app_core_AndroidCoreRuntime_initialize(
mut env: JNIEnv<'_>,
_receiver: JObject<'_>,
context: JObject<'_>,
) -> jboolean {
std::panic::catch_unwind(AssertUnwindSafe(|| {
initialize_android_context(&mut env, context)
}))
.ok()
.and_then(Result::ok)
.map_or(0, |()| 1)
}
fn initialize_android_context(
env: &mut JNIEnv<'_>,
context: JObject<'_>,
) -> Result<(), SecureSecretStoreError> {
let mut stored = ANDROID_APPLICATION_CONTEXT
.lock()
.map_err(|_| SecureSecretStoreError::Unavailable)?;
if stored.is_some() {
return Ok(());
}
let vm = env
.get_java_vm()
.map_err(|_| SecureSecretStoreError::Unavailable)?;
let context = env
.new_global_ref(context)
.map_err(|_| SecureSecretStoreError::Unavailable)?;
unsafe {
ndk_context::initialize_android_context(
vm.get_java_vm_pointer().cast(),
context.as_obj().as_raw().cast(),
);
}
*stored = Some(context);
Ok(())
}
/// JNI-backed Android Keystore engine. The VM pointer comes from the Android
/// runtime; no Context or secret bytes are exposed through UniFFI.
pub(crate) struct AndroidJniKeystore {
vm: JavaVM,
}
impl AndroidJniKeystore {
/// Constructs the engine after the Android runtime has initialized
/// `ndk-context` with the process Java VM.
pub(crate) fn from_android_runtime() -> Result<Self, SecureSecretStoreError> {
let context = std::panic::catch_unwind(ndk_context::android_context)
.map_err(|_| SecureSecretStoreError::Unavailable)?;
let vm = context.vm();
if vm.is_null() {
return Err(SecureSecretStoreError::Unavailable);
}
// Android owns the process VM for longer than every core instance.
let vm = unsafe { JavaVM::from_raw(vm.cast()) }
.map_err(|_| SecureSecretStoreError::Unavailable)?;
Ok(Self { vm })
}
fn with_env<T>(
&self,
operation: impl FnOnce(&mut JNIEnv<'_>) -> Result<T, SecureSecretStoreError>,
) -> Result<T, SecureSecretStoreError> {
let mut env = self
.vm
.attach_current_thread()
.map_err(|_| SecureSecretStoreError::Unavailable)?;
operation(&mut env)
}
fn no_backup_files_dir(&self) -> Result<PathBuf, SecureSecretStoreError> {
self.with_env(|env| {
let context = std::panic::catch_unwind(ndk_context::android_context)
.map_err(|_| SecureSecretStoreError::Unavailable)?
.context();
if context.is_null() {
return Err(SecureSecretStoreError::Unavailable);
}
let context = local_ref_from_process_context(env, context.cast())?;
let directory = env
.call_method(&context, "getNoBackupFilesDir", "()Ljava/io/File;", &[])
.map_err(|error| map_jni_error(env, error))?
.l()
.map_err(|error| map_jni_error(env, error))?;
if directory.is_null() {
return Err(SecureSecretStoreError::Unavailable);
}
let path = env
.call_method(&directory, "getAbsolutePath", "()Ljava/lang/String;", &[])
.map_err(|error| map_jni_error(env, error))?
.l()
.map_err(|error| map_jni_error(env, error))?;
let path = JString::from(path);
let path: String = env
.get_string(&path)
.map_err(|error| map_jni_error(env, error))?
.into();
Ok(PathBuf::from(path))
})
}
}
pub(crate) fn create_store_from_android_runtime(
) -> Result<Arc<dyn SecureSecretStore>, SecureSecretStoreError> {
let keystore = Arc::new(AndroidJniKeystore::from_android_runtime()?);
let no_backup_dir = keystore.no_backup_files_dir()?;
Ok(Arc::new(AndroidSecureSecretStore::new(
&no_backup_dir,
keystore,
)?))
}
impl AndroidKeystore for AndroidJniKeystore {
fn seal(
&self,
alias: &str,
plaintext: &[u8],
) -> Result<AndroidSealedValue, SecureSecretStoreError> {
self.with_env(|env| {
let key_store = load_key_store(env)?;
let alias_string = env
.new_string(alias)
.map_err(|error| map_jni_error(env, error))?;
let alias_object = JObject::from(alias_string);
let contains = env
.call_method(
&key_store,
"containsAlias",
"(Ljava/lang/String;)Z",
&[JValue::Object(&alias_object)],
)
.map_err(|error| map_jni_error(env, error))?
.z()
.map_err(|error| map_jni_error(env, error))?;
if !contains {
generate_key(env, alias)?;
}
let key = get_key(env, &key_store, alias)?;
let cipher = cipher_instance(env)?;
env.call_method(
&cipher,
"init",
"(ILjava/security/Key;)V",
&[JValue::Int(1), JValue::Object(&key)],
)
.map_err(|error| map_jni_error(env, error))?;
let plaintext = env
.byte_array_from_slice(plaintext)
.map_err(|error| map_jni_error(env, error))?;
let plaintext_object = JObject::from(plaintext);
let ciphertext = env
.call_method(
&cipher,
"doFinal",
"([B)[B",
&[JValue::Object(&plaintext_object)],
)
.map_err(|error| map_jni_error(env, error))?
.l()
.map_err(|error| map_jni_error(env, error))?;
let nonce = env
.call_method(&cipher, "getIV", "()[B", &[])
.map_err(|error| map_jni_error(env, error))?
.l()
.map_err(|error| map_jni_error(env, error))?;
Ok(AndroidSealedValue {
nonce: env
.convert_byte_array(JByteArray::from(nonce))
.map_err(|error| map_jni_error(env, error))?,
ciphertext: env
.convert_byte_array(JByteArray::from(ciphertext))
.map_err(|error| map_jni_error(env, error))?,
})
})
}
fn open(
&self,
alias: &str,
sealed: &AndroidSealedValue,
) -> Result<Vec<u8>, SecureSecretStoreError> {
self.with_env(|env| {
let key_store = load_key_store(env)?;
let key = get_key(env, &key_store, alias)?;
let cipher = cipher_instance(env)?;
let nonce = env
.byte_array_from_slice(&sealed.nonce)
.map_err(|error| map_jni_error(env, error))?;
let nonce_object = JObject::from(nonce);
let parameters = env
.new_object(
"javax/crypto/spec/GCMParameterSpec",
"(I[B)V",
&[JValue::Int(128), JValue::Object(&nonce_object)],
)
.map_err(|error| map_jni_error(env, error))?;
env.call_method(
&cipher,
"init",
"(ILjava/security/Key;Ljava/security/spec/AlgorithmParameterSpec;)V",
&[
JValue::Int(2),
JValue::Object(&key),
JValue::Object(&parameters),
],
)
.map_err(|error| map_jni_error(env, error))?;
let ciphertext = env
.byte_array_from_slice(&sealed.ciphertext)
.map_err(|error| map_jni_error(env, error))?;
let ciphertext_object = JObject::from(ciphertext);
let plaintext = env
.call_method(
&cipher,
"doFinal",
"([B)[B",
&[JValue::Object(&ciphertext_object)],
)
.map_err(|error| map_jni_error(env, error))?
.l()
.map_err(|error| map_jni_error(env, error))?;
env.convert_byte_array(JByteArray::from(plaintext))
.map_err(|error| map_jni_error(env, error))
})
}
fn delete(&self, alias: &str) -> Result<(), SecureSecretStoreError> {
self.with_env(|env| {
let key_store = load_key_store(env)?;
let alias = env
.new_string(alias)
.map_err(|error| map_jni_error(env, error))?;
let alias_object = JObject::from(alias);
env.call_method(
&key_store,
"deleteEntry",
"(Ljava/lang/String;)V",
&[JValue::Object(&alias_object)],
)
.map_err(|error| map_jni_error(env, error))?;
Ok(())
})
}
}
fn load_key_store<'local>(
env: &mut JNIEnv<'local>,
) -> Result<JObject<'local>, SecureSecretStoreError> {
let provider = env
.new_string(ANDROID_KEYSTORE)
.map_err(|error| map_jni_error(env, error))?;
let provider_object = JObject::from(provider);
let key_store = env
.call_static_method(
"java/security/KeyStore",
"getInstance",
"(Ljava/lang/String;)Ljava/security/KeyStore;",
&[JValue::Object(&provider_object)],
)
.map_err(|error| map_jni_error(env, error))?
.l()
.map_err(|error| map_jni_error(env, error))?;
env.call_method(
&key_store,
"load",
"(Ljava/security/KeyStore$LoadStoreParameter;)V",
&[JValue::Object(&JObject::null())],
)
.map_err(|error| map_jni_error(env, error))?;
Ok(key_store)
}
fn generate_key(env: &mut JNIEnv<'_>, alias: &str) -> Result<(), SecureSecretStoreError> {
let algorithm = env
.new_string(AES)
.map_err(|error| map_jni_error(env, error))?;
let provider = env
.new_string(ANDROID_KEYSTORE)
.map_err(|error| map_jni_error(env, error))?;
let algorithm_object = JObject::from(algorithm);
let provider_object = JObject::from(provider);
let generator = env
.call_static_method(
"javax/crypto/KeyGenerator",
"getInstance",
"(Ljava/lang/String;Ljava/lang/String;)Ljavax/crypto/KeyGenerator;",
&[
JValue::Object(&algorithm_object),
JValue::Object(&provider_object),
],
)
.map_err(|error| map_jni_error(env, error))?
.l()
.map_err(|error| map_jni_error(env, error))?;
let alias = env
.new_string(alias)
.map_err(|error| map_jni_error(env, error))?;
let alias_object = JObject::from(alias);
let builder = env
.new_object(
"android/security/keystore/KeyGenParameterSpec$Builder",
"(Ljava/lang/String;I)V",
&[JValue::Object(&alias_object), JValue::Int(3)],
)
.map_err(|error| map_jni_error(env, error))?;
let modes = java_string_array(env, "GCM")?;
env.call_method(
&builder,
"setBlockModes",
"([Ljava/lang/String;)Landroid/security/keystore/KeyGenParameterSpec$Builder;",
&[JValue::Object(&modes)],
)
.map_err(|error| map_jni_error(env, error))?;
let paddings = java_string_array(env, "NoPadding")?;
env.call_method(
&builder,
"setEncryptionPaddings",
"([Ljava/lang/String;)Landroid/security/keystore/KeyGenParameterSpec$Builder;",
&[JValue::Object(&paddings)],
)
.map_err(|error| map_jni_error(env, error))?;
env.call_method(
&builder,
"setKeySize",
"(I)Landroid/security/keystore/KeyGenParameterSpec$Builder;",
&[JValue::Int(256)],
)
.map_err(|error| map_jni_error(env, error))?;
env.call_method(
&builder,
"setRandomizedEncryptionRequired",
"(Z)Landroid/security/keystore/KeyGenParameterSpec$Builder;",
&[JValue::Bool(1)],
)
.map_err(|error| map_jni_error(env, error))?;
let parameters = env
.call_method(
&builder,
"build",
"()Landroid/security/keystore/KeyGenParameterSpec;",
&[],
)
.map_err(|error| map_jni_error(env, error))?
.l()
.map_err(|error| map_jni_error(env, error))?;
env.call_method(
&generator,
"init",
"(Ljava/security/spec/AlgorithmParameterSpec;)V",
&[JValue::Object(&parameters)],
)
.map_err(|error| map_jni_error(env, error))?;
env.call_method(&generator, "generateKey", "()Ljavax/crypto/SecretKey;", &[])
.map_err(|error| map_jni_error(env, error))?;
Ok(())
}
fn get_key<'local>(
env: &mut JNIEnv<'local>,
key_store: &JObject<'local>,
alias: &str,
) -> Result<JObject<'local>, SecureSecretStoreError> {
let alias = env
.new_string(alias)
.map_err(|error| map_jni_error(env, error))?;
let alias_object = JObject::from(alias);
let key = env
.call_method(
key_store,
"getKey",
"(Ljava/lang/String;[C)Ljava/security/Key;",
&[
JValue::Object(&alias_object),
JValue::Object(&JObject::null()),
],
)
.map_err(|error| map_jni_error(env, error))?
.l()
.map_err(|error| map_jni_error(env, error))?;
if key.is_null() {
return Err(SecureSecretStoreError::Missing);
}
Ok(key)
}
fn cipher_instance<'local>(
env: &mut JNIEnv<'local>,
) -> Result<JObject<'local>, SecureSecretStoreError> {
let transformation = env
.new_string(TRANSFORMATION)
.map_err(|error| map_jni_error(env, error))?;
let transformation_object = JObject::from(transformation);
env.call_static_method(
"javax/crypto/Cipher",
"getInstance",
"(Ljava/lang/String;)Ljavax/crypto/Cipher;",
&[JValue::Object(&transformation_object)],
)
.map_err(|error| map_jni_error(env, error))?
.l()
.map_err(|error| map_jni_error(env, error))
}
fn java_string_array<'local>(
env: &mut JNIEnv<'local>,
value: &str,
) -> Result<JObject<'local>, SecureSecretStoreError> {
let class = env
.find_class("java/lang/String")
.map_err(|error| map_jni_error(env, error))?;
let array = env
.new_object_array(1, class, JObject::null())
.map_err(|error| map_jni_error(env, error))?;
let value = env
.new_string(value)
.map_err(|error| map_jni_error(env, error))?;
env.set_object_array_element(&array, 0, value)
.map_err(|error| map_jni_error(env, error))?;
Ok(JObject::from(array))
}
fn map_jni_error(env: &mut JNIEnv<'_>, _error: JniError) -> SecureSecretStoreError {
let has_exception = env.exception_check().unwrap_or(false);
if !has_exception {
return SecureSecretStoreError::Unavailable;
}
let exception = match env.exception_occurred() {
Ok(exception) => exception,
Err(_) => return SecureSecretStoreError::Unavailable,
};
let _ = env.exception_clear();
if is_instance_of(
env,
&exception,
"android/security/keystore/UserNotAuthenticatedException",
) {
SecureSecretStoreError::Locked
} else if is_instance_of(
env,
&exception,
"android/security/keystore/KeyPermanentlyInvalidatedException",
) || is_instance_of(env, &exception, "javax/crypto/AEADBadTagException")
|| is_instance_of(env, &exception, "javax/crypto/BadPaddingException")
{
SecureSecretStoreError::Corrupted
} else if is_instance_of(env, &exception, "java/security/UnrecoverableKeyException") {
SecureSecretStoreError::Missing
} else {
SecureSecretStoreError::Unavailable
}
}
fn is_instance_of(env: &mut JNIEnv<'_>, object: &JObject<'_>, class: &str) -> bool {
env.is_instance_of(object, class).unwrap_or(false)
}
fn local_ref_from_process_context<'local>(
env: &mut JNIEnv<'local>,
context: jni::sys::jobject,
) -> Result<JObject<'local>, SecureSecretStoreError> {
let interface = env.get_native_interface();
// ndk-context retains a process-wide global Context reference. JNI NewLocalRef
// is required before representing it as a frame-bound JObject.
let context = unsafe {
let new_local_ref = (**interface)
.NewLocalRef
.ok_or(SecureSecretStoreError::Unavailable)?;
new_local_ref(interface, context)
};
if context.is_null() {
return Err(SecureSecretStoreError::Unavailable);
}
// NewLocalRef created this reference in the currently attached JNI frame.
Ok(unsafe { JObject::from_raw(context) })
}

View File

@@ -0,0 +1,207 @@
use super::{SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError};
use security_framework::{
access_control::{ProtectionMode, SecAccessControl},
item::{ItemClass, ItemSearchOptions, Limit, SearchResult},
passwords::{
delete_generic_password_options, generic_password, set_generic_password_options,
PasswordOptions,
},
};
use std::sync::Arc;
const SERVICE: &str = "com.vnidrop.secure-secrets.v1";
const ACCOUNT_ATTRIBUTE: &str = "acct";
const ERR_SEC_PARAM: i32 = -50;
const ERR_SEC_AUTH_FAILED: i32 = -25_293;
const ERR_SEC_NOT_AVAILABLE: i32 = -25_291;
const ERR_SEC_ITEM_NOT_FOUND: i32 = -25_300;
const ERR_SEC_INTERACTION_NOT_ALLOWED: i32 = -25_308;
const ERR_SEC_DECODE: i32 = -26_275;
const ERR_SEC_MISSING_ENTITLEMENT: i32 = -34_018;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AppleAccessibility {
AfterFirstUnlockThisDeviceOnly,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct AppleKeychainPolicy {
accessibility: AppleAccessibility,
synchronizable: bool,
data_protection_keychain: bool,
}
impl Default for AppleKeychainPolicy {
fn default() -> Self {
Self {
accessibility: AppleAccessibility::AfterFirstUnlockThisDeviceOnly,
synchronizable: false,
data_protection_keychain: true,
}
}
}
pub(crate) trait AppleKeychainApi: Send + Sync {
fn put(
&self,
service: &str,
account: &str,
material: &[u8],
policy: AppleKeychainPolicy,
) -> Result<(), i32>;
fn get(&self, service: &str, account: &str) -> Result<Vec<u8>, i32>;
fn delete(&self, service: &str, account: &str) -> Result<(), i32>;
fn list_accounts(&self, service: &str) -> Result<Vec<String>, i32>;
}
#[derive(Default)]
struct SystemAppleKeychain;
impl SystemAppleKeychain {
fn options(service: &str, account: &str) -> PasswordOptions {
let mut options = PasswordOptions::new_generic_password(service, account);
options.set_access_synchronized(Some(false));
options.use_protected_keychain();
options
}
}
impl AppleKeychainApi for SystemAppleKeychain {
fn put(
&self,
service: &str,
account: &str,
material: &[u8],
policy: AppleKeychainPolicy,
) -> Result<(), i32> {
debug_assert_eq!(policy, AppleKeychainPolicy::default());
let access_control = SecAccessControl::create_with_protection(
Some(ProtectionMode::AccessibleAfterFirstUnlockThisDeviceOnly),
0,
)
.map_err(|error| error.code())?;
let mut options = Self::options(service, account);
options.set_access_control(access_control);
set_generic_password_options(material, options).map_err(|error| error.code())
}
fn get(&self, service: &str, account: &str) -> Result<Vec<u8>, i32> {
generic_password(Self::options(service, account)).map_err(|error| error.code())
}
fn delete(&self, service: &str, account: &str) -> Result<(), i32> {
delete_generic_password_options(Self::options(service, account))
.map_err(|error| error.code())
}
fn list_accounts(&self, service: &str) -> Result<Vec<String>, i32> {
let mut options = ItemSearchOptions::new();
options
.class(ItemClass::generic_password())
.service(service)
.cloud_sync(Some(false))
.load_attributes(true)
.limit(Limit::All);
#[cfg(target_os = "macos")]
options.ignore_legacy_keychains();
let results = match options.search() {
Ok(results) => results,
Err(error) if error.code() == ERR_SEC_ITEM_NOT_FOUND => return Ok(Vec::new()),
Err(error) => return Err(error.code()),
};
results
.into_iter()
.map(|result| match result {
SearchResult::Dict(_) => result
.simplify_dict()
.and_then(|attributes| attributes.get(ACCOUNT_ATTRIBUTE).cloned())
.ok_or(ERR_SEC_DECODE),
_ => Err(ERR_SEC_DECODE),
})
.collect()
}
}
/// Stores VniDrop's protected material in Apple's device-local data-protection Keychain.
pub(crate) struct AppleKeychainSecretStore {
api: Arc<dyn AppleKeychainApi>,
}
impl AppleKeychainSecretStore {
pub(crate) fn new() -> Self {
Self {
api: Arc::new(SystemAppleKeychain),
}
}
#[cfg(test)]
pub(crate) fn with_api(api: impl AppleKeychainApi + 'static) -> Self {
Self { api: Arc::new(api) }
}
}
impl SecureSecretStore for AppleKeychainSecretStore {
fn put(
&self,
handle: &SecretHandle,
material: SecretMaterial,
) -> Result<(), SecureSecretStoreError> {
self.api
.put(
SERVICE,
handle.as_str(),
&material.0,
AppleKeychainPolicy::default(),
)
.map_err(map_status)
}
fn get(&self, handle: &SecretHandle) -> Result<SecretMaterial, SecureSecretStoreError> {
let material = self.api.get(SERVICE, handle.as_str()).map_err(map_status)?;
SecretMaterial::new(material).map_err(|_| SecureSecretStoreError::Corrupted)
}
fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> {
self.api
.delete(SERVICE, handle.as_str())
.map_err(map_status)
}
fn list_handles(&self) -> Result<Vec<SecretHandle>, SecureSecretStoreError> {
self.api
.list_accounts(SERVICE)
.map(|accounts| accounts.into_iter().map(SecretHandle).collect())
.map_err(map_status)
}
}
fn map_status(status: i32) -> SecureSecretStoreError {
match status {
ERR_SEC_ITEM_NOT_FOUND => SecureSecretStoreError::Missing,
ERR_SEC_INTERACTION_NOT_ALLOWED | ERR_SEC_AUTH_FAILED => SecureSecretStoreError::Locked,
ERR_SEC_DECODE | ERR_SEC_PARAM => SecureSecretStoreError::Corrupted,
ERR_SEC_NOT_AVAILABLE | ERR_SEC_MISSING_ENTITLEMENT => SecureSecretStoreError::Unavailable,
_ => SecureSecretStoreError::Unavailable,
}
}
#[cfg(test)]
pub(crate) fn expected_policy_for_test() -> AppleKeychainPolicy {
AppleKeychainPolicy::default()
}
#[cfg(test)]
pub(crate) fn service_for_test() -> &'static str {
SERVICE
}
#[cfg(test)]
pub(crate) fn handle_for_test(value: &str) -> SecretHandle {
SecretHandle(value.to_string())
}
#[cfg(test)]
pub(crate) fn map_status_for_test(status: i32) -> SecureSecretStoreError {
map_status(status)
}

View File

@@ -0,0 +1,184 @@
use std::{collections::HashMap, sync::Arc};
// Blocking Secret Service / zbus owns a nested Tokio runtime. Never call this
// adapter from a thread already inside Vnidrop's runtime — SecretCustody routes
// store IO through `spawn_blocking` for that reason.
use secret_service::{blocking::SecretService, EncryptionType, Error};
use super::{
SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError, HANDLE_NAMESPACE,
HANDLE_VERSION,
};
const ATTRIBUTE_APPLICATION: &str = "application";
const ATTRIBUTE_HANDLE: &str = "vnidrop-handle";
const APPLICATION_ID: &str = "com.vnidrop.VniDrop";
const ITEM_LABEL: &str = "VniDrop protected secret";
pub(crate) trait LinuxSecretServiceApi: Send + Sync {
fn put(&self, handle: &str, material: &[u8]) -> Result<(), SecureSecretStoreError>;
fn get(&self, handle: &str) -> Result<Vec<u8>, SecureSecretStoreError>;
fn delete(&self, handle: &str) -> Result<(), SecureSecretStoreError>;
fn list_handles(&self) -> Result<Vec<String>, SecureSecretStoreError>;
}
struct SystemLinuxSecretService;
impl SystemLinuxSecretService {
fn connect() -> Result<Self, SecureSecretStoreError> {
SecretService::connect(EncryptionType::Dh).map_err(map_error)?;
Ok(Self)
}
fn service(&self) -> Result<SecretService<'_>, SecureSecretStoreError> {
SecretService::connect(EncryptionType::Dh).map_err(map_error)
}
}
impl LinuxSecretServiceApi for SystemLinuxSecretService {
fn put(&self, handle: &str, material: &[u8]) -> Result<(), SecureSecretStoreError> {
let service = self.service()?;
let collection = service.get_default_collection().map_err(map_error)?;
if collection.is_locked().map_err(map_error)? {
return Err(SecureSecretStoreError::Locked);
}
collection
.create_item(
ITEM_LABEL,
HashMap::from([
(ATTRIBUTE_APPLICATION, APPLICATION_ID),
(ATTRIBUTE_HANDLE, handle),
]),
material,
true,
"application/octet-stream",
)
.map_err(map_error)?;
Ok(())
}
fn get(&self, handle: &str) -> Result<Vec<u8>, SecureSecretStoreError> {
let service = self.service()?;
let result = service
.search_items(HashMap::from([
(ATTRIBUTE_APPLICATION, APPLICATION_ID),
(ATTRIBUTE_HANDLE, handle),
]))
.map_err(map_error)?;
if !result.locked.is_empty() {
return Err(SecureSecretStoreError::Locked);
}
let mut items = result.unlocked.into_iter();
let item = items.next().ok_or(SecureSecretStoreError::Missing)?;
if items.next().is_some() {
return Err(SecureSecretStoreError::Corrupted);
}
item.get_secret().map_err(map_error)
}
fn delete(&self, handle: &str) -> Result<(), SecureSecretStoreError> {
let service = self.service()?;
let result = service
.search_items(HashMap::from([
(ATTRIBUTE_APPLICATION, APPLICATION_ID),
(ATTRIBUTE_HANDLE, handle),
]))
.map_err(map_error)?;
if !result.locked.is_empty() {
return Err(SecureSecretStoreError::Locked);
}
if result.unlocked.is_empty() {
return Err(SecureSecretStoreError::Missing);
}
for item in result.unlocked {
item.delete().map_err(map_error)?;
}
Ok(())
}
fn list_handles(&self) -> Result<Vec<String>, SecureSecretStoreError> {
let service = self.service()?;
let result = service
.search_items(HashMap::from([(ATTRIBUTE_APPLICATION, APPLICATION_ID)]))
.map_err(map_error)?;
if !result.locked.is_empty() {
return Err(SecureSecretStoreError::Locked);
}
result
.unlocked
.into_iter()
.map(|item| {
item.get_attributes()
.map_err(map_error)?
.remove(ATTRIBUTE_HANDLE)
.ok_or(SecureSecretStoreError::Corrupted)
})
.collect()
}
}
pub(crate) struct LinuxSecretServiceStore {
api: Arc<dyn LinuxSecretServiceApi>,
}
impl LinuxSecretServiceStore {
pub(crate) fn connect() -> Result<Self, SecureSecretStoreError> {
Ok(Self {
api: Arc::new(SystemLinuxSecretService::connect()?),
})
}
#[cfg(test)]
pub(crate) fn with_api(api: Arc<dyn LinuxSecretServiceApi>) -> Self {
Self { api }
}
}
impl SecureSecretStore for LinuxSecretServiceStore {
fn put(
&self,
handle: &SecretHandle,
material: SecretMaterial,
) -> Result<(), SecureSecretStoreError> {
self.api.put(handle.as_str(), &material.0)
}
fn get(&self, handle: &SecretHandle) -> Result<SecretMaterial, SecureSecretStoreError> {
let bytes = self.api.get(handle.as_str())?;
SecretMaterial::new(bytes).map_err(|_| SecureSecretStoreError::Corrupted)
}
fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> {
self.api.delete(handle.as_str())
}
fn list_handles(&self) -> Result<Vec<SecretHandle>, SecureSecretStoreError> {
let expected_prefix = format!("{HANDLE_NAMESPACE}/{HANDLE_VERSION}/");
let mut handles = self
.api
.list_handles()?
.into_iter()
.map(|handle| {
if handle.starts_with(&expected_prefix) {
Ok(SecretHandle(handle))
} else {
Err(SecureSecretStoreError::Corrupted)
}
})
.collect::<Result<Vec<_>, _>>()?;
handles.sort_by(|left, right| left.as_str().cmp(right.as_str()));
Ok(handles)
}
}
pub(crate) fn map_error(error: Error) -> SecureSecretStoreError {
match error {
Error::Locked | Error::Prompt => SecureSecretStoreError::Locked,
Error::NoResult => SecureSecretStoreError::Missing,
Error::Crypto(_) => SecureSecretStoreError::Corrupted,
Error::Unavailable | Error::Zvariant(_) | Error::Zbus(_) | Error::ZbusFdo(_) => {
SecureSecretStoreError::Unavailable
}
_ => SecureSecretStoreError::Unavailable,
}
}

View File

@@ -0,0 +1,191 @@
use std::{
fs::{File, OpenOptions},
io,
path::Path,
sync::Arc,
};
#[cfg(any(target_os = "android", target_os = "windows", target_os = "linux"))]
use super::map_store_error;
use super::{
SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError, HANDLE_NAMESPACE,
HANDLE_VERSION,
};
use crate::error::VnidropError;
struct ScopedSecretStore {
inner: Arc<dyn SecureSecretStore>,
physical_prefix: String,
}
pub(crate) struct ProfileLock {
_file: File,
}
pub(crate) fn lock_profile(app_data_dir: &Path) -> Result<ProfileLock, VnidropError> {
let file = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(app_data_dir.join("protected-secrets.lock"))
.map_err(VnidropError::filesystem)?;
lock_exclusive_nonblocking(&file)?;
Ok(ProfileLock { _file: file })
}
/// Acquire an exclusive advisory lock without blocking.
///
/// Prefer `libc::flock` on Unix: Rust's `File::try_lock` still returns
/// `ErrorKind::Unsupported` on Android even though the kernel supports flock.
fn lock_exclusive_nonblocking(file: &File) -> Result<(), VnidropError> {
#[cfg(unix)]
{
use std::os::fd::AsRawFd;
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if rc == 0 {
return Ok(());
}
let err = io::Error::last_os_error();
Err(match err.kind() {
io::ErrorKind::WouldBlock => VnidropError::SecureStorageUnavailable {
reason: "another protected core is already using this profile".to_string(),
},
_ => VnidropError::filesystem(err),
})
}
#[cfg(windows)]
{
match file.try_lock() {
Ok(()) => Ok(()),
Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
Err(VnidropError::SecureStorageUnavailable {
reason: "another protected core is already using this profile".to_string(),
})
}
Err(err) => Err(VnidropError::filesystem(err)),
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = file;
Err(VnidropError::SecureStorageUnavailable {
reason: "profile locking is unsupported on this platform".to_string(),
})
}
}
/// Opens the profile marker without locking so in-process restart tests can
/// reopen the same directory after dropping the previous core.
#[cfg(test)]
pub(crate) fn unlocked_profile_for_test(app_data_dir: &Path) -> Result<ProfileLock, VnidropError> {
let file = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(app_data_dir.join("protected-secrets.lock"))
.map_err(VnidropError::filesystem)?;
Ok(ProfileLock { _file: file })
}
impl ScopedSecretStore {
fn new(app_data_dir: &Path, inner: Arc<dyn SecureSecretStore>) -> Self {
let profile = blake3::hash(app_data_dir.to_string_lossy().as_bytes()).to_hex();
Self {
inner,
physical_prefix: format!("{HANDLE_NAMESPACE}/{HANDLE_VERSION}/scope-{profile}/"),
}
}
fn physical_handle(
&self,
handle: &SecretHandle,
) -> Result<SecretHandle, SecureSecretStoreError> {
let logical_prefix = format!("{HANDLE_NAMESPACE}/{HANDLE_VERSION}/");
let suffix = handle
.as_str()
.strip_prefix(&logical_prefix)
.ok_or(SecureSecretStoreError::Corrupted)?;
Ok(SecretHandle(format!("{}{suffix}", self.physical_prefix)))
}
}
impl SecureSecretStore for ScopedSecretStore {
fn put(
&self,
handle: &SecretHandle,
material: SecretMaterial,
) -> Result<(), SecureSecretStoreError> {
self.inner.put(&self.physical_handle(handle)?, material)
}
fn get(&self, handle: &SecretHandle) -> Result<SecretMaterial, SecureSecretStoreError> {
self.inner.get(&self.physical_handle(handle)?)
}
fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> {
self.inner.delete(&self.physical_handle(handle)?)
}
fn list_handles(&self) -> Result<Vec<SecretHandle>, SecureSecretStoreError> {
let handles = self
.inner
.list_handles()?
.into_iter()
.filter_map(|handle| {
handle
.as_str()
.strip_prefix(&self.physical_prefix)
.map(|suffix| {
SecretHandle(format!("{HANDLE_NAMESPACE}/{HANDLE_VERSION}/{suffix}"))
})
})
.collect();
Ok(handles)
}
}
pub(crate) fn scope_store(
app_data_dir: &Path,
store: Arc<dyn SecureSecretStore>,
) -> Arc<dyn SecureSecretStore> {
Arc::new(ScopedSecretStore::new(app_data_dir, store))
}
#[cfg(any(target_os = "macos", target_os = "ios"))]
pub(crate) fn platform_secret_store(
app_data_dir: &Path,
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> {
Ok(scope_store(
app_data_dir,
Arc::new(super::apple::AppleKeychainSecretStore::new()),
))
}
#[cfg(target_os = "android")]
pub(crate) fn platform_secret_store(
app_data_dir: &Path,
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> {
super::android::native::create_store_from_android_runtime()
.map(|store| scope_store(app_data_dir, store))
.map_err(map_store_error)
}
#[cfg(target_os = "windows")]
pub(crate) fn platform_secret_store(
app_data_dir: &Path,
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> {
super::windows::WindowsDpapiSecretStore::new(app_data_dir.join("protected-secrets-v1"))
.map(|store| scope_store(app_data_dir, Arc::new(store)))
.map_err(map_store_error)
}
#[cfg(target_os = "linux")]
pub(crate) fn platform_secret_store(
app_data_dir: &Path,
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> {
super::linux::LinuxSecretServiceStore::connect()
.map(|store| scope_store(app_data_dir, Arc::new(store)))
.map_err(map_store_error)
}

View File

@@ -0,0 +1,660 @@
use std::{
collections::HashSet,
ffi::OsStr,
fs::{self, OpenOptions},
io::{self, Write},
path::{Path, PathBuf},
sync::Arc,
};
use data_encoding::HEXLOWER;
use super::{SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError};
#[cfg(test)]
use super::SecretKind;
#[cfg(target_os = "windows")]
use std::{os::windows::ffi::OsStrExt, ptr};
#[cfg(target_os = "windows")]
use windows_sys::Win32::{
Foundation::{
GetLastError, LocalFree, ERROR_ACCESS_DENIED, ERROR_ALREADY_EXISTS,
ERROR_CALL_NOT_IMPLEMENTED, ERROR_FILE_EXISTS, ERROR_NOT_SUPPORTED,
ERROR_PASSWORD_RESTRICTION,
},
Security::Cryptography::{
CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB,
},
Storage::FileSystem::{MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH},
};
const ENVELOPE_MAGIC: &[u8; 8] = b"VNIDPAPI";
const ENVELOPE_VERSION: u8 = 1;
const FILE_EXTENSION: &str = "dpapi";
const MAX_ENVELOPE_BYTES: usize = 64 * 1024;
const DEFAULT_CONTEXT: &[u8] = b"com.vnidrop.secure-secret.dpapi.v1.current-user";
const PROTECTED_PAYLOAD_MAGIC: &[u8] = b"VNIDROP-SECRET-V1";
/// Current-user DPAPI (or injectable stand-in) used by [`WindowsDpapiSecretStore`].
pub(crate) trait WindowsDpapiApi: Send + Sync {
fn protect(
&self,
handle: &SecretHandle,
plaintext: &[u8],
) -> Result<Vec<u8>, SecureSecretStoreError>;
fn unprotect(
&self,
handle: &SecretHandle,
ciphertext: &[u8],
) -> Result<Vec<u8>, SecureSecretStoreError>;
}
/// Current-user DPAPI storage backed by atomically published protected blobs.
pub(crate) struct WindowsDpapiSecretStore {
directory: PathBuf,
api: Arc<dyn WindowsDpapiApi>,
}
impl WindowsDpapiSecretStore {
#[cfg(target_os = "windows")]
pub(crate) fn new(directory: impl AsRef<Path>) -> Result<Self, SecureSecretStoreError> {
Self::with_api(directory, Arc::new(SystemWindowsDpapiApi::new()))
}
pub(crate) fn with_api(
directory: impl AsRef<Path>,
api: Arc<dyn WindowsDpapiApi>,
) -> Result<Self, SecureSecretStoreError> {
let directory = directory.as_ref().to_path_buf();
fs::create_dir_all(&directory).map_err(map_io_error)?;
cleanup_interrupted_writes(&directory)?;
Ok(Self { directory, api })
}
fn path_for(&self, handle: &SecretHandle) -> PathBuf {
let digest = blake3::hash(handle.as_str().as_bytes());
self.directory.join(format!(
"{}.{}",
HEXLOWER.encode(digest.as_bytes()),
FILE_EXTENSION
))
}
#[cfg(all(test, target_os = "windows"))]
pub(crate) fn with_context_for_test(
directory: impl AsRef<Path>,
context: &[u8],
) -> Result<Self, SecureSecretStoreError> {
Self::with_api(
directory,
Arc::new(SystemWindowsDpapiApi::with_context_for_test(context)),
)
}
#[cfg(all(test, not(target_os = "windows")))]
pub(crate) fn with_context_for_test(
directory: impl AsRef<Path>,
context: &[u8],
) -> Result<Self, SecureSecretStoreError> {
Self::with_api(
directory,
Arc::new(FakeWindowsDpapiApi::with_context(context)),
)
}
#[cfg(test)]
pub(crate) fn path_for_test(&self, handle: &SecretHandle) -> PathBuf {
self.path_for(handle)
}
#[cfg(test)]
pub(crate) fn relationship_handle_for_test() -> SecretHandle {
SecretHandle::generate(SecretKind::RelationshipGrant)
}
}
impl SecureSecretStore for WindowsDpapiSecretStore {
fn put(
&self,
handle: &SecretHandle,
material: SecretMaterial,
) -> Result<(), SecureSecretStoreError> {
let destination = self.path_for(handle);
let replace_existing = match self.get(handle) {
Ok(existing) if existing == material => return Ok(()),
Ok(_) => true,
Err(SecureSecretStoreError::Missing) => false,
Err(error) => return Err(error),
};
let ciphertext = self.api.protect(handle, &material.0)?;
let envelope = encode_envelope(handle, &ciphertext)?;
let temporary = self.directory.join(format!(
"{}.tmp-{}",
destination
.file_stem()
.and_then(OsStr::to_str)
.ok_or(SecureSecretStoreError::Unavailable)?,
uuid::Uuid::new_v4()
));
let mut temporary_guard = TemporaryFile::new(temporary);
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(temporary_guard.path())
.map_err(map_io_error)?;
file.write_all(&envelope).map_err(map_io_error)?;
file.sync_all().map_err(map_io_error)?;
drop(file);
match move_write_through(temporary_guard.path(), &destination, replace_existing) {
Ok(()) => {
temporary_guard.disarm();
Ok(())
}
Err(error)
if matches!(
error.raw_os_error().map(|code| code as u32),
Some(ERROR_ALREADY_EXISTS_CODE) | Some(ERROR_FILE_EXISTS_CODE)
) || error.kind() == io::ErrorKind::AlreadyExists =>
{
match self.get(handle) {
Ok(existing) if existing == material => Ok(()),
Ok(_) => {
move_write_through(temporary_guard.path(), &destination, true)
.map_err(map_io_error)?;
temporary_guard.disarm();
Ok(())
}
Err(error) => Err(error),
}
}
Err(error) => Err(map_io_error(error)),
}
}
fn get(&self, handle: &SecretHandle) -> Result<SecretMaterial, SecureSecretStoreError> {
let envelope = fs::read(self.path_for(handle)).map_err(map_io_error)?;
let ciphertext = decode_envelope(&envelope, handle)?;
let plaintext = self.api.unprotect(handle, ciphertext)?;
SecretMaterial::new(plaintext).map_err(|_| SecureSecretStoreError::Corrupted)
}
fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> {
fs::remove_file(self.path_for(handle)).map_err(map_io_error)
}
fn list_handles(&self) -> Result<Vec<SecretHandle>, SecureSecretStoreError> {
let mut handles = Vec::new();
let mut unique = HashSet::new();
for entry in fs::read_dir(&self.directory).map_err(map_io_error)? {
let entry = entry.map_err(map_io_error)?;
let path = entry.path();
if path.extension() != Some(OsStr::new(FILE_EXTENSION)) {
continue;
}
let envelope = fs::read(&path).map_err(map_io_error)?;
let (handle, _) = decode_envelope_parts(&envelope)?;
if self.path_for(&handle) != path || !unique.insert(handle.clone()) {
return Err(SecureSecretStoreError::Corrupted);
}
handles.push(handle);
}
handles.sort_by(|left, right| left.as_str().cmp(right.as_str()));
Ok(handles)
}
}
fn encode_envelope(
handle: &SecretHandle,
ciphertext: &[u8],
) -> Result<Vec<u8>, SecureSecretStoreError> {
let handle_bytes = handle.as_str().as_bytes();
let handle_len =
u16::try_from(handle_bytes.len()).map_err(|_| SecureSecretStoreError::Unavailable)?;
let ciphertext_len =
u32::try_from(ciphertext.len()).map_err(|_| SecureSecretStoreError::Unavailable)?;
let capacity = ENVELOPE_MAGIC.len() + 1 + 2 + 4 + handle_bytes.len() + ciphertext.len();
if capacity > MAX_ENVELOPE_BYTES {
return Err(SecureSecretStoreError::Unavailable);
}
let mut envelope = Vec::with_capacity(capacity);
envelope.extend_from_slice(ENVELOPE_MAGIC);
envelope.push(ENVELOPE_VERSION);
envelope.extend_from_slice(&handle_len.to_le_bytes());
envelope.extend_from_slice(&ciphertext_len.to_le_bytes());
envelope.extend_from_slice(handle_bytes);
envelope.extend_from_slice(ciphertext);
Ok(envelope)
}
fn decode_envelope<'a>(
envelope: &'a [u8],
expected_handle: &SecretHandle,
) -> Result<&'a [u8], SecureSecretStoreError> {
let (handle, ciphertext) = decode_envelope_parts(envelope)?;
if handle != *expected_handle {
return Err(SecureSecretStoreError::Corrupted);
}
Ok(ciphertext)
}
fn decode_envelope_parts(envelope: &[u8]) -> Result<(SecretHandle, &[u8]), SecureSecretStoreError> {
const HEADER_BYTES: usize = 8 + 1 + 2 + 4;
if envelope.len() < HEADER_BYTES
|| envelope.len() > MAX_ENVELOPE_BYTES
|| &envelope[..8] != ENVELOPE_MAGIC
|| envelope[8] != ENVELOPE_VERSION
{
return Err(SecureSecretStoreError::Corrupted);
}
let handle_len = usize::from(u16::from_le_bytes([envelope[9], envelope[10]]));
let ciphertext_len =
u32::from_le_bytes([envelope[11], envelope[12], envelope[13], envelope[14]]) as usize;
let handle_end = HEADER_BYTES
.checked_add(handle_len)
.ok_or(SecureSecretStoreError::Corrupted)?;
let envelope_end = handle_end
.checked_add(ciphertext_len)
.ok_or(SecureSecretStoreError::Corrupted)?;
if handle_len == 0 || ciphertext_len == 0 || envelope_end != envelope.len() {
return Err(SecureSecretStoreError::Corrupted);
}
let handle = std::str::from_utf8(&envelope[HEADER_BYTES..handle_end])
.map_err(|_| SecureSecretStoreError::Corrupted)?;
Ok((
SecretHandle(handle.to_owned()),
&envelope[handle_end..envelope_end],
))
}
fn cleanup_interrupted_writes(directory: &Path) -> Result<(), SecureSecretStoreError> {
for entry in fs::read_dir(directory).map_err(map_io_error)? {
let entry = entry.map_err(map_io_error)?;
let name = entry.file_name();
if name.to_string_lossy().contains(".tmp-") {
fs::remove_file(entry.path()).map_err(map_io_error)?;
}
}
Ok(())
}
struct TemporaryFile {
path: PathBuf,
armed: bool,
}
impl TemporaryFile {
fn new(path: PathBuf) -> Self {
Self { path, armed: true }
}
fn path(&self) -> &Path {
&self.path
}
fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for TemporaryFile {
fn drop(&mut self) {
if self.armed {
let _ = fs::remove_file(&self.path);
}
}
}
#[cfg(target_os = "windows")]
const ERROR_ALREADY_EXISTS_CODE: u32 = ERROR_ALREADY_EXISTS;
#[cfg(target_os = "windows")]
const ERROR_FILE_EXISTS_CODE: u32 = ERROR_FILE_EXISTS;
#[cfg(not(target_os = "windows"))]
const ERROR_ALREADY_EXISTS_CODE: u32 = 183;
#[cfg(not(target_os = "windows"))]
const ERROR_FILE_EXISTS_CODE: u32 = 80;
fn move_write_through(source: &Path, destination: &Path, replace_existing: bool) -> io::Result<()> {
#[cfg(target_os = "windows")]
{
let source = wide_path(source);
let destination = wide_path(destination);
// The files share a directory, so MoveFileEx publishes the fully flushed blob as one rename.
let flags = if replace_existing {
MOVEFILE_WRITE_THROUGH | MOVEFILE_REPLACE_EXISTING
} else {
MOVEFILE_WRITE_THROUGH
};
let moved = unsafe { MoveFileExW(source.as_ptr(), destination.as_ptr(), flags) };
if moved == 0 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
#[cfg(not(target_os = "windows"))]
{
if !replace_existing && destination.exists() {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"destination already exists",
));
}
fs::rename(source, destination)
}
}
#[cfg(target_os = "windows")]
fn wide_path(path: &Path) -> Vec<u16> {
path.as_os_str().encode_wide().chain(Some(0)).collect()
}
fn dpapi_entropy(context: &[u8], handle: &SecretHandle) -> [u8; 32] {
let mut hasher = blake3::Hasher::new();
hasher.update(context);
hasher.update(&[0]);
hasher.update(handle.as_str().as_bytes());
*hasher.finalize().as_bytes()
}
fn encode_protected_payload(plaintext: &[u8]) -> Result<Vec<u8>, SecureSecretStoreError> {
let length = u32::try_from(plaintext.len()).map_err(|_| SecureSecretStoreError::Unavailable)?;
let mut payload = Vec::with_capacity(PROTECTED_PAYLOAD_MAGIC.len() + 4 + plaintext.len() + 32);
payload.extend_from_slice(PROTECTED_PAYLOAD_MAGIC);
payload.extend_from_slice(&length.to_le_bytes());
payload.extend_from_slice(plaintext);
let digest = blake3::hash(&payload);
payload.extend_from_slice(digest.as_bytes());
Ok(payload)
}
fn decode_protected_payload(payload: &[u8]) -> Result<&[u8], SecureSecretStoreError> {
let header_end = PROTECTED_PAYLOAD_MAGIC.len() + 4;
if payload.len() < header_end + 32 || !payload.starts_with(PROTECTED_PAYLOAD_MAGIC) {
return Err(SecureSecretStoreError::Corrupted);
}
let length = u32::from_le_bytes(
payload[PROTECTED_PAYLOAD_MAGIC.len()..header_end]
.try_into()
.map_err(|_| SecureSecretStoreError::Corrupted)?,
) as usize;
let material_end = header_end
.checked_add(length)
.ok_or(SecureSecretStoreError::Corrupted)?;
if material_end
.checked_add(32)
.ok_or(SecureSecretStoreError::Corrupted)?
!= payload.len()
{
return Err(SecureSecretStoreError::Corrupted);
}
let expected = blake3::hash(&payload[..material_end]);
if expected.as_bytes() != &payload[material_end..] {
return Err(SecureSecretStoreError::Corrupted);
}
Ok(&payload[header_end..material_end])
}
fn map_io_error(error: io::Error) -> SecureSecretStoreError {
match error.kind() {
io::ErrorKind::NotFound => SecureSecretStoreError::Missing,
io::ErrorKind::PermissionDenied => SecureSecretStoreError::Locked,
io::ErrorKind::InvalidData => SecureSecretStoreError::Corrupted,
_ => SecureSecretStoreError::Unavailable,
}
}
#[cfg(target_os = "windows")]
struct SystemWindowsDpapiApi {
context: Vec<u8>,
}
#[cfg(target_os = "windows")]
impl SystemWindowsDpapiApi {
fn new() -> Self {
Self {
context: DEFAULT_CONTEXT.to_vec(),
}
}
#[cfg(test)]
fn with_context_for_test(context: &[u8]) -> Self {
Self {
context: context.to_vec(),
}
}
fn entropy(&self, handle: &SecretHandle) -> [u8; 32] {
dpapi_entropy(&self.context, handle)
}
}
#[cfg(target_os = "windows")]
impl WindowsDpapiApi for SystemWindowsDpapiApi {
fn protect(
&self,
handle: &SecretHandle,
plaintext: &[u8],
) -> Result<Vec<u8>, SecureSecretStoreError> {
let mut payload = encode_protected_payload(plaintext)?;
let input = blob(&payload)?;
let entropy_bytes = self.entropy(handle);
let entropy = blob(&entropy_bytes)?;
let mut output = CRYPT_INTEGER_BLOB::default();
// Omitting CRYPTPROTECT_LOCAL_MACHINE binds the blob to the current Windows user.
let success = unsafe {
CryptProtectData(
&input,
ptr::null(),
&entropy,
ptr::null(),
ptr::null(),
CRYPTPROTECT_UI_FORBIDDEN,
&mut output,
)
};
let result = if success == 0 {
Err(map_dpapi_error(false))
} else {
copy_and_free(output, false)
};
payload.fill(0);
result
}
fn unprotect(
&self,
handle: &SecretHandle,
ciphertext: &[u8],
) -> Result<Vec<u8>, SecureSecretStoreError> {
let input = blob(ciphertext)?;
let entropy_bytes = self.entropy(handle);
let entropy = blob(&entropy_bytes)?;
let mut output = CRYPT_INTEGER_BLOB::default();
let success = unsafe {
CryptUnprotectData(
&input,
ptr::null_mut(),
&entropy,
ptr::null(),
ptr::null(),
CRYPTPROTECT_UI_FORBIDDEN,
&mut output,
)
};
if success == 0 {
return Err(map_dpapi_error(true));
}
let mut payload = copy_and_free(output, true)?;
let plaintext = decode_protected_payload(&payload).map(<[u8]>::to_vec);
payload.fill(0);
plaintext
}
}
#[cfg(target_os = "windows")]
fn blob(bytes: &[u8]) -> Result<CRYPT_INTEGER_BLOB, SecureSecretStoreError> {
Ok(CRYPT_INTEGER_BLOB {
cbData: u32::try_from(bytes.len()).map_err(|_| SecureSecretStoreError::Unavailable)?,
pbData: bytes.as_ptr().cast_mut(),
})
}
#[cfg(target_os = "windows")]
fn copy_and_free(
output: CRYPT_INTEGER_BLOB,
clear_before_free: bool,
) -> Result<Vec<u8>, SecureSecretStoreError> {
if output.pbData.is_null() || output.cbData == 0 {
return Err(SecureSecretStoreError::Corrupted);
}
let result = unsafe {
let bytes = std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec();
if clear_before_free {
ptr::write_bytes(output.pbData, 0, output.cbData as usize);
}
LocalFree(output.pbData.cast());
bytes
};
Ok(result)
}
#[cfg(target_os = "windows")]
fn map_dpapi_error(unprotecting: bool) -> SecureSecretStoreError {
let code = unsafe { GetLastError() };
match code {
ERROR_ACCESS_DENIED | ERROR_PASSWORD_RESTRICTION => SecureSecretStoreError::Locked,
ERROR_NOT_SUPPORTED | ERROR_CALL_NOT_IMPLEMENTED => SecureSecretStoreError::Unavailable,
_ if unprotecting => SecureSecretStoreError::Corrupted,
_ => SecureSecretStoreError::Unavailable,
}
}
/// Host-portable stand-in for current-user DPAPI used by contract harnesses.
///
/// Blobs are bound to an injectable user context so wrong-user decryption fails
/// closed the same way real DPAPI fails across Windows accounts.
#[cfg(any(test, not(target_os = "windows")))]
pub(crate) struct FakeWindowsDpapiApi {
context: Vec<u8>,
failure: std::sync::Mutex<Option<SecureSecretStoreError>>,
unavailable_handle_substrings: std::sync::Mutex<Vec<String>>,
}
#[cfg(any(test, not(target_os = "windows")))]
impl FakeWindowsDpapiApi {
pub(crate) fn new() -> Self {
Self::with_context(DEFAULT_CONTEXT)
}
pub(crate) fn with_context(context: &[u8]) -> Self {
Self {
context: context.to_vec(),
failure: std::sync::Mutex::new(None),
unavailable_handle_substrings: std::sync::Mutex::new(Vec::new()),
}
}
#[cfg(test)]
pub(crate) fn fail_with(&self, failure: Option<SecureSecretStoreError>) {
*self.failure.lock().unwrap() = failure;
}
#[cfg(test)]
pub(crate) fn set_unavailable_for_handles_containing(&self, substring: &str) {
self.unavailable_handle_substrings
.lock()
.unwrap()
.push(substring.to_owned());
}
fn check(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> {
match &*self.failure.lock().unwrap() {
Some(SecureSecretStoreError::Locked) => return Err(SecureSecretStoreError::Locked),
Some(SecureSecretStoreError::Missing) => return Err(SecureSecretStoreError::Missing),
Some(SecureSecretStoreError::Corrupted) => {
return Err(SecureSecretStoreError::Corrupted)
}
Some(SecureSecretStoreError::Unavailable) => {
return Err(SecureSecretStoreError::Unavailable)
}
None => {}
}
if self
.unavailable_handle_substrings
.lock()
.unwrap()
.iter()
.any(|needle| handle.as_str().contains(needle))
{
return Err(SecureSecretStoreError::Unavailable);
}
Ok(())
}
fn seal(
&self,
handle: &SecretHandle,
plaintext: &[u8],
) -> Result<Vec<u8>, SecureSecretStoreError> {
let payload = encode_protected_payload(plaintext)?;
let key = dpapi_entropy(&self.context, handle);
Ok(xor_keystream(&key, &payload))
}
fn open(
&self,
handle: &SecretHandle,
ciphertext: &[u8],
) -> Result<Vec<u8>, SecureSecretStoreError> {
let key = dpapi_entropy(&self.context, handle);
let payload = xor_keystream(&key, ciphertext);
decode_protected_payload(&payload).map(<[u8]>::to_vec)
}
}
#[cfg(any(test, not(target_os = "windows")))]
impl WindowsDpapiApi for FakeWindowsDpapiApi {
fn protect(
&self,
handle: &SecretHandle,
plaintext: &[u8],
) -> Result<Vec<u8>, SecureSecretStoreError> {
self.check(handle)?;
self.seal(handle, plaintext)
}
fn unprotect(
&self,
handle: &SecretHandle,
ciphertext: &[u8],
) -> Result<Vec<u8>, SecureSecretStoreError> {
self.check(handle)?;
self.open(handle, ciphertext)
}
}
#[cfg(any(test, not(target_os = "windows")))]
fn xor_keystream(key: &[u8; 32], bytes: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(bytes.len());
let mut counter = 0u64;
let mut offset = 0usize;
let mut block = [0u8; 32];
while offset < bytes.len() {
let mut hasher = blake3::Hasher::new();
hasher.update(key);
hasher.update(&counter.to_le_bytes());
block.copy_from_slice(hasher.finalize().as_bytes());
let take = (bytes.len() - offset).min(block.len());
for (index, byte) in bytes[offset..offset + take].iter().enumerate() {
out.push(byte ^ block[index]);
}
offset += take;
counter = counter.wrapping_add(1);
}
out
}

View File

@@ -0,0 +1,180 @@
//! Bound post-approval authorization for a single targeted transfer.
//!
//! The pre-approval offer never carries this material. After explicit approval,
//! the sender issues a capability whose MAC binds the exact recipient, sender,
//! transfer, manifest, hashes, sizes, and protocol generation. Tampering with
//! the receiver identity invalidates the MAC; presenting an intact capability
//! from another endpoint still fails provider ACL and local identity checks.
use data_encoding::{BASE64URL_NOPAD, HEXLOWER};
use serde::{Deserialize, Serialize};
use crate::{error::VnidropError, secure_secret::SecretMaterial};
const AUTH_CONTEXT: &[u8] = b"vnidrop-targeted-auth-v1";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct TargetedAuthorization {
pub(crate) transfer_id: String,
pub(crate) protocol_transfer_id: u64,
pub(crate) sender_endpoint_id: String,
pub(crate) receiver_endpoint_id: String,
pub(crate) manifest_id: String,
pub(crate) content_hash: String,
pub(crate) file_count: u64,
pub(crate) total_size: u64,
pub(crate) protocol_version: u16,
pub(crate) transfer_name: String,
/// BlobTicket string used only after approval to pull through existing sinks.
pub(crate) blob_ticket: String,
pub(crate) auth_secret: String,
pub(crate) mac: String,
}
#[derive(Debug, Clone)]
pub(crate) struct TargetedAuthorizationDraft {
pub(crate) transfer_id: String,
pub(crate) protocol_transfer_id: u64,
pub(crate) sender_endpoint_id: String,
pub(crate) receiver_endpoint_id: String,
pub(crate) manifest_id: String,
pub(crate) content_hash: String,
pub(crate) file_count: u64,
pub(crate) total_size: u64,
pub(crate) protocol_version: u16,
pub(crate) transfer_name: String,
pub(crate) blob_ticket: String,
}
impl TargetedAuthorization {
pub(crate) fn issue(draft: TargetedAuthorizationDraft) -> Result<Self, VnidropError> {
let auth_secret = crate::grant::GrantSecret::generate().encode();
let mut auth = Self {
transfer_id: draft.transfer_id,
protocol_transfer_id: draft.protocol_transfer_id,
sender_endpoint_id: draft.sender_endpoint_id,
receiver_endpoint_id: draft.receiver_endpoint_id,
manifest_id: draft.manifest_id,
content_hash: draft.content_hash,
file_count: draft.file_count,
total_size: draft.total_size,
protocol_version: draft.protocol_version,
transfer_name: draft.transfer_name,
blob_ticket: draft.blob_ticket,
auth_secret,
mac: String::new(),
};
auth.mac = HEXLOWER.encode(&auth.compute_mac()?);
Ok(auth)
}
pub(crate) fn encode(&self) -> Result<String, VnidropError> {
let bytes = serde_json::to_vec(self).map_err(VnidropError::internal)?;
Ok(format!("vndta1:{}", BASE64URL_NOPAD.encode(&bytes)))
}
pub(crate) fn decode(value: &str) -> Result<Self, VnidropError> {
let encoded = value.strip_prefix("vndta1:").ok_or_else(|| {
VnidropError::invalid_input(anyhow::anyhow!("not a targeted authorization"))
})?;
let bytes = BASE64URL_NOPAD.decode(encoded.as_bytes()).map_err(|_| {
VnidropError::invalid_input(anyhow::anyhow!("invalid targeted authorization encoding"))
})?;
let auth: Self = serde_json::from_slice(&bytes).map_err(|_| {
VnidropError::invalid_input(anyhow::anyhow!("invalid targeted authorization payload"))
})?;
auth.verify_integrity()?;
Ok(auth)
}
pub(crate) fn verify_for_receiver(&self, local_endpoint_id: &str) -> Result<(), VnidropError> {
self.verify_integrity()?;
if self.receiver_endpoint_id != local_endpoint_id {
return Err(VnidropError::permission(anyhow::anyhow!(
"targeted authorization is bound to a different receiver"
)));
}
Ok(())
}
fn verify_integrity(&self) -> Result<(), VnidropError> {
let expected = self.compute_mac()?;
let presented = HEXLOWER.decode(self.mac.as_bytes()).map_err(|_| {
VnidropError::invalid_input(anyhow::anyhow!("invalid authorization mac"))
})?;
if presented.as_slice() != expected.as_slice() {
return Err(VnidropError::permission(anyhow::anyhow!(
"targeted authorization mac mismatch"
)));
}
Ok(())
}
fn compute_mac(&self) -> Result<[u8; 32], VnidropError> {
let secret_bytes = HEXLOWER.decode(self.auth_secret.as_bytes()).map_err(|_| {
VnidropError::invalid_input(anyhow::anyhow!("invalid authorization secret"))
})?;
let key: [u8; 32] = secret_bytes.try_into().map_err(|_| {
VnidropError::invalid_input(anyhow::anyhow!("invalid authorization secret length"))
})?;
let mut hasher = blake3::Hasher::new_keyed(&key);
hasher.update(AUTH_CONTEXT);
for field in [
self.transfer_id.as_bytes(),
self.sender_endpoint_id.as_bytes(),
self.receiver_endpoint_id.as_bytes(),
self.manifest_id.as_bytes(),
self.content_hash.as_bytes(),
self.transfer_name.as_bytes(),
self.blob_ticket.as_bytes(),
] {
hasher.update(&(field.len() as u64).to_le_bytes());
hasher.update(field);
}
hasher.update(&self.protocol_transfer_id.to_le_bytes());
hasher.update(&self.file_count.to_le_bytes());
hasher.update(&self.total_size.to_le_bytes());
hasher.update(&self.protocol_version.to_le_bytes());
Ok(*hasher.finalize().as_bytes())
}
pub(crate) fn secret_bytes(&self) -> Result<[u8; 32], VnidropError> {
let secret_bytes = HEXLOWER.decode(self.auth_secret.as_bytes()).map_err(|_| {
VnidropError::invalid_input(anyhow::anyhow!("invalid authorization secret"))
})?;
secret_bytes.try_into().map_err(|_| {
VnidropError::invalid_input(anyhow::anyhow!("invalid authorization secret length"))
})
}
}
pub(crate) fn auth_secret_material(
auth: &TargetedAuthorization,
) -> Result<SecretMaterial, VnidropError> {
SecretMaterial::new(auth.secret_bytes()?.to_vec())
}
/// Rebuild a bound authorization from durable row fields + custody secret.
pub(crate) fn reconstruct_authorization(
draft: TargetedAuthorizationDraft,
secret_material: &SecretMaterial,
) -> Result<TargetedAuthorization, VnidropError> {
let auth_secret = HEXLOWER.encode(secret_material.as_bytes());
let mut auth = TargetedAuthorization {
transfer_id: draft.transfer_id,
protocol_transfer_id: draft.protocol_transfer_id,
sender_endpoint_id: draft.sender_endpoint_id,
receiver_endpoint_id: draft.receiver_endpoint_id,
manifest_id: draft.manifest_id,
content_hash: draft.content_hash,
file_count: draft.file_count,
total_size: draft.total_size,
protocol_version: draft.protocol_version,
transfer_name: draft.transfer_name,
blob_ticket: draft.blob_ticket,
auth_secret,
mac: String::new(),
};
auth.mac = HEXLOWER.encode(&auth.compute_mac()?);
Ok(auth)
}

View File

@@ -0,0 +1,402 @@
//! Live-session inbox for unapproved targeted-transfer offers.
//!
//! Offers are not durable: cancellation, timeout, disconnect, or restart drops
//! them. Authorization is delivered only after the local user accepts.
//! Settled results are cached briefly so lost-response replays stay idempotent.
use std::{collections::HashMap, sync::Arc, time::Duration};
use serde_json::json;
use tokio::sync::{watch, Mutex};
use uuid::Uuid;
use crate::{api::PendingTargetedOffer, control_plane::IdentityCooldown, event_hub::EventHub};
#[derive(Debug, Clone)]
pub(crate) struct PendingTargetedOfferRecord {
pub(crate) offer: PendingTargetedOffer,
}
struct PendingWaiter {
decision: watch::Sender<Option<bool>>,
}
struct AuthWaiter {
auth: watch::Sender<Option<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SettledOfferResult {
Accepted { authorization: Option<String> },
Declined { reason: String },
}
#[derive(Clone)]
pub(crate) struct TargetedOfferInbox {
event_hub: Arc<EventHub>,
pending: Arc<Mutex<HashMap<String, PendingTargetedOfferRecord>>>,
decisions: Arc<Mutex<HashMap<String, PendingWaiter>>>,
auths: Arc<Mutex<HashMap<String, AuthWaiter>>>,
settled: Arc<Mutex<HashMap<String, SettledOfferResult>>>,
cooldown: IdentityCooldown,
max_pending: usize,
offer_timeout: Duration,
}
impl TargetedOfferInbox {
pub(crate) fn new(
event_hub: Arc<EventHub>,
max_pending: usize,
cooldown: IdentityCooldown,
offer_timeout_ms: u64,
) -> Self {
Self {
event_hub,
pending: Arc::new(Mutex::new(HashMap::new())),
decisions: Arc::new(Mutex::new(HashMap::new())),
auths: Arc::new(Mutex::new(HashMap::new())),
settled: Arc::new(Mutex::new(HashMap::new())),
cooldown,
max_pending,
offer_timeout: Duration::from_millis(offer_timeout_ms),
}
}
pub(crate) fn cooldown(&self) -> &IdentityCooldown {
&self.cooldown
}
/// Surface a validated offer and block until the local user decides.
///
/// Replaying the same transfer identity returns the settled result or joins
/// the existing pending wait — never a second prompt.
pub(crate) async fn submit(&self, offer: PendingTargetedOffer) -> TargetedOfferDecision {
let transfer_id = offer.transfer_id.clone();
if self.cooldown.is_cooling(&offer.sender_endpoint_id) {
return TargetedOfferDecision::Refused {
reason: "identity-cooldown".to_string(),
};
}
if let Some(settled) = self.settled.lock().await.get(&transfer_id).cloned() {
return settled_to_decision(settled);
}
{
let pending = self.pending.lock().await;
if let Some(existing) = pending.get(&transfer_id) {
if offers_equivalent(&existing.offer, &offer) {
drop(pending);
return self.wait_existing_decision(&transfer_id).await;
}
return TargetedOfferDecision::Refused {
reason: "immutable-transfer-mismatch".to_string(),
};
}
// Prefer the more specific per-sender refusal before the global bound.
if pending
.values()
.any(|entry| entry.offer.sender_endpoint_id == offer.sender_endpoint_id)
{
return TargetedOfferDecision::Refused {
reason: "offer-already-pending".to_string(),
};
}
if pending.len() >= self.max_pending {
return TargetedOfferDecision::Refused {
reason: "too-many-pending-offers".to_string(),
};
}
}
let (decision_tx, _decision_rx) = watch::channel(None);
{
let mut pending = self.pending.lock().await;
if pending.contains_key(&transfer_id) {
// Lost the race with another submit of the same id.
drop(pending);
return self.wait_existing_decision(&transfer_id).await;
}
if pending
.values()
.any(|entry| entry.offer.sender_endpoint_id == offer.sender_endpoint_id)
{
return TargetedOfferDecision::Refused {
reason: "offer-already-pending".to_string(),
};
}
if pending.len() >= self.max_pending {
return TargetedOfferDecision::Refused {
reason: "too-many-pending-offers".to_string(),
};
}
pending.insert(
transfer_id.clone(),
PendingTargetedOfferRecord {
offer: offer.clone(),
},
);
}
self.decisions.lock().await.insert(
transfer_id.clone(),
PendingWaiter {
decision: decision_tx,
},
);
self.cooldown.clear_strikes(&offer.sender_endpoint_id);
self.event_hub.emit_endpoint(
"targeted_transfer",
"offer-received",
json!({
"transfer_id": offer.transfer_id,
"sender_endpoint_id": offer.sender_endpoint_id,
"file_count": offer.file_count,
"total_size": offer.total_size,
"manifest_id": offer.manifest_id,
}),
);
self.wait_existing_decision(&transfer_id).await
}
async fn wait_existing_decision(&self, transfer_id: &str) -> TargetedOfferDecision {
let mut rx = {
let decisions = self.decisions.lock().await;
let Some(waiter) = decisions.get(transfer_id) else {
if let Some(settled) = self.settled.lock().await.get(transfer_id).cloned() {
return settled_to_decision(settled);
}
return TargetedOfferDecision::Declined {
reason: "no-response".to_string(),
};
};
waiter.decision.subscribe()
};
let wait = async {
loop {
if let Some(accepted) = *rx.borrow_and_update() {
return accepted;
}
if rx.changed().await.is_err() {
return false;
}
}
};
match tokio::time::timeout(self.offer_timeout, wait).await {
Ok(true) => TargetedOfferDecision::Accepted,
Ok(false) => {
self.discard(transfer_id).await;
TargetedOfferDecision::Declined {
reason: "receiver-declined".to_string(),
}
}
Err(_) => {
self.discard(transfer_id).await;
TargetedOfferDecision::Declined {
reason: "no-response".to_string(),
}
}
}
}
pub(crate) async fn list(&self) -> Vec<PendingTargetedOffer> {
self.pending
.lock()
.await
.values()
.map(|entry| entry.offer.clone())
.collect()
}
pub(crate) async fn get_pending(&self, transfer_id: &str) -> Option<PendingTargetedOffer> {
self.pending
.lock()
.await
.get(transfer_id)
.map(|entry| entry.offer.clone())
}
pub(crate) async fn settled_authorization(&self, transfer_id: &str) -> Option<String> {
match self.settled.lock().await.get(transfer_id) {
Some(SettledOfferResult::Accepted {
authorization: Some(auth),
}) => Some(auth.clone()),
_ => None,
}
}
pub(crate) async fn is_settled(&self, transfer_id: &str) -> bool {
self.settled.lock().await.contains_key(transfer_id)
}
/// Record the local decision. On accept, wait for sender-issued authorization.
pub(crate) async fn respond(
&self,
transfer_id: &str,
accepted: bool,
) -> Result<Option<String>, RespondError> {
if let Some(auth) = self.settled_authorization(transfer_id).await {
return Ok(Some(auth));
}
let sender_endpoint_id = {
let pending = self.pending.lock().await;
pending
.get(transfer_id)
.map(|entry| entry.offer.sender_endpoint_id.clone())
};
let Some(sender_endpoint_id) = sender_endpoint_id else {
return Err(RespondError::Unknown);
};
let waiter = {
let decisions = self.decisions.lock().await;
decisions
.get(transfer_id)
.map(|entry| entry.decision.clone())
};
let Some(decision_tx) = waiter else {
return Err(RespondError::Unknown);
};
if !accepted {
let _ = decision_tx.send(Some(false));
self.discard(transfer_id).await;
self.cooldown.record_decline(&sender_endpoint_id);
self.settled.lock().await.insert(
transfer_id.to_string(),
SettledOfferResult::Declined {
reason: "receiver-declined".to_string(),
},
);
self.event_hub.emit_endpoint(
"targeted_transfer",
"offer-declined",
json!({ "transfer_id": transfer_id }),
);
return Ok(None);
}
let (auth_tx, mut auth_rx) = watch::channel(None);
self.auths
.lock()
.await
.insert(transfer_id.to_string(), AuthWaiter { auth: auth_tx });
if decision_tx.send(Some(true)).is_err() {
self.auths.lock().await.remove(transfer_id);
self.discard(transfer_id).await;
return Err(RespondError::SenderGone);
}
let wait_auth = async {
loop {
if let Some(auth) = auth_rx.borrow_and_update().clone() {
return Ok(auth);
}
if auth_rx.changed().await.is_err() {
return Err(());
}
}
};
match tokio::time::timeout(self.offer_timeout, wait_auth).await {
Ok(Ok(auth)) => {
self.pending.lock().await.remove(transfer_id);
self.auths.lock().await.remove(transfer_id);
self.settled.lock().await.insert(
transfer_id.to_string(),
SettledOfferResult::Accepted {
authorization: Some(auth.clone()),
},
);
self.event_hub.emit_endpoint(
"targeted_transfer",
"offer-accepted",
json!({ "transfer_id": transfer_id }),
);
Ok(Some(auth))
}
Ok(Err(())) | Err(_) => {
self.discard(transfer_id).await;
Err(RespondError::AuthorizationTimeout)
}
}
}
pub(crate) async fn deliver_authorization(
&self,
transfer_id: &str,
authorization: String,
) -> bool {
if let Some(SettledOfferResult::Accepted {
authorization: Some(existing),
}) = self.settled.lock().await.get(transfer_id)
{
return existing == &authorization;
}
if let Some(waiter) = self.auths.lock().await.get(transfer_id) {
waiter.auth.send(Some(authorization)).is_ok()
} else {
false
}
}
pub(crate) async fn discard_from(&self, endpoint_id: &str) {
let ids: Vec<String> = {
let pending = self.pending.lock().await;
pending
.values()
.filter(|entry| entry.offer.sender_endpoint_id == endpoint_id)
.map(|entry| entry.offer.transfer_id.clone())
.collect()
};
for id in ids {
self.discard(&id).await;
}
}
pub(crate) async fn discard(&self, transfer_id: &str) {
self.pending.lock().await.remove(transfer_id);
if let Some(waiter) = self.decisions.lock().await.remove(transfer_id) {
let _ = waiter.decision.send(Some(false));
}
self.auths.lock().await.remove(transfer_id);
}
}
fn offers_equivalent(left: &PendingTargetedOffer, right: &PendingTargetedOffer) -> bool {
left.transfer_id == right.transfer_id
&& left.sender_endpoint_id == right.sender_endpoint_id
&& left.receiver_endpoint_id == right.receiver_endpoint_id
&& left.manifest_id == right.manifest_id
&& left.content_hash == right.content_hash
&& left.file_count == right.file_count
&& left.total_size == right.total_size
&& left.protocol_version == right.protocol_version
}
fn settled_to_decision(settled: SettledOfferResult) -> TargetedOfferDecision {
match settled {
SettledOfferResult::Accepted { .. } => TargetedOfferDecision::Accepted,
SettledOfferResult::Declined { reason } => TargetedOfferDecision::Declined { reason },
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum TargetedOfferDecision {
Accepted,
Declined { reason: String },
Refused { reason: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RespondError {
Unknown,
SenderGone,
AuthorizationTimeout,
}
#[allow(dead_code)]
pub(crate) fn new_offer_id() -> String {
Uuid::new_v4().to_string()
}

View File

@@ -0,0 +1,20 @@
//! Immutable one-sender, one-receiver transfers between Saved devices.
//!
//! Separate from ordinary multi-receiver shares: own protocol, authorization,
//! and public APIs. Blob import/streaming/output sinks are reused.
mod auth;
pub(crate) mod inbox;
pub(crate) mod protocol;
mod state;
mod store;
pub(crate) use auth::{
auth_secret_material, reconstruct_authorization, TargetedAuthorization,
TargetedAuthorizationDraft,
};
pub(crate) use inbox::{RespondError, TargetedOfferInbox};
pub(crate) use protocol::TargetedTransferProtocol;
pub(crate) use store::{
ensure_schema, state_as_str, TargetedTransferRole, TargetedTransferRow, TargetedTransferStore,
};

View File

@@ -0,0 +1,457 @@
//! Targeted-transfer control-plane protocol.
//!
//! Separate ALPN from ordinary offers: pre-approval messages carry a manifest
//! summary and relationship proof only — never a reusable share ticket.
use std::fmt;
use anyhow::Result;
use iroh::{
endpoint::Connection,
protocol::{AcceptError, ProtocolHandler},
Endpoint, EndpointAddr, RelayUrl,
};
use irpc::{channel::oneshot, rpc_requests, Client, WithChannels};
use irpc_iroh::{read_request, IrohLazyRemoteConnection};
use serde::{Deserialize, Serialize};
use super::{
auth::TargetedAuthorization,
inbox::{TargetedOfferDecision, TargetedOfferInbox},
state_as_str, TargetedTransferStore,
};
use crate::{
api::{
experimental_saved_device_capabilities, CoreRelayMode, PendingTargetedOffer,
TargetedTransferState,
},
device_relationship::{DeviceRelationshipService, WireProof},
error::VnidropError,
grant::Challenge,
ticket::relay_profiles_compatible,
util::now_ms,
};
#[derive(Clone)]
pub(crate) struct TargetedTransferProtocol {
relationships: std::sync::Arc<DeviceRelationshipService>,
inbox: TargetedOfferInbox,
store: TargetedTransferStore,
limits: crate::api::CoreLimits,
local_endpoint_id: String,
relay_mode: CoreRelayMode,
custom_relay_urls: Vec<RelayUrl>,
}
impl fmt::Debug for TargetedTransferProtocol {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("TargetedTransferProtocol")
}
}
impl TargetedTransferProtocol {
pub(crate) const ALPN: &'static [u8] = b"/vnidrop/targeted-transfer/1";
pub(crate) fn new(
relationships: std::sync::Arc<DeviceRelationshipService>,
inbox: TargetedOfferInbox,
store: TargetedTransferStore,
limits: crate::api::CoreLimits,
local_endpoint_id: String,
relay_mode: CoreRelayMode,
custom_relay_urls: Vec<RelayUrl>,
) -> Self {
Self {
relationships,
inbox,
store,
limits,
local_endpoint_id,
relay_mode,
custom_relay_urls,
}
}
pub(crate) fn client(endpoint: Endpoint, addr: EndpointAddr) -> TargetedTransferClient {
TargetedTransferClient {
inner: Client::boxed(IrohLazyRemoteConnection::new(
endpoint,
addr,
Self::ALPN.to_vec(),
)),
}
}
async fn handle_offer(
&self,
remote_endpoint_id: &str,
challenge: &Challenge,
offer: SubmitTargetedOffer,
) -> WireOfferResponse {
let expected = experimental_saved_device_capabilities().targeted_transfer_protocol_version;
if self.inbox.cooldown().is_cooling(remote_endpoint_id) {
return WireOfferResponse::Refused {
reason: "identity-cooldown".to_string(),
};
}
if offer.protocol_version != expected {
self.inbox.cooldown().record_malformed(remote_endpoint_id);
return WireOfferResponse::Refused {
reason: "protocol-incompatible".to_string(),
};
}
if offer.receiver_endpoint_id != self.local_endpoint_id {
self.inbox.cooldown().record_malformed(remote_endpoint_id);
return WireOfferResponse::Refused {
reason: "receiver-mismatch".to_string(),
};
}
if offer.sender_endpoint_id != remote_endpoint_id {
self.inbox.cooldown().record_malformed(remote_endpoint_id);
return WireOfferResponse::Refused {
reason: "sender-mismatch".to_string(),
};
}
if offer.file_count == 0
|| offer.file_count > self.limits.max_collection_files
|| offer.total_size == 0
|| offer.total_size > self.limits.max_total_bytes
|| offer.transfer_id.is_empty()
|| offer.manifest_id.is_empty()
|| offer.content_hash.is_empty()
{
self.inbox.cooldown().record_malformed(remote_endpoint_id);
return WireOfferResponse::Refused {
reason: "manifest-limits".to_string(),
};
}
if let Err(error) = self
.limits
.validate_metadata_text("transfer name", Some(offer.transfer_name.as_str()))
{
self.inbox.cooldown().record_malformed(remote_endpoint_id);
return WireOfferResponse::Refused {
reason: error.to_string(),
};
}
if let Ok(Some(existing)) = self.store.get_row(&offer.transfer_id).await {
if existing.manifest_id != offer.manifest_id
|| existing.content_hash != offer.content_hash
|| existing.file_count != offer.file_count
|| existing.total_size != offer.total_size
|| existing.sender_endpoint_id != offer.sender_endpoint_id
|| existing.receiver_endpoint_id != offer.receiver_endpoint_id
{
return WireOfferResponse::Refused {
reason: "immutable-transfer-mismatch".to_string(),
};
}
return match existing.state {
TargetedTransferState::Approved
| TargetedTransferState::Connecting
| TargetedTransferState::Transferring
| TargetedTransferState::Interrupted
| TargetedTransferState::Completed => WireOfferResponse::Accepted,
TargetedTransferState::Declined => WireOfferResponse::Declined {
reason: "receiver-declined".to_string(),
},
TargetedTransferState::Cancelled => WireOfferResponse::Declined {
reason: "cancelled".to_string(),
},
TargetedTransferState::Failed | TargetedTransferState::Deleted => {
WireOfferResponse::Refused {
reason: format!("transfer-{}", state_as_str(existing.state)),
}
}
TargetedTransferState::Preparing
| TargetedTransferState::Offering
| TargetedTransferState::AwaitingApproval => WireOfferResponse::Accepted,
};
}
let remote_urls = match parse_offer_relay_urls(&offer.relay_urls) {
Ok(urls) => urls,
Err(_) => {
return WireOfferResponse::Refused {
reason: "relay-policy-incompatible".to_string(),
};
}
};
if !relay_profiles_compatible(
self.relay_mode,
&self.custom_relay_urls,
offer.relay_mode,
&remote_urls,
) {
return WireOfferResponse::Refused {
reason: "relay-policy-incompatible".to_string(),
};
}
if let Err(error) = self
.relationships
.verify_saved_possession(
remote_endpoint_id,
challenge,
&offer.proof,
offer.generation,
offer.relationship_protocol_version,
)
.await
{
tracing::debug!(error = %error, "targeted offer relationship proof rejected");
self.inbox.cooldown().record_malformed(remote_endpoint_id);
if matches!(error, VnidropError::ProtocolIncompatible { .. }) {
return WireOfferResponse::Refused {
reason: "protocol-incompatible".to_string(),
};
}
return WireOfferResponse::Refused {
reason: "unauthenticated".to_string(),
};
}
let pending = PendingTargetedOffer {
transfer_id: offer.transfer_id,
sender_endpoint_id: remote_endpoint_id.to_string(),
receiver_endpoint_id: self.local_endpoint_id.clone(),
manifest_id: offer.manifest_id,
content_hash: offer.content_hash,
transfer_name: offer.transfer_name,
file_count: offer.file_count,
total_size: offer.total_size,
protocol_version: offer.protocol_version,
received_at: now_ms(),
};
match self.inbox.submit(pending).await {
TargetedOfferDecision::Accepted => WireOfferResponse::Accepted,
TargetedOfferDecision::Declined { reason } => WireOfferResponse::Declined { reason },
TargetedOfferDecision::Refused { reason } => WireOfferResponse::Refused { reason },
}
}
async fn handle_deliver_authorization(
&self,
remote_endpoint_id: &str,
delivery: DeliverTargetedAuthorization,
) -> DeliverAuthorizationResponse {
let Ok(auth) = TargetedAuthorization::decode(&delivery.authorization) else {
return DeliverAuthorizationResponse::Rejected;
};
if auth.sender_endpoint_id != remote_endpoint_id
|| auth.receiver_endpoint_id != self.local_endpoint_id
|| auth.transfer_id != delivery.transfer_id
{
return DeliverAuthorizationResponse::Rejected;
}
if let Ok(Some(row)) = self.store.get_row(&delivery.transfer_id).await {
if row.authorization_secret_handle.is_some()
&& row.manifest_id == auth.manifest_id
&& row.content_hash == auth.content_hash
{
return DeliverAuthorizationResponse::Stored;
}
}
if self
.inbox
.deliver_authorization(&delivery.transfer_id, delivery.authorization)
.await
{
DeliverAuthorizationResponse::Stored
} else {
DeliverAuthorizationResponse::Rejected
}
}
async fn handle_cancel(
&self,
remote_endpoint_id: &str,
cancel: CancelTargetedOffer,
) -> CancelWireOfferResponse {
if let Some(pending) = self.inbox.get_pending(&cancel.transfer_id).await {
if pending.sender_endpoint_id != remote_endpoint_id {
return CancelWireOfferResponse::Rejected;
}
self.inbox.discard(&cancel.transfer_id).await;
return CancelWireOfferResponse::Cancelled;
}
if let Ok(Some(row)) = self.store.get_row(&cancel.transfer_id).await {
if row.sender_endpoint_id != remote_endpoint_id {
return CancelWireOfferResponse::Rejected;
}
return CancelWireOfferResponse::Cancelled;
}
CancelWireOfferResponse::Cancelled
}
}
impl ProtocolHandler for TargetedTransferProtocol {
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
let remote_endpoint_id = connection.remote_id().to_string();
let challenge = Challenge::generate();
while let Some(message) = read_request::<TargetedTransferMessages>(&connection).await? {
match message {
TargetedTransferMessage::RequestChallenge(message) => {
let WithChannels { tx, .. } = message;
let _ = tx
.send(ChallengeResponse {
challenge: challenge.clone(),
})
.await;
}
TargetedTransferMessage::SubmitTargetedOffer(message) => {
let WithChannels { inner, tx, .. } = message;
let response = self
.handle_offer(&remote_endpoint_id, &challenge, inner)
.await;
let _ = tx.send(response).await;
}
TargetedTransferMessage::DeliverTargetedAuthorization(message) => {
let WithChannels { inner, tx, .. } = message;
let response = self
.handle_deliver_authorization(&remote_endpoint_id, inner)
.await;
let _ = tx.send(response).await;
}
TargetedTransferMessage::CancelTargetedOffer(message) => {
let WithChannels { inner, tx, .. } = message;
let response = self.handle_cancel(&remote_endpoint_id, inner).await;
let _ = tx.send(response).await;
}
}
}
connection.closed().await;
Ok(())
}
}
#[derive(Debug, Clone)]
pub(crate) struct TargetedTransferClient {
inner: Client<TargetedTransferMessages>,
}
impl TargetedTransferClient {
pub(crate) async fn request_challenge(&self) -> Result<Challenge, irpc::Error> {
Ok(self.inner.rpc(RequestChallenge).await?.challenge)
}
pub(crate) async fn submit_offer(
&self,
offer: SubmitTargetedOffer,
) -> Result<WireOfferResponse, irpc::Error> {
self.inner.rpc(offer).await
}
pub(crate) async fn deliver_authorization(
&self,
delivery: DeliverTargetedAuthorization,
) -> Result<DeliverAuthorizationResponse, irpc::Error> {
self.inner.rpc(delivery).await
}
pub(crate) async fn cancel_offer(
&self,
cancel: CancelTargetedOffer,
) -> Result<CancelWireOfferResponse, irpc::Error> {
self.inner.rpc(cancel).await
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RequestChallenge;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ChallengeResponse {
challenge: Challenge,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct SubmitTargetedOffer {
pub(crate) proof: WireProof,
pub(crate) generation: u64,
pub(crate) relationship_protocol_version: u16,
pub(crate) protocol_version: u16,
pub(crate) transfer_id: String,
pub(crate) sender_endpoint_id: String,
pub(crate) receiver_endpoint_id: String,
pub(crate) manifest_id: String,
pub(crate) content_hash: String,
pub(crate) transfer_name: String,
pub(crate) file_count: u64,
pub(crate) total_size: u64,
pub(crate) relay_mode: CoreRelayMode,
pub(crate) relay_urls: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) enum WireOfferResponse {
Accepted,
Declined { reason: String },
Refused { reason: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct DeliverTargetedAuthorization {
pub(crate) transfer_id: String,
pub(crate) authorization: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) enum DeliverAuthorizationResponse {
Stored,
Rejected,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct CancelTargetedOffer {
pub(crate) transfer_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) enum CancelWireOfferResponse {
Cancelled,
Rejected,
}
#[rpc_requests(message = TargetedTransferMessage)]
#[derive(Debug, Serialize, Deserialize)]
#[allow(
clippy::large_enum_variant,
reason = "offer payload carries relay profile + manifest summary; boxing breaks irpc channels"
)]
enum TargetedTransferMessages {
#[rpc(tx = oneshot::Sender<ChallengeResponse>)]
RequestChallenge(RequestChallenge),
#[rpc(tx = oneshot::Sender<WireOfferResponse>)]
SubmitTargetedOffer(SubmitTargetedOffer),
#[rpc(tx = oneshot::Sender<DeliverAuthorizationResponse>)]
DeliverTargetedAuthorization(DeliverTargetedAuthorization),
#[rpc(tx = oneshot::Sender<CancelWireOfferResponse>)]
CancelTargetedOffer(CancelTargetedOffer),
}
fn parse_offer_relay_urls(values: &[String]) -> Result<Vec<RelayUrl>, ()> {
let mut urls = Vec::with_capacity(values.len());
for value in values {
let Ok(url) = value.parse::<RelayUrl>() else {
return Err(());
};
urls.push(url);
}
Ok(urls)
}
/// Map a receiver refuse reason to a typed public error.
pub(crate) fn map_offer_refuse_reason(reason: &str) -> VnidropError {
match reason {
"relay-policy-incompatible" => VnidropError::relay_policy_incompatible(anyhow::anyhow!(
"sender and receiver network profiles are incompatible"
)),
"protocol-incompatible" => VnidropError::protocol_incompatible(anyhow::anyhow!(
"targeted-transfer protocol is incompatible"
)),
other => VnidropError::permission(anyhow::anyhow!("targeted offer refused: {other}")),
}
}

View File

@@ -0,0 +1,59 @@
use crate::{api::TargetedTransferState, error::VnidropError};
impl TargetedTransferState {
/// Validates a durable state change without exposing foreign state mutation.
pub fn validate_transition_to(self, next: Self) -> Result<(), VnidropError> {
let allowed = matches!(
(self, next),
(
Self::Preparing,
Self::Offering | Self::Cancelled | Self::Failed
) | (
Self::Offering,
Self::AwaitingApproval | Self::Cancelled | Self::Failed
) | (
Self::AwaitingApproval,
Self::Approved | Self::Declined | Self::Cancelled | Self::Failed
) | (
Self::Approved,
Self::Connecting | Self::Cancelled | Self::Failed | Self::Deleted
) | (
Self::Connecting,
Self::Transferring | Self::Interrupted | Self::Cancelled | Self::Failed
) | (
Self::Transferring,
Self::Completed | Self::Interrupted | Self::Cancelled | Self::Failed
) | (
Self::Interrupted,
Self::Connecting | Self::Cancelled | Self::Failed | Self::Deleted
) | (
Self::Completed | Self::Declined | Self::Cancelled | Self::Failed,
Self::Deleted
)
);
if allowed {
Ok(())
} else {
Err(VnidropError::InvalidTransition {
reason: format!("{} -> {}", self.as_str(), next.as_str()),
})
}
}
const fn as_str(self) -> &'static str {
match self {
Self::Preparing => "preparing",
Self::Offering => "offering",
Self::AwaitingApproval => "awaiting_approval",
Self::Approved => "approved",
Self::Connecting => "connecting",
Self::Transferring => "transferring",
Self::Interrupted => "interrupted",
Self::Completed => "completed",
Self::Declined => "declined",
Self::Cancelled => "cancelled",
Self::Failed => "failed",
Self::Deleted => "deleted",
}
}
}

View File

@@ -0,0 +1,486 @@
//! Durable targeted-transfer rows (schema + queries).
//!
//! This is the domain store adapter for targeted transfers. Callers use store
//! methods — not a raw SQL pool.
use sqlx::{Row, SqlitePool};
use crate::{
api::{TargetedTransfer, TargetedTransferState},
error::VnidropError,
util::now_ms,
};
pub(crate) async fn ensure_schema(pool: &SqlitePool) -> anyhow::Result<()> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS targeted_transfers (
id TEXT PRIMARY KEY,
protocol_transfer_id INTEGER NOT NULL UNIQUE,
sender_endpoint_id TEXT NOT NULL,
receiver_endpoint_id TEXT NOT NULL,
manifest_id TEXT NOT NULL,
content_hash TEXT NOT NULL,
transfer_name TEXT NOT NULL,
file_count INTEGER NOT NULL,
total_size INTEGER NOT NULL,
verified_bytes INTEGER NOT NULL DEFAULT 0,
blob_ticket TEXT,
authorization_secret_handle TEXT,
role TEXT NOT NULL DEFAULT 'sender',
state TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
"#,
)
.execute(pool)
.await?;
let columns = sqlx::query("PRAGMA table_info(targeted_transfers)")
.fetch_all(pool)
.await?;
let has = |name: &str| columns.iter().any(|row| row.get::<String, _>(1) == name);
if !has("verified_bytes") {
sqlx::query(
"ALTER TABLE targeted_transfers ADD COLUMN verified_bytes INTEGER NOT NULL DEFAULT 0",
)
.execute(pool)
.await?;
}
if !has("blob_ticket") {
sqlx::query("ALTER TABLE targeted_transfers ADD COLUMN blob_ticket TEXT")
.execute(pool)
.await?;
}
if !has("authorization_secret_handle") {
sqlx::query("ALTER TABLE targeted_transfers ADD COLUMN authorization_secret_handle TEXT")
.execute(pool)
.await?;
}
if !has("role") {
sqlx::query(
"ALTER TABLE targeted_transfers ADD COLUMN role TEXT NOT NULL DEFAULT 'sender'",
)
.execute(pool)
.await?;
}
Ok(())
}
#[derive(Clone)]
pub(crate) struct TargetedTransferStore {
pool: SqlitePool,
}
impl TargetedTransferStore {
pub(crate) fn new(pool: SqlitePool) -> Self {
Self { pool }
}
pub(crate) async fn insert(&self, transfer: &TargetedTransferRow) -> Result<(), VnidropError> {
sqlx::query(
r#"
INSERT INTO targeted_transfers (
id, protocol_transfer_id, sender_endpoint_id, receiver_endpoint_id,
manifest_id, content_hash, transfer_name, file_count, total_size,
verified_bytes, blob_ticket, authorization_secret_handle, role,
state, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)
"#,
)
.bind(&transfer.id)
.bind(transfer.protocol_transfer_id as i64)
.bind(&transfer.sender_endpoint_id)
.bind(&transfer.receiver_endpoint_id)
.bind(&transfer.manifest_id)
.bind(&transfer.content_hash)
.bind(&transfer.transfer_name)
.bind(transfer.file_count as i64)
.bind(transfer.total_size as i64)
.bind(transfer.verified_bytes as i64)
.bind(&transfer.blob_ticket)
.bind(&transfer.authorization_secret_handle)
.bind(role_as_str(transfer.role))
.bind(state_as_str(transfer.state))
.bind(transfer.created_at)
.bind(transfer.updated_at)
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(())
}
pub(crate) async fn set_state(
&self,
id: &str,
from: TargetedTransferState,
to: TargetedTransferState,
) -> Result<(), VnidropError> {
from.validate_transition_to(to)?;
let result = sqlx::query(
r#"
UPDATE targeted_transfers
SET state = ?2, updated_at = ?3
WHERE id = ?1 AND state = ?4
"#,
)
.bind(id)
.bind(state_as_str(to))
.bind(now_ms())
.bind(state_as_str(from))
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
if result.rows_affected() == 0 {
return Err(VnidropError::InvalidTransition {
reason: format!("{} -> {}", state_as_str(from), state_as_str(to)),
});
}
Ok(())
}
/// Transition from any non-terminal state; used by cancel/delete.
pub(crate) async fn set_state_from_any(
&self,
id: &str,
to: TargetedTransferState,
) -> Result<(), VnidropError> {
let Some(row) = self.get_row(id).await? else {
return Err(VnidropError::invalid_input(anyhow::anyhow!(
"unknown targeted transfer"
)));
};
if row.state == to {
return Ok(());
}
row.state.validate_transition_to(to)?;
let result = sqlx::query(
r#"
UPDATE targeted_transfers
SET state = ?2, updated_at = ?3
WHERE id = ?1 AND state = ?4
"#,
)
.bind(id)
.bind(state_as_str(to))
.bind(now_ms())
.bind(state_as_str(row.state))
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
if result.rows_affected() == 0 {
return Err(VnidropError::InvalidTransition {
reason: format!("{} -> {}", state_as_str(row.state), state_as_str(to)),
});
}
Ok(())
}
pub(crate) async fn set_verified_bytes(
&self,
id: &str,
verified_bytes: u64,
) -> Result<(), VnidropError> {
sqlx::query(
r#"
UPDATE targeted_transfers
SET verified_bytes = ?2, updated_at = ?3
WHERE id = ?1
"#,
)
.bind(id)
.bind(verified_bytes as i64)
.bind(now_ms())
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(())
}
pub(crate) async fn store_authorization(
&self,
id: &str,
blob_ticket: &str,
authorization_secret_handle: &str,
) -> Result<(), VnidropError> {
sqlx::query(
r#"
UPDATE targeted_transfers
SET blob_ticket = ?2,
authorization_secret_handle = ?3,
updated_at = ?4
WHERE id = ?1
"#,
)
.bind(id)
.bind(blob_ticket)
.bind(authorization_secret_handle)
.bind(now_ms())
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(())
}
pub(crate) async fn clear_authorization(&self, id: &str) -> Result<(), VnidropError> {
sqlx::query(
r#"
UPDATE targeted_transfers
SET blob_ticket = NULL,
authorization_secret_handle = NULL,
verified_bytes = 0,
updated_at = ?2
WHERE id = ?1
"#,
)
.bind(id)
.bind(now_ms())
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(())
}
pub(crate) async fn get(&self, id: &str) -> Result<Option<TargetedTransfer>, VnidropError> {
let row = sqlx::query(
r#"
SELECT id, sender_endpoint_id, receiver_endpoint_id, manifest_id,
file_count, total_size, verified_bytes, state, created_at, updated_at
FROM targeted_transfers WHERE id = ?1
"#,
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(VnidropError::repository)?;
row.map(row_to_transfer).transpose()
}
pub(crate) async fn get_row(
&self,
id: &str,
) -> Result<Option<TargetedTransferRow>, VnidropError> {
let row = sqlx::query(
r#"
SELECT id, protocol_transfer_id, sender_endpoint_id, receiver_endpoint_id,
manifest_id, content_hash, transfer_name, file_count, total_size,
verified_bytes, blob_ticket, authorization_secret_handle, role,
state, created_at, updated_at
FROM targeted_transfers WHERE id = ?1
"#,
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(VnidropError::repository)?;
row.map(row_to_full).transpose()
}
pub(crate) async fn list(&self) -> Result<Vec<TargetedTransfer>, VnidropError> {
let rows = sqlx::query(
r#"
SELECT id, sender_endpoint_id, receiver_endpoint_id, manifest_id,
file_count, total_size, verified_bytes, state, created_at, updated_at
FROM targeted_transfers
ORDER BY updated_at DESC
"#,
)
.fetch_all(&self.pool)
.await
.map_err(VnidropError::repository)?;
rows.into_iter().map(row_to_transfer).collect()
}
pub(crate) async fn list_resumable_sender_rows(
&self,
) -> Result<Vec<TargetedTransferRow>, VnidropError> {
let rows = sqlx::query(
r#"
SELECT id, protocol_transfer_id, sender_endpoint_id, receiver_endpoint_id,
manifest_id, content_hash, transfer_name, file_count, total_size,
verified_bytes, blob_ticket, authorization_secret_handle, role,
state, created_at, updated_at
FROM targeted_transfers
WHERE role = 'sender'
AND state IN ('approved', 'connecting', 'transferring', 'interrupted')
"#,
)
.fetch_all(&self.pool)
.await
.map_err(VnidropError::repository)?;
rows.into_iter().map(row_to_full).collect()
}
pub(crate) async fn cancel_by_peer(&self, peer_endpoint_id: &str) -> Result<u64, VnidropError> {
let now = now_ms();
let result = sqlx::query(
r#"
UPDATE targeted_transfers
SET state = 'cancelled', updated_at = ?2
WHERE (sender_endpoint_id = ?1 OR receiver_endpoint_id = ?1)
AND state NOT IN ('completed', 'declined', 'cancelled', 'failed', 'deleted')
"#,
)
.bind(peer_endpoint_id)
.bind(now)
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(result.rows_affected())
}
pub(crate) async fn protocol_ids_for_peer(
&self,
peer_endpoint_id: &str,
) -> Result<Vec<u64>, VnidropError> {
let rows = sqlx::query(
r#"
SELECT protocol_transfer_id FROM targeted_transfers
WHERE sender_endpoint_id = ?1 OR receiver_endpoint_id = ?1
"#,
)
.bind(peer_endpoint_id)
.fetch_all(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(rows
.into_iter()
.map(|row| row.get::<i64, _>(0) as u64)
.collect())
}
pub(crate) async fn mark_interrupted_in_flight(&self) -> Result<u64, VnidropError> {
let now = now_ms();
let result = sqlx::query(
r#"
UPDATE targeted_transfers
SET state = 'interrupted', updated_at = ?1
WHERE state IN ('connecting', 'transferring')
"#,
)
.bind(now)
.execute(&self.pool)
.await
.map_err(VnidropError::repository)?;
Ok(result.rows_affected())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TargetedTransferRole {
Sender,
Receiver,
}
#[derive(Debug, Clone)]
pub(crate) struct TargetedTransferRow {
pub(crate) id: String,
pub(crate) protocol_transfer_id: u64,
pub(crate) sender_endpoint_id: String,
pub(crate) receiver_endpoint_id: String,
pub(crate) manifest_id: String,
pub(crate) content_hash: String,
pub(crate) transfer_name: String,
pub(crate) file_count: u64,
pub(crate) total_size: u64,
pub(crate) verified_bytes: u64,
pub(crate) blob_ticket: Option<String>,
pub(crate) authorization_secret_handle: Option<String>,
pub(crate) role: TargetedTransferRole,
pub(crate) state: TargetedTransferState,
pub(crate) created_at: i64,
pub(crate) updated_at: i64,
}
fn row_to_transfer(row: sqlx::sqlite::SqliteRow) -> Result<TargetedTransfer, VnidropError> {
Ok(TargetedTransfer {
id: row.get("id"),
sender_endpoint_id: row.get("sender_endpoint_id"),
receiver_endpoint_id: row.get("receiver_endpoint_id"),
manifest_id: row.get("manifest_id"),
file_count: row.get::<i64, _>("file_count") as u64,
total_size: row.get::<i64, _>("total_size") as u64,
verified_bytes: row.try_get::<i64, _>("verified_bytes").unwrap_or(0) as u64,
state: parse_state(&row.get::<String, _>("state"))?,
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
})
}
fn row_to_full(row: sqlx::sqlite::SqliteRow) -> Result<TargetedTransferRow, VnidropError> {
Ok(TargetedTransferRow {
id: row.get("id"),
protocol_transfer_id: row.get::<i64, _>("protocol_transfer_id") as u64,
sender_endpoint_id: row.get("sender_endpoint_id"),
receiver_endpoint_id: row.get("receiver_endpoint_id"),
manifest_id: row.get("manifest_id"),
content_hash: row.get("content_hash"),
transfer_name: row.get("transfer_name"),
file_count: row.get::<i64, _>("file_count") as u64,
total_size: row.get::<i64, _>("total_size") as u64,
verified_bytes: row.try_get::<i64, _>("verified_bytes").unwrap_or(0) as u64,
blob_ticket: row.try_get("blob_ticket").ok().flatten(),
authorization_secret_handle: row.try_get("authorization_secret_handle").ok().flatten(),
role: parse_role(
&row.try_get::<String, _>("role")
.unwrap_or_else(|_| "sender".to_string()),
)?,
state: parse_state(&row.get::<String, _>("state"))?,
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
})
}
pub(crate) fn state_as_str(state: TargetedTransferState) -> &'static str {
match state {
TargetedTransferState::Preparing => "preparing",
TargetedTransferState::Offering => "offering",
TargetedTransferState::AwaitingApproval => "awaiting_approval",
TargetedTransferState::Approved => "approved",
TargetedTransferState::Connecting => "connecting",
TargetedTransferState::Transferring => "transferring",
TargetedTransferState::Interrupted => "interrupted",
TargetedTransferState::Completed => "completed",
TargetedTransferState::Declined => "declined",
TargetedTransferState::Cancelled => "cancelled",
TargetedTransferState::Failed => "failed",
TargetedTransferState::Deleted => "deleted",
}
}
fn role_as_str(role: TargetedTransferRole) -> &'static str {
match role {
TargetedTransferRole::Sender => "sender",
TargetedTransferRole::Receiver => "receiver",
}
}
fn parse_role(value: &str) -> Result<TargetedTransferRole, VnidropError> {
match value {
"sender" => Ok(TargetedTransferRole::Sender),
"receiver" => Ok(TargetedTransferRole::Receiver),
other => Err(VnidropError::repository(anyhow::anyhow!(
"unknown targeted transfer role: {other}"
))),
}
}
fn parse_state(value: &str) -> Result<TargetedTransferState, VnidropError> {
match value {
"preparing" => Ok(TargetedTransferState::Preparing),
"offering" => Ok(TargetedTransferState::Offering),
"awaiting_approval" => Ok(TargetedTransferState::AwaitingApproval),
"approved" => Ok(TargetedTransferState::Approved),
"connecting" => Ok(TargetedTransferState::Connecting),
"transferring" => Ok(TargetedTransferState::Transferring),
"interrupted" => Ok(TargetedTransferState::Interrupted),
"completed" => Ok(TargetedTransferState::Completed),
"declined" => Ok(TargetedTransferState::Declined),
"cancelled" => Ok(TargetedTransferState::Cancelled),
"failed" => Ok(TargetedTransferState::Failed),
"deleted" => Ok(TargetedTransferState::Deleted),
other => Err(VnidropError::repository(anyhow::anyhow!(
"unknown targeted transfer state: {other}"
))),
}
}

View File

@@ -1,21 +1,58 @@
#[path = "tests/access_policy.rs"]
mod access_policy_tests;
#[path = "tests/api_surface.rs"]
mod api_surface_tests;
#[path = "tests/blocked_devices.rs"]
mod blocked_devices_tests;
#[path = "tests/control_plane.rs"]
mod control_plane_tests;
#[path = "tests/device_relationship.rs"]
mod device_relationship_tests;
#[path = "tests/error.rs"]
mod error_tests;
#[path = "tests/filesystem.rs"]
mod filesystem_tests;
#[path = "tests/grant.rs"]
mod grant_tests;
#[path = "tests/handshake.rs"]
mod handshake_tests;
#[path = "tests/limits.rs"]
mod limits_tests;
#[path = "tests/network_config.rs"]
mod network_config_tests;
#[path = "tests/pairing_eligibility.rs"]
mod pairing_eligibility_tests;
#[path = "tests/persistence.rs"]
mod persistence_tests;
#[path = "tests/platform_contract_android.rs"]
mod platform_contract_android_tests;
#[cfg(any(target_os = "macos", target_os = "ios"))]
#[path = "tests/platform_contract_apple.rs"]
mod platform_contract_apple_tests;
#[path = "tests/platform_contract_linux.rs"]
mod platform_contract_linux_tests;
#[path = "tests/platform_contract_windows.rs"]
mod platform_contract_windows_tests;
#[path = "tests/repository.rs"]
mod repository_tests;
#[path = "tests/runtime.rs"]
mod runtime_tests;
#[path = "tests/secret.rs"]
mod secret_tests;
#[path = "tests/secure_secret_android.rs"]
mod secure_secret_android_tests;
#[cfg(any(target_os = "macos", target_os = "ios"))]
#[path = "tests/secure_secret_apple.rs"]
mod secure_secret_apple_tests;
#[path = "tests/secure_secret_linux.rs"]
mod secure_secret_linux_tests;
#[path = "tests/secure_secret.rs"]
mod secure_secret_tests;
#[cfg(target_os = "windows")]
#[path = "tests/secure_secret_windows.rs"]
mod secure_secret_windows_tests;
#[path = "tests/targeted_transfer.rs"]
mod targeted_transfer_tests;
#[path = "tests/ticket.rs"]
mod ticket_tests;
#[path = "tests/transfer_state.rs"]

View File

@@ -0,0 +1,73 @@
//! Public API surface after prototype contact/offer removal.
#[test]
fn public_api_exposes_saved_device_surface_without_prototype_contact_entry_points() {
let facade = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/src/runtime/facade.rs"
));
let api = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/api.rs"));
let lib = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/lib.rs"));
for forbidden in [
"fn list_contacts(",
"fn send_to_contact(",
"fn poll_contacts_for_offers(",
"fn offer_transfer_to_contact(",
"fn list_held_offers(",
"fn list_pending_offers(",
"fn respond_to_offer(",
"fn list_pending_pairings(",
"fn allow_device_to_reach_me(",
"fn respond_to_pairing(",
"fn forget_contact(",
"fn forget_all_contacts(",
"fn block_contact(",
"fn unblock_contact(",
"fn list_blocked_contacts(",
"fn set_contact_label(",
"fn set_grant_lifetime(",
"struct ContactSummary",
"struct ContactSendResult",
"struct HeldOfferSummary",
"struct IncomingOffer",
"struct PendingPairing",
"enum GrantLifetimeSetting",
] {
assert!(
!facade.contains(forbidden),
"facade must not expose prototype entry point {forbidden}"
);
assert!(
!api.contains(forbidden),
"api.rs must not define prototype type {forbidden}"
);
assert!(
!lib.contains(forbidden),
"lib.rs must not re-export prototype symbol {forbidden}"
);
}
for required in [
"fn list_saved_devices(",
"fn list_device_relationships(",
"fn request_saved_device_pairing(",
"fn create_targeted_transfer(",
"fn list_pending_targeted_offers(",
"fn block_device(",
"fn forget_saved_device(",
"fn share_files(",
"fn receive(",
"experimental_saved_device_capabilities",
] {
assert!(
facade.contains(required) || api.contains(required) || lib.contains(required),
"public surface must keep {required}"
);
}
let caps = crate::experimental_saved_device_capabilities();
assert_eq!(caps.domain_contract_version, 1);
assert_eq!(caps.relationship_protocol_version, 1);
assert_eq!(caps.targeted_transfer_protocol_version, 1);
}

View File

@@ -0,0 +1,68 @@
use crate::{blocked_devices::BlockStore, invitation::Repository, persistence};
async fn store(temp: &tempfile::TempDir) -> BlockStore {
persistence::open_all(temp.path()).await.unwrap().blocked
}
#[tokio::test]
async fn block_list_persists_and_unblocks() {
let temp = tempfile::tempdir().unwrap();
let blocks = store(&temp).await;
assert!(!blocks.is_blocked("peer-a").await.unwrap());
blocks.block_endpoint("peer-a", 100).await.unwrap();
assert!(blocks.is_blocked("peer-a").await.unwrap());
assert_eq!(
blocks.list_blocked().await.unwrap(),
vec!["peer-a".to_string()]
);
blocks.unblock_endpoint("peer-a").await.unwrap();
assert!(!blocks.is_blocked("peer-a").await.unwrap());
assert!(blocks.list_blocked().await.unwrap().is_empty());
}
#[tokio::test]
async fn opening_app_data_drops_unreleased_prototype_tables() {
let temp = tempfile::tempdir().unwrap();
let db = temp.path().join("vnidrop.sqlite3");
{
let options = sqlx::sqlite::SqliteConnectOptions::new()
.filename(&db)
.create_if_missing(true);
let pool = sqlx::SqlitePool::connect_with(options).await.unwrap();
for ddl in [
"CREATE TABLE contacts (endpoint_id TEXT PRIMARY KEY)",
"CREATE TABLE grants_issued (grant_id TEXT PRIMARY KEY, grant_secret TEXT NOT NULL)",
"CREATE TABLE grants_held (grant_id TEXT PRIMARY KEY, grant_secret TEXT NOT NULL)",
"CREATE TABLE held_offers (offer_id TEXT PRIMARY KEY, ticket TEXT NOT NULL)",
"CREATE TABLE blocked_endpoints (endpoint_id TEXT PRIMARY KEY, created_at INTEGER NOT NULL)",
"INSERT INTO blocked_endpoints (endpoint_id, created_at) VALUES ('keep-me', 1)",
"INSERT INTO held_offers (offer_id, ticket) VALUES ('orphan', 'ticket')",
] {
sqlx::query(ddl).execute(&pool).await.unwrap();
}
}
let stores = persistence::open_all(temp.path()).await.unwrap();
let pool = {
let options = sqlx::sqlite::SqliteConnectOptions::new()
.filename(temp.path().join("vnidrop.sqlite3"))
.create_if_missing(false);
sqlx::SqlitePool::connect_with(options).await.unwrap()
};
for table in ["contacts", "grants_issued", "grants_held", "held_offers"] {
let row = sqlx::query(&format!(
"SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = '{table}'"
))
.fetch_one(&pool)
.await
.unwrap();
let n: i64 = sqlx::Row::get(&row, "n");
assert_eq!(n, 0, "{table} must be dropped without migration");
}
assert!(stores.blocked.is_blocked("keep-me").await.unwrap());
// Invitation repository remains reachable from the bag.
let _ = Repository::open(temp.path()).await.unwrap();
}

View File

@@ -0,0 +1,527 @@
//! Control-plane hardening: offer bounds, cooldowns, saved-device cap, redaction.
use std::sync::{Arc, Mutex};
use crate::{
api::{CoreEvent, CoreEventSink, CoreLimits, PendingTargetedOffer},
control_plane::IdentityCooldown,
event_hub::EventHub,
invitation::Repository,
secure_secret::FaultInjectingSecretStore,
targeted_transfer::inbox::{TargetedOfferDecision, TargetedOfferInbox},
CoreNetworkConfig, DeviceRelationshipState, ShareMetadataInput, ShareSource, SourceKind,
TransferAccessMode, VnidropCore, VnidropError,
};
struct RecordingSink {
events: Mutex<Vec<CoreEvent>>,
}
impl CoreEventSink for RecordingSink {
fn on_event(&self, event: CoreEvent) {
self.events.lock().unwrap().push(event);
}
}
impl RecordingSink {
fn events(&self) -> Vec<CoreEvent> {
self.events.lock().unwrap().clone()
}
fn kinds(&self) -> Vec<String> {
self.events().into_iter().map(|event| event.kind).collect()
}
}
fn sample_offer(transfer_id: &str, sender: &str) -> PendingTargetedOffer {
PendingTargetedOffer {
transfer_id: transfer_id.to_string(),
sender_endpoint_id: sender.to_string(),
receiver_endpoint_id: "receiver".to_string(),
manifest_id: "manifest".to_string(),
content_hash: "hash".to_string(),
transfer_name: "secret-name.pdf".to_string(),
file_count: 1,
total_size: 12,
protocol_version: 1,
received_at: 1,
}
}
async fn inbox_with_limits(
max_pending: usize,
cooldown_ms: u64,
strikes: u64,
) -> (TargetedOfferInbox, Arc<RecordingSink>) {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let hub = Arc::new(EventHub::start(repository, sink.clone(), 64, 100));
let cooldown = IdentityCooldown::new(cooldown_ms, strikes);
let inbox = TargetedOfferInbox::new(hub, max_pending, cooldown, 5_000);
// Keep temp dir alive for the hub's repository by leaking — tests are short-lived.
std::mem::forget(temp);
(inbox, sink)
}
#[tokio::test]
async fn one_unresolved_offer_per_sender_and_global_queue_bound() {
let (inbox, sink) = inbox_with_limits(1, 60_000, 5).await;
let first = sample_offer("t1", "sender-a");
let submit_first = {
let inbox = inbox.clone();
tokio::spawn(async move { inbox.submit(first).await })
};
// Wait until the prompt is live.
let started = std::time::Instant::now();
loop {
if !inbox.list().await.is_empty() {
break;
}
assert!(started.elapsed() < std::time::Duration::from_secs(2));
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
assert!(sink.kinds().contains(&"offer-received".to_string()));
let same_sender = inbox.submit(sample_offer("t2", "sender-a")).await;
assert_eq!(
same_sender,
TargetedOfferDecision::Refused {
reason: "offer-already-pending".to_string()
}
);
let other_sender = inbox.submit(sample_offer("t3", "sender-b")).await;
assert_eq!(
other_sender,
TargetedOfferDecision::Refused {
reason: "too-many-pending-offers".to_string()
}
);
assert_eq!(inbox.list().await.len(), 1);
// Excess rejects never emit a second prompt.
assert_eq!(
sink.kinds()
.into_iter()
.filter(|kind| kind == "offer-received")
.count(),
1
);
inbox.respond("t1", false).await.unwrap();
let _ = submit_first.await.unwrap();
}
#[tokio::test]
async fn decline_cools_sender_without_affecting_unrelated_devices() {
let (inbox, _sink) = inbox_with_limits(8, 60_000, 5).await;
let offer = sample_offer("decline-1", "noisy");
let wait = {
let inbox = inbox.clone();
tokio::spawn(async move { inbox.submit(offer).await })
};
let started = std::time::Instant::now();
loop {
if inbox.get_pending("decline-1").await.is_some() {
break;
}
assert!(started.elapsed() < std::time::Duration::from_secs(2));
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
inbox.respond("decline-1", false).await.unwrap();
let _ = wait.await.unwrap();
let cooled = inbox.submit(sample_offer("decline-2", "noisy")).await;
assert_eq!(
cooled,
TargetedOfferDecision::Refused {
reason: "identity-cooldown".to_string()
}
);
assert!(inbox.list().await.is_empty());
let unrelated = {
let inbox = inbox.clone();
tokio::spawn(async move { inbox.submit(sample_offer("ok", "friend")).await })
};
let started = std::time::Instant::now();
loop {
if inbox.get_pending("ok").await.is_some() {
break;
}
assert!(started.elapsed() < std::time::Duration::from_secs(2));
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
inbox.respond("ok", false).await.unwrap();
let _ = unrelated.await.unwrap();
}
#[tokio::test]
async fn malformed_strikes_trip_cooldown() {
let cooldown = IdentityCooldown::new(60_000, 2);
assert!(!cooldown.record_malformed("attacker"));
assert!(cooldown.record_malformed("attacker"));
assert!(cooldown.is_cooling("attacker"));
assert!(!cooldown.is_cooling("bystander"));
}
#[tokio::test]
async fn offer_received_events_redact_endpoint_ids_and_names() {
let (inbox, sink) = inbox_with_limits(4, 60_000, 5).await;
let sender = "abc123endpointid000000000000000000000000000000000000000000000000";
let wait = {
let inbox = inbox.clone();
let offer = sample_offer("redact-1", sender);
tokio::spawn(async move { inbox.submit(offer).await })
};
let started = std::time::Instant::now();
loop {
if sink
.kinds()
.into_iter()
.any(|kind| kind == "offer-received")
{
break;
}
assert!(started.elapsed() < std::time::Duration::from_secs(2));
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
let event = sink
.events()
.into_iter()
.find(|event| event.kind == "offer-received")
.expect("offer event");
assert!(!event.data_json.contains(sender));
assert!(!event.data_json.contains("secret-name"));
assert!(event.data_json.contains("redacted"));
inbox.respond("redact-1", false).await.unwrap();
let _ = wait.await.unwrap();
}
struct ProtectedNode {
_data_dir: tempfile::TempDir,
_secret_store: Arc<FaultInjectingSecretStore>,
_limits: CoreLimits,
_network_config: CoreNetworkConfig,
sink: Arc<RecordingSink>,
core: Option<Arc<VnidropCore>>,
}
impl ProtectedNode {
fn with_limits(limits: CoreLimits) -> Self {
let data_dir = tempfile::tempdir().unwrap();
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let store = Arc::new(FaultInjectingSecretStore::default());
let network_config = CoreNetworkConfig::default();
let core = VnidropCore::initialize_with_test_secret_store_limits_and_network(
data_dir.path().to_string_lossy().into_owned(),
sink.clone(),
store.clone(),
limits.clone(),
network_config.clone(),
)
.expect("protected test core");
Self {
_data_dir: data_dir,
_secret_store: store,
_limits: limits,
_network_config: network_config,
sink,
core: Some(core),
}
}
fn core(&self) -> Arc<VnidropCore> {
self.core.as_ref().expect("core alive").clone()
}
}
impl Drop for ProtectedNode {
fn drop(&mut self) {
if let Some(core) = self.core.take() {
core.shutdown();
}
}
}
fn share_path(
core: &VnidropCore,
source: &std::path::Path,
transfer_id: u64,
) -> crate::ShareResult {
core.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source.to_string_lossy().into_owned(),
display_name: Some("hello.txt".to_string()),
is_directory: false,
}],
ShareMetadataInput {
transfer_id,
transfer_name: Some("hello.txt".to_string()),
sender_name: Some("sender".to_string()),
access_mode: TransferAccessMode::ApprovalRequired,
},
)
.unwrap()
}
fn wait_for_receiver_request(sender: &VnidropCore, transfer_id: u64) -> crate::ReceiverRequest {
let started = std::time::Instant::now();
loop {
if let Some(request) = sender
.list_receiver_requests(transfer_id)
.unwrap()
.into_iter()
.find(|request| request.status == "requested")
{
return request;
}
assert!(
started.elapsed() < std::time::Duration::from_secs(15),
"timed out waiting for receiver request"
);
std::thread::sleep(std::time::Duration::from_millis(25));
}
}
fn complete_transfer(sender: &ProtectedNode, receiver: &ProtectedNode, transfer_id: u64) {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("hello.txt");
std::fs::write(&source_path, b"mutual consent").unwrap();
let share = share_path(&sender.core(), &source_path, transfer_id);
let output_dir = output_dir.path().to_string_lossy().to_string();
let receiver_core = receiver.core().clone();
let ticket = share.ticket.clone();
let handle = std::thread::spawn(move || {
receiver_core.receive(ticket, output_dir, Some("receiver".to_string()))
});
let request = wait_for_receiver_request(&sender.core(), share.transfer_id);
sender
.core()
.respond_receiver_request(request.id, true, None)
.unwrap();
handle.join().unwrap().unwrap();
let started = std::time::Instant::now();
let peer = receiver.core().status().endpoint_id.clone();
loop {
if sender
.core()
.list_pairing_eligibilities()
.unwrap()
.iter()
.any(|entry| entry.peer_endpoint_id == peer)
{
break;
}
assert!(
started.elapsed() < std::time::Duration::from_secs(15),
"eligibility never appeared"
);
std::thread::sleep(std::time::Duration::from_millis(25));
}
}
fn establish_saved(alice: &ProtectedNode, bob: &ProtectedNode, transfer_id: u64) {
complete_transfer(alice, bob, transfer_id);
let bob_id = bob.core().status().endpoint_id.clone();
assert!(alice
.core()
.request_saved_device_pairing(bob_id.clone())
.unwrap());
let started = std::time::Instant::now();
loop {
let pending = bob
.core()
.list_device_relationships()
.unwrap()
.into_iter()
.find(|entry| {
entry.remote_endpoint_id == alice.core().status().endpoint_id
&& entry.state == DeviceRelationshipState::PendingIncoming
});
if pending.is_some() {
break;
}
assert!(
started.elapsed() < std::time::Duration::from_secs(15),
"pairing prompt never arrived"
);
std::thread::sleep(std::time::Duration::from_millis(25));
}
assert!(bob
.core()
.respond_to_device_pairing(alice.core().status().endpoint_id.clone(), true)
.unwrap());
let started = std::time::Instant::now();
loop {
if alice.core().list_saved_devices().unwrap().len() == 1
&& bob.core().list_saved_devices().unwrap().len() == 1
{
break;
}
assert!(
started.elapsed() < std::time::Duration::from_secs(15),
"saved relationship never activated"
);
std::thread::sleep(std::time::Duration::from_millis(25));
}
}
#[test]
fn default_limits_include_saved_device_cap_and_control_plane_timeouts() {
let limits = CoreLimits::default();
limits.validate().unwrap();
assert_eq!(limits.max_saved_devices, 256);
assert!(limits.identity_cooldown_ms > 0);
assert!(limits.malformed_strike_limit > 0);
assert!(limits.pairing_timeout_ms > 0);
assert!(limits.offer_timeout_ms > 0);
assert!(limits.connection_timeout_ms > 0);
assert!(
limits.max_pending_offers <= 64,
"pending offers stay tightly bounded"
);
}
#[test]
fn saved_device_cap_blocks_only_new_relationships() {
let tight = CoreLimits {
max_saved_devices: 1,
..CoreLimits::default()
};
let alice = ProtectedNode::with_limits(tight.clone());
let bob = ProtectedNode::with_limits(tight.clone());
let carol = ProtectedNode::with_limits(tight);
establish_saved(&alice, &bob, 13_001);
assert_eq!(alice.core().list_saved_devices().unwrap().len(), 1);
// Existing relationship remains usable for targeted transfer.
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("payload.txt");
std::fs::write(&source_path, b"still works").unwrap();
let bob_id = bob.core().status().endpoint_id.clone();
let bob_core = bob.core().clone();
let accept = std::thread::spawn(move || {
let started = std::time::Instant::now();
let offer = loop {
if let Some(offer) = bob_core.list_pending_targeted_offers().into_iter().next() {
break offer;
}
assert!(
started.elapsed() < std::time::Duration::from_secs(20),
"offer never arrived for existing saved peer"
);
std::thread::sleep(std::time::Duration::from_millis(25));
};
bob_core
.respond_to_targeted_offer(offer.transfer_id, true)
.unwrap()
});
alice
.core()
.create_targeted_transfer(
bob_id,
vec![ShareSource {
kind: SourceKind::Path,
value: source_path.to_string_lossy().into_owned(),
display_name: Some("payload.txt".to_string()),
is_directory: false,
}],
Some("payload.txt".to_string()),
)
.unwrap();
accept.join().unwrap();
// New relationship is refused while the cap is full.
complete_transfer(&alice, &carol, 13_002);
let carol_id = carol.core().status().endpoint_id.clone();
assert!(
!alice.core().request_saved_device_pairing(carol_id).unwrap(),
"cap must block only new relationships"
);
assert!(alice.core().list_saved_devices().unwrap().len() <= 1);
assert!(carol.core().list_saved_devices().unwrap().is_empty());
}
#[test]
fn silent_reject_keeps_invalid_offers_off_the_prompt_surface() {
let alice = ProtectedNode::with_limits(CoreLimits::default());
let bob = ProtectedNode::with_limits(CoreLimits::default());
// No Saved relationship — create must fail before any receiver prompt.
let bob_id = bob.core().status().endpoint_id.clone();
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("payload.txt");
std::fs::write(&source_path, b"nope").unwrap();
let err = alice
.core()
.create_targeted_transfer(
bob_id,
vec![ShareSource {
kind: SourceKind::Path,
value: source_path.to_string_lossy().into_owned(),
display_name: Some("payload.txt".to_string()),
is_directory: false,
}],
Some("payload.txt".to_string()),
)
.unwrap_err();
assert!(matches!(err, VnidropError::Permission { .. }));
assert!(bob.core().list_pending_targeted_offers().is_empty());
assert!(
!bob.sink
.kinds()
.into_iter()
.any(|kind| kind == "offer-received"),
"silent reject must not emit offer prompts"
);
}
#[test]
fn blocked_peer_cannot_create_pairing_prompt() {
let alice = ProtectedNode::with_limits(CoreLimits::default());
let bob = ProtectedNode::with_limits(CoreLimits::default());
complete_transfer(&alice, &bob, 13_010);
let alice_id = alice.core().status().endpoint_id.clone();
bob.core().block_device(alice_id.clone()).unwrap();
assert!(
!alice
.core()
.request_saved_device_pairing(bob.core().status().endpoint_id.clone())
.unwrap()
|| bob
.core()
.list_device_relationships()
.unwrap()
.iter()
.all(|entry| entry.state != DeviceRelationshipState::PendingIncoming)
);
// Stronger: after block, bob must not surface an incoming pairing prompt.
std::thread::sleep(std::time::Duration::from_millis(200));
assert!(bob
.core()
.list_device_relationships()
.unwrap()
.into_iter()
.filter(|entry| entry.remote_endpoint_id == alice_id)
.all(|entry| entry.state != DeviceRelationshipState::PendingIncoming));
}
#[test]
fn ticket_errors_redact_raw_ticket_blobs() {
let err = VnidropError::ticket(anyhow::anyhow!(
"bad ticket vnd1:abcDEF1234567890 and endpoint 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
));
let rendered = err.to_string();
assert!(!rendered.contains("vnd1:abcDEF"));
assert!(!rendered.contains("0123456789abcdef0123456789abcdef"));
assert!(rendered.contains("[redacted]"));
}

View File

@@ -0,0 +1,599 @@
use std::{
path::Path,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use crate::{
secure_secret::FaultInjectingSecretStore, CoreEvent, CoreEventSink, DeviceRelationshipState,
ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode, VnidropCore,
};
struct RecordingSink {
events: Mutex<Vec<CoreEvent>>,
}
impl CoreEventSink for RecordingSink {
fn on_event(&self, event: CoreEvent) {
self.events.lock().unwrap().push(event);
}
}
struct ProtectedNode {
_data_dir: tempfile::TempDir,
core: Arc<VnidropCore>,
}
impl ProtectedNode {
fn new() -> Self {
let data_dir = tempfile::tempdir().unwrap();
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let store = Arc::new(FaultInjectingSecretStore::default());
let core = VnidropCore::initialize_with_test_secret_store(
data_dir.path().to_string_lossy().into_owned(),
sink,
store,
)
.expect("protected test core");
Self {
_data_dir: data_dir,
core,
}
}
}
impl Drop for ProtectedNode {
fn drop(&mut self) {
self.core.shutdown();
}
}
fn share_path(core: &VnidropCore, source: &Path, transfer_id: u64) -> crate::ShareResult {
core.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source.to_string_lossy().into_owned(),
display_name: Some("hello.txt".to_string()),
is_directory: false,
}],
ShareMetadataInput {
transfer_id,
transfer_name: Some("hello.txt".to_string()),
sender_name: Some("sender".to_string()),
access_mode: TransferAccessMode::ApprovalRequired,
},
)
.unwrap()
}
fn wait_for_receiver_request(sender: &VnidropCore, transfer_id: u64) -> crate::ReceiverRequest {
let started = Instant::now();
loop {
if let Some(request) = sender
.list_receiver_requests(transfer_id)
.unwrap()
.into_iter()
.find(|request| request.status == "requested")
{
return request;
}
assert!(
started.elapsed() < Duration::from_secs(15),
"timed out waiting for receiver request"
);
std::thread::sleep(Duration::from_millis(25));
}
}
fn complete_transfer(sender: &ProtectedNode, receiver: &ProtectedNode, transfer_id: u64) {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("hello.txt");
std::fs::write(&source_path, b"mutual consent").unwrap();
let share = share_path(&sender.core, &source_path, transfer_id);
let output_dir = output_dir.path().to_string_lossy().to_string();
let receiver_core = receiver.core.clone();
let ticket = share.ticket.clone();
let handle = std::thread::spawn(move || {
receiver_core.receive(ticket, output_dir, Some("receiver".to_string()))
});
let request = wait_for_receiver_request(&sender.core, share.transfer_id);
sender
.core
.respond_receiver_request(request.id, true, None)
.unwrap();
handle.join().unwrap().unwrap();
let started = Instant::now();
let peer = receiver.core.status().endpoint_id.clone();
loop {
if sender
.core
.list_pairing_eligibilities()
.unwrap()
.iter()
.any(|entry| entry.peer_endpoint_id == peer)
{
break;
}
assert!(
started.elapsed() < Duration::from_secs(10),
"eligibility never appeared"
);
std::thread::sleep(Duration::from_millis(25));
}
}
fn wait_for_relationship(
core: &VnidropCore,
peer: &str,
state: DeviceRelationshipState,
) -> crate::DeviceRelationship {
let started = Instant::now();
loop {
if let Some(relationship) = core
.list_device_relationships()
.unwrap()
.into_iter()
.find(|entry| entry.remote_endpoint_id == peer && entry.state == state)
{
return relationship;
}
assert!(
started.elapsed() < Duration::from_secs(15),
"relationship {peer} never reached {state:?}"
);
std::thread::sleep(Duration::from_millis(25));
}
}
#[test]
fn mutual_consent_reaches_saved_after_both_grants_and_acknowledgement() {
let alice = ProtectedNode::new();
let bob = ProtectedNode::new();
let alice_id = alice.core.status().endpoint_id.clone();
let bob_id = bob.core.status().endpoint_id.clone();
complete_transfer(&alice, &bob, 80_001);
assert!(alice
.core
.request_saved_device_pairing(bob_id.clone())
.unwrap());
wait_for_relationship(
&alice.core,
&bob_id,
DeviceRelationshipState::PendingOutgoing,
);
wait_for_relationship(
&bob.core,
&alice_id,
DeviceRelationshipState::PendingIncoming,
);
assert!(
alice.core.list_saved_devices().unwrap().is_empty(),
"pending outgoing must not surface as a saved device"
);
assert!(
bob.core.list_saved_devices().unwrap().is_empty(),
"pending incoming must not surface as a saved device"
);
assert!(bob
.core
.respond_to_device_pairing(alice_id.clone(), true)
.unwrap());
wait_for_relationship(&alice.core, &bob_id, DeviceRelationshipState::Saved);
wait_for_relationship(&bob.core, &alice_id, DeviceRelationshipState::Saved);
let alice_saved = alice.core.list_saved_devices().unwrap();
let bob_saved = bob.core.list_saved_devices().unwrap();
assert_eq!(alice_saved.len(), 1);
assert_eq!(alice_saved[0].endpoint_id, bob_id);
assert_eq!(bob_saved.len(), 1);
assert_eq!(bob_saved[0].endpoint_id, alice_id);
}
#[test]
fn declining_pending_incoming_consumes_eligibility_and_never_saves() {
let alice = ProtectedNode::new();
let bob = ProtectedNode::new();
let alice_id = alice.core.status().endpoint_id.clone();
let bob_id = bob.core.status().endpoint_id.clone();
complete_transfer(&alice, &bob, 80_010);
assert!(alice
.core
.request_saved_device_pairing(bob_id.clone())
.unwrap());
wait_for_relationship(
&bob.core,
&alice_id,
DeviceRelationshipState::PendingIncoming,
);
assert!(bob
.core
.respond_to_device_pairing(alice_id.clone(), false)
.unwrap());
assert!(bob.core.list_saved_devices().unwrap().is_empty());
assert!(alice.core.list_saved_devices().unwrap().is_empty());
assert!(bob
.core
.list_pairing_eligibilities()
.unwrap()
.iter()
.all(|entry| entry.peer_endpoint_id != alice_id));
// Declined eligibility cannot prompt again without a new qualifying transfer.
assert!(!alice.core.request_saved_device_pairing(bob_id).unwrap());
}
#[test]
fn repeated_consent_is_idempotent_and_does_not_duplicate_saved_rows() {
let alice = ProtectedNode::new();
let bob = ProtectedNode::new();
let alice_id = alice.core.status().endpoint_id.clone();
let bob_id = bob.core.status().endpoint_id.clone();
complete_transfer(&alice, &bob, 80_020);
assert!(alice
.core
.request_saved_device_pairing(bob_id.clone())
.unwrap());
wait_for_relationship(
&bob.core,
&alice_id,
DeviceRelationshipState::PendingIncoming,
);
assert!(bob
.core
.respond_to_device_pairing(alice_id.clone(), true)
.unwrap());
wait_for_relationship(&alice.core, &bob_id, DeviceRelationshipState::Saved);
wait_for_relationship(&bob.core, &alice_id, DeviceRelationshipState::Saved);
assert!(bob.core.respond_to_device_pairing(alice_id, true).unwrap());
assert_eq!(alice.core.list_saved_devices().unwrap().len(), 1);
assert_eq!(bob.core.list_saved_devices().unwrap().len(), 1);
assert_eq!(alice.core.list_device_relationships().unwrap().len(), 1);
assert_eq!(bob.core.list_device_relationships().unwrap().len(), 1);
}
#[test]
fn simultaneous_initiation_merges_into_one_relationship_per_side() {
let alice = ProtectedNode::new();
let bob = ProtectedNode::new();
let alice_id = alice.core.status().endpoint_id.clone();
let bob_id = bob.core.status().endpoint_id.clone();
complete_transfer(&alice, &bob, 80_030);
let alice_core = alice.core.clone();
let bob_core = bob.core.clone();
let bob_id_clone = bob_id.clone();
let alice_id_clone = alice_id.clone();
let alice_handle =
std::thread::spawn(move || alice_core.request_saved_device_pairing(bob_id_clone));
let bob_handle =
std::thread::spawn(move || bob_core.request_saved_device_pairing(alice_id_clone));
let alice_ok = alice_handle.join().unwrap().unwrap();
let bob_ok = bob_handle.join().unwrap().unwrap();
assert!(alice_ok || bob_ok);
wait_for_relationship(&alice.core, &bob_id, DeviceRelationshipState::Saved);
wait_for_relationship(&bob.core, &alice_id, DeviceRelationshipState::Saved);
assert_eq!(alice.core.list_device_relationships().unwrap().len(), 1);
assert_eq!(bob.core.list_device_relationships().unwrap().len(), 1);
assert_eq!(alice.core.list_saved_devices().unwrap().len(), 1);
assert_eq!(bob.core.list_saved_devices().unwrap().len(), 1);
}
#[test]
fn request_timeout_leaves_recoverable_pending_not_saved() {
let alice = ProtectedNode::new();
let bob = ProtectedNode::new();
let bob_id = bob.core.status().endpoint_id.clone();
complete_transfer(&alice, &bob, 80_040);
// Shut down Bob so Alice's pairing request cannot complete on the wire.
bob.core.shutdown();
assert!(alice
.core
.request_saved_device_pairing(bob_id.clone())
.unwrap());
wait_for_relationship(
&alice.core,
&bob_id,
DeviceRelationshipState::PendingOutgoing,
);
assert!(
alice.core.list_saved_devices().unwrap().is_empty(),
"timed-out pairing must not surface as saved"
);
}
fn reach_saved(alice: &ProtectedNode, bob: &ProtectedNode, transfer_id: u64) {
let alice_id = alice.core.status().endpoint_id.clone();
let bob_id = bob.core.status().endpoint_id.clone();
complete_transfer(alice, bob, transfer_id);
assert!(alice
.core
.request_saved_device_pairing(bob_id.clone())
.unwrap());
wait_for_relationship(
&bob.core,
&alice_id,
DeviceRelationshipState::PendingIncoming,
);
assert!(bob
.core
.respond_to_device_pairing(alice_id.clone(), true)
.unwrap());
wait_for_relationship(&alice.core, &bob_id, DeviceRelationshipState::Saved);
wait_for_relationship(&bob.core, &alice_id, DeviceRelationshipState::Saved);
}
#[test]
fn rotating_grant_invalidates_prior_generation_and_leaves_one_active() {
let alice = ProtectedNode::new();
let bob = ProtectedNode::new();
let bob_id = bob.core.status().endpoint_id.clone();
reach_saved(&alice, &bob, 90_001);
let (old_generation, old_grant_id) = alice
.core
.relationship_issued_grant_for_test(bob_id.clone())
.unwrap()
.expect("issued grant before rotate");
assert_eq!(old_generation, 1);
let new_generation = alice
.core
.rotate_relationship_grant(bob_id.clone())
.unwrap();
assert_eq!(new_generation, 2);
let relationships = alice.core.list_device_relationships().unwrap();
assert_eq!(relationships.len(), 1);
assert_eq!(relationships[0].generation, 2);
assert_eq!(relationships[0].state, DeviceRelationshipState::Saved);
let (active_generation, active_grant_id) = alice
.core
.relationship_issued_grant_for_test(bob_id.clone())
.unwrap()
.expect("issued grant after rotate");
assert_eq!(active_generation, 2);
assert_ne!(active_grant_id, old_grant_id);
let err = alice
.core
.reject_relationship_generation_for_test(
bob_id.clone(),
old_generation,
Some(old_grant_id.clone()),
)
.expect_err("tombstoned generation must be rejected");
assert_eq!(err, "revoked");
alice
.core
.reject_relationship_generation_for_test(bob_id.clone(), new_generation, None)
.expect("active generation remains usable");
let tombstones = alice.core.relationship_tombstones_for_test(bob_id).unwrap();
assert_eq!(tombstones.len(), 1);
assert_eq!(tombstones[0].generation, old_generation);
assert_eq!(
tombstones[0].issued_grant_id.as_deref(),
Some(old_grant_id.as_str())
);
// Minimal non-secret payload only.
assert!(tombstones[0].revoked_at > 0);
}
#[test]
fn forget_saved_device_revokes_locally_and_hooks_targeted_cancel() {
let alice = ProtectedNode::new();
let bob = ProtectedNode::new();
let bob_id = bob.core.status().endpoint_id.clone();
reach_saved(&alice, &bob, 90_010);
alice.core.forget_saved_device(bob_id.clone()).unwrap();
assert!(alice.core.list_saved_devices().unwrap().is_empty());
let relationships = alice.core.list_device_relationships().unwrap();
assert!(
relationships.is_empty(),
"forgotten relationship must not remain listed"
);
let cancels = alice.core.targeted_cancel_log_for_test();
assert_eq!(cancels, vec![bob_id.clone()]);
let tombstones = alice
.core
.relationship_tombstones_for_test(bob_id.clone())
.unwrap();
assert_eq!(tombstones.len(), 1);
assert!(alice
.core
.reject_relationship_generation_for_test(bob_id, tombstones[0].generation, None)
.is_err());
}
#[test]
fn forget_does_not_cancel_active_invitation_transfer() {
let alice = ProtectedNode::new();
let bob = ProtectedNode::new();
let bob_id = bob.core.status().endpoint_id.clone();
reach_saved(&alice, &bob, 90_020);
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("hello.txt");
std::fs::write(&source_path, b"invitation continues after forget").unwrap();
let share = share_path(&alice.core, &source_path, 90_021);
let output = output_dir.path().to_string_lossy().to_string();
let receiver_core = bob.core.clone();
let ticket = share.ticket.clone();
let handle = std::thread::spawn(move || {
receiver_core.receive(ticket, output, Some("receiver".to_string()))
});
let request = wait_for_receiver_request(&alice.core, share.transfer_id);
alice
.core
.respond_receiver_request(request.id, true, None)
.unwrap();
// Forget after the invitation is approved; the share-domain transfer must finish.
alice.core.forget_saved_device(bob_id).unwrap();
handle.join().unwrap().unwrap();
let transfers = alice.core.list_transfers().unwrap();
let invitation = transfers
.iter()
.find(|entry| entry.transfer_id == share.transfer_id)
.expect("invitation transfer retained");
assert_ne!(
invitation.status.to_lowercase(),
"cancelled",
"forget must not cancel independently approved invitation transfers"
);
}
#[test]
fn block_rejects_pairing_and_invitation_handshake_unblock_restores_neither() {
let alice = ProtectedNode::new();
let bob = ProtectedNode::new();
let alice_id = alice.core.status().endpoint_id.clone();
reach_saved(&alice, &bob, 90_030);
bob.core.block_device(alice_id.clone()).unwrap();
assert_eq!(
bob.core.list_blocked_devices().unwrap(),
vec![alice_id.clone()]
);
assert!(bob.core.list_saved_devices().unwrap().is_empty());
assert!(bob.core.list_device_relationships().unwrap().is_empty());
// Outbound pairing toward a blocked identity is refused locally.
assert!(!bob
.core
.request_saved_device_pairing(alice_id.clone())
.unwrap());
// Invitation handshake from the blocked identity is refused.
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("blocked.txt");
std::fs::write(&source_path, b"blocked").unwrap();
let share = share_path(&bob.core, &source_path, 90_032);
let receive_result = alice.core.receive(
share.ticket,
output_dir.path().to_string_lossy().into_owned(),
Some("alice".to_string()),
);
assert!(
receive_result.is_err(),
"blocked endpoint must fail invitation handshake"
);
bob.core.unblock_device(alice_id).unwrap();
assert!(bob.core.list_blocked_devices().unwrap().is_empty());
assert!(
bob.core.list_saved_devices().unwrap().is_empty(),
"unblock must not restore the relationship"
);
assert!(bob.core.list_device_relationships().unwrap().is_empty());
}
#[test]
fn reinstalled_peer_is_never_merged_by_name_or_metadata() {
let alice = ProtectedNode::new();
let bob = ProtectedNode::new();
let charlie = ProtectedNode::new();
let bob_id = bob.core.status().endpoint_id.clone();
let charlie_id = charlie.core.status().endpoint_id.clone();
assert_ne!(bob_id, charlie_id);
reach_saved(&alice, &bob, 90_040);
// Same display-facing label on a different endpoint identity must not merge.
alice
.core
.set_saved_device_label(bob_id.clone(), Some("Kitchen Tablet".to_string()))
.unwrap();
reach_saved(&alice, &charlie, 90_041);
alice
.core
.set_saved_device_label(charlie_id.clone(), Some("Kitchen Tablet".to_string()))
.unwrap();
let saved = alice.core.list_saved_devices().unwrap();
assert_eq!(saved.len(), 2);
let ids: std::collections::HashSet<_> =
saved.into_iter().map(|device| device.endpoint_id).collect();
assert!(ids.contains(&bob_id));
assert!(ids.contains(&charlie_id));
assert_eq!(alice.core.list_device_relationships().unwrap().len(), 2);
}
#[test]
fn saved_device_local_label_survives_listing_and_rejects_non_saved_peers() {
let alice = ProtectedNode::new();
let bob = ProtectedNode::new();
let bob_id = bob.core.status().endpoint_id.clone();
reach_saved(&alice, &bob, 90_050);
alice
.core
.set_saved_device_label(bob_id.clone(), Some("Kitchen Tablet".to_string()))
.unwrap();
let saved = alice.core.list_saved_devices().unwrap();
assert_eq!(saved.len(), 1);
assert_eq!(saved[0].local_label.as_deref(), Some("Kitchen Tablet"));
alice
.core
.set_saved_device_label(bob_id.clone(), None)
.unwrap();
assert!(alice.core.list_saved_devices().unwrap()[0]
.local_label
.is_none());
let err = alice
.core
.set_saved_device_label("unknown-peer".to_string(), Some("x".to_string()))
.unwrap_err();
assert!(matches!(err, crate::VnidropError::InvalidInput { .. }));
}
#[test]
fn events_carry_stable_ids_and_monotonic_revisions() {
let alice = ProtectedNode::new();
let bob = ProtectedNode::new();
reach_saved(&alice, &bob, 90_051);
let events = alice.core.list_events(None).unwrap();
assert!(!events.is_empty());
let mut seen_ids = std::collections::HashSet::new();
let mut revisions: Vec<u64> = Vec::new();
for event in &events {
assert!(
seen_ids.insert(event.id.clone()),
"event ids must be unique"
);
assert!(event.revision >= 1, "revisions start at 1");
revisions.push(event.revision);
}
revisions.sort_unstable();
revisions.dedup();
assert_eq!(
revisions.len(),
events.len(),
"each event must have a distinct revision"
);
}

View File

@@ -44,6 +44,22 @@ fn transfer_boundary_preserves_typed_errors_through_context() {
assert_eq!(classified.code(), "network");
}
#[test]
fn initialization_boundary_preserves_secure_storage_failures() {
let error = anyhow::Error::new(VnidropError::SecureStorageLocked {
reason: "credential store is locked".to_string(),
})
.context("endpoint identity could not be loaded");
let classified = VnidropError::initialization(error);
assert!(matches!(
classified,
VnidropError::SecureStorageLocked { ref reason }
if reason == "endpoint identity could not be loaded"
));
}
#[test]
fn transfer_boundary_classifies_database_failures() {
let transfer = VnidropError::transfer(sqlx::Error::RowNotFound);
@@ -52,3 +68,29 @@ fn transfer_boundary_classifies_database_failures() {
assert!(matches!(transfer, VnidropError::Repository { .. }));
assert!(matches!(approval, VnidropError::Repository { .. }));
}
#[test]
fn saved_device_failures_remain_distinguishable() {
let unavailable = VnidropError::device_unavailable(anyhow::anyhow!("offline"));
let timeout = VnidropError::offer_timeout(anyhow::anyhow!("no answer"));
let relay = VnidropError::relay_policy_incompatible(anyhow::anyhow!("profiles differ"));
let protocol = VnidropError::protocol_incompatible(anyhow::anyhow!("downgrade"));
assert_eq!(unavailable.code(), "device_unavailable");
assert_eq!(timeout.code(), "offer_timeout");
assert_eq!(relay.code(), "relay_policy_incompatible");
assert_eq!(protocol.code(), "protocol_incompatible");
assert!(matches!(
unavailable,
VnidropError::DeviceUnavailable { .. }
));
assert!(matches!(timeout, VnidropError::OfferTimeout { .. }));
assert!(matches!(
relay,
VnidropError::RelayPolicyIncompatible { .. }
));
assert!(matches!(
protocol,
VnidropError::ProtocolIncompatible { .. }
));
}

View File

@@ -0,0 +1,35 @@
use crate::grant::{Challenge, GrantId, GrantRejection, GrantSecret};
#[test]
fn grant_identifiers_round_trip() {
let grant_id = GrantId::generate();
assert_eq!(
GrantId::decode(&grant_id.encode()).expect("id decodes"),
grant_id
);
let secret = GrantSecret::generate();
assert_eq!(
GrantSecret::decode(&secret.encode()).expect("secret decodes"),
secret
);
assert_eq!(format!("{secret:?}"), "GrantSecret(redacted)");
let challenge = Challenge::generate();
assert_eq!(
Challenge::decode(&challenge.encode()).expect("challenge decodes"),
challenge
);
}
#[test]
fn grant_secret_decode_rejects_garbage() {
assert!(GrantSecret::decode("not-hex").is_err());
assert!(GrantSecret::decode("aabb").is_err(), "wrong length");
}
#[test]
fn grant_rejection_labels_are_stable() {
assert_eq!(GrantRejection::Unknown.as_str(), "unknown");
assert_eq!(GrantRejection::Revoked.as_str(), "revoked");
}

View File

@@ -11,6 +11,8 @@ fn default_limits_bound_ticket_and_approval_pressure() {
assert!(limits.max_ticket_bytes <= 256 * 1024);
assert!(limits.max_pending_approvals <= 64);
assert!(limits.max_total_bytes <= 256 * 1024 * 1024 * 1024);
assert_eq!(limits.max_saved_devices, 256);
assert!(limits.max_pending_offers <= 16);
}
#[test]
@@ -21,3 +23,12 @@ fn zero_limit_is_rejected() {
};
assert!(limits.validate().is_err());
}
#[test]
fn zero_saved_device_limit_is_rejected() {
let limits = CoreLimits {
max_saved_devices: 0,
..CoreLimits::default()
};
assert!(limits.validate().is_err());
}

View File

@@ -0,0 +1,558 @@
use std::{
path::Path,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use crate::{
experimental_saved_device_capabilities, secure_secret::FaultInjectingSecretStore, CoreEvent,
CoreEventSink, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode, VnidropCore,
VnidropError,
};
struct RecordingSink {
events: Mutex<Vec<CoreEvent>>,
}
impl CoreEventSink for RecordingSink {
fn on_event(&self, event: CoreEvent) {
self.events.lock().unwrap().push(event);
}
}
impl RecordingSink {
fn events(&self) -> Vec<CoreEvent> {
self.events.lock().unwrap().clone()
}
}
struct ProtectedNode {
_data_dir: tempfile::TempDir,
core: Arc<VnidropCore>,
sink: Arc<RecordingSink>,
}
impl ProtectedNode {
fn new() -> Self {
let data_dir = tempfile::tempdir().unwrap();
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let store = Arc::new(FaultInjectingSecretStore::default());
let core = VnidropCore::initialize_with_test_secret_store(
data_dir.path().to_string_lossy().into_owned(),
sink.clone(),
store,
)
.expect("protected test core");
Self {
_data_dir: data_dir,
core,
sink,
}
}
}
impl Drop for ProtectedNode {
fn drop(&mut self) {
self.core.shutdown();
}
}
fn share_path(core: &VnidropCore, source: &Path, transfer_id: u64) -> crate::ShareResult {
core.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source.to_string_lossy().into_owned(),
display_name: Some("hello.txt".to_string()),
is_directory: false,
}],
ShareMetadataInput {
transfer_id,
transfer_name: Some("hello.txt".to_string()),
sender_name: Some("sender".to_string()),
access_mode: TransferAccessMode::ApprovalRequired,
},
)
.unwrap()
}
fn wait_for_receiver_request(sender: &VnidropCore, transfer_id: u64) -> crate::ReceiverRequest {
let started = Instant::now();
loop {
if let Some(request) = sender
.list_receiver_requests(transfer_id)
.unwrap()
.into_iter()
.find(|request| request.status == "requested")
{
return request;
}
assert!(
started.elapsed() < Duration::from_secs(15),
"timed out waiting for receiver request"
);
std::thread::sleep(Duration::from_millis(25));
}
}
fn receive_with_response(
sender: &VnidropCore,
transfer_id: u64,
receiver: Arc<VnidropCore>,
ticket: String,
output_dir: &Path,
accepted: bool,
) -> Result<(), VnidropError> {
let output_dir = output_dir.to_string_lossy().to_string();
let handle = std::thread::spawn(move || {
receiver.receive(ticket, output_dir, Some("receiver".to_string()))
});
let request = wait_for_receiver_request(sender, transfer_id);
sender
.respond_receiver_request(
request.id,
accepted,
(!accepted).then(|| "sender-refused".to_string()),
)
.unwrap();
handle.join().unwrap()
}
fn wait_for_eligibility(core: &VnidropCore, peer_endpoint_id: &str) {
let started = Instant::now();
loop {
let found = core
.list_pairing_eligibilities()
.unwrap()
.into_iter()
.any(|entry| entry.peer_endpoint_id == peer_endpoint_id);
if found {
return;
}
assert!(
started.elapsed() < Duration::from_secs(10),
"eligibility for {peer_endpoint_id} never appeared"
);
std::thread::sleep(Duration::from_millis(25));
}
}
#[test]
fn completed_authenticated_transfer_creates_pairing_eligibility_on_both_sides() {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("hello.txt");
std::fs::write(&source_path, b"eligible after completion").unwrap();
let sender = ProtectedNode::new();
let receiver = ProtectedNode::new();
let sender_id = sender.core.status().endpoint_id.clone();
let receiver_id = receiver.core.status().endpoint_id.clone();
let share = share_path(&sender.core, &source_path, 70_001);
receive_with_response(
&sender.core,
share.transfer_id,
receiver.core.clone(),
share.ticket,
output_dir.path(),
true,
)
.unwrap();
wait_for_eligibility(&sender.core, &receiver_id);
wait_for_eligibility(&receiver.core, &sender_id);
let protocol = experimental_saved_device_capabilities().relationship_protocol_version;
let sender_entry = sender
.core
.list_pairing_eligibilities()
.unwrap()
.into_iter()
.find(|entry| entry.peer_endpoint_id == receiver_id)
.unwrap();
let receiver_entry = receiver
.core
.list_pairing_eligibilities()
.unwrap()
.into_iter()
.find(|entry| entry.peer_endpoint_id == sender_id)
.unwrap();
assert_eq!(sender_entry.session_id, receiver_entry.session_id);
assert_eq!(sender_entry.protocol_version, protocol);
assert_eq!(receiver_entry.protocol_version, protocol);
assert!(sender_entry.expires_at > sender_entry.created_at);
assert_eq!(
sender_entry.expires_at - sender_entry.created_at,
24 * 60 * 60 * 1_000
);
let sender_events = sender.sink.events();
assert!(
sender_events
.iter()
.any(|event| { event.phase == "pairing" && event.kind == "eligibility-available" }),
"sender should emit eligibility-available without capability material"
);
assert!(
!sender_events
.iter()
.any(|event| event.data_json.contains("capability")),
"events must not expose the eligibility capability"
);
}
#[test]
fn declined_cancelled_and_failed_transfers_create_no_eligibility() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("hello.txt");
std::fs::write(&source_path, b"not eligible").unwrap();
// Declined approval
{
let output_dir = tempfile::tempdir().unwrap();
let sender = ProtectedNode::new();
let receiver = ProtectedNode::new();
let share = share_path(&sender.core, &source_path, 70_010);
let _ = receive_with_response(
&sender.core,
share.transfer_id,
receiver.core.clone(),
share.ticket,
output_dir.path(),
false,
);
assert!(sender.core.list_pairing_eligibilities().unwrap().is_empty());
assert!(receiver
.core
.list_pairing_eligibilities()
.unwrap()
.is_empty());
}
// Failed export on the receiver
{
let sender = ProtectedNode::new();
let receiver = ProtectedNode::new();
let share = share_path(&sender.core, &source_path, 70_011);
let sink = Arc::new(FailingOutputSink);
let handle = {
let receiver = receiver.core.clone();
let ticket = share.ticket.clone();
std::thread::spawn(move || {
receiver.receive_with_output_sink(ticket, sink, Some("receiver".to_string()))
})
};
let request = wait_for_receiver_request(&sender.core, share.transfer_id);
sender
.core
.respond_receiver_request(request.id, true, None)
.unwrap();
let _ = handle.join().unwrap();
std::thread::sleep(Duration::from_millis(200));
assert!(sender.core.list_pairing_eligibilities().unwrap().is_empty());
assert!(receiver
.core
.list_pairing_eligibilities()
.unwrap()
.is_empty());
}
}
#[test]
fn eligibility_survives_restart_without_filenames_or_history_payload() {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("secret-name.txt");
std::fs::write(&source_path, b"persist eligibility").unwrap();
let data_dir = tempfile::tempdir().unwrap();
let store = Arc::new(FaultInjectingSecretStore::default());
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let sender = VnidropCore::initialize_with_test_secret_store(
data_dir.path().to_string_lossy().into_owned(),
sink.clone(),
store.clone(),
)
.unwrap();
let receiver = ProtectedNode::new();
let receiver_id = receiver.core.status().endpoint_id.clone();
let share = share_path(&sender, &source_path, 70_020);
receive_with_response(
&sender,
share.transfer_id,
receiver.core.clone(),
share.ticket,
output_dir.path(),
true,
)
.unwrap();
wait_for_eligibility(&sender, &receiver_id);
let before = sender.list_pairing_eligibilities().unwrap();
assert_eq!(before.len(), 1);
sender.shutdown();
drop(sender);
let restarted = VnidropCore::initialize_with_test_secret_store(
data_dir.path().to_string_lossy().into_owned(),
sink,
store,
)
.unwrap();
let after = restarted.list_pairing_eligibilities().unwrap();
assert_eq!(after, before);
assert!(!serde_json::to_string(&after)
.unwrap()
.contains("secret-name"));
assert!(!serde_json::to_string(&after)
.unwrap()
.contains("persist eligibility"));
restarted.shutdown();
}
#[test]
fn expired_eligibility_is_removed_and_cannot_authorize_pairing() {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("hello.txt");
std::fs::write(&source_path, b"expires").unwrap();
let sender = ProtectedNode::new();
let receiver = ProtectedNode::new();
let receiver_id = receiver.core.status().endpoint_id.clone();
let share = share_path(&sender.core, &source_path, 70_050);
receive_with_response(
&sender.core,
share.transfer_id,
receiver.core.clone(),
share.ticket,
output_dir.path(),
true,
)
.unwrap();
wait_for_eligibility(&sender.core, &receiver_id);
let session_id = sender.core.list_pairing_eligibilities().unwrap()[0]
.session_id
.clone();
sender
.core
.force_pairing_eligibility_expiry_for_test(session_id, 1)
.unwrap();
assert!(sender.core.list_pairing_eligibilities().unwrap().is_empty());
assert!(!sender
.core
.request_saved_device_pairing(receiver_id)
.unwrap());
}
#[test]
fn decline_forget_block_and_replay_remove_eligibility_idempotently() {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("hello.txt");
std::fs::write(&source_path, b"remove eligibility").unwrap();
let sender = ProtectedNode::new();
let receiver = ProtectedNode::new();
let receiver_id = receiver.core.status().endpoint_id.clone();
let share = share_path(&sender.core, &source_path, 70_030);
receive_with_response(
&sender.core,
share.transfer_id,
receiver.core.clone(),
share.ticket,
output_dir.path(),
true,
)
.unwrap();
wait_for_eligibility(&sender.core, &receiver_id);
sender
.core
.decline_pairing_eligibility(receiver_id.clone())
.unwrap();
assert!(sender.core.list_pairing_eligibilities().unwrap().is_empty());
sender
.core
.decline_pairing_eligibility(receiver_id.clone())
.unwrap();
assert!(sender.core.list_pairing_eligibilities().unwrap().is_empty());
// Fresh eligibility for forget/block coverage on the receiver side.
let sender2 = ProtectedNode::new();
let output_dir2 = tempfile::tempdir().unwrap();
let share2 = share_path(&sender2.core, &source_path, 70_031);
receive_with_response(
&sender2.core,
share2.transfer_id,
receiver.core.clone(),
share2.ticket,
output_dir2.path(),
true,
)
.unwrap();
wait_for_eligibility(&receiver.core, &sender2.core.status().endpoint_id);
receiver
.core
.forget_saved_device(sender2.core.status().endpoint_id.clone())
.unwrap();
assert!(receiver
.core
.list_pairing_eligibilities()
.unwrap()
.iter()
.all(|entry| entry.peer_endpoint_id != sender2.core.status().endpoint_id));
let sender3 = ProtectedNode::new();
let output_dir3 = tempfile::tempdir().unwrap();
let share3 = share_path(&sender3.core, &source_path, 70_032);
receive_with_response(
&sender3.core,
share3.transfer_id,
receiver.core.clone(),
share3.ticket,
output_dir3.path(),
true,
)
.unwrap();
wait_for_eligibility(&receiver.core, &sender3.core.status().endpoint_id);
receiver
.core
.block_device(sender3.core.status().endpoint_id.clone())
.unwrap();
assert!(receiver
.core
.list_pairing_eligibilities()
.unwrap()
.iter()
.all(|entry| entry.peer_endpoint_id != sender3.core.status().endpoint_id));
}
#[test]
fn missing_expired_replayed_and_fabricated_eligibility_are_silently_rejected() {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("hello.txt");
std::fs::write(&source_path, b"silent reject").unwrap();
let sender = ProtectedNode::new();
let receiver = ProtectedNode::new();
let receiver_id = receiver.core.status().endpoint_id.clone();
let events_before = receiver.sink.events().len();
// Missing eligibility: request produces no pending relationship prompt/event.
assert!(!receiver
.core
.request_saved_device_pairing(sender.core.status().endpoint_id.clone())
.unwrap());
assert!(receiver
.core
.list_device_relationships()
.unwrap()
.is_empty());
assert_eq!(
receiver
.sink
.events()
.iter()
.filter(|event| event.phase == "pairing" && event.kind.contains("pending"))
.count(),
0
);
let share = share_path(&sender.core, &source_path, 70_040);
receive_with_response(
&sender.core,
share.transfer_id,
receiver.core.clone(),
share.ticket,
output_dir.path(),
true,
)
.unwrap();
wait_for_eligibility(&sender.core, &receiver_id);
// Consume once, then replay must not create a second prompt.
assert!(sender
.core
.request_saved_device_pairing(receiver_id.clone())
.unwrap());
assert!(sender.core.list_pairing_eligibilities().unwrap().is_empty());
assert!(!sender
.core
.request_saved_device_pairing(receiver_id)
.unwrap());
assert_eq!(
sender
.core
.list_device_relationships()
.unwrap()
.iter()
.filter(|row| row.state == crate::DeviceRelationshipState::PendingOutgoing)
.count(),
1
);
assert!(receiver
.core
.list_device_relationships()
.unwrap()
.iter()
.any(|row| row.state == crate::DeviceRelationshipState::PendingIncoming));
// Fabricated peer identity is rejected without growing pairing events.
let pairing_events_before = receiver
.sink
.events()
.iter()
.filter(|event| event.phase == "pairing")
.count();
assert!(!receiver
.core
.submit_pairing_eligibility_for_test(
"fabricated-endpoint".to_string(),
"fabricated-session".to_string(),
vec![7u8; 32],
)
.unwrap());
assert!(receiver
.core
.list_device_relationships()
.unwrap()
.iter()
.all(|row| row.remote_endpoint_id != "fabricated-endpoint"));
let pairing_events_after = receiver
.sink
.events()
.iter()
.filter(|event| event.phase == "pairing")
.count();
assert_eq!(pairing_events_before, pairing_events_after);
let _ = events_before;
}
struct FailingOutputSink;
impl crate::ReceiveOutputSink for FailingOutputSink {
fn start_file(&self, _relative_path: String) -> Result<(), VnidropError> {
Ok(())
}
fn write_chunk(&self, _relative_path: String, _bytes: Vec<u8>) -> Result<(), VnidropError> {
Err(VnidropError::Filesystem {
reason: "export failed".to_string(),
})
}
fn finish_file(&self, _relative_path: String) -> Result<(), VnidropError> {
Ok(())
}
fn abort_file(&self, _relative_path: String, _reason: String) -> Result<(), VnidropError> {
Ok(())
}
}

View File

@@ -0,0 +1,52 @@
//! Persistence open returns domain stores without exporting a raw pool to callers.
use sqlx::Row;
use crate::persistence;
async fn open_profile_pool(app_data_dir: &std::path::Path) -> sqlx::SqlitePool {
let db = app_data_dir.join("vnidrop.sqlite3");
let options = sqlx::sqlite::SqliteConnectOptions::new()
.filename(&db)
.create_if_missing(false);
sqlx::SqlitePool::connect_with(options).await.unwrap()
}
#[tokio::test]
async fn open_all_returns_all_domain_stores_and_schemas() {
let temp = tempfile::tempdir().unwrap();
let stores = persistence::open_all(temp.path()).await.unwrap();
assert!(stores.blocked.list_blocked().await.unwrap().is_empty());
assert!(stores.targeted.list().await.unwrap().is_empty());
assert!(stores.invitation.list_transfers().await.unwrap().is_empty());
assert!(stores
.eligibility
.list_summaries()
.await
.unwrap()
.is_empty());
let pool = open_profile_pool(temp.path()).await;
for table in [
"device_relationships",
"relationship_generation_tombstones",
"pairing_eligibilities",
"protected_secret_refs",
"blocked_endpoints",
"targeted_transfers",
"transfers",
] {
let row = sqlx::query(&format!(
"SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = '{table}'"
))
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
row.get::<i64, _>("n"),
1,
"{table} must exist after open_all"
);
}
}

View File

@@ -0,0 +1,889 @@
//! Android platform contract for the experimental saved-device foundation.
//!
//! These tests drive the public UniFFI surface through an
//! [`AndroidSecureSecretStore`] backed by an in-process Keystore fake so the
//! contract can run on host CI without a device or product UI.
use std::{
collections::{HashMap, HashSet},
path::Path,
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
},
time::{Duration, Instant},
};
use tempfile::TempDir;
use crate::{
secure_secret::{
android::{AndroidKeystore, AndroidSealedValue, AndroidSecureSecretStore},
SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError,
},
CoreEvent, CoreEventSink, DeviceRelationshipState, PublishedOutput, ReceiveOutputSinkV2,
ReceivedLocatorKind, ShareMetadataInput, ShareSource, SourceKind, TargetedTransferState,
TransferAccessMode, VnidropCore, VnidropError,
};
#[derive(Default)]
struct FakeAndroidKeystore {
keys: Mutex<HashMap<String, u8>>,
locked: AtomicBool,
}
impl FakeAndroidKeystore {
fn lock(&self) {
self.locked.store(true, Ordering::SeqCst);
}
}
impl AndroidKeystore for FakeAndroidKeystore {
fn seal(
&self,
alias: &str,
plaintext: &[u8],
) -> Result<AndroidSealedValue, SecureSecretStoreError> {
if self.locked.load(Ordering::SeqCst) {
return Err(SecureSecretStoreError::Locked);
}
let mask = 0xb3;
self.keys.lock().unwrap().insert(alias.to_string(), mask);
Ok(AndroidSealedValue {
nonce: vec![9; 12],
ciphertext: plaintext.iter().map(|byte| byte ^ mask).collect(),
})
}
fn open(
&self,
alias: &str,
sealed: &AndroidSealedValue,
) -> Result<Vec<u8>, SecureSecretStoreError> {
if self.locked.load(Ordering::SeqCst) {
return Err(SecureSecretStoreError::Locked);
}
let mask = *self
.keys
.lock()
.unwrap()
.get(alias)
.ok_or(SecureSecretStoreError::Missing)?;
Ok(sealed.ciphertext.iter().map(|byte| byte ^ mask).collect())
}
fn delete(&self, alias: &str) -> Result<(), SecureSecretStoreError> {
if self.locked.load(Ordering::SeqCst) {
return Err(SecureSecretStoreError::Locked);
}
self.keys.lock().unwrap().remove(alias);
Ok(())
}
}
/// Fails closed for relationship/targeted secrets while leaving identity usable.
struct RelationshipGatedStore {
inner: Arc<dyn SecureSecretStore>,
gate_relationship_secrets: AtomicBool,
}
impl RelationshipGatedStore {
fn new(inner: Arc<dyn SecureSecretStore>) -> Self {
Self {
inner,
gate_relationship_secrets: AtomicBool::new(false),
}
}
fn disable_relationship_secrets(&self) {
self.gate_relationship_secrets.store(true, Ordering::SeqCst);
}
fn check(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> {
if !self.gate_relationship_secrets.load(Ordering::SeqCst) {
return Ok(());
}
let value = handle.as_str();
if value.contains("/relationship-grant/") || value.contains("/targeted-authorization/") {
return Err(SecureSecretStoreError::Unavailable);
}
Ok(())
}
}
impl SecureSecretStore for RelationshipGatedStore {
fn put(
&self,
handle: &SecretHandle,
material: SecretMaterial,
) -> Result<(), SecureSecretStoreError> {
self.check(handle)?;
self.inner.put(handle, material)
}
fn get(&self, handle: &SecretHandle) -> Result<SecretMaterial, SecureSecretStoreError> {
self.check(handle)?;
self.inner.get(handle)
}
fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> {
self.check(handle)?;
self.inner.delete(handle)
}
fn list_handles(&self) -> Result<Vec<SecretHandle>, SecureSecretStoreError> {
// Listing remains available so identity restart and orphan discovery work.
self.inner.list_handles()
}
}
struct RecordingSink {
events: Mutex<Vec<CoreEvent>>,
}
impl CoreEventSink for RecordingSink {
fn on_event(&self, event: CoreEvent) {
self.events.lock().unwrap().push(event);
}
}
struct AndroidContractNode {
_no_backup: TempDir,
data_dir: TempDir,
keystore: Arc<FakeAndroidKeystore>,
store: Arc<RelationshipGatedStore>,
sink: Arc<RecordingSink>,
core: Option<Arc<VnidropCore>>,
}
impl AndroidContractNode {
fn new() -> Self {
let no_backup = TempDir::new().unwrap();
let data_dir = TempDir::new().unwrap();
let keystore = Arc::new(FakeAndroidKeystore::default());
let android_store =
AndroidSecureSecretStore::new(no_backup.path(), keystore.clone()).unwrap();
let store = Arc::new(RelationshipGatedStore::new(Arc::new(android_store)));
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let core = VnidropCore::initialize_with_test_secret_store(
data_dir.path().to_string_lossy().into_owned(),
sink.clone(),
store.clone(),
)
.expect("android-backed protected core");
Self {
_no_backup: no_backup,
data_dir,
keystore,
store,
sink,
core: Some(core),
}
}
fn core(&self) -> Arc<VnidropCore> {
self.core.as_ref().expect("core alive").clone()
}
fn restart_with_sink(&mut self, sink: Arc<RecordingSink>) {
if let Some(core) = self.core.take() {
core.shutdown();
}
let core = VnidropCore::initialize_with_test_secret_store(
self.data_dir.path().to_string_lossy().into_owned(),
sink.clone(),
self.store.clone(),
)
.expect("restarted android-backed core");
self.sink = sink;
self.core = Some(core);
}
fn restart(&mut self) {
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
self.restart_with_sink(sink);
}
fn try_restart(&mut self) -> Result<(), VnidropError> {
if let Some(core) = self.core.take() {
core.shutdown();
}
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
match VnidropCore::initialize_with_test_secret_store(
self.data_dir.path().to_string_lossy().into_owned(),
sink.clone(),
self.store.clone(),
) {
Ok(core) => {
self.sink = sink;
self.core = Some(core);
Ok(())
}
Err(error) => Err(error),
}
}
}
impl Drop for AndroidContractNode {
fn drop(&mut self) {
if let Some(core) = self.core.take() {
core.shutdown();
}
}
}
fn share_path(core: &VnidropCore, source: &Path, transfer_id: u64) -> crate::ShareResult {
core.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source.to_string_lossy().into_owned(),
display_name: Some(
source
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned(),
),
is_directory: false,
}],
ShareMetadataInput {
transfer_id,
transfer_name: Some(
source
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned(),
),
sender_name: Some("sender".to_string()),
access_mode: TransferAccessMode::ApprovalRequired,
},
)
.unwrap()
}
fn wait_for_receiver_request(sender: &VnidropCore, transfer_id: u64) -> crate::ReceiverRequest {
let started = Instant::now();
loop {
if let Some(request) = sender
.list_receiver_requests(transfer_id)
.unwrap()
.into_iter()
.find(|request| request.status == "requested")
{
return request;
}
assert!(
started.elapsed() < Duration::from_secs(15),
"timed out waiting for receiver request"
);
std::thread::sleep(Duration::from_millis(25));
}
}
fn complete_invitation_transfer(
sender: &AndroidContractNode,
receiver: &AndroidContractNode,
transfer_id: u64,
payload: &[u8],
) {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("hello.txt");
std::fs::write(&source_path, payload).unwrap();
let share = share_path(&sender.core(), &source_path, transfer_id);
let output_dir = output_dir.path().to_string_lossy().to_string();
let receiver_core = receiver.core().clone();
let ticket = share.ticket.clone();
let handle = std::thread::spawn(move || {
receiver_core.receive(ticket, output_dir, Some("receiver".to_string()))
});
let request = wait_for_receiver_request(&sender.core(), share.transfer_id);
sender
.core()
.respond_receiver_request(request.id, true, None)
.unwrap();
handle.join().unwrap().unwrap();
let started = Instant::now();
let peer = receiver.core().status().endpoint_id.clone();
loop {
if sender
.core()
.list_pairing_eligibilities()
.unwrap()
.iter()
.any(|entry| entry.peer_endpoint_id == peer)
{
break;
}
assert!(
started.elapsed() < Duration::from_secs(10),
"eligibility never appeared"
);
std::thread::sleep(Duration::from_millis(25));
}
}
fn wait_for_relationship(
core: &VnidropCore,
peer: &str,
state: DeviceRelationshipState,
) -> crate::DeviceRelationship {
let started = Instant::now();
loop {
if let Some(relationship) = core
.list_device_relationships()
.unwrap()
.into_iter()
.find(|entry| entry.remote_endpoint_id == peer && entry.state == state)
{
return relationship;
}
assert!(
started.elapsed() < Duration::from_secs(15),
"relationship {peer} never reached {state:?}"
);
std::thread::sleep(Duration::from_millis(25));
}
}
fn establish_saved(alice: &AndroidContractNode, bob: &AndroidContractNode, transfer_id: u64) {
let alice_id = alice.core().status().endpoint_id.clone();
let bob_id = bob.core().status().endpoint_id.clone();
complete_invitation_transfer(alice, bob, transfer_id, b"android contract consent");
assert!(alice
.core()
.request_saved_device_pairing(bob_id.clone())
.unwrap());
wait_for_relationship(
&bob.core(),
&alice_id,
DeviceRelationshipState::PendingIncoming,
);
assert!(bob
.core()
.respond_to_device_pairing(alice_id.clone(), true)
.unwrap());
wait_for_relationship(&alice.core(), &bob_id, DeviceRelationshipState::Saved);
wait_for_relationship(&bob.core(), &alice_id, DeviceRelationshipState::Saved);
}
fn wait_for_pending_offer(core: &VnidropCore) -> crate::PendingTargetedOffer {
let started = Instant::now();
loop {
let pending = core.list_pending_targeted_offers();
if let Some(offer) = pending.into_iter().next() {
return offer;
}
assert!(
started.elapsed() < Duration::from_secs(20),
"timed out waiting for pending targeted offer"
);
std::thread::sleep(Duration::from_millis(25));
}
}
fn targeted_source(path: &Path) -> ShareSource {
ShareSource {
kind: SourceKind::Path,
value: path.to_string_lossy().into_owned(),
display_name: Some(
path.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned(),
),
is_directory: false,
}
}
fn approve_targeted(
alice: &AndroidContractNode,
bob: &AndroidContractNode,
payload: &[u8],
name: &str,
) -> crate::TargetedTransfer {
let bob_id = bob.core().status().endpoint_id.clone();
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join(name);
std::fs::write(&source_path, payload).unwrap();
let bob_core = bob.core().clone();
let accept = std::thread::spawn(move || {
let offer = wait_for_pending_offer(&bob_core);
bob_core
.respond_to_targeted_offer(offer.transfer_id, true)
.unwrap()
});
let transfer = alice
.core()
.create_targeted_transfer(
bob_id,
vec![targeted_source(&source_path)],
Some(name.to_string()),
)
.unwrap();
let response = accept.join().unwrap();
assert!(matches!(
response,
crate::TargetedOfferResponse::Approved { .. }
));
transfer
}
#[test]
fn android_keystore_backed_identity_survives_core_restart() {
let mut node = AndroidContractNode::new();
let endpoint_id = node.core().status().endpoint_id.clone();
assert!(!endpoint_id.is_empty());
node.restart();
assert_eq!(node.core().status().endpoint_id, endpoint_id);
}
#[test]
fn android_public_api_covers_saved_device_and_targeted_lifecycle() {
let mut alice = AndroidContractNode::new();
let mut bob = AndroidContractNode::new();
let alice_id = alice.core().status().endpoint_id.clone();
let bob_id = bob.core().status().endpoint_id.clone();
establish_saved(&alice, &bob, 15_001);
alice
.core()
.set_saved_device_label(bob_id.clone(), Some("Kitchen Tablet".to_string()))
.unwrap();
let saved = alice.core().list_saved_devices().unwrap();
assert_eq!(saved.len(), 1);
assert_eq!(saved[0].endpoint_id, bob_id);
assert_eq!(saved[0].local_label.as_deref(), Some("Kitchen Tablet"));
let transfer = approve_targeted(&alice, &bob, b"android payload", "payload.txt");
assert_eq!(transfer.receiver_endpoint_id, bob_id);
assert_eq!(
bob.core()
.get_targeted_transfer(transfer.id.clone())
.unwrap()
.expect("receiver durable row")
.state,
TargetedTransferState::Approved
);
alice.restart();
bob.restart();
let output = tempfile::tempdir().unwrap();
bob.core()
.resume_targeted_transfer(
transfer.id.clone(),
output.path().to_string_lossy().into_owned(),
)
.unwrap();
assert_eq!(
std::fs::read(output.path().join("payload.txt")).unwrap(),
b"android payload"
);
alice.core().forget_saved_device(bob_id.clone()).unwrap();
assert!(alice.core().list_saved_devices().unwrap().is_empty());
assert!(alice.core().list_device_relationships().unwrap().is_empty());
bob.core().block_device(alice_id.clone()).unwrap();
assert_eq!(
bob.core().list_blocked_devices().unwrap(),
vec![alice_id.clone()]
);
bob.core().unblock_device(alice_id).unwrap();
assert!(bob.core().list_blocked_devices().unwrap().is_empty());
// Alice already forgot; unblock on Bob must not restore Alice's local saved list.
assert!(alice.core().list_saved_devices().unwrap().is_empty());
}
/// Host-side stand-in for MediaStore Downloads publish: durable Android locator, not a path dir.
#[derive(Default)]
struct AndroidMediaStoreSink {
files: Mutex<HashMap<String, Vec<u8>>>,
published: Mutex<HashMap<String, PublishedOutput>>,
}
impl AndroidMediaStoreSink {
fn bytes(&self, relative_path: &str) -> Vec<u8> {
self.files.lock().unwrap()[relative_path].clone()
}
fn published(&self, relative_path: &str) -> PublishedOutput {
self.published.lock().unwrap()[relative_path].clone()
}
}
impl ReceiveOutputSinkV2 for AndroidMediaStoreSink {
fn start_file(&self, relative_path: String) -> Result<(), VnidropError> {
self.files.lock().unwrap().insert(relative_path, Vec::new());
Ok(())
}
fn write_chunk(&self, relative_path: String, bytes: Vec<u8>) -> Result<(), VnidropError> {
self.files
.lock()
.unwrap()
.get_mut(&relative_path)
.expect("started")
.extend(bytes);
Ok(())
}
fn finish_file(&self, relative_path: String) -> Result<PublishedOutput, VnidropError> {
let published = PublishedOutput {
locator_kind: ReceivedLocatorKind::AndroidMediaStore,
locator: format!("content://media/external/downloads/{relative_path}"),
};
self.published
.lock()
.unwrap()
.insert(relative_path, published.clone());
Ok(published)
}
fn abort_file(&self, relative_path: String, _reason: String) -> Result<(), VnidropError> {
self.files.lock().unwrap().remove(&relative_path);
self.published.lock().unwrap().remove(&relative_path);
Ok(())
}
}
#[test]
fn android_targeted_receive_and_resume_via_media_store_sink() {
let alice = AndroidContractNode::new();
let mut bob = AndroidContractNode::new();
establish_saved(&alice, &bob, 15_101);
let transfer = approve_targeted(&alice, &bob, b"media store payload", "download.txt");
let sink = Arc::new(AndroidMediaStoreSink::default());
bob.core()
.receive_targeted_transfer_with_output_sink_v2(transfer.id.clone(), sink.clone())
.unwrap();
assert_eq!(sink.bytes("download.txt"), b"media store payload");
let published = sink.published("download.txt");
assert_eq!(
published.locator_kind,
ReceivedLocatorKind::AndroidMediaStore
);
assert!(published.locator.starts_with("content://media/"));
assert_eq!(
bob.core()
.get_targeted_transfer(transfer.id)
.unwrap()
.unwrap()
.state,
TargetedTransferState::Completed
);
let transfer2 = approve_targeted(&alice, &bob, b"resume via sink", "resume.txt");
bob.restart();
let resume_sink = Arc::new(AndroidMediaStoreSink::default());
bob.core()
.resume_targeted_transfer_with_output_sink_v2(transfer2.id.clone(), resume_sink.clone())
.unwrap();
assert_eq!(resume_sink.bytes("resume.txt"), b"resume via sink");
assert_eq!(
resume_sink.published("resume.txt").locator_kind,
ReceivedLocatorKind::AndroidMediaStore
);
assert_eq!(
bob.core()
.get_targeted_transfer(transfer2.id)
.unwrap()
.unwrap()
.state,
TargetedTransferState::Completed
);
}
#[test]
fn android_targeted_sink_failure_marks_transfer_interrupted() {
let alice = AndroidContractNode::new();
let bob = AndroidContractNode::new();
establish_saved(&alice, &bob, 15_102);
let transfer = approve_targeted(&alice, &bob, b"will fail", "fail.txt");
struct FailingMediaStoreSink;
impl ReceiveOutputSinkV2 for FailingMediaStoreSink {
fn start_file(&self, _relative_path: String) -> Result<(), VnidropError> {
Ok(())
}
fn write_chunk(&self, _relative_path: String, _bytes: Vec<u8>) -> Result<(), VnidropError> {
Err(VnidropError::Filesystem {
reason: "media store write failed".to_string(),
})
}
fn finish_file(&self, _relative_path: String) -> Result<PublishedOutput, VnidropError> {
unreachable!("write failed")
}
fn abort_file(&self, _relative_path: String, _reason: String) -> Result<(), VnidropError> {
Ok(())
}
}
let err = bob
.core()
.receive_targeted_transfer_with_output_sink_v2(
transfer.id.clone(),
Arc::new(FailingMediaStoreSink),
)
.unwrap_err();
assert!(
matches!(
err,
VnidropError::Transfer { .. } | VnidropError::Filesystem { .. }
),
"expected transfer/filesystem failure, got {err:?}"
);
assert_eq!(
bob.core()
.get_targeted_transfer(transfer.id)
.unwrap()
.unwrap()
.state,
TargetedTransferState::Interrupted
);
}
#[test]
fn locked_or_invalidated_identity_blocks_restart() {
let mut alice = AndroidContractNode::new();
assert!(!alice.core().status().endpoint_id.is_empty());
alice.keystore.lock();
let locked = alice
.try_restart()
.expect_err("locked identity must fail closed");
assert!(
matches!(locked, VnidropError::SecureStorageLocked { .. }),
"expected SecureStorageLocked, got {locked:?}"
);
// Invalidated Keystore material (keys wiped) is a distinct closed failure.
let mut bob = AndroidContractNode::new();
assert!(!bob.core().status().endpoint_id.is_empty());
bob.keystore.keys.lock().unwrap().clear();
let missing = bob
.try_restart()
.expect_err("invalidated identity must fail closed");
assert!(
matches!(
missing,
VnidropError::SecureStorageMissing { .. }
| VnidropError::SecureStorageCorrupted { .. }
| VnidropError::SecureStorageUnavailable { .. }
),
"expected missing/corrupted/unavailable identity, got {missing:?}"
);
}
#[test]
fn unavailable_relationship_secrets_disable_only_saved_device_paths() {
let alice = AndroidContractNode::new();
let bob = AndroidContractNode::new();
establish_saved(&alice, &bob, 15_011);
let bob_id = bob.core().status().endpoint_id.clone();
let endpoint_before = alice.core().status().endpoint_id.clone();
alice.store.disable_relationship_secrets();
// Invitation transfers only need the already-loaded endpoint identity.
complete_invitation_transfer(&alice, &bob, 15_012, b"invitation still works");
assert_eq!(alice.core().status().endpoint_id, endpoint_before);
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("x.txt");
std::fs::write(&source_path, b"x").unwrap();
let targeted = alice.core().create_targeted_transfer(
bob_id,
vec![targeted_source(&source_path)],
Some("x.txt".to_string()),
);
assert!(
targeted.is_err(),
"targeted transfers require usable relationship secrets"
);
let stranger = AndroidContractNode::new();
complete_invitation_transfer(&alice, &stranger, 15_013, b"new eligibility");
let alice_id = alice.core().status().endpoint_id.clone();
let stranger_id = stranger.core().status().endpoint_id.clone();
// Eligibility may still start a pairing attempt; grant minting must fail closed.
assert!(alice
.core()
.request_saved_device_pairing(stranger_id.clone())
.unwrap());
let accept = stranger
.core()
.respond_to_device_pairing(alice_id.clone(), true);
assert!(
matching_pairing_failure(&accept),
"consent must not mint grants when relationship secrets are unavailable: {accept:?}"
);
assert!(
alice
.core()
.list_saved_devices()
.unwrap()
.iter()
.all(|device| device.endpoint_id != stranger_id),
"pairing must not reach Saved without relationship secrets"
);
assert!(
stranger
.core()
.list_saved_devices()
.unwrap()
.iter()
.all(|device| device.endpoint_id != alice_id),
"peer must not reach Saved without relationship secrets"
);
}
fn matching_pairing_failure(result: &Result<bool, VnidropError>) -> bool {
match result {
Ok(false) => true,
Err(VnidropError::SecureStorageUnavailable { .. })
| Err(VnidropError::SecureStorageMissing { .. })
| Err(VnidropError::SecureStorageLocked { .. })
| Err(VnidropError::SecureStorageCorrupted { .. })
| Err(VnidropError::Permission { .. })
| Err(VnidropError::InvalidInput { .. })
| Err(VnidropError::Internal { .. })
| Err(VnidropError::Transfer { .. }) => true,
Ok(true) => false,
Err(_) => true,
}
}
#[test]
fn event_ids_and_revisions_recover_authoritative_state_after_listener_restart() {
let mut alice = AndroidContractNode::new();
let bob = AndroidContractNode::new();
establish_saved(&alice, &bob, 15_020);
alice
.core()
.set_saved_device_label(
bob.core().status().endpoint_id.clone(),
Some("Desk Phone".to_string()),
)
.unwrap();
let before = alice.core().list_events(None).unwrap();
assert!(!before.is_empty());
let mut seen = HashSet::new();
let mut max_revision = 0_u64;
for event in &before {
assert!(
seen.insert(event.id.clone()),
"live event ids must be unique"
);
assert!(event.revision >= 1);
max_revision = max_revision.max(event.revision);
}
// Simulate at-least-once delivery with duplicates, then drop the listener.
let mut recovered_ids = HashSet::new();
let mut recovered_revision = 0_u64;
for event in before.iter().chain(before.iter()) {
if recovered_ids.insert(event.id.clone()) {
recovered_revision = recovered_revision.max(event.revision);
}
}
assert_eq!(recovered_ids.len(), seen.len());
assert_eq!(recovered_revision, max_revision);
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
alice.restart_with_sink(sink.clone());
let after = alice.core().list_events(None).unwrap();
assert!(!after.is_empty());
let durable_ids: HashSet<_> = after.iter().map(|event| event.id.clone()).collect();
assert!(
recovered_ids.is_subset(&durable_ids) || durable_ids.is_subset(&recovered_ids),
"durable event history must remain reconcilable by stable id"
);
let durable_max = after.iter().map(|event| event.revision).max().unwrap_or(0);
assert!(durable_max >= recovered_revision);
let saved = alice.core().list_saved_devices().unwrap();
assert_eq!(saved.len(), 1);
assert_eq!(saved[0].local_label.as_deref(), Some("Desk Phone"));
}
#[test]
fn android_public_surface_omits_raw_secrets_and_generic_mutation() {
// Only the UniFFI-exported façade is binding-visible; cfg(test) helpers above it are not.
let facade = include_str!("../runtime/facade.rs");
let export_start = facade
.find("#[uniffi::export]")
.expect("UniFFI export block");
let api = include_str!("../api.rs");
for source in [&facade[export_start..], api] {
for forbidden in [
"SecretMaterial",
"SecretHandle",
"SecureSecretStore",
"execute_sql",
"mutate_state",
"raw_secret",
"set_raw_state",
"iroh.secret",
] {
assert!(
!source.contains(forbidden),
"public UniFFI modules must not expose {forbidden}"
);
}
}
let candidates = [
Path::new(
"shared/build/generated/uniffi/commonMain/kotlin/uniffi/vnidrop/vnidrop.common.kt",
),
Path::new(
"../shared/build/generated/uniffi/commonMain/kotlin/uniffi/vnidrop/vnidrop.common.kt",
),
Path::new(
"../../shared/build/generated/uniffi/commonMain/kotlin/uniffi/vnidrop/vnidrop.common.kt",
),
];
if let Some(path) = candidates.iter().find(|path| path.exists()) {
let kotlin = std::fs::read_to_string(path).unwrap();
for forbidden in [
"SecretMaterial",
"SecretHandle",
"SecureSecretStore",
"executeSql",
"mutateState",
"rawSecret",
"setRawState",
"iroh.secret",
] {
assert!(
!kotlin.contains(forbidden),
"generated Kotlin bindings at {} must not expose {forbidden}",
path.display()
);
}
assert!(
kotlin.contains("initializeWithExperimentalSavedDevices"),
"experimental Android init must remain on the public binding surface"
);
assert!(
kotlin.contains("SavedDevice"),
"saved-device models must remain visible without secret escape hatches"
);
}
}

View File

@@ -0,0 +1,841 @@
//! Apple core/platform contract harness for saved devices (ticket 14).
//!
//! Proves the protected Keychain bridge can drive identity restart, the public
//! saved-device lifecycle, fault isolation, event recovery, and binding hygiene
//! without product UI.
use std::{
collections::{HashMap, HashSet},
path::Path,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use crate::{
secure_secret::{
apple::{handle_for_test, AppleKeychainApi, AppleKeychainPolicy, AppleKeychainSecretStore},
FaultInjectingSecretStore, ReferenceStoreFailure, SecureSecretStore,
SecureSecretStoreError,
},
CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, DeviceRelationshipState,
ShareMetadataInput, ShareSource, SourceKind, TargetedTransferState, TransferAccessMode,
VnidropCore, VnidropError,
};
const ERR_SEC_INTERACTION_NOT_ALLOWED: i32 = -25_308;
const ERR_SEC_ITEM_NOT_FOUND: i32 = -25_300;
struct RecordingSink {
events: Mutex<Vec<CoreEvent>>,
}
impl CoreEventSink for RecordingSink {
fn on_event(&self, event: CoreEvent) {
self.events.lock().unwrap().push(event);
}
}
impl RecordingSink {
fn snapshot(&self) -> Vec<CoreEvent> {
self.events.lock().unwrap().clone()
}
fn clear(&self) {
self.events.lock().unwrap().clear();
}
}
/// Node backed by the Apple Keychain adapter (injectable API for headless cargo).
///
/// Production `initialize_with_experimental_saved_devices` uses the same
/// `AppleKeychainSecretStore` + profile scoping. CLI unit tests lack the app
/// Keychain entitlement, so the system Keychain returns Unavailable; the
/// injectable API exercises the identical adapter path. Swift XCTest covers
/// the real experimental constructor under the app entitlements.
struct KeychainNode {
data_dir: tempfile::TempDir,
api: RecordingKeychain,
sink: Arc<RecordingSink>,
core: Option<Arc<VnidropCore>>,
}
#[derive(Clone, Default)]
struct RecordingKeychain {
state: Arc<Mutex<RecordingState>>,
}
#[derive(Default)]
struct RecordingState {
entries: HashMap<(String, String), Vec<u8>>,
}
impl AppleKeychainApi for RecordingKeychain {
fn put(
&self,
service: &str,
account: &str,
material: &[u8],
_policy: AppleKeychainPolicy,
) -> Result<(), i32> {
self.state.lock().unwrap().entries.insert(
(service.to_string(), account.to_string()),
material.to_vec(),
);
Ok(())
}
fn get(&self, service: &str, account: &str) -> Result<Vec<u8>, i32> {
self.state
.lock()
.unwrap()
.entries
.get(&(service.to_string(), account.to_string()))
.cloned()
.ok_or(ERR_SEC_ITEM_NOT_FOUND)
}
fn delete(&self, service: &str, account: &str) -> Result<(), i32> {
self.state
.lock()
.unwrap()
.entries
.remove(&(service.to_string(), account.to_string()))
.map(|_| ())
.ok_or(ERR_SEC_ITEM_NOT_FOUND)
}
fn list_accounts(&self, service: &str) -> Result<Vec<String>, i32> {
Ok(self
.state
.lock()
.unwrap()
.entries
.keys()
.filter(|(entry_service, _)| entry_service == service)
.map(|(_, account)| account.clone())
.collect())
}
}
impl KeychainNode {
fn new() -> Self {
let data_dir = tempfile::tempdir().unwrap();
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let api = RecordingKeychain::default();
let store = crate::secure_secret::scope_store(
data_dir.path(),
Arc::new(AppleKeychainSecretStore::with_api(api.clone())),
);
let core = VnidropCore::initialize_with_test_secret_store(
data_dir.path().to_string_lossy().into_owned(),
sink.clone(),
store,
)
.expect("Apple Keychain-adapter core");
Self {
data_dir,
api,
sink,
core: Some(core),
}
}
fn core(&self) -> Arc<VnidropCore> {
self.core.as_ref().expect("core alive").clone()
}
fn restart(mut self) -> Self {
if let Some(core) = self.core.take() {
core.shutdown();
drop(core);
}
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let store = crate::secure_secret::scope_store(
self.data_dir.path(),
Arc::new(AppleKeychainSecretStore::with_api(self.api.clone())),
);
let core = VnidropCore::initialize_with_test_secret_store(
self.data_dir.path().to_string_lossy().into_owned(),
sink.clone(),
store,
)
.expect("restarted Apple Keychain-adapter core");
self.sink = sink;
self.core = Some(core);
self
}
}
impl Drop for KeychainNode {
fn drop(&mut self) {
if let Some(core) = self.core.take() {
core.shutdown();
}
}
}
fn try_experimental_keychain_init(app_data_dir: &Path) -> Result<Arc<VnidropCore>, VnidropError> {
VnidropCore::initialize_with_experimental_saved_devices(
app_data_dir.to_string_lossy().into_owned(),
Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
}),
CoreLimits::default(),
CoreNetworkConfig::default(),
)
}
struct FaultNode {
data_dir: tempfile::TempDir,
secret_store: Arc<FaultInjectingSecretStore>,
sink: Arc<RecordingSink>,
core: Option<Arc<VnidropCore>>,
}
impl FaultNode {
fn new() -> Self {
let data_dir = tempfile::tempdir().unwrap();
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let store = Arc::new(FaultInjectingSecretStore::default());
let core = VnidropCore::initialize_with_test_secret_store(
data_dir.path().to_string_lossy().into_owned(),
sink.clone(),
store.clone(),
)
.expect("fault-injecting protected core");
Self {
data_dir,
secret_store: store,
sink,
core: Some(core),
}
}
fn core(&self) -> Arc<VnidropCore> {
self.core.as_ref().expect("core alive").clone()
}
fn restart(mut self) -> Self {
if let Some(core) = self.core.take() {
core.shutdown();
}
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let core = VnidropCore::initialize_with_test_secret_store(
self.data_dir.path().to_string_lossy().into_owned(),
sink.clone(),
self.secret_store.clone(),
)
.expect("restarted fault-injecting core");
self.sink = sink;
self.core = Some(core);
self
}
fn try_restart(mut self) -> Result<Self, (Self, VnidropError)> {
if let Some(core) = self.core.take() {
core.shutdown();
}
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
match VnidropCore::initialize_with_test_secret_store(
self.data_dir.path().to_string_lossy().into_owned(),
sink.clone(),
self.secret_store.clone(),
) {
Ok(core) => {
self.sink = sink;
self.core = Some(core);
Ok(self)
}
Err(error) => {
self.sink = sink;
Err((self, error))
}
}
}
}
impl Drop for FaultNode {
fn drop(&mut self) {
if let Some(core) = self.core.take() {
core.shutdown();
}
}
}
#[derive(Clone, Default)]
struct ControllableKeychain {
state: Arc<Mutex<ControllableState>>,
}
#[derive(Default)]
struct ControllableState {
entries: HashMap<(String, String), Vec<u8>>,
locked_accounts: HashSet<String>,
unavailable: bool,
}
impl ControllableKeychain {
fn lock_account(&self, account: &str) {
self.state
.lock()
.unwrap()
.locked_accounts
.insert(account.to_string());
}
fn set_unavailable(&self, unavailable: bool) {
self.state.lock().unwrap().unavailable = unavailable;
}
}
impl AppleKeychainApi for ControllableKeychain {
fn put(
&self,
service: &str,
account: &str,
material: &[u8],
_policy: AppleKeychainPolicy,
) -> Result<(), i32> {
let mut state = self.state.lock().unwrap();
if state.unavailable {
return Err(-25_291);
}
if state.locked_accounts.contains(account) {
return Err(ERR_SEC_INTERACTION_NOT_ALLOWED);
}
state.entries.insert(
(service.to_string(), account.to_string()),
material.to_vec(),
);
Ok(())
}
fn get(&self, service: &str, account: &str) -> Result<Vec<u8>, i32> {
let state = self.state.lock().unwrap();
if state.unavailable {
return Err(-25_291);
}
if state.locked_accounts.contains(account) {
return Err(ERR_SEC_INTERACTION_NOT_ALLOWED);
}
state
.entries
.get(&(service.to_string(), account.to_string()))
.cloned()
.ok_or(ERR_SEC_ITEM_NOT_FOUND)
}
fn delete(&self, service: &str, account: &str) -> Result<(), i32> {
let mut state = self.state.lock().unwrap();
if state.unavailable {
return Err(-25_291);
}
if state.locked_accounts.contains(account) {
return Err(ERR_SEC_INTERACTION_NOT_ALLOWED);
}
state
.entries
.remove(&(service.to_string(), account.to_string()))
.map(|_| ())
.ok_or(ERR_SEC_ITEM_NOT_FOUND)
}
fn list_accounts(&self, service: &str) -> Result<Vec<String>, i32> {
let state = self.state.lock().unwrap();
if state.unavailable {
return Err(-25_291);
}
Ok(state
.entries
.keys()
.filter(|(entry_service, _)| entry_service == service)
.map(|(_, account)| account.clone())
.collect())
}
}
fn share_path(core: &VnidropCore, source: &Path, transfer_id: u64) -> crate::ShareResult {
core.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source.to_string_lossy().into_owned(),
display_name: Some("hello.txt".to_string()),
is_directory: false,
}],
ShareMetadataInput {
transfer_id,
transfer_name: Some("hello.txt".to_string()),
sender_name: Some("sender".to_string()),
access_mode: TransferAccessMode::ApprovalRequired,
},
)
.unwrap()
}
fn wait_for_receiver_request(sender: &VnidropCore, transfer_id: u64) -> crate::ReceiverRequest {
let started = Instant::now();
loop {
if let Some(request) = sender
.list_receiver_requests(transfer_id)
.unwrap()
.into_iter()
.find(|request| request.status == "requested")
{
return request;
}
assert!(
started.elapsed() < Duration::from_secs(15),
"timed out waiting for receiver request"
);
std::thread::sleep(Duration::from_millis(25));
}
}
fn complete_transfer(sender: &VnidropCore, receiver: &Arc<VnidropCore>, transfer_id: u64) {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("hello.txt");
std::fs::write(&source_path, b"mutual consent").unwrap();
let share = share_path(sender, &source_path, transfer_id);
let output_dir = output_dir.path().to_string_lossy().to_string();
let receiver_core = receiver.clone();
let ticket = share.ticket.clone();
let handle = std::thread::spawn(move || {
receiver_core.receive(ticket, output_dir, Some("receiver".to_string()))
});
let request = wait_for_receiver_request(sender, share.transfer_id);
sender
.respond_receiver_request(request.id, true, None)
.unwrap();
handle.join().unwrap().unwrap();
let started = Instant::now();
let peer = receiver.status().endpoint_id.clone();
loop {
if sender
.list_pairing_eligibilities()
.unwrap()
.iter()
.any(|entry| entry.peer_endpoint_id == peer)
{
break;
}
assert!(
started.elapsed() < Duration::from_secs(10),
"eligibility never appeared"
);
std::thread::sleep(Duration::from_millis(25));
}
}
fn wait_for_relationship(
core: &VnidropCore,
peer: &str,
state: DeviceRelationshipState,
) -> crate::DeviceRelationship {
let started = Instant::now();
loop {
if let Some(relationship) = core
.list_device_relationships()
.unwrap()
.into_iter()
.find(|entry| entry.remote_endpoint_id == peer && entry.state == state)
{
return relationship;
}
assert!(
started.elapsed() < Duration::from_secs(15),
"relationship {peer} never reached {state:?}"
);
std::thread::sleep(Duration::from_millis(25));
}
}
fn establish_saved(alice: &Arc<VnidropCore>, bob: &Arc<VnidropCore>, transfer_id: u64) {
let alice_id = alice.status().endpoint_id.clone();
let bob_id = bob.status().endpoint_id.clone();
complete_transfer(alice, bob, transfer_id);
assert!(alice.request_saved_device_pairing(bob_id.clone()).unwrap());
wait_for_relationship(bob, &alice_id, DeviceRelationshipState::PendingIncoming);
assert!(bob
.respond_to_device_pairing(alice_id.clone(), true)
.unwrap());
wait_for_relationship(alice, &bob_id, DeviceRelationshipState::Saved);
wait_for_relationship(bob, &alice_id, DeviceRelationshipState::Saved);
}
fn wait_for_pending_offer(core: &VnidropCore) -> crate::PendingTargetedOffer {
let started = Instant::now();
loop {
let pending = core.list_pending_targeted_offers();
if let Some(offer) = pending.into_iter().next() {
return offer;
}
assert!(
started.elapsed() < Duration::from_secs(20),
"timed out waiting for pending targeted offer"
);
std::thread::sleep(Duration::from_millis(25));
}
}
fn targeted_source(path: &Path) -> ShareSource {
ShareSource {
kind: SourceKind::Path,
value: path.to_string_lossy().into_owned(),
display_name: Some(
path.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned(),
),
is_directory: false,
}
}
fn approve_one(
alice: &Arc<VnidropCore>,
bob: &Arc<VnidropCore>,
payload: &[u8],
name: &str,
) -> crate::TargetedTransfer {
let bob_id = bob.status().endpoint_id.clone();
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join(name);
std::fs::write(&source_path, payload).unwrap();
let bob_core = bob.clone();
let accept = std::thread::spawn(move || {
let offer = wait_for_pending_offer(&bob_core);
bob_core
.respond_to_targeted_offer(offer.transfer_id, true)
.unwrap()
});
let transfer = alice
.create_targeted_transfer(
bob_id,
vec![targeted_source(&source_path)],
Some(name.to_string()),
)
.unwrap();
let response = accept.join().unwrap();
assert!(matches!(
response,
crate::TargetedOfferResponse::Approved { .. }
));
transfer
}
fn recover_authoritative_state(
live_events: &[CoreEvent],
replayed: &[CoreEvent],
) -> (HashSet<String>, u64) {
let mut seen_ids = HashSet::new();
let mut max_revision = 0u64;
for event in live_events.iter().chain(replayed.iter()) {
if !seen_ids.insert(event.id.clone()) {
continue;
}
max_revision = max_revision.max(event.revision);
}
(seen_ids, max_revision)
}
#[test]
fn experimental_keychain_identity_survives_core_restart() {
let node = KeychainNode::new();
let endpoint_id = node.core().status().endpoint_id.clone();
assert!(!endpoint_id.is_empty());
assert!(
!node.data_dir.path().join("iroh.secret").exists(),
"protected identity must not fall back to plaintext"
);
let node = node.restart();
assert_eq!(node.core().status().endpoint_id, endpoint_id);
assert!(!node.data_dir.path().join("iroh.secret").exists());
// When the process has Keychain entitlements (app/XCTest), the production
// constructor must also preserve identity. Headless cargo often lacks that
// entitlement and maps it to Unavailable — that path is covered by Swift.
let live = tempfile::tempdir().unwrap();
match try_experimental_keychain_init(live.path()) {
Ok(core) => {
let id = core.status().endpoint_id.clone();
core.shutdown();
drop(core);
let restarted = try_experimental_keychain_init(live.path()).expect("restart");
assert_eq!(restarted.status().endpoint_id, id);
restarted.shutdown();
cleanup_scoped_keychain(live.path());
}
Err(VnidropError::SecureStorageUnavailable { .. }) => {}
Err(error) => panic!("unexpected experimental init failure: {error:?}"),
}
}
fn cleanup_scoped_keychain(app_data_dir: &Path) {
let profile = blake3::hash(app_data_dir.to_string_lossy().as_bytes()).to_hex();
let prefix = format!("vnidrop/v1/scope-{profile}/");
let store = AppleKeychainSecretStore::new();
let Ok(handles) = store.list_handles() else {
return;
};
for handle in handles {
if handle.as_str().starts_with(&prefix) {
let _ = store.delete(&handle);
}
}
}
#[test]
fn public_api_contract_eligibility_through_unblock_on_apple_path() {
// Full lifecycle uses the same public UniFFI surface Apple bindings expose.
// FaultInjecting backs custody so the harness stays deterministic; Keychain
// restart + binding hygiene cover the real Apple adapter separately.
let alice = FaultNode::new();
let bob = FaultNode::new();
let alice_id = alice.core().status().endpoint_id.clone();
let bob_id = bob.core().status().endpoint_id.clone();
establish_saved(&alice.core(), &bob.core(), 14_001);
alice
.core()
.set_saved_device_label(bob_id.clone(), Some("Bob Mac".to_string()))
.unwrap();
let saved = alice.core().list_saved_devices().unwrap();
assert_eq!(saved.len(), 1);
assert_eq!(saved[0].endpoint_id, bob_id);
assert_eq!(saved[0].local_label.as_deref(), Some("Bob Mac"));
let transfer = approve_one(&alice.core(), &bob.core(), b"apple contract", "a.txt");
assert_eq!(transfer.receiver_endpoint_id, bob_id);
let alice = alice.restart();
let bob = bob.restart();
let resumed_output = tempfile::tempdir().unwrap();
bob.core()
.resume_targeted_transfer(
transfer.id.clone(),
resumed_output.path().to_string_lossy().into_owned(),
)
.unwrap();
assert_eq!(
std::fs::read(resumed_output.path().join("a.txt")).unwrap(),
b"apple contract"
);
let completed = bob
.core()
.get_targeted_transfer(transfer.id)
.unwrap()
.unwrap();
assert_eq!(completed.state, TargetedTransferState::Completed);
alice.core().forget_saved_device(bob_id.clone()).unwrap();
assert!(alice.core().list_saved_devices().unwrap().is_empty());
bob.core().block_device(alice_id.clone()).unwrap();
assert!(bob
.core()
.list_blocked_devices()
.unwrap()
.contains(&alice_id));
bob.core().unblock_device(alice_id.clone()).unwrap();
assert!(!bob
.core()
.list_blocked_devices()
.unwrap()
.contains(&alice_id));
assert!(
bob.core().list_saved_devices().unwrap().is_empty(),
"unblock must not restore a blocked relationship"
);
}
#[test]
fn locked_identity_fails_closed_while_missing_relationship_secrets_keep_identity() {
let alice = FaultNode::new();
let bob = FaultNode::new();
let bob_id = bob.core().status().endpoint_id.clone();
establish_saved(&alice.core(), &bob.core(), 14_010);
let endpoint_before = alice.core().status().endpoint_id.clone();
// Drop only relationship-grant material; identity stays loadable.
let handles = alice.secret_store.list_handles().unwrap();
for handle in handles {
if handle.as_str().contains("relationship-grant") {
alice.secret_store.remove_for_test(&handle);
}
}
let alice = alice.restart();
assert_eq!(alice.core().status().endpoint_id, endpoint_before);
assert!(
alice.core().list_saved_devices().unwrap().is_empty(),
"missing relationship secrets must disable saved-device rows"
);
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("still-works.txt");
std::fs::write(&source_path, b"identity ok").unwrap();
let share = share_path(&alice.core(), &source_path, 14_011);
assert!(
!share.ticket.is_empty(),
"identity must still serve tickets"
);
let create_err = alice.core().create_targeted_transfer(
bob_id,
vec![targeted_source(&source_path)],
Some("still-works.txt".to_string()),
);
assert!(
create_err.is_err(),
"saved-device transfer must fail without relationship secrets"
);
// Locked identity storage refuses networking on the next start.
alice
.secret_store
.fail_with(Some(ReferenceStoreFailure::Locked));
match alice.try_restart() {
Err((_alice, error)) => {
assert!(matches!(error, VnidropError::SecureStorageLocked { .. }));
}
Ok(_) => panic!("locked identity must fail closed"),
}
// Apple Keychain adapter maps lock statuses the same way for identity gets.
let api = ControllableKeychain::default();
let store = AppleKeychainSecretStore::with_api(api.clone());
let identity = handle_for_test("vnidrop/v1/endpoint-identity/contract-lock");
store
.put(
&identity,
crate::secure_secret::SecretMaterial::new(vec![0x41; 32]).unwrap(),
)
.unwrap();
api.lock_account(identity.as_str());
assert!(matches!(
store.get(&identity),
Err(SecureSecretStoreError::Locked)
));
api.set_unavailable(true);
assert!(matches!(
store.get(&identity),
Err(SecureSecretStoreError::Unavailable)
));
}
#[test]
fn event_ids_and_revisions_recover_authoritative_state_after_listener_restart() {
let alice = FaultNode::new();
let bob = FaultNode::new();
establish_saved(&alice.core(), &bob.core(), 14_020);
let live = alice.sink.snapshot();
assert!(!live.is_empty());
let mut revisions = live.iter().map(|event| event.revision).collect::<Vec<_>>();
revisions.sort_unstable();
let unique = revisions.iter().copied().collect::<HashSet<_>>();
assert_eq!(
unique.len(),
live.len(),
"live revisions must be unique and monotonic per emission"
);
// Simulate at-least-once delivery: duplicates + a fresh listener.
let duplicates = live.clone();
alice.sink.clear();
let alice = alice.restart();
let after_restart = alice.core().list_events(None).unwrap();
assert!(!after_restart.is_empty());
let (seen_ids, max_revision) = recover_authoritative_state(&after_restart, &duplicates);
assert_eq!(seen_ids.len(), after_restart.len());
assert!(max_revision >= 1);
let saved = alice.core().list_saved_devices().unwrap();
assert_eq!(saved.len(), 1);
let relationships = alice.core().list_device_relationships().unwrap();
assert!(relationships
.iter()
.any(|entry| entry.state == DeviceRelationshipState::Saved));
}
#[test]
fn apple_public_bindings_omit_raw_secrets_and_generic_mutation() {
// Generated Swift bindings (after build-core.sh) are the Apple public surface.
// When absent, assert the UniFFI-exported Rust API module has no secret types.
let swift = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../apple/VnidropCore/Sources/VnidropCore/Vnidrop.swift");
if let Ok(source) = std::fs::read_to_string(&swift) {
for forbidden in [
"SecretMaterial",
"SecretHandle",
"SecureSecretStore",
"executeSql",
"executeSQL",
"mutateState",
"applyRawState",
"rawSecret",
"grantSecret",
"pairingCapabilityBytes",
"func setState(",
"func mutate(",
] {
assert!(
!source.contains(forbidden),
"{} must not expose {forbidden}",
swift.display()
);
}
assert!(
source.contains("initializeWithExperimentalSavedDevices"),
"Swift bindings must expose experimental saved-device init"
);
assert!(
source.contains("setSavedDeviceLabel"),
"Swift bindings must expose saved-device rename"
);
assert!(
source.contains("revision"),
"Swift CoreEvent must carry revision"
);
return;
}
let api = std::fs::read_to_string(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/api.rs"),
)
.expect("api.rs");
for forbidden in [
"SecretMaterial",
"SecretHandle",
"SecureSecretStore",
"execute_sql",
"mutate_state",
"apply_raw_state",
"raw_secret",
"grant_secret",
"pairing_capability_bytes",
] {
assert!(
!api.contains(forbidden),
"public api.rs must not expose {forbidden}"
);
}
assert!(
api.contains("pub revision: u64"),
"CoreEvent must expose revision for recovery"
);
}

View File

@@ -0,0 +1,843 @@
//! Linux core/platform contract harness for saved devices (ticket 17).
//!
//! Proves the Secret Service bridge can drive identity restart, the public
//! saved-device lifecycle, fault isolation, event recovery, and binding hygiene
//! without product UI. Injectable Secret Service fakes run on every host;
//! real Secret Service connect is exercised under `cfg(target_os = "linux")`.
use std::{
collections::{HashMap, HashSet},
path::Path,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use crate::{
secure_secret::{
linux::{LinuxSecretServiceApi, LinuxSecretServiceStore},
FaultInjectingSecretStore, ReferenceStoreFailure, SecretMaterial, SecureSecretStore,
SecureSecretStoreError,
},
CoreEvent, CoreEventSink, DeviceRelationshipState, ShareMetadataInput, ShareSource, SourceKind,
TargetedTransferState, TransferAccessMode, VnidropCore, VnidropError,
};
#[cfg(target_os = "linux")]
use crate::{CoreLimits, CoreNetworkConfig};
struct RecordingSink {
events: Mutex<Vec<CoreEvent>>,
}
impl CoreEventSink for RecordingSink {
fn on_event(&self, event: CoreEvent) {
self.events.lock().unwrap().push(event);
}
}
impl RecordingSink {
fn snapshot(&self) -> Vec<CoreEvent> {
self.events.lock().unwrap().clone()
}
fn clear(&self) {
self.events.lock().unwrap().clear();
}
}
/// Node backed by an injectable Secret Service API (runs on non-Linux hosts).
struct SecretServiceNode {
data_dir: tempfile::TempDir,
api: Arc<ControllableSecretService>,
sink: Arc<RecordingSink>,
core: Option<Arc<VnidropCore>>,
}
impl SecretServiceNode {
fn new() -> Self {
let data_dir = tempfile::tempdir().unwrap();
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let api = Arc::new(ControllableSecretService::default());
let store = Arc::new(LinuxSecretServiceStore::with_api(api.clone()));
let core = VnidropCore::initialize_with_test_secret_store(
data_dir.path().to_string_lossy().into_owned(),
sink.clone(),
store,
)
.expect("Secret Service-backed protected core");
Self {
data_dir,
api,
sink,
core: Some(core),
}
}
fn core(&self) -> Arc<VnidropCore> {
self.core.as_ref().expect("core alive").clone()
}
fn restart(mut self) -> Self {
if let Some(core) = self.core.take() {
core.shutdown();
}
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let store = Arc::new(LinuxSecretServiceStore::with_api(self.api.clone()));
let core = VnidropCore::initialize_with_test_secret_store(
self.data_dir.path().to_string_lossy().into_owned(),
sink.clone(),
store,
)
.expect("restarted Secret Service-backed core");
self.sink = sink;
self.core = Some(core);
self
}
fn try_restart(mut self) -> Result<Self, (Self, VnidropError)> {
if let Some(core) = self.core.take() {
core.shutdown();
}
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let store = Arc::new(LinuxSecretServiceStore::with_api(self.api.clone()));
match VnidropCore::initialize_with_test_secret_store(
self.data_dir.path().to_string_lossy().into_owned(),
sink.clone(),
store,
) {
Ok(core) => {
self.sink = sink;
self.core = Some(core);
Ok(self)
}
Err(error) => {
self.sink = sink;
Err((self, error))
}
}
}
}
impl Drop for SecretServiceNode {
fn drop(&mut self) {
if let Some(core) = self.core.take() {
core.shutdown();
}
}
}
struct FaultNode {
data_dir: tempfile::TempDir,
secret_store: Arc<FaultInjectingSecretStore>,
sink: Arc<RecordingSink>,
core: Option<Arc<VnidropCore>>,
}
impl FaultNode {
fn new() -> Self {
let data_dir = tempfile::tempdir().unwrap();
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let store = Arc::new(FaultInjectingSecretStore::default());
let core = VnidropCore::initialize_with_test_secret_store(
data_dir.path().to_string_lossy().into_owned(),
sink.clone(),
store.clone(),
)
.expect("fault-injecting protected core");
Self {
data_dir,
secret_store: store,
sink,
core: Some(core),
}
}
fn core(&self) -> Arc<VnidropCore> {
self.core.as_ref().expect("core alive").clone()
}
fn restart(mut self) -> Self {
if let Some(core) = self.core.take() {
core.shutdown();
}
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let core = VnidropCore::initialize_with_test_secret_store(
self.data_dir.path().to_string_lossy().into_owned(),
sink.clone(),
self.secret_store.clone(),
)
.expect("restarted fault-injecting core");
self.sink = sink;
self.core = Some(core);
self
}
fn try_restart(mut self) -> Result<Self, (Self, VnidropError)> {
if let Some(core) = self.core.take() {
core.shutdown();
}
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
match VnidropCore::initialize_with_test_secret_store(
self.data_dir.path().to_string_lossy().into_owned(),
sink.clone(),
self.secret_store.clone(),
) {
Ok(core) => {
self.sink = sink;
self.core = Some(core);
Ok(self)
}
Err(error) => {
self.sink = sink;
Err((self, error))
}
}
}
}
impl Drop for FaultNode {
fn drop(&mut self) {
if let Some(core) = self.core.take() {
core.shutdown();
}
}
}
#[derive(Clone, Default)]
struct ControllableSecretService {
state: Arc<Mutex<ControllableState>>,
}
#[derive(Default)]
struct ControllableState {
values: HashMap<String, Vec<u8>>,
locked_handles: HashSet<String>,
global_failure: Option<SecureSecretStoreError>,
}
impl ControllableSecretService {
fn lock_handle(&self, handle: &str) {
self.state
.lock()
.unwrap()
.locked_handles
.insert(handle.to_string());
}
fn fail_all(&self, failure: Option<SecureSecretStoreError>) {
self.state.lock().unwrap().global_failure = failure;
}
fn check(&self, handle: &str) -> Result<(), SecureSecretStoreError> {
let state = self.state.lock().unwrap();
if let Some(failure) = &state.global_failure {
return Err(match failure {
SecureSecretStoreError::Locked => SecureSecretStoreError::Locked,
SecureSecretStoreError::Missing => SecureSecretStoreError::Missing,
SecureSecretStoreError::Corrupted => SecureSecretStoreError::Corrupted,
SecureSecretStoreError::Unavailable => SecureSecretStoreError::Unavailable,
});
}
if state.locked_handles.contains(handle) {
return Err(SecureSecretStoreError::Locked);
}
Ok(())
}
}
impl LinuxSecretServiceApi for ControllableSecretService {
fn put(&self, handle: &str, material: &[u8]) -> Result<(), SecureSecretStoreError> {
self.check(handle)?;
self.state
.lock()
.unwrap()
.values
.insert(handle.to_string(), material.to_vec());
Ok(())
}
fn get(&self, handle: &str) -> Result<Vec<u8>, SecureSecretStoreError> {
self.check(handle)?;
self.state
.lock()
.unwrap()
.values
.get(handle)
.cloned()
.ok_or(SecureSecretStoreError::Missing)
}
fn delete(&self, handle: &str) -> Result<(), SecureSecretStoreError> {
self.check(handle)?;
self.state
.lock()
.unwrap()
.values
.remove(handle)
.map(|_| ())
.ok_or(SecureSecretStoreError::Missing)
}
fn list_handles(&self) -> Result<Vec<String>, SecureSecretStoreError> {
let state = self.state.lock().unwrap();
if let Some(failure) = &state.global_failure {
return Err(match failure {
SecureSecretStoreError::Locked => SecureSecretStoreError::Locked,
SecureSecretStoreError::Missing => SecureSecretStoreError::Missing,
SecureSecretStoreError::Corrupted => SecureSecretStoreError::Corrupted,
SecureSecretStoreError::Unavailable => SecureSecretStoreError::Unavailable,
});
}
Ok(state.values.keys().cloned().collect())
}
}
fn share_path(core: &VnidropCore, source: &Path, transfer_id: u64) -> crate::ShareResult {
core.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source.to_string_lossy().into_owned(),
display_name: Some("hello.txt".to_string()),
is_directory: false,
}],
ShareMetadataInput {
transfer_id,
transfer_name: Some("hello.txt".to_string()),
sender_name: Some("sender".to_string()),
access_mode: TransferAccessMode::ApprovalRequired,
},
)
.unwrap()
}
fn wait_for_receiver_request(sender: &VnidropCore, transfer_id: u64) -> crate::ReceiverRequest {
let started = Instant::now();
loop {
if let Some(request) = sender
.list_receiver_requests(transfer_id)
.unwrap()
.into_iter()
.find(|request| request.status == "requested")
{
return request;
}
assert!(
started.elapsed() < Duration::from_secs(15),
"timed out waiting for receiver request"
);
std::thread::sleep(Duration::from_millis(25));
}
}
fn complete_transfer(sender: &Arc<VnidropCore>, receiver: &Arc<VnidropCore>, transfer_id: u64) {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("hello.txt");
std::fs::write(&source_path, b"mutual consent").unwrap();
let share = share_path(sender, &source_path, transfer_id);
let output_dir = output_dir.path().to_string_lossy().to_string();
let receiver_core = receiver.clone();
let ticket = share.ticket.clone();
let handle = std::thread::spawn(move || {
receiver_core.receive(ticket, output_dir, Some("receiver".to_string()))
});
let request = wait_for_receiver_request(sender, share.transfer_id);
sender
.respond_receiver_request(request.id, true, None)
.unwrap();
handle.join().unwrap().unwrap();
let started = Instant::now();
let peer = receiver.status().endpoint_id.clone();
loop {
if sender
.list_pairing_eligibilities()
.unwrap()
.iter()
.any(|entry| entry.peer_endpoint_id == peer)
{
break;
}
assert!(
started.elapsed() < Duration::from_secs(10),
"eligibility never appeared"
);
std::thread::sleep(Duration::from_millis(25));
}
}
fn wait_for_relationship(
core: &VnidropCore,
peer: &str,
state: DeviceRelationshipState,
) -> crate::DeviceRelationship {
let started = Instant::now();
loop {
if let Some(relationship) = core
.list_device_relationships()
.unwrap()
.into_iter()
.find(|entry| entry.remote_endpoint_id == peer && entry.state == state)
{
return relationship;
}
assert!(
started.elapsed() < Duration::from_secs(15),
"relationship {peer} never reached {state:?}"
);
std::thread::sleep(Duration::from_millis(25));
}
}
fn establish_saved(alice: &Arc<VnidropCore>, bob: &Arc<VnidropCore>, transfer_id: u64) {
let alice_id = alice.status().endpoint_id.clone();
let bob_id = bob.status().endpoint_id.clone();
complete_transfer(alice, bob, transfer_id);
assert!(alice.request_saved_device_pairing(bob_id.clone()).unwrap());
wait_for_relationship(bob, &alice_id, DeviceRelationshipState::PendingIncoming);
assert!(bob
.respond_to_device_pairing(alice_id.clone(), true)
.unwrap());
wait_for_relationship(alice, &bob_id, DeviceRelationshipState::Saved);
wait_for_relationship(bob, &alice_id, DeviceRelationshipState::Saved);
}
fn wait_for_pending_offer(core: &VnidropCore) -> crate::PendingTargetedOffer {
let started = Instant::now();
loop {
let pending = core.list_pending_targeted_offers();
if let Some(offer) = pending.into_iter().next() {
return offer;
}
assert!(
started.elapsed() < Duration::from_secs(20),
"timed out waiting for pending targeted offer"
);
std::thread::sleep(Duration::from_millis(25));
}
}
fn targeted_source(path: &Path) -> ShareSource {
ShareSource {
kind: SourceKind::Path,
value: path.to_string_lossy().into_owned(),
display_name: Some(
path.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned(),
),
is_directory: false,
}
}
fn approve_one(
alice: &Arc<VnidropCore>,
bob: &Arc<VnidropCore>,
payload: &[u8],
name: &str,
) -> crate::TargetedTransfer {
let bob_id = bob.status().endpoint_id.clone();
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join(name);
std::fs::write(&source_path, payload).unwrap();
let bob_core = bob.clone();
let accept = std::thread::spawn(move || {
let offer = wait_for_pending_offer(&bob_core);
bob_core
.respond_to_targeted_offer(offer.transfer_id, true)
.unwrap()
});
let transfer = alice
.create_targeted_transfer(
bob_id,
vec![targeted_source(&source_path)],
Some(name.to_string()),
)
.unwrap();
let response = accept.join().unwrap();
assert!(matches!(
response,
crate::TargetedOfferResponse::Approved { .. }
));
transfer
}
fn recover_authoritative_state(
live_events: &[CoreEvent],
replayed: &[CoreEvent],
) -> (HashSet<String>, u64) {
let mut seen_ids = HashSet::new();
let mut max_revision = 0u64;
for event in live_events.iter().chain(replayed.iter()) {
if !seen_ids.insert(event.id.clone()) {
continue;
}
max_revision = max_revision.max(event.revision);
}
(seen_ids, max_revision)
}
#[test]
fn secret_service_identity_survives_core_restart() {
let node = SecretServiceNode::new();
let endpoint_id = node.core().status().endpoint_id.clone();
assert!(!endpoint_id.is_empty());
assert!(
!node.data_dir.path().join("iroh.secret").exists(),
"protected identity must not fall back to plaintext"
);
let node = node.restart();
assert_eq!(node.core().status().endpoint_id, endpoint_id);
assert!(!node.data_dir.path().join("iroh.secret").exists());
}
#[cfg(target_os = "linux")]
#[test]
fn experimental_secret_service_identity_survives_core_restart_on_linux() {
// Regression: protected init used to call blocking Secret Service on the
// Tokio worker that drives `CoreInner::start`, which nested `block_on` and
// aborted desktop startup with "Cannot start a runtime from within a runtime".
let data_dir = tempfile::tempdir().unwrap();
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let core = VnidropCore::initialize_with_experimental_saved_devices(
data_dir.path().to_string_lossy().into_owned(),
sink,
CoreLimits::default(),
CoreNetworkConfig::default(),
)
.expect("experimental Linux Secret Service core");
let endpoint_id = core.status().endpoint_id.clone();
assert!(!endpoint_id.is_empty());
assert!(!data_dir.path().join("iroh.secret").exists());
core.shutdown();
drop(core);
let path = data_dir.path().to_string_lossy().into_owned();
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let started = Instant::now();
let restarted = loop {
match VnidropCore::initialize_with_experimental_saved_devices(
path.clone(),
sink.clone(),
CoreLimits::default(),
CoreNetworkConfig::default(),
) {
Ok(core) => break core,
Err(VnidropError::SecureStorageUnavailable { .. })
if started.elapsed() < Duration::from_secs(2) =>
{
std::thread::sleep(Duration::from_millis(25));
}
Err(error) => panic!("restart experimental Linux core: {error:?}"),
}
};
assert_eq!(restarted.status().endpoint_id, endpoint_id);
restarted.shutdown();
cleanup_scoped_secret_service(data_dir.path());
}
#[cfg(target_os = "linux")]
fn cleanup_scoped_secret_service(app_data_dir: &Path) {
let profile = blake3::hash(app_data_dir.to_string_lossy().as_bytes()).to_hex();
let prefix = format!("vnidrop/v1/scope-{profile}/");
let Ok(store) = LinuxSecretServiceStore::connect() else {
return;
};
let Ok(handles) = store.list_handles() else {
return;
};
for handle in handles {
if handle.as_str().starts_with(&prefix) {
let _ = store.delete(&handle);
}
}
}
#[test]
fn public_api_contract_eligibility_through_unblock_on_linux_path() {
// Full lifecycle uses the public UniFFI surface Linux/desktop bindings expose.
// FaultInjecting backs custody so the harness stays deterministic; Secret
// Service restart + binding hygiene cover the real Linux adapter separately.
let alice = FaultNode::new();
let bob = FaultNode::new();
let alice_id = alice.core().status().endpoint_id.clone();
let bob_id = bob.core().status().endpoint_id.clone();
establish_saved(&alice.core(), &bob.core(), 17_001);
alice
.core()
.set_saved_device_label(bob_id.clone(), Some("Bob Linux".to_string()))
.unwrap();
let saved = alice.core().list_saved_devices().unwrap();
assert_eq!(saved.len(), 1);
assert_eq!(saved[0].endpoint_id, bob_id);
assert_eq!(saved[0].local_label.as_deref(), Some("Bob Linux"));
let transfer = approve_one(&alice.core(), &bob.core(), b"linux contract", "a.txt");
assert_eq!(transfer.receiver_endpoint_id, bob_id);
let alice = alice.restart();
let bob = bob.restart();
let resumed_output = tempfile::tempdir().unwrap();
bob.core()
.resume_targeted_transfer(
transfer.id.clone(),
resumed_output.path().to_string_lossy().into_owned(),
)
.unwrap();
assert_eq!(
std::fs::read(resumed_output.path().join("a.txt")).unwrap(),
b"linux contract"
);
let completed = bob
.core()
.get_targeted_transfer(transfer.id)
.unwrap()
.unwrap();
assert_eq!(completed.state, TargetedTransferState::Completed);
alice.core().forget_saved_device(bob_id.clone()).unwrap();
assert!(alice.core().list_saved_devices().unwrap().is_empty());
bob.core().block_device(alice_id.clone()).unwrap();
assert!(bob
.core()
.list_blocked_devices()
.unwrap()
.contains(&alice_id));
bob.core().unblock_device(alice_id.clone()).unwrap();
assert!(!bob
.core()
.list_blocked_devices()
.unwrap()
.contains(&alice_id));
assert!(
bob.core().list_saved_devices().unwrap().is_empty()
|| bob
.core()
.list_saved_devices()
.unwrap()
.iter()
.all(|device| device.endpoint_id != alice_id),
"unblock must not restore a forgotten/revoked relationship"
);
}
#[test]
fn locked_or_absent_identity_fails_closed_while_missing_relationship_secrets_keep_networking() {
let alice = FaultNode::new();
let bob = FaultNode::new();
let bob_id = bob.core().status().endpoint_id.clone();
establish_saved(&alice.core(), &bob.core(), 17_010);
let endpoint_before = alice.core().status().endpoint_id.clone();
// Drop only relationship-grant material; identity stays loadable.
let handles = alice.secret_store.list_handles().unwrap();
for handle in handles {
if handle.as_str().contains("relationship-grant") {
alice.secret_store.remove_for_test(&handle);
}
}
let alice = alice.restart();
assert_eq!(alice.core().status().endpoint_id, endpoint_before);
assert!(
alice.core().list_saved_devices().unwrap().is_empty(),
"missing relationship secrets must disable saved-device rows"
);
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("still-works.txt");
std::fs::write(&source_path, b"identity ok").unwrap();
let share = share_path(&alice.core(), &source_path, 17_011);
assert!(
!share.ticket.is_empty(),
"identity must still serve tickets"
);
let create_err = alice.core().create_targeted_transfer(
bob_id,
vec![targeted_source(&source_path)],
Some("still-works.txt".to_string()),
);
assert!(
create_err.is_err(),
"saved-device transfer must fail without relationship secrets"
);
// Locked identity storage refuses networking on the next start.
alice
.secret_store
.fail_with(Some(ReferenceStoreFailure::Locked));
match alice.try_restart() {
Err((_alice, error)) => {
assert!(matches!(error, VnidropError::SecureStorageLocked { .. }));
}
Ok(_) => panic!("locked identity must fail closed"),
}
// Absent identity (store unavailable for every secret) also refuses start.
let missing = SecretServiceNode::new();
missing.api.fail_all(Some(SecureSecretStoreError::Missing));
match missing.try_restart() {
Err((_node, error)) => {
assert!(matches!(
error,
VnidropError::SecureStorageMissing { .. }
| VnidropError::SecureStorageUnavailable { .. }
| VnidropError::SecureStorageCorrupted { .. }
));
}
Ok(_) => panic!("absent Secret Service identity must fail closed"),
}
// Secret Service adapter maps lock / unavailable the same way for identity gets.
let api = Arc::new(ControllableSecretService::default());
let store = LinuxSecretServiceStore::with_api(api.clone());
let identity = crate::secure_secret::secret_handle_for_test(
"vnidrop/v1/endpoint-identity/linux-contract-lock".to_string(),
);
store
.put(&identity, SecretMaterial::new(vec![0x41; 32]).unwrap())
.unwrap();
api.lock_handle(identity.as_str());
assert!(matches!(
store.get(&identity),
Err(SecureSecretStoreError::Locked)
));
api.fail_all(Some(SecureSecretStoreError::Unavailable));
assert!(matches!(
store.get(&identity),
Err(SecureSecretStoreError::Unavailable)
));
}
#[test]
fn event_ids_and_revisions_recover_authoritative_state_after_listener_restart() {
let alice = FaultNode::new();
let bob = FaultNode::new();
establish_saved(&alice.core(), &bob.core(), 17_020);
let live = alice.sink.snapshot();
assert!(!live.is_empty());
let mut revisions = live.iter().map(|event| event.revision).collect::<Vec<_>>();
revisions.sort_unstable();
let unique = revisions.iter().copied().collect::<HashSet<_>>();
assert_eq!(
unique.len(),
live.len(),
"live revisions must be unique and monotonic per emission"
);
// Simulate at-least-once delivery: duplicates + a fresh listener.
let duplicates = live.clone();
alice.sink.clear();
let alice = alice.restart();
let after_restart = alice.core().list_events(None).unwrap();
assert!(!after_restart.is_empty());
let (seen_ids, max_revision) = recover_authoritative_state(&after_restart, &duplicates);
assert_eq!(seen_ids.len(), after_restart.len());
assert!(max_revision >= 1);
let saved = alice.core().list_saved_devices().unwrap();
assert_eq!(saved.len(), 1);
let relationships = alice.core().list_device_relationships().unwrap();
assert!(relationships
.iter()
.any(|entry| entry.state == DeviceRelationshipState::Saved));
}
#[test]
fn linux_public_bindings_omit_raw_secrets_and_generic_mutation() {
let api = std::fs::read_to_string(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/api.rs"),
)
.expect("api.rs");
let facade = std::fs::read_to_string(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/runtime/facade.rs"),
)
.expect("facade.rs");
// Test-only injectors may name SecureSecretStore; strip that impl for hygiene.
let public_facade = strip_cfg_test_impls(&facade);
for (label, source) in [
("api.rs", api.as_str()),
("facade.rs", public_facade.as_str()),
] {
for forbidden in [
"SecretMaterial",
"SecretHandle",
"SecureSecretStore",
"executeSql",
"executeSQL",
"mutateState",
"applyRawState",
"rawSecret",
"grantSecret",
"pairingCapabilityBytes",
"iroh.secret",
] {
assert!(
!source.contains(forbidden),
"{label} must not expose {forbidden} on the public surface"
);
}
}
assert!(
public_facade.contains("initialize_with_experimental_saved_devices"),
"facade must expose experimental saved-device init"
);
assert!(
!public_facade.contains("fn set_state(") && !public_facade.contains("fn mutate_state("),
"facade must not expose a generic state-mutation escape hatch"
);
assert!(api.contains("revision"), "CoreEvent must carry revision");
}
fn strip_cfg_test_impls(source: &str) -> String {
let mut out = String::new();
let mut lines = source.lines().peekable();
while let Some(line) = lines.next() {
let trimmed = line.trim_start();
if trimmed.starts_with("#[cfg(test)]") {
let mut brace_depth = 0i32;
let mut seen_brace = false;
for next in lines.by_ref() {
brace_depth += next.chars().filter(|c| *c == '{').count() as i32;
brace_depth -= next.chars().filter(|c| *c == '}').count() as i32;
if next.contains('{') {
seen_brace = true;
}
if seen_brace && brace_depth <= 0 {
break;
}
if !seen_brace && next.trim_end().ends_with(';') {
break;
}
}
continue;
}
out.push_str(line);
out.push('\n');
}
out
}

View File

@@ -0,0 +1,646 @@
//! Windows saved-device core contract harness.
//!
//! Compiles on every host. Uses [`FakeWindowsDpapiApi`] as an injectable
//! current-user DPAPI stand-in; real DPAPI is exercised under `cfg(windows)`.
use std::{
collections::HashSet,
path::Path,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use crate::{
secure_secret::{
scope_store,
windows::{FakeWindowsDpapiApi, WindowsDpapiSecretStore},
FaultInjectingSecretStore, ReferenceStoreFailure, SecretMaterial, SecureSecretStore,
SecureSecretStoreError,
},
CoreEvent, CoreEventSink, DeviceRelationshipState, ShareMetadataInput, ShareSource, SourceKind,
TargetedTransferState, TransferAccessMode, VnidropCore, VnidropError,
};
#[cfg(target_os = "windows")]
use crate::{CoreLimits, CoreNetworkConfig};
struct RecordingSink {
events: Mutex<Vec<CoreEvent>>,
}
impl CoreEventSink for RecordingSink {
fn on_event(&self, event: CoreEvent) {
self.events.lock().unwrap().push(event);
}
}
impl RecordingSink {
fn events(&self) -> Vec<CoreEvent> {
self.events.lock().unwrap().clone()
}
}
struct WindowsContractNode {
data_dir: tempfile::TempDir,
api: Arc<FakeWindowsDpapiApi>,
sink: Arc<RecordingSink>,
core: Option<Arc<VnidropCore>>,
}
impl WindowsContractNode {
fn new() -> Self {
let data_dir = tempfile::tempdir().unwrap();
let api = Arc::new(FakeWindowsDpapiApi::new());
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let store = windows_scoped_store(data_dir.path(), api.clone());
let core = VnidropCore::initialize_with_test_secret_store(
data_dir.path().to_string_lossy().into_owned(),
sink.clone(),
store,
)
.expect("windows contract core");
Self {
data_dir,
api,
sink,
core: Some(core),
}
}
fn core(&self) -> Arc<VnidropCore> {
self.core.as_ref().expect("core alive").clone()
}
fn restart(&mut self) -> Arc<VnidropCore> {
if let Some(core) = self.core.take() {
core.shutdown();
drop(core);
}
self.sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let store = windows_scoped_store(self.data_dir.path(), self.api.clone());
let core = VnidropCore::initialize_with_test_secret_store(
self.data_dir.path().to_string_lossy().into_owned(),
self.sink.clone(),
store,
)
.expect("restarted windows contract core");
self.core = Some(core.clone());
core
}
}
impl Drop for WindowsContractNode {
fn drop(&mut self) {
if let Some(core) = self.core.take() {
core.shutdown();
}
}
}
fn windows_scoped_store(
app_data_dir: &Path,
api: Arc<FakeWindowsDpapiApi>,
) -> Arc<dyn SecureSecretStore> {
let store = WindowsDpapiSecretStore::with_api(app_data_dir.join("protected-secrets-v1"), api)
.expect("windows dpapi store");
scope_store(app_data_dir, Arc::new(store))
}
fn share_path(core: &VnidropCore, source: &Path, transfer_id: u64) -> crate::ShareResult {
core.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source.to_string_lossy().into_owned(),
display_name: Some("hello.txt".to_string()),
is_directory: false,
}],
ShareMetadataInput {
transfer_id,
transfer_name: Some("hello.txt".to_string()),
sender_name: Some("sender".to_string()),
access_mode: TransferAccessMode::ApprovalRequired,
},
)
.unwrap()
}
fn wait_for_receiver_request(sender: &VnidropCore, transfer_id: u64) -> crate::ReceiverRequest {
let started = Instant::now();
loop {
if let Some(request) = sender
.list_receiver_requests(transfer_id)
.unwrap()
.into_iter()
.find(|request| request.status == "requested")
{
return request;
}
assert!(
started.elapsed() < Duration::from_secs(15),
"timed out waiting for receiver request"
);
std::thread::sleep(Duration::from_millis(25));
}
}
fn complete_transfer(
sender: &WindowsContractNode,
receiver: &WindowsContractNode,
transfer_id: u64,
) {
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("hello.txt");
std::fs::write(&source_path, b"windows contract").unwrap();
let share = share_path(&sender.core(), &source_path, transfer_id);
let output = output_dir.path().to_string_lossy().to_string();
let receiver_core = receiver.core();
let ticket = share.ticket.clone();
let handle = std::thread::spawn(move || {
receiver_core.receive(ticket, output, Some("receiver".to_string()))
});
let request = wait_for_receiver_request(&sender.core(), share.transfer_id);
sender
.core()
.respond_receiver_request(request.id, true, None)
.unwrap();
handle.join().unwrap().unwrap();
let started = Instant::now();
let peer = receiver.core().status().endpoint_id.clone();
loop {
if sender
.core()
.list_pairing_eligibilities()
.unwrap()
.iter()
.any(|entry| entry.peer_endpoint_id == peer)
{
break;
}
assert!(
started.elapsed() < Duration::from_secs(10),
"eligibility never appeared"
);
std::thread::sleep(Duration::from_millis(25));
}
}
fn wait_for_relationship(
core: &VnidropCore,
peer: &str,
state: DeviceRelationshipState,
) -> crate::DeviceRelationship {
let started = Instant::now();
loop {
if let Some(relationship) = core
.list_device_relationships()
.unwrap()
.into_iter()
.find(|entry| entry.remote_endpoint_id == peer && entry.state == state)
{
return relationship;
}
assert!(
started.elapsed() < Duration::from_secs(15),
"relationship {peer} never reached {state:?}"
);
std::thread::sleep(Duration::from_millis(25));
}
}
fn reach_saved(alice: &WindowsContractNode, bob: &WindowsContractNode, transfer_id: u64) {
let alice_id = alice.core().status().endpoint_id.clone();
let bob_id = bob.core().status().endpoint_id.clone();
complete_transfer(alice, bob, transfer_id);
assert!(alice
.core()
.request_saved_device_pairing(bob_id.clone())
.unwrap());
wait_for_relationship(
&bob.core(),
&alice_id,
DeviceRelationshipState::PendingIncoming,
);
assert!(bob
.core()
.respond_to_device_pairing(alice_id, true)
.unwrap());
wait_for_relationship(&alice.core(), &bob_id, DeviceRelationshipState::Saved);
wait_for_relationship(
&bob.core(),
&alice.core().status().endpoint_id,
DeviceRelationshipState::Saved,
);
}
fn wait_for_pending_offer(core: &VnidropCore) -> crate::PendingTargetedOffer {
let started = Instant::now();
loop {
if let Some(offer) = core.list_pending_targeted_offers().into_iter().next() {
return offer;
}
assert!(
started.elapsed() < Duration::from_secs(20),
"timed out waiting for pending targeted offer"
);
std::thread::sleep(Duration::from_millis(25));
}
}
fn targeted_source(path: &Path) -> ShareSource {
ShareSource {
kind: SourceKind::Path,
value: path.to_string_lossy().into_owned(),
display_name: Some(
path.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned(),
),
is_directory: false,
}
}
#[test]
fn windows_dpapi_identity_survives_core_restart() {
let mut node = WindowsContractNode::new();
let first = node.core().status().endpoint_id.clone();
assert!(!first.is_empty());
let restarted = node.restart();
assert_eq!(restarted.status().endpoint_id, first);
}
#[cfg(target_os = "windows")]
#[test]
fn real_windows_dpapi_experimental_init_preserves_identity() {
let data_dir = tempfile::tempdir().unwrap();
let path = data_dir.path().to_string_lossy().into_owned();
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let core = VnidropCore::initialize_with_experimental_saved_devices(
path.clone(),
sink,
CoreLimits::default(),
CoreNetworkConfig::default(),
)
.expect("experimental windows core");
let first = core.status().endpoint_id.clone();
core.shutdown();
drop(core);
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let restarted = VnidropCore::initialize_with_experimental_saved_devices(
path,
sink,
CoreLimits::default(),
CoreNetworkConfig::default(),
)
.expect("restarted experimental windows core");
assert_eq!(restarted.status().endpoint_id, first);
restarted.shutdown();
}
#[test]
fn public_api_exercises_complete_windows_saved_device_contract() {
let mut alice = WindowsContractNode::new();
let mut bob = WindowsContractNode::new();
let alice_id = alice.core().status().endpoint_id.clone();
let bob_id = bob.core().status().endpoint_id.clone();
complete_transfer(&alice, &bob, 16_001);
assert!(alice
.core()
.list_pairing_eligibilities()
.unwrap()
.iter()
.any(|entry| entry.peer_endpoint_id == bob_id));
assert!(alice
.core()
.request_saved_device_pairing(bob_id.clone())
.unwrap());
wait_for_relationship(
&bob.core(),
&alice_id,
DeviceRelationshipState::PendingIncoming,
);
assert!(bob
.core()
.respond_to_device_pairing(alice_id.clone(), true)
.unwrap());
wait_for_relationship(&alice.core(), &bob_id, DeviceRelationshipState::Saved);
wait_for_relationship(&bob.core(), &alice_id, DeviceRelationshipState::Saved);
alice
.core()
.set_saved_device_label(bob_id.clone(), Some("Bob PC".to_string()))
.unwrap();
let listed = alice.core().list_saved_devices().unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].endpoint_id, bob_id);
assert_eq!(listed[0].local_label.as_deref(), Some("Bob PC"));
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("payload.txt");
std::fs::write(&source_path, b"windows targeted payload").unwrap();
let bob_core = bob.core();
let accept = std::thread::spawn(move || {
let offer = wait_for_pending_offer(&bob_core);
bob_core
.respond_to_targeted_offer(offer.transfer_id, true)
.unwrap()
});
let transfer = alice
.core()
.create_targeted_transfer(
bob_id.clone(),
vec![targeted_source(&source_path)],
Some("payload.txt".to_string()),
)
.unwrap();
let response = accept.join().unwrap();
assert!(matches!(
response,
crate::TargetedOfferResponse::Approved { .. }
));
assert_eq!(transfer.state, TargetedTransferState::Approved);
// Interrupt via receiver restart, then resume without re-approval.
let bob_core = bob.restart();
let interrupted = bob_core
.get_targeted_transfer(transfer.id.clone())
.unwrap()
.expect("durable transfer");
assert!(matches!(
interrupted.state,
TargetedTransferState::Approved | TargetedTransferState::Interrupted
));
let output = tempfile::tempdir().unwrap();
bob_core
.resume_targeted_transfer(
transfer.id.clone(),
output.path().to_string_lossy().into_owned(),
)
.unwrap();
assert_eq!(
std::fs::read(output.path().join("payload.txt")).unwrap(),
b"windows targeted payload"
);
alice.core().forget_saved_device(bob_id.clone()).unwrap();
assert!(alice.core().list_saved_devices().unwrap().is_empty());
bob_core.block_device(alice_id.clone()).unwrap();
assert_eq!(
bob_core.list_blocked_devices().unwrap(),
vec![alice_id.clone()]
);
bob_core.unblock_device(alice_id).unwrap();
assert!(bob_core.list_blocked_devices().unwrap().is_empty());
// Unblock does not restore forgotten relationships.
assert!(alice.core().list_saved_devices().unwrap().is_empty());
let _ = alice.restart();
}
#[test]
fn wrong_user_or_unavailable_identity_prevents_networking() {
let data_dir = tempfile::tempdir().unwrap();
let first_api = Arc::new(FakeWindowsDpapiApi::with_context(b"windows-user-a"));
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let store = windows_scoped_store(data_dir.path(), first_api);
let core = VnidropCore::initialize_with_test_secret_store(
data_dir.path().to_string_lossy().into_owned(),
sink,
store,
)
.unwrap();
let endpoint = core.status().endpoint_id.clone();
core.shutdown();
drop(core);
let wrong_user = Arc::new(FakeWindowsDpapiApi::with_context(b"windows-user-b"));
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let store = windows_scoped_store(data_dir.path(), wrong_user);
let err = match VnidropCore::initialize_with_test_secret_store(
data_dir.path().to_string_lossy().into_owned(),
sink,
store,
) {
Ok(_) => panic!("wrong-user DPAPI context must not start networking"),
Err(error) => error,
};
assert!(
matches!(
err,
VnidropError::SecureStorageCorrupted { .. }
| VnidropError::SecureStorageUnavailable { .. }
| VnidropError::SecureStorageLocked { .. }
| VnidropError::SecureStorageMissing { .. }
| VnidropError::Initialization { .. }
),
"unexpected error for wrong-user identity: {err:?}"
);
assert!(!endpoint.is_empty());
let unavailable = Arc::new(FaultInjectingSecretStore::default());
unavailable.fail_with(Some(ReferenceStoreFailure::Unavailable));
let sink = Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
});
let err = match VnidropCore::initialize_with_test_secret_store(
tempfile::tempdir()
.unwrap()
.path()
.to_string_lossy()
.into_owned(),
sink,
unavailable,
) {
Ok(_) => panic!("unavailable identity store must not start networking"),
Err(error) => error,
};
assert!(matches!(
err,
VnidropError::SecureStorageUnavailable { .. } | VnidropError::Initialization { .. }
));
}
#[test]
fn unavailable_relationship_secrets_disable_only_saved_device_behavior() {
let alice = WindowsContractNode::new();
let bob = WindowsContractNode::new();
reach_saved(&alice, &bob, 16_010);
alice
.api
.set_unavailable_for_handles_containing("relationship-grant");
bob.api
.set_unavailable_for_handles_containing("relationship-grant");
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("invite.txt");
std::fs::write(&source_path, b"invitation still works").unwrap();
let share = share_path(&alice.core(), &source_path, 16_011);
let output_dir = tempfile::tempdir().unwrap();
let output = output_dir.path().to_string_lossy().to_string();
let receiver = bob.core();
let ticket = share.ticket.clone();
let handle =
std::thread::spawn(move || receiver.receive(ticket, output, Some("receiver".to_string())));
let request = wait_for_receiver_request(&alice.core(), share.transfer_id);
alice
.core()
.respond_receiver_request(request.id, true, None)
.unwrap();
handle.join().unwrap().unwrap();
assert_eq!(
std::fs::read(output_dir.path().join("hello.txt")).unwrap(),
b"invitation still works"
);
let targeted = alice.core().create_targeted_transfer(
bob.core().status().endpoint_id,
vec![targeted_source(&source_path)],
Some("invite.txt".to_string()),
);
assert!(
targeted.is_err(),
"saved-device targeted transfer must fail closed when relationship secrets are unavailable"
);
let err = targeted.unwrap_err();
assert!(
matches!(
err,
VnidropError::SecureStorageUnavailable { .. }
| VnidropError::SecureStorageCorrupted { .. }
| VnidropError::SecureStorageMissing { .. }
| VnidropError::SecureStorageLocked { .. }
| VnidropError::Permission { .. }
| VnidropError::Network { .. }
),
"unexpected targeted failure: {err:?}"
);
}
#[test]
fn event_ids_and_revisions_recover_authoritative_state_after_listener_restart() {
let mut alice = WindowsContractNode::new();
let bob = WindowsContractNode::new();
reach_saved(&alice, &bob, 16_020);
let live = alice.sink.events();
assert!(!live.is_empty());
let mut seen = HashSet::new();
let mut revisions = Vec::new();
for event in &live {
assert!(seen.insert(event.id.clone()), "duplicate live event id");
assert!(event.revision >= 1);
revisions.push(event.revision);
}
revisions.sort_unstable();
let unique = revisions.len();
revisions.dedup();
assert_eq!(revisions.len(), unique, "live revisions must be unique");
// Duplicate delivery: same events observed twice must still dedupe by id.
let mut merged = live.clone();
merged.extend(live.iter().cloned());
let mut deduped = HashSet::new();
for event in &merged {
deduped.insert((event.id.clone(), event.revision));
}
assert_eq!(deduped.len(), live.len());
let before_restart = alice.core().list_events(None).unwrap();
alice.restart();
let authoritative = alice.core().list_events(None).unwrap();
assert!(!authoritative.is_empty());
assert!(
authoritative.len() >= before_restart.len().saturating_sub(8),
"restart must retain durable events for recovery"
);
let devices = alice.core().list_saved_devices().unwrap();
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].endpoint_id, bob.core().status().endpoint_id);
}
#[test]
fn public_bindings_omit_raw_secrets_and_generic_mutation() {
// UniFFI only exports the typed public surface from api.rs / VnidropCore.
// Secret custody types and test-only mutation helpers must stay crate-private.
let exported = include_str!("../lib.rs");
assert!(
!exported.contains("SecretMaterial") && !exported.contains("SecretHandle"),
"raw secret types must not be re-exported from the crate root"
);
assert!(
!exported.contains("SecureSecretStore"),
"secure secret store must not cross the public binding boundary"
);
let facade = include_str!("../runtime/facade.rs");
for needle in [
"fn execute_sql",
"fn mutate_state",
"fn put_secret",
"fn load_secret",
"SecretMaterial",
"SecretHandle",
] {
assert!(
!facade.contains(needle),
"public facade must not expose generic mutation / raw secrets ({needle})"
);
}
// for_test helpers are cfg(test) only and never part of UniFFI export.
assert!(facade.contains("cfg(test)"));
assert!(facade.contains("for_test"));
let api = include_str!("../api.rs");
assert!(api.contains("struct SavedDevice"));
assert!(api.contains("struct PairingEligibilitySummary"));
assert!(
!api.contains("grant_bytes")
&& !api.contains("private_key")
&& !api.contains("secret_material"),
"public API records must not carry raw secret fields"
);
}
#[test]
fn fake_windows_dpapi_wrong_context_fails_closed() {
let directory = tempfile::tempdir().unwrap();
let handle = WindowsDpapiSecretStore::relationship_handle_for_test();
let material = SecretMaterial::new(vec![0xa1; 32]).unwrap();
let original = WindowsDpapiSecretStore::with_api(
directory.path(),
Arc::new(FakeWindowsDpapiApi::with_context(b"user-a")),
)
.unwrap();
original.put(&handle, material).unwrap();
let wrong = WindowsDpapiSecretStore::with_api(
directory.path(),
Arc::new(FakeWindowsDpapiApi::with_context(b"user-b")),
)
.unwrap();
assert!(matches!(
wrong.get(&handle),
Err(SecureSecretStoreError::Corrupted)
));
}

View File

@@ -1,6 +1,6 @@
use crate::{
api::{CoreEvent, ReceivedLocatorKind},
repository::{
invitation::{
PendingDeliveryReceiptInsert, ReceivedArtifactInsert, ReceiverRequestInsert, Repository,
TransferUpsert,
},
@@ -66,7 +66,7 @@ async fn received_artifacts_survive_history_deletion() {
async fn persists_transfers_and_events_across_reopen() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
assert_eq!(repository.schema_version().await.unwrap(), 7);
assert_eq!(repository.schema_version().await.unwrap(), 13);
repository
.insert_transfer(transfer(
7,
@@ -87,6 +87,7 @@ async fn persists_transfers_and_events_across_reopen() {
.insert_event(
&CoreEvent {
id: "event-1".to_string(),
revision: 1,
timestamp: 10,
scope: "transfer".to_string(),
transfer_id: Some(7),
@@ -645,7 +646,7 @@ async fn migrates_schema_v2_identity_without_losing_transfer() {
pool.close().await;
let repository = Repository::open(temp.path()).await.unwrap();
assert_eq!(repository.schema_version().await.unwrap(), 7);
assert_eq!(repository.schema_version().await.unwrap(), 13);
let stored = repository.list_transfers().await.unwrap().remove(0);
assert_eq!(stored.transfer_id, 7);
assert_eq!(stored.local_id, "legacy-7-send");
@@ -663,6 +664,7 @@ async fn event_reads_respect_configured_history_limit() {
.insert_event(
&CoreEvent {
id: format!("event-{sequence}"),
revision: 1,
timestamp: sequence,
scope: "endpoint".to_string(),
transfer_id: None,
@@ -711,6 +713,7 @@ async fn deleting_transfer_removes_related_history_transactionally() {
.insert_event(
&CoreEvent {
id: "event-delete".to_string(),
revision: 1,
timestamp: 1,
scope: "transfer".to_string(),
transfer_id: Some(88),
@@ -775,6 +778,7 @@ async fn deleting_receive_history_only_removes_terminal_receives_and_dependants(
.insert_event(
&CoreEvent {
id: format!("event-{transfer_id}"),
revision: 1,
timestamp: transfer_id as i64,
scope: "transfer".to_string(),
transfer_id: Some(transfer_id),
@@ -857,6 +861,7 @@ async fn receive_history_mid_transaction_failure_preserves_all_related_rows() {
.insert_event(
&CoreEvent {
id: "event-preserved".to_string(),
revision: 1,
timestamp: 1,
scope: "transfer".to_string(),
transfer_id: Some(106),

View File

@@ -9,10 +9,11 @@ use iroh_blobs::{
};
use crate::{
repository::{PendingDeliveryReceiptInsert, Repository, TransferUpsert},
runtime::{consume_request_updates, RequestStreamOutcome},
invitation::{PendingDeliveryReceiptInsert, Repository, TransferUpsert},
runtime::{consume_request_updates, CoreInner, IdentityMode, RequestStreamOutcome},
secure_secret::{lock_profile, FaultInjectingSecretStore},
transfer_state::{TransferDirection, TransferStatus},
CoreEvent, CoreEventSink, VnidropCore, VnidropError,
CoreEvent, CoreEventSink, CoreLimits, CoreRelayMode, VnidropCore, VnidropError,
};
struct TestSink;
@@ -65,6 +66,46 @@ fn initializes_and_reports_endpoint() {
core.shutdown();
}
#[tokio::test]
async fn protected_runtime_restart_preserves_identity_without_plaintext_fallback() {
let temp = tempfile::tempdir().unwrap();
let store = Arc::new(FaultInjectingSecretStore::default());
let first = CoreInner::start(
temp.path().to_path_buf(),
Arc::new(TestSink),
CoreLimits::default(),
CoreRelayMode::LocalOnly,
Vec::new(),
IdentityMode::Protected {
store: store.clone(),
profile_lock: lock_profile(temp.path()).unwrap(),
},
)
.await
.unwrap();
let endpoint_id = first.endpoint.id();
first.shutdown().await;
drop(first);
let restarted = CoreInner::start(
temp.path().to_path_buf(),
Arc::new(TestSink),
CoreLimits::default(),
CoreRelayMode::LocalOnly,
Vec::new(),
IdentityMode::Protected {
store,
profile_lock: lock_profile(temp.path()).unwrap(),
},
)
.await
.unwrap();
assert_eq!(restarted.endpoint.id(), endpoint_id);
assert!(!temp.path().join("iroh.secret").exists());
restarted.shutdown().await;
}
#[test]
fn invalid_receive_ticket_is_typed_and_persisted_as_event() {
let temp = tempfile::tempdir().unwrap();

View File

@@ -0,0 +1,412 @@
use std::{
io::{self, Write},
sync::{Arc, Mutex},
};
use data_encoding::HEXLOWER;
use iroh::SecretKey;
use crate::{
persistence,
secure_secret::{
lock_profile, scope_store, CustodyCrashPoint, FaultInjectingSecretStore,
ReferenceStoreFailure, SecretCustody, SecretKind, SecretMaterial, SecureSecretStore,
},
VnidropError,
};
#[derive(Clone, Default)]
struct CapturedOutput(Arc<Mutex<Vec<u8>>>);
struct CapturedWriter(CapturedOutput);
impl Write for CapturedWriter {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.0 .0.lock().unwrap().extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn a_profile_allows_only_one_protected_core_mutator() {
let temp = tempfile::tempdir().unwrap();
let first = lock_profile(temp.path()).unwrap();
assert!(matches!(
lock_profile(temp.path()),
Err(VnidropError::SecureStorageUnavailable { reason })
if reason.contains("already using this profile")
));
drop(first);
assert!(lock_profile(temp.path()).is_ok());
}
#[test]
fn profile_lock_maps_contention_not_generic_io_failures() {
// Regression: Android's std File::try_lock returns Unsupported; lock_profile
// must use flock and only treat WouldBlock as "already using this profile".
let temp = tempfile::tempdir().unwrap();
let held = lock_profile(temp.path()).unwrap();
match lock_profile(temp.path()) {
Err(VnidropError::SecureStorageUnavailable { reason }) => {
assert!(
reason.contains("already using this profile"),
"unexpected reason: {reason}"
);
}
Ok(_) => panic!("expected contended lock to fail"),
Err(other) => panic!("expected SecureStorageUnavailable, got {other:?}"),
}
drop(held);
}
#[tokio::test]
async fn reconciliation_is_scoped_to_one_application_profile() {
let root = tempfile::tempdir().unwrap();
let first_dir = root.path().join("first");
let second_dir = root.path().join("second");
std::fs::create_dir_all(&first_dir).unwrap();
std::fs::create_dir_all(&second_dir).unwrap();
let shared_platform_store = Arc::new(FaultInjectingSecretStore::default());
let first_store = scope_store(&first_dir, shared_platform_store.clone());
let second_store = scope_store(&second_dir, shared_platform_store);
let first_stores = persistence::open_all(&first_dir).await.unwrap();
let second_stores = persistence::open_all(&second_dir).await.unwrap();
let first = SecretCustody::new(first_stores.secrets.clone(), first_store.clone());
let second = SecretCustody::new(second_stores.secrets.clone(), second_store.clone());
let first_handle = first
.protect(
SecretKind::RelationshipGrant,
SecretMaterial::new(vec![0x31; 32]).unwrap(),
None,
)
.await
.unwrap();
let second_handle = second
.protect(
SecretKind::RelationshipGrant,
SecretMaterial::new(vec![0x42; 32]).unwrap(),
None,
)
.await
.unwrap();
drop(first);
let (restarted, summary) = SecretCustody::start(first_stores.secrets.clone(), first_store)
.await
.unwrap();
assert_eq!(summary.orphans_deleted, 0);
assert_eq!(
restarted.load(&first_handle).await.unwrap(),
SecretMaterial::new(vec![0x31; 32]).unwrap()
);
assert_eq!(
second.load(&second_handle).await.unwrap(),
SecretMaterial::new(vec![0x42; 32]).unwrap()
);
}
#[tokio::test]
async fn custody_maps_reference_store_failures_to_typed_core_errors() {
let temp = tempfile::tempdir().unwrap();
let stores = persistence::open_all(temp.path()).await.unwrap();
let store = Arc::new(FaultInjectingSecretStore::default());
let custody = SecretCustody::new(stores.secrets.clone(), store.clone());
let secret = SecretMaterial::new(vec![0x5a; 32]).unwrap();
let handle = custody
.protect(SecretKind::RelationshipGrant, secret.clone(), None)
.await
.unwrap();
assert_eq!(custody.load(&handle).await.unwrap(), secret);
store.fail_with(Some(ReferenceStoreFailure::Locked));
assert!(matches!(
custody.load(&handle).await,
Err(VnidropError::SecureStorageLocked { .. })
));
store.fail_with(Some(ReferenceStoreFailure::Unavailable));
assert!(matches!(
custody.load(&handle).await,
Err(VnidropError::SecureStorageUnavailable { .. })
));
store.fail_with(None);
store.remove_for_test(&handle);
assert!(matches!(
custody.load(&handle).await,
Err(VnidropError::SecureStorageMissing { .. })
));
let corrupted_handle = custody
.protect(SecretKind::RelationshipGrant, secret, None)
.await
.unwrap();
store.corrupt_for_test(&corrupted_handle);
assert!(matches!(
custody.load(&corrupted_handle).await,
Err(VnidropError::SecureStorageCorrupted { .. })
));
}
#[tokio::test]
async fn reconciliation_repairs_staged_metadata_and_disables_unusable_secrets() {
let temp = tempfile::tempdir().unwrap();
let mut stores = persistence::open_all(temp.path()).await.unwrap();
let store = Arc::new(FaultInjectingSecretStore::default());
let custody = SecretCustody::new(stores.secrets.clone(), store.clone());
custody.crash_once_at(CustodyCrashPoint::StoreWrite);
assert!(custody
.protect(
SecretKind::PairingEligibility,
SecretMaterial::new(vec![0x11; 32]).unwrap(),
None,
)
.await
.is_err());
drop(custody);
drop(stores);
stores = persistence::open_all(temp.path()).await.unwrap();
let (custody, summary) = SecretCustody::start(stores.secrets.clone(), store.clone())
.await
.unwrap();
assert_eq!(summary.orphans_deleted, 1);
assert_eq!(summary.staged_activated, 0);
custody.crash_once_at(CustodyCrashPoint::MetadataStage);
assert!(custody
.protect(
SecretKind::PairingEligibility,
SecretMaterial::new(vec![0x22; 32]).unwrap(),
None,
)
.await
.is_err());
let staged_handle = store.only_handle_for_test();
drop(custody);
drop(stores);
stores = persistence::open_all(temp.path()).await.unwrap();
let (custody, summary) = SecretCustody::start(stores.secrets.clone(), store.clone())
.await
.unwrap();
assert_eq!(summary.staged_activated, 1);
assert_eq!(
custody.load(&staged_handle).await.unwrap(),
SecretMaterial::new(vec![0x22; 32]).unwrap()
);
store.remove_for_test(&staged_handle);
drop(custody);
drop(stores);
stores = persistence::open_all(temp.path()).await.unwrap();
let (custody, summary) = SecretCustody::start(stores.secrets.clone(), store.clone())
.await
.unwrap();
assert_eq!(summary.disabled, 1);
assert!(matches!(
custody.load(&staged_handle).await,
Err(VnidropError::SecureStorageUnavailable { .. })
));
let corrupted = custody
.protect(
SecretKind::RelationshipGrant,
SecretMaterial::new(vec![0x33; 32]).unwrap(),
None,
)
.await
.unwrap();
store.corrupt_for_test(&corrupted);
drop(custody);
let stores = persistence::open_all(temp.path()).await.unwrap();
let (custody, summary) = SecretCustody::start(stores.secrets.clone(), store.clone())
.await
.unwrap();
assert_eq!(summary.disabled, 1);
assert!(matches!(
custody.load(&corrupted).await,
Err(VnidropError::SecureStorageUnavailable { .. })
));
}
#[tokio::test]
async fn endpoint_migration_preserves_identity_across_crash_and_rejects_replacement() {
let temp = tempfile::tempdir().unwrap();
let legacy_path = temp.path().join("iroh.secret");
let original = SecretKey::generate();
std::fs::write(&legacy_path, HEXLOWER.encode(&original.to_bytes())).unwrap();
let stores = persistence::open_all(temp.path()).await.unwrap();
let store = Arc::new(FaultInjectingSecretStore::default());
let custody = SecretCustody::new(stores.secrets.clone(), store.clone());
custody.crash_once_at(CustodyCrashPoint::MetadataActivation);
assert!(custody
.migrate_legacy_endpoint_identity(&legacy_path)
.await
.is_err());
assert!(
legacy_path.exists(),
"legacy key must survive before activation"
);
drop(custody);
let stores = persistence::open_all(temp.path()).await.unwrap();
let (custody, summary) = SecretCustody::start(stores.secrets.clone(), store.clone())
.await
.unwrap();
assert_eq!(summary.staged_activated, 0);
let handle = custody
.migrate_legacy_endpoint_identity(&legacy_path)
.await
.unwrap();
assert!(!legacy_path.exists());
assert_eq!(
custody.load(&handle).await.unwrap(),
SecretMaterial::new(original.to_bytes().to_vec()).unwrap()
);
let replacement = SecretKey::generate();
std::fs::write(&legacy_path, HEXLOWER.encode(&replacement.to_bytes())).unwrap();
assert!(matches!(
custody.migrate_legacy_endpoint_identity(&legacy_path).await,
Err(VnidropError::SecureStorageCorrupted { .. })
));
assert!(legacy_path.exists());
assert_eq!(
custody.load(&handle).await.unwrap(),
SecretMaterial::new(original.to_bytes().to_vec()).unwrap()
);
let missing = temp.path().join("missing.secret");
let empty_store = Arc::new(FaultInjectingSecretStore::default());
let other_dir = temp.path().join("other");
std::fs::create_dir(&other_dir).unwrap();
let other_stores = persistence::open_all(&other_dir).await.unwrap();
let empty_custody = SecretCustody::new(other_stores.secrets.clone(), empty_store);
assert!(matches!(
empty_custody
.migrate_legacy_endpoint_identity(&missing)
.await,
Err(VnidropError::SecureStorageMissing { .. })
));
}
#[tokio::test]
async fn first_install_identity_is_protected_once_and_never_silently_replaced() {
let temp = tempfile::tempdir().unwrap();
let legacy_path = temp.path().join("iroh.secret");
let stores = persistence::open_all(temp.path()).await.unwrap();
let store = Arc::new(FaultInjectingSecretStore::default());
let (custody, _) = SecretCustody::start(stores.secrets.clone(), store.clone())
.await
.unwrap();
let original = custody
.initialize_endpoint_identity(&legacy_path)
.await
.unwrap();
assert!(!legacy_path.exists());
let handle = store.only_handle_for_test();
drop(custody);
let stores = persistence::open_all(temp.path()).await.unwrap();
let (custody, _) = SecretCustody::start(stores.secrets.clone(), store.clone())
.await
.unwrap();
assert_eq!(
custody
.initialize_endpoint_identity(&legacy_path)
.await
.unwrap(),
original
);
store.remove_for_test(&handle);
drop(custody);
let stores = persistence::open_all(temp.path()).await.unwrap();
let (custody, summary) = SecretCustody::start(stores.secrets.clone(), store.clone())
.await
.unwrap();
assert_eq!(summary.disabled, 1);
assert!(matches!(
custody.initialize_endpoint_identity(&legacy_path).await,
Err(VnidropError::SecureStorageUnavailable { .. })
));
assert!(store.list_handles().unwrap().is_empty());
}
#[tokio::test]
async fn concurrent_first_starts_converge_on_one_protected_endpoint_identity() {
let temp = tempfile::tempdir().unwrap();
let legacy_path = temp.path().join("iroh.secret");
let stores = persistence::open_all(temp.path()).await.unwrap();
let store = Arc::new(FaultInjectingSecretStore::default());
let first = SecretCustody::new(stores.secrets.clone(), store.clone());
let second = SecretCustody::new(stores.secrets.clone(), store.clone());
let (first_identity, second_identity) = tokio::join!(
first.initialize_endpoint_identity(&legacy_path),
second.initialize_endpoint_identity(&legacy_path),
);
assert_eq!(first_identity.unwrap(), second_identity.unwrap());
assert_eq!(store.list_handles().unwrap().len(), 1);
}
#[tokio::test]
async fn protected_material_is_absent_from_database_and_diagnostics() {
let temp = tempfile::tempdir().unwrap();
let stores = persistence::open_all(temp.path()).await.unwrap();
let store = Arc::new(FaultInjectingSecretStore::default());
let custody = SecretCustody::new(stores.secrets.clone(), store.clone());
let raw = (0u8..32).map(|value| value + 1).collect::<Vec<_>>();
let encoded = HEXLOWER.encode(&raw);
let material = SecretMaterial::new(raw.clone()).unwrap();
let handle = custody
.protect(SecretKind::RelationshipGrant, material.clone(), None)
.await
.unwrap();
assert!(handle
.as_str()
.starts_with("vnidrop/v1/relationship-grant/"));
assert_eq!(format!("{material:?}"), "SecretMaterial(redacted)");
store.corrupt_for_test(&handle);
let error = custody.load(&handle).await.unwrap_err().to_string();
assert!(!error.contains(&encoded));
let captured = CapturedOutput::default();
let writer_output = captured.clone();
let subscriber = tracing_subscriber::fmt()
.without_time()
.with_writer(move || CapturedWriter(writer_output.clone()))
.finish();
let _subscriber = tracing::subscriber::set_default(subscriber);
tracing::info!(material = ?material, error, "custody diagnostic");
let diagnostics = String::from_utf8(captured.0.lock().unwrap().clone()).unwrap();
assert!(!diagnostics.contains(&encoded));
let repository = stores.invitation.clone();
assert!(repository.list_events(None, 500).await.unwrap().is_empty());
let mut persisted = Vec::new();
for entry in std::fs::read_dir(temp.path()).unwrap() {
let path = entry.unwrap().path();
if path.is_file() {
persisted.extend(std::fs::read(path).unwrap());
}
}
assert!(!persisted.windows(raw.len()).any(|window| window == raw));
assert!(!persisted
.windows(encoded.len())
.any(|window| window == encoded.as_bytes()));
}

View File

@@ -0,0 +1,204 @@
use std::{
collections::HashMap,
fs,
sync::{Arc, Mutex},
};
use tempfile::TempDir;
use crate::secure_secret::{
android::{
secret_handle_for_test, AndroidKeystore, AndroidSealedValue, AndroidSecureSecretStore,
},
SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError,
};
const TEST_SECRET_BYTES: usize = 32;
#[derive(Default)]
struct FakeKeystore {
keys: Mutex<HashMap<String, u8>>,
seal_failure: Mutex<Option<SecureSecretStoreError>>,
delete_failure: Mutex<Option<SecureSecretStoreError>>,
}
impl AndroidKeystore for FakeKeystore {
fn seal(
&self,
alias: &str,
plaintext: &[u8],
) -> Result<AndroidSealedValue, SecureSecretStoreError> {
if let Some(error) = self.seal_failure.lock().unwrap().take() {
return Err(error);
}
let mask = 0xa7;
self.keys.lock().unwrap().insert(alias.to_string(), mask);
Ok(AndroidSealedValue {
nonce: vec![4; 12],
ciphertext: plaintext.iter().map(|byte| byte ^ mask).collect(),
})
}
fn open(
&self,
alias: &str,
sealed: &AndroidSealedValue,
) -> Result<Vec<u8>, SecureSecretStoreError> {
let mask = *self
.keys
.lock()
.unwrap()
.get(alias)
.ok_or(SecureSecretStoreError::Missing)?;
Ok(sealed.ciphertext.iter().map(|byte| byte ^ mask).collect())
}
fn delete(&self, alias: &str) -> Result<(), SecureSecretStoreError> {
if let Some(error) = self.delete_failure.lock().unwrap().take() {
return Err(error);
}
self.keys.lock().unwrap().remove(alias);
Ok(())
}
}
fn fixture() -> (TempDir, AndroidSecureSecretStore, Arc<FakeKeystore>) {
let directory = TempDir::new().unwrap();
let keystore = Arc::new(FakeKeystore::default());
let store = AndroidSecureSecretStore::new(directory.path(), keystore.clone()).unwrap();
(directory, store, keystore)
}
fn handle() -> SecretHandle {
secret_handle_for_test("vnidrop/v1/endpoint-identity/test")
}
#[test]
fn adapter_round_trips_lists_and_deletes_without_plaintext_persistence() {
let (directory, store, keystore) = fixture();
let handle = handle();
let plaintext = vec![0x5a; TEST_SECRET_BYTES];
let material = SecretMaterial::new(plaintext.clone()).unwrap();
store.put(&handle, material.clone()).unwrap();
let persisted = fs::read(store.record_path_for_test(&handle)).unwrap();
assert!(!persisted
.windows(plaintext.len())
.any(|window| window == plaintext));
drop(store);
let restarted = AndroidSecureSecretStore::new(directory.path(), keystore).unwrap();
assert_eq!(restarted.list_handles().unwrap(), vec![handle.clone()]);
assert_eq!(restarted.get(&handle).unwrap(), material);
restarted.delete(&handle).unwrap();
assert!(restarted.list_handles().unwrap().is_empty());
assert!(matches!(
restarted.get(&handle),
Err(SecureSecretStoreError::Missing)
));
}
#[test]
fn staged_crash_record_remains_discoverable_and_fails_closed() {
let (_directory, store, _keystore) = fixture();
let handle = handle();
store.stage_for_test(&handle).unwrap();
assert_eq!(store.list_handles().unwrap(), vec![handle.clone()]);
assert!(matches!(
store.get(&handle),
Err(SecureSecretStoreError::Corrupted)
));
store.delete(&handle).unwrap();
assert!(store.list_handles().unwrap().is_empty());
}
#[test]
fn tampering_and_missing_keystore_keys_are_distinct_failures() {
let (_directory, store, keystore) = fixture();
let handle = handle();
store
.put(
&handle,
SecretMaterial::new(vec![9; TEST_SECRET_BYTES]).unwrap(),
)
.unwrap();
keystore.keys.lock().unwrap().clear();
assert!(matches!(
store.get(&handle),
Err(SecureSecretStoreError::Missing)
));
fs::write(store.record_path_for_test(&handle), b"tampered").unwrap();
assert!(matches!(
store.get(&handle),
Err(SecureSecretStoreError::Corrupted)
));
}
#[test]
fn failed_key_deletion_retains_the_record_for_safe_retry() {
let (_directory, store, keystore) = fixture();
let handle = handle();
store
.put(
&handle,
SecretMaterial::new(vec![7; TEST_SECRET_BYTES]).unwrap(),
)
.unwrap();
*keystore.delete_failure.lock().unwrap() = Some(SecureSecretStoreError::Locked);
assert!(matches!(
store.delete(&handle),
Err(SecureSecretStoreError::Locked)
));
assert_eq!(store.list_handles().unwrap(), vec![handle.clone()]);
store.delete(&handle).unwrap();
assert!(store.list_handles().unwrap().is_empty());
}
#[test]
fn failed_replacement_keeps_the_previous_secret_readable() {
let (_directory, store, keystore) = fixture();
let handle = handle();
let original = SecretMaterial::new(vec![3; TEST_SECRET_BYTES]).unwrap();
store.put(&handle, original.clone()).unwrap();
*keystore.seal_failure.lock().unwrap() = Some(SecureSecretStoreError::Locked);
assert!(matches!(
store.put(
&handle,
SecretMaterial::new(vec![8; TEST_SECRET_BYTES]).unwrap()
),
Err(SecureSecretStoreError::Locked)
));
assert_eq!(store.get(&handle).unwrap(), original);
}
#[test]
fn scoped_handle_record_names_fit_linux_name_max() {
let (_directory, store, _keystore) = fixture();
// Mirrors ScopedSecretStore physical handles used on Android profiles.
let handle = secret_handle_for_test(&format!(
"vnidrop/v1/scope-{}/endpoint-identity/{}",
"a".repeat(64),
"b".repeat(36),
));
assert!(handle.as_str().len() > 100);
let path = store.record_path_for_test(&handle);
let file_name = path.file_name().and_then(|value| value.to_str()).unwrap();
assert!(
file_name.len() <= 255,
"record file name exceeds NAME_MAX: {} bytes ({file_name})",
file_name.len()
);
let material = SecretMaterial::new(vec![0x5a; TEST_SECRET_BYTES]).unwrap();
store.put(&handle, material.clone()).unwrap();
assert_eq!(store.list_handles().unwrap(), vec![handle.clone()]);
assert_eq!(store.get(&handle).unwrap(), material);
}

View File

@@ -0,0 +1,188 @@
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use crate::secure_secret::{
apple::{
expected_policy_for_test, handle_for_test, map_status_for_test, service_for_test,
AppleKeychainApi, AppleKeychainPolicy, AppleKeychainSecretStore,
},
SecretMaterial, SecureSecretStore, SecureSecretStoreError,
};
const ERR_SEC_AUTH_FAILED: i32 = -25_293;
const ERR_SEC_NOT_AVAILABLE: i32 = -25_291;
const ERR_SEC_ITEM_NOT_FOUND: i32 = -25_300;
const ERR_SEC_INTERACTION_NOT_ALLOWED: i32 = -25_308;
const ERR_SEC_DECODE: i32 = -26_275;
#[derive(Clone, Default)]
struct RecordingKeychain {
state: Arc<Mutex<RecordingState>>,
}
#[derive(Default)]
struct RecordingState {
entries: HashMap<(String, String), Vec<u8>>,
last_policy: Option<AppleKeychainPolicy>,
}
impl AppleKeychainApi for RecordingKeychain {
fn put(
&self,
service: &str,
account: &str,
material: &[u8],
policy: AppleKeychainPolicy,
) -> Result<(), i32> {
let mut state = self.state.lock().unwrap();
state.last_policy = Some(policy);
state.entries.insert(
(service.to_string(), account.to_string()),
material.to_vec(),
);
Ok(())
}
fn get(&self, service: &str, account: &str) -> Result<Vec<u8>, i32> {
self.state
.lock()
.unwrap()
.entries
.get(&(service.to_string(), account.to_string()))
.cloned()
.ok_or(ERR_SEC_ITEM_NOT_FOUND)
}
fn delete(&self, service: &str, account: &str) -> Result<(), i32> {
self.state
.lock()
.unwrap()
.entries
.remove(&(service.to_string(), account.to_string()))
.map(|_| ())
.ok_or(ERR_SEC_ITEM_NOT_FOUND)
}
fn list_accounts(&self, service: &str) -> Result<Vec<String>, i32> {
Ok(self
.state
.lock()
.unwrap()
.entries
.keys()
.filter(|(entry_service, _)| entry_service == service)
.map(|(_, account)| account.clone())
.collect())
}
}
#[test]
fn adapter_creates_replaces_reads_lists_and_deletes_only_its_service() {
let api = RecordingKeychain::default();
api.state.lock().unwrap().entries.insert(
("com.example.unrelated".to_string(), "leave-me".to_string()),
vec![0x77; 32],
);
let store = AppleKeychainSecretStore::with_api(api.clone());
let owned = handle_for_test("vnidrop/v1/endpoint-identity/apple-test");
store
.put(&owned, SecretMaterial::new(vec![0x31; 32]).unwrap())
.unwrap();
drop(store);
let reopened_store = AppleKeychainSecretStore::with_api(api.clone());
assert_eq!(
reopened_store.get(&owned).unwrap(),
SecretMaterial::new(vec![0x31; 32]).unwrap()
);
reopened_store
.put(&owned, SecretMaterial::new(vec![0x42; 32]).unwrap())
.unwrap();
assert_eq!(
reopened_store.get(&owned).unwrap(),
SecretMaterial::new(vec![0x42; 32]).unwrap()
);
assert_eq!(reopened_store.list_handles().unwrap(), vec![owned.clone()]);
reopened_store.delete(&owned).unwrap();
assert!(matches!(
reopened_store.get(&owned),
Err(SecureSecretStoreError::Missing)
));
assert!(api
.state
.lock()
.unwrap()
.entries
.contains_key(&("com.example.unrelated".to_string(), "leave-me".to_string())));
}
#[test]
fn adapter_always_requests_device_local_non_synchronizing_protection() {
let api = RecordingKeychain::default();
let store = AppleKeychainSecretStore::with_api(api.clone());
store
.put(
&handle_for_test("vnidrop/v1/relationship-grant/apple-policy"),
SecretMaterial::new(vec![0x51; 32]).unwrap(),
)
.unwrap();
assert_eq!(
api.state.lock().unwrap().last_policy,
Some(expected_policy_for_test())
);
}
#[test]
fn apple_statuses_map_to_fail_closed_contract_outcomes() {
assert!(matches!(
map_status_for_test(ERR_SEC_INTERACTION_NOT_ALLOWED),
SecureSecretStoreError::Locked
));
assert!(matches!(
map_status_for_test(ERR_SEC_AUTH_FAILED),
SecureSecretStoreError::Locked
));
assert!(matches!(
map_status_for_test(ERR_SEC_ITEM_NOT_FOUND),
SecureSecretStoreError::Missing
));
assert!(matches!(
map_status_for_test(ERR_SEC_DECODE),
SecureSecretStoreError::Corrupted
));
assert!(matches!(
map_status_for_test(ERR_SEC_NOT_AVAILABLE),
SecureSecretStoreError::Unavailable
));
assert!(matches!(
map_status_for_test(-1),
SecureSecretStoreError::Unavailable
));
}
#[test]
fn malformed_keychain_values_are_corrupted_without_diagnostic_disclosure() {
let api = RecordingKeychain::default();
let secret = vec![0x6d; 31];
api.state.lock().unwrap().entries.insert(
(
service_for_test().to_string(),
"vnidrop/v1/pairing-eligibility/corrupt".to_string(),
),
secret.clone(),
);
let store = AppleKeychainSecretStore::with_api(api);
let error = store
.get(&handle_for_test("vnidrop/v1/pairing-eligibility/corrupt"))
.unwrap_err();
assert!(matches!(&error, SecureSecretStoreError::Corrupted));
assert!(!format!("{error:?}").contains(&data_encoding::HEXLOWER.encode(&secret)));
}

View File

@@ -0,0 +1,169 @@
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use secret_service::Error;
use crate::{
persistence,
secure_secret::{
linux::{map_error, LinuxSecretServiceApi, LinuxSecretServiceStore},
SecretCustody, SecretHandle, SecretKind, SecretMaterial, SecureSecretStore,
SecureSecretStoreError,
},
VnidropError,
};
#[derive(Default)]
struct RecordingSecretService {
values: Mutex<HashMap<String, Vec<u8>>>,
failure: Mutex<Option<SecureSecretStoreError>>,
}
impl RecordingSecretService {
fn failure(&self) -> Result<(), SecureSecretStoreError> {
match &*self.failure.lock().unwrap() {
Some(SecureSecretStoreError::Locked) => Err(SecureSecretStoreError::Locked),
Some(SecureSecretStoreError::Missing) => Err(SecureSecretStoreError::Missing),
Some(SecureSecretStoreError::Corrupted) => Err(SecureSecretStoreError::Corrupted),
Some(SecureSecretStoreError::Unavailable) => Err(SecureSecretStoreError::Unavailable),
None => Ok(()),
}
}
}
impl LinuxSecretServiceApi for RecordingSecretService {
fn put(&self, handle: &str, material: &[u8]) -> Result<(), SecureSecretStoreError> {
self.failure()?;
self.values
.lock()
.unwrap()
.insert(handle.to_string(), material.to_vec());
Ok(())
}
fn get(&self, handle: &str) -> Result<Vec<u8>, SecureSecretStoreError> {
self.failure()?;
self.values
.lock()
.unwrap()
.get(handle)
.cloned()
.ok_or(SecureSecretStoreError::Missing)
}
fn delete(&self, handle: &str) -> Result<(), SecureSecretStoreError> {
self.failure()?;
self.values
.lock()
.unwrap()
.remove(handle)
.map(|_| ())
.ok_or(SecureSecretStoreError::Missing)
}
fn list_handles(&self) -> Result<Vec<String>, SecureSecretStoreError> {
self.failure()?;
Ok(self.values.lock().unwrap().keys().cloned().collect())
}
}
fn handle(suffix: &str) -> SecretHandle {
crate::secure_secret::secret_handle_for_test(format!("vnidrop/v1/relationship-grant/{suffix}"))
}
#[test]
fn adapter_survives_restart_and_deletes_only_the_selected_item() {
let api = Arc::new(RecordingSecretService::default());
let first = handle("first");
let second = handle("second");
let material = SecretMaterial::new(vec![0x5a; 32]).unwrap();
let store = LinuxSecretServiceStore::with_api(api.clone());
store.put(&first, material.clone()).unwrap();
store
.put(&second, SecretMaterial::new(vec![0x6b; 32]).unwrap())
.unwrap();
let restarted = LinuxSecretServiceStore::with_api(api);
assert_eq!(restarted.get(&first).unwrap(), material);
assert_eq!(
restarted.list_handles().unwrap(),
vec![first.clone(), second]
);
restarted.delete(&first).unwrap();
assert!(matches!(
restarted.get(&first),
Err(SecureSecretStoreError::Missing)
));
}
#[tokio::test]
async fn transient_backend_failures_do_not_delete_protected_metadata_or_material() {
let temp = tempfile::tempdir().unwrap();
let stores = persistence::open_all(temp.path()).await.unwrap();
let api = Arc::new(RecordingSecretService::default());
let store = Arc::new(LinuxSecretServiceStore::with_api(api.clone()));
let custody = SecretCustody::new(stores.secrets.clone(), store.clone());
let protected = custody
.protect(
SecretKind::RelationshipGrant,
SecretMaterial::new(vec![0x7c; 32]).unwrap(),
None,
)
.await
.unwrap();
*api.failure.lock().unwrap() = Some(SecureSecretStoreError::Unavailable);
drop(custody);
assert!(matches!(
SecretCustody::start(stores.secrets.clone(), store.clone()).await,
Err(VnidropError::SecureStorageUnavailable { .. })
));
*api.failure.lock().unwrap() = None;
let (restarted, _) = SecretCustody::start(stores.secrets.clone(), store)
.await
.unwrap();
assert_eq!(
restarted.load(&protected).await.unwrap(),
SecretMaterial::new(vec![0x7c; 32]).unwrap()
);
}
#[test]
fn failures_are_typed_and_secret_material_is_redacted() {
let api = Arc::new(RecordingSecretService::default());
let store = LinuxSecretServiceStore::with_api(api.clone());
let secret = SecretMaterial::new(vec![0x7c; 32]).unwrap();
assert_eq!(format!("{secret:?}"), "SecretMaterial(redacted)");
for failure in [
SecureSecretStoreError::Locked,
SecureSecretStoreError::Unavailable,
SecureSecretStoreError::Corrupted,
] {
*api.failure.lock().unwrap() = Some(failure);
assert!(store.get(&handle("failure")).is_err());
}
}
#[test]
fn secret_service_errors_map_without_exposing_details() {
assert!(matches!(
map_error(Error::Locked),
SecureSecretStoreError::Locked
));
assert!(matches!(
map_error(Error::NoResult),
SecureSecretStoreError::Missing
));
assert!(matches!(
map_error(Error::Crypto("distinctive-secret")),
SecureSecretStoreError::Corrupted
));
assert!(matches!(
map_error(Error::Unavailable),
SecureSecretStoreError::Unavailable
));
}

View File

@@ -0,0 +1,185 @@
use std::{fs, sync::Arc};
use data_encoding::HEXLOWER;
use iroh::SecretKey;
use crate::{
persistence,
secure_secret::{
windows::WindowsDpapiSecretStore, CustodyCrashPoint, SecretCustody, SecretMaterial,
SecureSecretStore, SecureSecretStoreError,
},
VnidropError,
};
fn handle() -> crate::secure_secret::SecretHandle {
WindowsDpapiSecretStore::relationship_handle_for_test()
}
fn material(seed: u8) -> SecretMaterial {
SecretMaterial::new(vec![seed; 32]).unwrap()
}
#[test]
fn round_trip_survives_adapter_restart_and_never_persists_plaintext() {
let directory = tempfile::tempdir().unwrap();
let handle = handle();
let secret = material(0xa7);
WindowsDpapiSecretStore::new(directory.path())
.unwrap()
.put(&handle, secret.clone())
.unwrap();
let restarted = WindowsDpapiSecretStore::new(directory.path()).unwrap();
assert_eq!(restarted.get(&handle).unwrap(), secret);
assert_eq!(restarted.list_handles().unwrap(), vec![handle]);
for entry in fs::read_dir(directory.path()).unwrap() {
let bytes = fs::read(entry.unwrap().path()).unwrap();
assert!(!bytes.windows(32).any(|window| window == [0xa7; 32]));
}
}
#[test]
fn delete_removes_only_the_selected_protected_value() {
let directory = tempfile::tempdir().unwrap();
let store = WindowsDpapiSecretStore::new(directory.path()).unwrap();
let retained = handle();
let removed = handle();
store.put(&retained, material(1)).unwrap();
store.put(&removed, material(2)).unwrap();
store.delete(&removed).unwrap();
assert!(matches!(
store.get(&removed),
Err(SecureSecretStoreError::Missing)
));
assert_eq!(store.get(&retained).unwrap(), material(1));
assert_eq!(store.list_handles().unwrap(), vec![retained]);
}
#[test]
fn repeated_put_is_idempotent_and_atomically_updates_changed_material() {
let directory = tempfile::tempdir().unwrap();
let store = WindowsDpapiSecretStore::new(directory.path()).unwrap();
let handle = handle();
store.put(&handle, material(6)).unwrap();
let first_blob = fs::read(store.path_for_test(&handle)).unwrap();
store.put(&handle, material(6)).unwrap();
assert_eq!(fs::read(store.path_for_test(&handle)).unwrap(), first_blob);
store.put(&handle, material(7)).unwrap();
assert_eq!(store.get(&handle).unwrap(), material(7));
assert!(!fs::read(store.path_for_test(&handle))
.unwrap()
.windows(32)
.any(|window| window == [7; 32]));
}
#[test]
fn missing_corrupt_and_wrong_context_values_fail_closed() {
let directory = tempfile::tempdir().unwrap();
let handle = handle();
let store = WindowsDpapiSecretStore::new(directory.path()).unwrap();
assert!(matches!(
store.get(&handle),
Err(SecureSecretStoreError::Missing)
));
store.put(&handle, material(3)).unwrap();
fs::write(store.path_for_test(&handle), b"not a protected envelope").unwrap();
assert!(matches!(
store.get(&handle),
Err(SecureSecretStoreError::Corrupted)
));
let isolated = tempfile::tempdir().unwrap();
let original =
WindowsDpapiSecretStore::with_context_for_test(isolated.path(), b"first-context").unwrap();
original.put(&handle, material(4)).unwrap();
let wrong_context =
WindowsDpapiSecretStore::with_context_for_test(isolated.path(), b"second-context").unwrap();
assert!(matches!(
wrong_context.get(&handle),
Err(SecureSecretStoreError::Corrupted)
));
}
#[test]
fn interrupted_replacement_preserves_the_previous_value() {
let directory = tempfile::tempdir().unwrap();
let handle = handle();
let store = WindowsDpapiSecretStore::new(directory.path()).unwrap();
store.put(&handle, material(5)).unwrap();
let temporary = directory.path().join("interrupted.tmp-123");
fs::write(&temporary, b"incomplete protected replacement").unwrap();
let restarted = WindowsDpapiSecretStore::new(directory.path()).unwrap();
assert!(!temporary.exists());
assert_eq!(restarted.get(&handle).unwrap(), material(5));
}
#[test]
fn unusable_backing_path_is_reported_as_unavailable() {
let directory = tempfile::tempdir().unwrap();
let file = directory.path().join("not-a-directory");
fs::write(&file, b"occupied").unwrap();
assert!(matches!(
WindowsDpapiSecretStore::new(&file),
Err(SecureSecretStoreError::Unavailable)
));
}
#[tokio::test]
async fn endpoint_migration_survives_activation_crash_without_changing_identity() {
let directory = tempfile::tempdir().unwrap();
let app_data = directory.path().join("app-data");
fs::create_dir(&app_data).unwrap();
let legacy = app_data.join("iroh.secret");
let original = SecretKey::generate();
fs::write(&legacy, HEXLOWER.encode(&original.to_bytes())).unwrap();
let stores = persistence::open_all(&app_data).await.unwrap();
let protected_directory = app_data.join("protected-secrets");
let store = Arc::new(WindowsDpapiSecretStore::new(&protected_directory).unwrap());
let custody = SecretCustody::new(stores.secrets.clone(), store);
custody.crash_once_at(CustodyCrashPoint::MetadataActivation);
assert!(custody
.migrate_legacy_endpoint_identity(&legacy)
.await
.is_err());
assert!(legacy.exists());
drop(custody);
let stores = persistence::open_all(&app_data).await.unwrap();
let restarted_store = Arc::new(WindowsDpapiSecretStore::new(&protected_directory).unwrap());
let (custody, _) = SecretCustody::start(stores.secrets.clone(), restarted_store)
.await
.unwrap();
let handle = custody
.migrate_legacy_endpoint_identity(&legacy)
.await
.unwrap();
assert!(!legacy.exists());
assert_eq!(
custody.load(&handle).await.unwrap(),
SecretMaterial::new(original.to_bytes().to_vec()).unwrap()
);
let replacement = SecretKey::generate();
fs::write(&legacy, HEXLOWER.encode(&replacement.to_bytes())).unwrap();
assert!(matches!(
custody.migrate_legacy_endpoint_identity(&legacy).await,
Err(VnidropError::SecureStorageCorrupted { .. })
));
assert!(legacy.exists());
assert_eq!(
custody.load(&handle).await.unwrap(),
SecretMaterial::new(original.to_bytes().to_vec()).unwrap()
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,8 @@ use crate::{
api::{CoreLimits, CoreRelayMode, TransferMetadata},
ticket::{
encode_persisted_sender_address, parse_persisted_sender_address, parse_transfer_ticket,
parse_transfer_ticket_with_limits, ticket_matches_relay_profile, VnidropTicket,
parse_transfer_ticket_with_limits, relay_profiles_compatible, ticket_matches_relay_profile,
VnidropTicket,
},
};
@@ -285,3 +286,60 @@ fn parser_rejects_or_parses_generated_inputs_without_panicking() {
let _ = parse_transfer_ticket(&input);
}
}
#[test]
fn relay_profiles_compatible_matches_network_profile_matrix() {
let relay: RelayUrl = "https://relay.example.com".parse().unwrap();
let other: RelayUrl = "https://other.example.com".parse().unwrap();
let relay_urls = std::slice::from_ref(&relay);
let other_urls = std::slice::from_ref(&other);
assert!(relay_profiles_compatible(
CoreRelayMode::Automatic,
&[],
CoreRelayMode::Automatic,
&[],
));
assert!(relay_profiles_compatible(
CoreRelayMode::LocalOnly,
&[],
CoreRelayMode::LocalOnly,
&[],
));
assert!(relay_profiles_compatible(
CoreRelayMode::LocalOnly,
&[],
CoreRelayMode::Automatic,
&[],
));
assert!(relay_profiles_compatible(
CoreRelayMode::StrictCustom,
relay_urls,
CoreRelayMode::StrictCustom,
relay_urls,
));
assert!(relay_profiles_compatible(
CoreRelayMode::CustomWithDirectFallback,
relay_urls,
CoreRelayMode::StrictCustom,
relay_urls,
));
assert!(!relay_profiles_compatible(
CoreRelayMode::Automatic,
&[],
CoreRelayMode::StrictCustom,
relay_urls,
));
assert!(!relay_profiles_compatible(
CoreRelayMode::StrictCustom,
relay_urls,
CoreRelayMode::StrictCustom,
other_urls,
));
assert!(!relay_profiles_compatible(
CoreRelayMode::LocalOnly,
&[],
CoreRelayMode::StrictCustom,
relay_urls,
));
}

View File

@@ -184,6 +184,76 @@ pub(crate) fn ticket_matches_relay_profile(
}
}
/// Whether a remote peer's advertised network profile can be used under the
/// local profile.
pub(crate) fn relay_profiles_compatible(
local_mode: CoreRelayMode,
local_urls: &[RelayUrl],
remote_mode: CoreRelayMode,
remote_urls: &[RelayUrl],
) -> bool {
match (local_mode, remote_mode) {
(CoreRelayMode::Automatic, CoreRelayMode::Automatic) => {
local_urls.is_empty() && remote_urls.is_empty()
}
(CoreRelayMode::LocalOnly, CoreRelayMode::LocalOnly)
| (CoreRelayMode::LocalOnly, CoreRelayMode::Automatic)
| (CoreRelayMode::Automatic, CoreRelayMode::LocalOnly) => {
// Local-only never enables relay fallback; Automatic peers are only
// compatible when neither side advertises custom relays.
local_urls.is_empty() && remote_urls.is_empty()
}
(
CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback,
CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback,
) => {
let local = local_urls.iter().collect::<BTreeSet<_>>();
let remote = remote_urls.iter().collect::<BTreeSet<_>>();
!local.is_empty() && local == remote
}
_ => false,
}
}
pub(crate) fn filter_peer_addr_for_relay_mode(
addr: &EndpointAddr,
relay_mode: CoreRelayMode,
custom_relay_urls: &[RelayUrl],
) -> Result<EndpointAddr> {
match relay_mode {
CoreRelayMode::Automatic => Ok(addr.clone()),
CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => {
let mut filtered = EndpointAddr::new(addr.id);
for ip_addr in addr.ip_addrs().copied() {
filtered = filtered.with_ip_addr(ip_addr);
}
for relay_url in addr
.relay_urls()
.filter(|relay_url| custom_relay_urls.contains(relay_url))
.cloned()
{
filtered = filtered.with_relay_url(relay_url);
}
if filtered.is_empty() {
anyhow::bail!(
"invitation has no direct address or relay allowed by strict custom relay mode"
);
}
Ok(filtered)
}
CoreRelayMode::LocalOnly => {
let mut filtered = EndpointAddr::new(addr.id);
for ip_addr in addr.ip_addrs().copied() {
filtered = filtered.with_ip_addr(ip_addr);
}
if filtered.is_empty() {
anyhow::bail!("invitation has no direct address allowed by local-only mode");
}
Ok(filtered)
}
}
}
fn normalize_ticket_input(value: &str) -> String {
// Tickets are commonly copied from text views or chat apps that insert line
// breaks. Strip whitespace only; other corrupt characters should still be

View File

@@ -0,0 +1,143 @@
mod support;
use support::TestNode;
use vnidrop::{
experimental_saved_device_capabilities, DeviceRelationship, DeviceRelationshipState,
ExperimentalSavedDeviceCapabilities, SavedDevice, ShareMetadataInput, ShareSource, SourceKind,
TargetedTransfer, TargetedTransferState, TransferAccessMode, VnidropError,
};
#[test]
fn saved_device_protocols_are_explicitly_experimental_and_versioned() {
assert_eq!(
experimental_saved_device_capabilities(),
ExperimentalSavedDeviceCapabilities {
domain_contract_version: 1,
relationship_protocol_version: 1,
targeted_transfer_protocol_version: 1,
}
);
}
#[test]
fn saved_devices_relationships_and_targeted_transfers_are_distinct_contracts() {
let device = SavedDevice {
endpoint_id: "receiver-endpoint".to_string(),
local_label: Some("Kitchen tablet".to_string()),
remote_display_name: Some("Tablet".to_string()),
created_at: 1_000,
last_authenticated_at: Some(2_000),
};
let relationship = DeviceRelationship {
remote_endpoint_id: device.endpoint_id.clone(),
state: DeviceRelationshipState::Saved,
generation: 4,
minimum_protocol_version: 1,
created_at: 1_000,
updated_at: 2_000,
};
let transfer = TargetedTransfer {
id: "targeted-transfer-id".to_string(),
sender_endpoint_id: "sender-endpoint".to_string(),
receiver_endpoint_id: device.endpoint_id.clone(),
manifest_id: "immutable-manifest-id".to_string(),
file_count: 2,
total_size: 42,
verified_bytes: 0,
state: TargetedTransferState::AwaitingApproval,
created_at: 3_000,
updated_at: 3_000,
};
assert_eq!(relationship.remote_endpoint_id, device.endpoint_id);
assert_eq!(relationship.state, DeviceRelationshipState::Saved);
assert_eq!(
transfer.receiver_endpoint_id,
relationship.remote_endpoint_id
);
assert_eq!(transfer.state, TargetedTransferState::AwaitingApproval);
}
#[test]
fn targeted_transfer_transitions_are_validated_by_the_domain() {
use TargetedTransferState as State;
let valid = [
(State::Preparing, State::Offering),
(State::Offering, State::AwaitingApproval),
(State::AwaitingApproval, State::Approved),
(State::AwaitingApproval, State::Declined),
(State::Approved, State::Connecting),
(State::Approved, State::Deleted),
(State::Connecting, State::Transferring),
(State::Connecting, State::Interrupted),
(State::Transferring, State::Completed),
(State::Transferring, State::Interrupted),
(State::Interrupted, State::Connecting),
(State::Completed, State::Deleted),
(State::Declined, State::Deleted),
(State::Cancelled, State::Deleted),
(State::Failed, State::Deleted),
];
for (current, next) in valid {
current
.validate_transition_to(next)
.unwrap_or_else(|error| panic!("{current:?} -> {next:?} failed: {error}"));
}
let error = State::Completed
.validate_transition_to(State::Transferring)
.unwrap_err();
assert!(matches!(error, VnidropError::InvalidTransition { .. }));
assert_eq!(
error.to_string(),
"invalid targeted transfer transition: completed -> transferring"
);
}
#[test]
fn experimental_domain_seam_does_not_change_multi_receiver_shares() {
let source_dir = tempfile::tempdir().unwrap();
let first_output = tempfile::tempdir().unwrap();
let second_output = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("shared.txt");
std::fs::write(&source_path, b"shared with both receivers").unwrap();
let sender = TestNode::new();
let first_receiver = TestNode::new();
let second_receiver = TestNode::new();
let share = sender
.core
.share_files(
vec![ShareSource {
kind: SourceKind::Path,
value: source_path.to_string_lossy().into_owned(),
display_name: Some("shared.txt".to_string()),
is_directory: false,
}],
ShareMetadataInput {
transfer_id: 90_001,
transfer_name: Some("Existing share".to_string()),
sender_name: Some("Sender".to_string()),
access_mode: TransferAccessMode::Public,
},
)
.unwrap();
for (receiver, output) in [
(&first_receiver, first_output.path()),
(&second_receiver, second_output.path()),
] {
receiver
.core
.receive(
share.ticket.clone(),
output.to_string_lossy().into_owned(),
Some("Receiver".to_string()),
)
.unwrap();
assert_eq!(
std::fs::read(output.join("shared.txt")).unwrap(),
b"shared with both receivers"
);
}
}

View File

@@ -98,9 +98,11 @@ fn transfers_file_between_two_cores() {
let completed = wait_for_sender_transfer_event(&sender, share.transfer_id, "completed");
assert!(completed.data_json.contains("\"connection_id\":"));
assert!(completed.data_json.contains("\"request_id\":"));
assert!(completed
// Production events redact endpoint ids; typed APIs remain the source of truth.
assert!(!completed
.data_json
.contains(receiver.core.status().endpoint_id.as_str()));
assert!(completed.data_json.contains("redacted"));
receiver.core.delete_receive_history().unwrap();
assert_eq!(receiver.core.list_received_artifacts().unwrap(), artifacts);

View File

@@ -1458,6 +1458,57 @@
"ru": "Не удалось обработать данные передачи. Попросите отправителя поделиться ими снова."
}
},
"experimental_saved_devices_description": {
"context": "Settings > Experimental: toggle description for saved devices and targeted transfers.",
"targets": [
"kmp"
],
"translations": {
"en": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.",
"fr": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.",
"es": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.",
"it": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.",
"de": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.",
"pt": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.",
"pl": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.",
"nl": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.",
"ru": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change."
}
},
"experimental_saved_devices_title": {
"context": "Settings > Experimental: toggle title for saved devices feature.",
"targets": [
"kmp"
],
"translations": {
"en": "Saved devices",
"fr": "Saved devices",
"es": "Saved devices",
"it": "Saved devices",
"de": "Saved devices",
"pt": "Saved devices",
"pl": "Saved devices",
"nl": "Saved devices",
"ru": "Saved devices"
}
},
"experimental_settings_title": {
"context": "Settings: experimental section title (Android).",
"targets": [
"kmp"
],
"translations": {
"en": "Experimental",
"fr": "Experimental",
"es": "Experimental",
"it": "Experimental",
"de": "Experimental",
"pt": "Experimental",
"pl": "Experimental",
"nl": "Experimental",
"ru": "Experimental"
}
},
"field_receiver_name": {
"context": "Text field label: the receiver's display name.",
"translations": {
@@ -4617,6 +4668,550 @@
"nl": "App-versie",
"ru": "Версия приложения"
}
},
"pairing_request_title": {
"context": "Pairing prompt: title asking whether to remember a device that offered to be reachable.",
"translations": {
"en": "Remember this device?",
"fr": "Mémoriser cet appareil ?",
"es": "¿Recordar este dispositivo?",
"it": "Ricordare questo dispositivo?",
"de": "Dieses Gerät speichern?",
"pt": "Lembrar este dispositivo?",
"pl": "Zapamiętać to urządzenie?",
"nl": "Dit apparaat onthouden?",
"ru": "Запомнить это устройство?"
}
},
"pairing_request_body": {
"context": "Pairing prompt: explains what remembering a device allows. {device} = peer display name.",
"args": [
{
"name": "device",
"type": "string"
}
],
"translations": {
"en": "{device} offered to let you send it files without a new invitation.",
"fr": "{device} propose de recevoir vos fichiers sans nouvelle invitation.",
"es": "{device} te ofrece enviarle archivos sin una nueva invitación.",
"it": "{device} ti consente di inviargli file senza un nuovo invito.",
"de": "{device} bietet an, Dateien ohne neue Einladung von Ihnen zu empfangen.",
"pt": "{device} ofereceu-se para receber ficheiros seus sem um novo convite.",
"pl": "{device} umożliwia wysyłanie plików bez nowego zaproszenia.",
"nl": "{device} biedt aan bestanden van je te ontvangen zonder nieuwe uitnodiging.",
"ru": "{device} разрешает отправлять файлы без нового приглашения."
}
},
"pairing_accept": {
"context": "Pairing prompt: button that remembers the device.",
"translations": {
"en": "Remember",
"fr": "Mémoriser",
"es": "Recordar",
"it": "Ricorda",
"de": "Speichern",
"pt": "Lembrar",
"pl": "Zapamiętaj",
"nl": "Onthouden",
"ru": "Запомнить"
}
},
"pairing_decline": {
"context": "Pairing prompt: button that declines to remember the device.",
"translations": {
"en": "Not now",
"fr": "Pas maintenant",
"es": "Ahora no",
"it": "Non ora",
"de": "Jetzt nicht",
"pt": "Agora não",
"pl": "Nie teraz",
"nl": "Niet nu",
"ru": "Не сейчас"
}
},
"pairing_allow_title": {
"context": "Post-transfer prompt: asks whether the other device may send files later without an invitation.",
"translations": {
"en": "Let this device send to you?",
"fr": "Autoriser cet appareil à vous envoyer des fichiers ?",
"es": "¿Permitir que este dispositivo te envíe archivos?",
"it": "Consentire a questo dispositivo di inviarti file?",
"de": "Diesem Gerät erlauben, Ihnen Dateien zu senden?",
"pt": "Permitir que este dispositivo lhe envie ficheiros?",
"pl": "Zezwolić temu urządzeniu na wysyłanie plików?",
"nl": "Dit apparaat toestaan je bestanden te sturen?",
"ru": "Разрешить этому устройству отправлять вам файлы?"
}
},
"pairing_allow_body": {
"context": "Post-transfer prompt: explains that the permission is revocable at any time.",
"translations": {
"en": "You will still confirm every transfer, and you can withdraw this at any time.",
"fr": "Vous confirmerez toujours chaque transfert et pourrez révoquer cette autorisation à tout moment.",
"es": "Seguirás confirmando cada transferencia y podrás retirar este permiso cuando quieras.",
"it": "Confermerai comunque ogni trasferimento e potrai revocare questa autorizzazione in qualsiasi momento.",
"de": "Sie bestätigen weiterhin jede Übertragung und können dies jederzeit widerrufen.",
"pt": "Continuará a confirmar cada transferência e pode retirar esta permissão a qualquer momento.",
"pl": "Nadal będziesz potwierdzać każde przesłanie i możesz w każdej chwili cofnąć zgodę.",
"nl": "Je bevestigt nog steeds elke overdracht en kunt dit altijd intrekken.",
"ru": "Вы по-прежнему будете подтверждать каждую передачу и сможете отозвать разрешение в любой момент."
}
},
"pairing_allow_confirm": {
"context": "Post-transfer prompt: button granting the other device permission to send later.",
"translations": {
"en": "Allow",
"fr": "Autoriser",
"es": "Permitir",
"it": "Consenti",
"de": "Erlauben",
"pt": "Permitir",
"pl": "Zezwól",
"nl": "Toestaan",
"ru": "Разрешить"
}
},
"offer_title": {
"context": "Offer prompt: title when a remembered device wants to send files.",
"translations": {
"en": "Incoming transfer",
"fr": "Transfert entrant",
"es": "Transferencia entrante",
"it": "Trasferimento in arrivo",
"de": "Eingehende Übertragung",
"pt": "Transferência recebida",
"pl": "Przychodzące przesłanie",
"nl": "Inkomende overdracht",
"ru": "Входящая передача"
}
},
"offer_body": {
"context": "Offer prompt: names the sender and what they want to send. {device} = sender, {transferName} = transfer title.",
"args": [
{
"name": "device",
"type": "string"
},
{
"name": "transferName",
"type": "string"
}
],
"translations": {
"en": "{device} wants to send you “{transferName}”.",
"fr": "{device} souhaite vous envoyer « {transferName} ».",
"es": "{device} quiere enviarte «{transferName}».",
"it": "{device} vuole inviarti «{transferName}».",
"de": "{device} möchte Ihnen „{transferName}“ senden.",
"pt": "{device} quer enviar-lhe “{transferName}”.",
"pl": "{device} chce wysłać Ci „{transferName}”.",
"nl": "{device} wil je “{transferName}” sturen.",
"ru": "{device} хочет отправить вам «{transferName}»."
}
},
"offer_accept": {
"context": "Offer prompt: button that accepts the transfer and starts receiving.",
"translations": {
"en": "Receive",
"fr": "Recevoir",
"es": "Recibir",
"it": "Ricevi",
"de": "Empfangen",
"pt": "Receber",
"pl": "Odbierz",
"nl": "Ontvangen",
"ru": "Получить"
}
},
"offer_decline": {
"context": "Offer prompt: button that declines the incoming transfer.",
"translations": {
"en": "Decline",
"fr": "Refuser",
"es": "Rechazar",
"it": "Rifiuta",
"de": "Ablehnen",
"pt": "Recusar",
"pl": "Odrzuć",
"nl": "Weigeren",
"ru": "Отклонить"
}
},
"saved_devices_empty": {
"context": "Experimental saved devices: empty list when none are saved yet.",
"translations": {
"en": "No saved devices yet. Finish a transfer, then remember a device.",
"fr": "No saved devices yet. Finish a transfer, then remember a device.",
"es": "No saved devices yet. Finish a transfer, then remember a device.",
"it": "No saved devices yet. Finish a transfer, then remember a device.",
"de": "No saved devices yet. Finish a transfer, then remember a device.",
"pt": "No saved devices yet. Finish a transfer, then remember a device.",
"pl": "No saved devices yet. Finish a transfer, then remember a device.",
"nl": "No saved devices yet. Finish a transfer, then remember a device.",
"ru": "No saved devices yet. Finish a transfer, then remember a device."
},
"targets": [
"kmp"
]
},
"saved_devices_eligibility_title": {
"context": "Experimental saved devices: section for peers eligible to pair after a transfer.",
"translations": {
"en": "Ready to remember",
"fr": "Ready to remember",
"es": "Ready to remember",
"it": "Ready to remember",
"de": "Ready to remember",
"pt": "Ready to remember",
"pl": "Ready to remember",
"nl": "Ready to remember",
"ru": "Ready to remember"
},
"targets": [
"kmp"
]
},
"saved_devices_pending_title": {
"context": "Experimental saved devices: section for in-progress pairing.",
"translations": {
"en": "Pending pairing",
"fr": "Pending pairing",
"es": "Pending pairing",
"it": "Pending pairing",
"de": "Pending pairing",
"pt": "Pending pairing",
"pl": "Pending pairing",
"nl": "Pending pairing",
"ru": "Pending pairing"
},
"targets": [
"kmp"
]
},
"saved_devices_list_title": {
"context": "Experimental saved devices: section listing saved devices.",
"translations": {
"en": "Saved devices",
"fr": "Saved devices",
"es": "Saved devices",
"it": "Saved devices",
"de": "Saved devices",
"pt": "Saved devices",
"pl": "Saved devices",
"nl": "Saved devices",
"ru": "Saved devices"
},
"targets": [
"kmp"
]
},
"saved_devices_unnamed": {
"context": "Fallback display when a saved device has no local label or remote name.",
"translations": {
"en": "Saved device",
"fr": "Saved device",
"es": "Saved device",
"it": "Saved device",
"de": "Saved device",
"pt": "Saved device",
"pl": "Saved device",
"nl": "Saved device",
"ru": "Saved device"
},
"targets": [
"kmp"
]
},
"saved_devices_remember_action": {
"context": "Action that starts mutual-consent pairing for an eligible peer.",
"translations": {
"en": "Remember",
"fr": "Remember",
"es": "Remember",
"it": "Remember",
"de": "Remember",
"pt": "Remember",
"pl": "Remember",
"nl": "Remember",
"ru": "Remember"
},
"targets": [
"kmp"
]
},
"saved_devices_decline_action": {
"context": "Action that declines pairing eligibility for a peer.",
"translations": {
"en": "Decline",
"fr": "Decline",
"es": "Decline",
"it": "Decline",
"de": "Decline",
"pt": "Decline",
"pl": "Decline",
"nl": "Decline",
"ru": "Decline"
},
"targets": [
"kmp"
]
},
"saved_devices_accept_pairing_action": {
"context": "Action that accepts an incoming pairing request.",
"translations": {
"en": "Accept",
"fr": "Accept",
"es": "Accept",
"it": "Accept",
"de": "Accept",
"pt": "Accept",
"pl": "Accept",
"nl": "Accept",
"ru": "Accept"
},
"targets": [
"kmp"
]
},
"saved_devices_send_action": {
"context": "Action that starts a targeted send to a saved device.",
"translations": {
"en": "Send files",
"fr": "Send files",
"es": "Send files",
"it": "Send files",
"de": "Send files",
"pt": "Send files",
"pl": "Send files",
"nl": "Send files",
"ru": "Send files"
},
"targets": [
"kmp"
]
},
"saved_devices_label_action": {
"context": "Action that edits the local label for a saved device.",
"translations": {
"en": "Label",
"fr": "Label",
"es": "Label",
"it": "Label",
"de": "Label",
"pt": "Label",
"pl": "Label",
"nl": "Label",
"ru": "Label"
},
"targets": [
"kmp"
]
},
"saved_devices_forget_action": {
"context": "Action that forgets a saved device relationship.",
"translations": {
"en": "Forget",
"fr": "Forget",
"es": "Forget",
"it": "Forget",
"de": "Forget",
"pt": "Forget",
"pl": "Forget",
"nl": "Forget",
"ru": "Forget"
},
"targets": [
"kmp"
]
},
"saved_devices_block_action": {
"context": "Action that blocks a device identity.",
"translations": {
"en": "Block",
"fr": "Block",
"es": "Block",
"it": "Block",
"de": "Block",
"pt": "Block",
"pl": "Block",
"nl": "Block",
"ru": "Block"
},
"targets": [
"kmp"
]
},
"saved_devices_label_title": {
"context": "Dialog title for editing a saved device local label.",
"translations": {
"en": "Device label",
"fr": "Device label",
"es": "Device label",
"it": "Device label",
"de": "Device label",
"pt": "Device label",
"pl": "Device label",
"nl": "Device label",
"ru": "Device label"
},
"targets": [
"kmp"
]
},
"saved_devices_label_placeholder": {
"context": "Placeholder for the local label text field.",
"translations": {
"en": "Label",
"fr": "Label",
"es": "Label",
"it": "Label",
"de": "Label",
"pt": "Label",
"pl": "Label",
"nl": "Label",
"ru": "Label"
},
"targets": [
"kmp"
]
},
"saved_devices_label_save": {
"context": "Button that saves the local device label.",
"translations": {
"en": "Save",
"fr": "Save",
"es": "Save",
"it": "Save",
"de": "Save",
"pt": "Save",
"pl": "Save",
"nl": "Save",
"ru": "Save"
},
"targets": [
"kmp"
]
},
"saved_devices_label_clear": {
"context": "Button that clears the local device label.",
"translations": {
"en": "Clear label",
"fr": "Clear label",
"es": "Clear label",
"it": "Clear label",
"de": "Clear label",
"pt": "Clear label",
"pl": "Clear label",
"nl": "Clear label",
"ru": "Clear label"
},
"targets": [
"kmp"
]
},
"saved_devices_pending_outgoing": {
"context": "Status text while waiting for the peer to accept pairing.",
"translations": {
"en": "Waiting for the other device",
"fr": "Waiting for the other device",
"es": "Waiting for the other device",
"it": "Waiting for the other device",
"de": "Waiting for the other device",
"pt": "Waiting for the other device",
"pl": "Waiting for the other device",
"nl": "Waiting for the other device",
"ru": "Waiting for the other device"
},
"targets": [
"kmp"
]
},
"saved_devices_pending_incoming": {
"context": "Status text when this device must accept a pairing request.",
"translations": {
"en": "Wants to remember this device",
"fr": "Wants to remember this device",
"es": "Wants to remember this device",
"it": "Wants to remember this device",
"de": "Wants to remember this device",
"pt": "Wants to remember this device",
"pl": "Wants to remember this device",
"nl": "Wants to remember this device",
"ru": "Wants to remember this device"
},
"targets": [
"kmp"
]
},
"saved_devices_send_started": {
"context": "Snackbar after creating a targeted transfer offer.",
"translations": {
"en": "Transfer offer sent",
"fr": "Transfer offer sent",
"es": "Transfer offer sent",
"it": "Transfer offer sent",
"de": "Transfer offer sent",
"pt": "Transfer offer sent",
"pl": "Transfer offer sent",
"nl": "Transfer offer sent",
"ru": "Transfer offer sent"
},
"targets": [
"kmp"
]
},
"saved_devices_forgotten": {
"context": "Snackbar after forgetting a saved device.",
"translations": {
"en": "Saved device forgotten",
"fr": "Saved device forgotten",
"es": "Saved device forgotten",
"it": "Saved device forgotten",
"de": "Saved device forgotten",
"pt": "Saved device forgotten",
"pl": "Saved device forgotten",
"nl": "Saved device forgotten",
"ru": "Saved device forgotten"
},
"targets": [
"kmp"
]
},
"saved_devices_blocked": {
"context": "Snackbar after blocking a device.",
"translations": {
"en": "Device blocked",
"fr": "Device blocked",
"es": "Device blocked",
"it": "Device blocked",
"de": "Device blocked",
"pt": "Device blocked",
"pl": "Device blocked",
"nl": "Device blocked",
"ru": "Device blocked"
},
"targets": [
"kmp"
]
},
"saved_devices_labeled": {
"context": "Snackbar after updating a saved device label.",
"translations": {
"en": "Label updated",
"fr": "Label updated",
"es": "Label updated",
"it": "Label updated",
"de": "Label updated",
"pt": "Label updated",
"pl": "Label updated",
"nl": "Label updated",
"ru": "Label updated"
},
"targets": [
"kmp"
]
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Some files were not shown because too many files have changed in this diff Show More