mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-12 05:29:57 +02:00
Compare commits
5 Commits
feat/devic
...
v0.2.6
| Author | SHA1 | Date | |
|---|---|---|---|
| a484bdbbf7 | |||
| 318102f32f | |||
| 785114038f | |||
|
|
387298d137 | ||
| 3118afdba0 |
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(swift test *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -20,14 +20,10 @@ 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
|
||||
.scratch/
|
||||
|
||||
@@ -139,7 +139,7 @@ crates/vnidrop/src/runtime/
|
||||
provider.rs # provider events, per-connection send progress
|
||||
```
|
||||
|
||||
Other core modules: `filesystem.rs`, `invitation/`, `approval.rs`,
|
||||
Other core modules: `filesystem.rs`, `repository.rs`, `approval.rs`,
|
||||
`handshake.rs`, `ticket.rs`, `access_policy.rs`, `event_hub.rs`, `api.rs`.
|
||||
|
||||
### Shared app
|
||||
|
||||
39
CONTEXT.md
39
CONTEXT.md
@@ -1,39 +0,0 @@
|
||||
# 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`; today’s 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 profile’s 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
283
Cargo.lock
generated
@@ -219,18 +219,6 @@ 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"
|
||||
@@ -256,17 +244,6 @@ 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"
|
||||
@@ -444,15 +421,6 @@ 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"
|
||||
@@ -515,15 +483,6 @@ 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"
|
||||
@@ -905,7 +864,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
|
||||
dependencies = [
|
||||
"data-encoding",
|
||||
"syn 2.0.118",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1090,12 +1049,6 @@ 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"
|
||||
@@ -1107,27 +1060,6 @@ 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"
|
||||
@@ -1998,7 +1930,6 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"block-padding",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
@@ -2580,15 +2511,6 @@ 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"
|
||||
@@ -2889,20 +2811,6 @@ 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"
|
||||
@@ -2929,15 +2837,6 @@ 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"
|
||||
@@ -2964,17 +2863,6 @@ 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"
|
||||
@@ -3144,16 +3032,6 @@ 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"
|
||||
@@ -3988,25 +3866,6 @@ 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"
|
||||
@@ -4115,17 +3974,6 @@ 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"
|
||||
@@ -4637,17 +4485,6 @@ 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"
|
||||
@@ -4713,7 +4550,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -4847,7 +4684,6 @@ dependencies = [
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -5141,17 +4977,6 @@ 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"
|
||||
@@ -5402,19 +5227,14 @@ 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",
|
||||
@@ -5427,7 +5247,6 @@ dependencies = [
|
||||
"uniffi",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5623,7 +5442,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6097,62 +5916,6 @@ 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"
|
||||
@@ -6252,43 +6015,3 @@ 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",
|
||||
]
|
||||
|
||||
@@ -1,551 +0,0 @@
|
||||
# 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.
|
||||
@@ -7,7 +7,6 @@ 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
|
||||
@@ -23,7 +22,6 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
initializeAndroidCoreRuntime(applicationContext)
|
||||
setContent {
|
||||
App(rememberAndroidAppDependencies(this, externalInvitations))
|
||||
}
|
||||
|
||||
43
apple/Package.swift
Normal file
43
apple/Package.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
// 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"
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -18,8 +18,9 @@ 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 bundle (VniDropTests target)
|
||||
project.yml # XcodeGen spec for the iOS/macOS app and test targets
|
||||
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
|
||||
```
|
||||
|
||||
## Build & run
|
||||
@@ -71,22 +72,19 @@ 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.
|
||||
|
||||
## Typecheck & tests
|
||||
## Command-line typecheck & tests
|
||||
|
||||
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`:
|
||||
`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:
|
||||
|
||||
```bash
|
||||
make check-apple # iOS simulator unit tests
|
||||
make build-apple-macos # unsigned macOS build (typecheck)
|
||||
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)"
|
||||
```
|
||||
|
||||
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`):
|
||||
@@ -108,7 +106,8 @@ Rust crate itself is never changed.
|
||||
## System frameworks
|
||||
|
||||
The Rust core (iroh network stack) links `SystemConfiguration`, `Security`, and
|
||||
`libresolv`. These are declared in `project.yml` for the app target.
|
||||
`libresolv`. These are declared in both `Package.swift` (for CLI build/test) and
|
||||
`project.yml` (for the app target).
|
||||
|
||||
## Parity & scope
|
||||
|
||||
|
||||
@@ -92,16 +92,8 @@ final class FakeFileSystemService: FileSystemService {
|
||||
func defaultReceiveFolder() -> ReceiveFolder { folder }
|
||||
func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus { .writable }
|
||||
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
|
||||
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
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ final class ProgressDerivationTests: XCTestCase {
|
||||
|
||||
private func event(phase: String, kind: String, json: String) -> CoreEventModel {
|
||||
CoreEventModel(
|
||||
id: UUID().uuidString, revision: 1, timestamp: 0, scope: "transfer", transferId: 1,
|
||||
id: UUID().uuidString, timestamp: 0, scope: "transfer", transferId: 1,
|
||||
direction: "send", phase: phase, kind: kind, dataJson: json
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
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"))
|
||||
}
|
||||
}
|
||||
@@ -167,8 +167,7 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,10 +283,3 @@ 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.
|
||||
|
||||
@@ -47,3 +47,4 @@ 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>
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ struct CoreStatus: Equatable, Sendable {
|
||||
|
||||
struct CoreEventModel: Equatable, Identifiable, Sendable {
|
||||
let id: String
|
||||
let revision: UInt64
|
||||
let timestamp: Int64
|
||||
let scope: String
|
||||
let transferId: UInt64?
|
||||
@@ -73,11 +72,6 @@ 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
|
||||
|
||||
@@ -53,10 +53,9 @@ struct NativeCoreBindingFactory: CoreBindingFactory {
|
||||
case .localOnly:
|
||||
nativeConfiguration = CoreNetworkConfig(mode: .localOnly, relayUrls: [])
|
||||
}
|
||||
return try VnidropCore.initializeWithExperimentalSavedDevices(
|
||||
return try VnidropCore.initializeWithNetworkConfig(
|
||||
appDataDir: appDataDir,
|
||||
eventSink: eventSink,
|
||||
limits: defaultCoreLimits(),
|
||||
networkConfig: nativeConfiguration
|
||||
)
|
||||
}
|
||||
@@ -293,6 +292,7 @@ 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, revision: revision, timestamp: timestamp, scope: scope, transferId: transferId,
|
||||
id: id, timestamp: timestamp, scope: scope, transferId: transferId,
|
||||
direction: direction, phase: phase, kind: kind, dataJson: dataJson
|
||||
)
|
||||
}
|
||||
@@ -519,8 +519,3 @@ private extension ReceiverRequest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -33,15 +33,12 @@ 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,
|
||||
destination: ShareDestination
|
||||
accessPolicy: ShareAccessPolicy
|
||||
) async -> Result<Share, Error>
|
||||
}
|
||||
|
||||
|
||||
@@ -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, .transfersChanged:
|
||||
case .approvalChanged:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,6 @@ 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 {
|
||||
@@ -157,40 +154,6 @@ 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 }
|
||||
|
||||
@@ -351,9 +351,9 @@ final class SendModel: ObservableObject {
|
||||
files: current.selectedFiles,
|
||||
transferName: current.transferName.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
senderName: current.senderName.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
destination: .invitation(accessPolicy: current.accessPolicy)
|
||||
accessPolicy: current.accessPolicy
|
||||
)
|
||||
switch result.map(\.share) {
|
||||
switch result {
|
||||
case .success(let share):
|
||||
await fileSystemService.discardPickedFiles(current.selectedFiles)
|
||||
if let thumb = current.selectedFiles.compactMap(\.thumbnailData).first {
|
||||
|
||||
@@ -174,7 +174,6 @@ final class SettingsModel: ObservableObject {
|
||||
loadDeviceInfo()
|
||||
}
|
||||
|
||||
|
||||
func selectSection(_ section: SettingsSection) {
|
||||
state.selectedSection = section
|
||||
if section == .about || section == .bugReport {
|
||||
|
||||
@@ -80,11 +80,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -59,15 +59,12 @@ struct IosFileSystemService: FileSystemService {
|
||||
files: [PickedShareFile],
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
destination: ShareDestination
|
||||
accessPolicy: ShareAccessPolicy
|
||||
) 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
|
||||
)
|
||||
|
||||
@@ -39,7 +39,7 @@ struct MacFileSystemService: FileSystemService {
|
||||
files: [PickedShareFile],
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
destination: ShareDestination
|
||||
accessPolicy: ShareAccessPolicy
|
||||
) async -> Result<Share, Error> {
|
||||
guard !files.isEmpty else {
|
||||
return .failure(InvitationError.shareEmpty)
|
||||
@@ -63,9 +63,6 @@ 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
|
||||
)
|
||||
|
||||
@@ -70,9 +70,6 @@ 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)
|
||||
@@ -132,5 +129,4 @@ extension View {
|
||||
func sendPickers(model: SendModel) -> some View {
|
||||
modifier(SendPickers(model: model))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,12 +3,6 @@ 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 {
|
||||
@@ -38,12 +32,6 @@ 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):
|
||||
@@ -69,19 +57,6 @@ 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 {
|
||||
@@ -89,9 +64,7 @@ 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), .InvalidTransition(let r), .SecureStorageLocked(let r),
|
||||
.SecureStorageMissing(let r), .SecureStorageCorrupted(let r),
|
||||
.SecureStorageUnavailable(let r), .Internal(let r):
|
||||
.InvalidInput(let r), .Internal(let r):
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ CONFIG="Release-Direct"
|
||||
APP_NAME="VniDrop"
|
||||
VERSION_RESOLVER="$REPO_ROOT/packaging/version/resolve-version.sh"
|
||||
VERSION_CONFIG_GENERATOR="$REPO_ROOT/packaging/version/generate-apple-xcconfig.sh"
|
||||
APP_CONFIG_GENERATOR="$SCRIPT_DIR/generate-appconfig.sh"
|
||||
|
||||
VERSION="$("$VERSION_RESOLVER" product)"
|
||||
export VNIDROP_BUILD_TIME_UTC="${VNIDROP_BUILD_TIME_UTC:-$(date -u +%Y%m%d%H%M%S)}"
|
||||
@@ -61,6 +62,8 @@ echo "==> Building Rust core (release)"
|
||||
CARGO_PROFILE_RELEASE_LTO=false "$SCRIPT_DIR/build-core.sh" release
|
||||
echo "==> Regenerating Xcode project"
|
||||
"$VERSION_CONFIG_GENERATOR" all
|
||||
# AppConfig.swift is gitignored codegen — a clean CI checkout has none.
|
||||
"$APP_CONFIG_GENERATOR"
|
||||
( cd "$APPLE_DIR" && xcodegen generate >/dev/null )
|
||||
|
||||
rm -rf "$BUILD_DIR" && mkdir -p "$BUILD_DIR" "$DIST_DIR"
|
||||
|
||||
@@ -54,16 +54,8 @@ 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
|
||||
@@ -71,8 +63,7 @@ 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. Do not add new `SqlitePool` call sites —
|
||||
open domain stores via `persistence::open_all`.
|
||||
file approaches ~800 LoC of non-test code.
|
||||
|
||||
---
|
||||
|
||||
@@ -87,14 +78,11 @@ open domain stores via `persistence::open_all`.
|
||||
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. **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
|
||||
5. **No lock across await:** Clippy `await_holding_lock` fails CI.
|
||||
6. **ReceiveOutputSink:** after successful `start_file`, exactly one of
|
||||
`finish_file` or `abort_file` (see `OutputSinkFile` Drop).
|
||||
8. **No-overwrite publish** for path receives (temp + hard link / exclusive rename).
|
||||
9. Integration tests must use the **public** API + `tests/support/` only.
|
||||
7. **No-overwrite publish** for path receives (temp + hard link / exclusive rename).
|
||||
8. Integration tests must use the **public** API + `tests/support/` only.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -41,23 +41,11 @@ 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`,
|
||||
plus experimental `pairing` and `targeted_transfer` (see catalog below).
|
||||
`access`, `transfer`, `download`, `export`, `delivery`, `lifecycle`, `error`.
|
||||
- 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.
|
||||
|
||||
@@ -16,7 +16,6 @@ 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"
|
||||
@@ -36,20 +35,6 @@ 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"
|
||||
|
||||
@@ -29,6 +29,13 @@ 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
|
||||
|
||||
@@ -10,124 +10,6 @@ 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,
|
||||
@@ -298,22 +180,8 @@ 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 {
|
||||
@@ -330,17 +198,8 @@ 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -356,15 +215,8 @@ 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 {
|
||||
@@ -373,11 +225,8 @@ 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"))?;
|
||||
@@ -414,8 +263,6 @@ 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>,
|
||||
|
||||
@@ -6,15 +6,13 @@ use tokio::sync::{oneshot, Mutex};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
access_policy::{AccessDecision, AccessPolicy, APPROVAL_SESSION_TTL_MS},
|
||||
blocked_devices::BlockStore,
|
||||
access_policy::{AccessPolicy, APPROVAL_SESSION_TTL_MS},
|
||||
event_hub::EventHub,
|
||||
handshake::{
|
||||
DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse,
|
||||
RequestTransfer,
|
||||
},
|
||||
invitation::{ReceiverRequestInsert, Repository},
|
||||
pairing_eligibility::PairingEligibilityService,
|
||||
repository::{ReceiverRequestInsert, Repository},
|
||||
transfer_state::ReceiverRequestStatus,
|
||||
util::now_ms,
|
||||
};
|
||||
@@ -32,13 +30,11 @@ 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 {
|
||||
@@ -59,18 +55,6 @@ 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",
|
||||
@@ -131,22 +115,18 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,17 +162,6 @@ 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(),
|
||||
@@ -229,15 +198,10 @@ 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
|
||||
.decide(request.transfer_id, Some(&remote_endpoint_id))
|
||||
.allows_without_approval(request.transfer_id)
|
||||
.await
|
||||
== AccessDecision::Allow
|
||||
{
|
||||
self.allow_without_sender_decision(remote_endpoint_id, request)
|
||||
.await
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
//! 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())
|
||||
}
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
//! 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
//! 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
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
//! 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;
|
||||
@@ -1,226 +0,0 @@
|
||||
//! 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
@@ -1,574 +0,0 @@
|
||||
//! 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"),
|
||||
})
|
||||
}
|
||||
@@ -16,14 +16,6 @@ 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}")]
|
||||
@@ -34,28 +26,20 @@ 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::from_error(error.into(), |reason| Self::Initialization { reason })
|
||||
Self::Initialization {
|
||||
reason: error.into().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ticket(error: impl Into<anyhow::Error>) -> Self {
|
||||
Self::Ticket {
|
||||
reason: crate::control_plane::redact_text(&error.into().to_string()),
|
||||
reason: error.into().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,24 +52,6 @@ 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 })
|
||||
@@ -122,20 +88,11 @@ 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",
|
||||
}
|
||||
}
|
||||
@@ -149,26 +106,17 @@ 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 = crate::control_plane::redact_text(&error.to_string());
|
||||
let reason = error.to_string();
|
||||
if let Some(existing) = error.chain().find_map(|cause| cause.downcast_ref::<Self>()) {
|
||||
return existing.with_reason(reason);
|
||||
}
|
||||
@@ -193,7 +141,7 @@ impl VnidropError {
|
||||
}
|
||||
|
||||
fn from_error(error: anyhow::Error, fallback: impl FnOnce(String) -> Self) -> Self {
|
||||
let reason = crate::control_plane::redact_text(&error.to_string());
|
||||
let reason = error.to_string();
|
||||
if let Some(existing) = error.chain().find_map(|cause| cause.downcast_ref::<Self>()) {
|
||||
existing.with_reason(reason)
|
||||
} else {
|
||||
@@ -210,20 +158,11 @@ 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 },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@ use tokio::{
|
||||
|
||||
use crate::{
|
||||
api::{CoreEvent, CoreEventSink},
|
||||
control_plane::redact_json,
|
||||
invitation::Repository,
|
||||
repository::Repository,
|
||||
transfer_state::TransferDirection,
|
||||
util::now_ms,
|
||||
};
|
||||
@@ -48,10 +47,6 @@ 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 {
|
||||
@@ -73,8 +68,6 @@ impl EventPhase {
|
||||
"approval" => Some(Self::Approval),
|
||||
"transfer" => Some(Self::Transfer),
|
||||
"delivery" => Some(Self::Delivery),
|
||||
"pairing" => Some(Self::Pairing),
|
||||
"targeted_transfer" => Some(Self::TargetedTransfer),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -97,8 +90,6 @@ impl EventPhase {
|
||||
Self::Approval => "approval",
|
||||
Self::Transfer => "transfer",
|
||||
Self::Delivery => "delivery",
|
||||
Self::Pairing => "pairing",
|
||||
Self::TargetedTransfer => "targeted_transfer",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,26 +230,22 @@ impl EventHub {
|
||||
.sequence
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let revision = *sequence;
|
||||
let id = format!("{timestamp}-{revision}");
|
||||
*sequence = sequence.saturating_add(1);
|
||||
let id = format!("{timestamp}-{}", *sequence);
|
||||
*sequence += 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: redact_json(data).to_string(),
|
||||
data_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");
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
//! 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
|
||||
}
|
||||
@@ -1,40 +1,24 @@
|
||||
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 pairing_eligibility;
|
||||
mod persistence;
|
||||
mod repository;
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
};
|
||||
pub use error::VnidropError;
|
||||
pub use runtime::VnidropCore;
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
//! 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())
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
//! 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"),
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
//! 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,
|
||||
})
|
||||
}
|
||||
@@ -1,11 +1,4 @@
|
||||
//! 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;
|
||||
use std::{path::Path, str::FromStr};
|
||||
|
||||
#[cfg(test)]
|
||||
use std::sync::{
|
||||
@@ -14,7 +7,10 @@ use std::sync::{
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use sqlx::{
|
||||
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
||||
Row, SqlitePool,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
@@ -24,11 +20,8 @@ use crate::{
|
||||
util::now_ms,
|
||||
};
|
||||
|
||||
const SCHEMA_VERSION: i64 = 13;
|
||||
const SCHEMA_VERSION: i64 = 7;
|
||||
|
||||
/// 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,
|
||||
@@ -106,26 +99,29 @@ pub(crate) struct PendingDeliveryReceiptInsert<'a> {
|
||||
}
|
||||
|
||||
impl Repository {
|
||||
pub(crate) fn from_pool(pool: SqlitePool) -> Self {
|
||||
Self {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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.
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS transfers (
|
||||
@@ -225,7 +221,6 @@ 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,
|
||||
@@ -239,20 +234,6 @@ 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);",
|
||||
)
|
||||
@@ -805,13 +786,12 @@ impl Repository {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT OR REPLACE INTO transfer_events (
|
||||
id, revision, timestamp, scope, transfer_id, direction, phase, kind, data_json
|
||||
id, timestamp, scope, transfer_id, direction, phase, kind, data_json
|
||||
)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9);
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);
|
||||
"#,
|
||||
)
|
||||
.bind(&event.id)
|
||||
.bind(to_db_id(event.revision)?)
|
||||
.bind(event.timestamp)
|
||||
.bind(&event.scope)
|
||||
.bind(event.transfer_id.map(to_db_id).transpose()?)
|
||||
@@ -1155,10 +1135,10 @@ impl Repository {
|
||||
let rows = if let Some(transfer_id) = transfer_id {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT id, revision, timestamp, scope, transfer_id, direction, phase, kind, data_json
|
||||
SELECT id, timestamp, scope, transfer_id, direction, phase, kind, data_json
|
||||
FROM transfer_events
|
||||
WHERE transfer_id = ?1
|
||||
ORDER BY timestamp ASC, revision ASC
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT ?2
|
||||
"#,
|
||||
)
|
||||
@@ -1169,9 +1149,9 @@ impl Repository {
|
||||
} else {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT id, revision, timestamp, scope, transfer_id, direction, phase, kind, data_json
|
||||
SELECT id, timestamp, scope, transfer_id, direction, phase, kind, data_json
|
||||
FROM transfer_events
|
||||
ORDER BY timestamp DESC, revision DESC
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?1
|
||||
"#,
|
||||
)
|
||||
@@ -1238,7 +1218,6 @@ 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
|
||||
@@ -7,7 +7,7 @@ use crate::{
|
||||
handshake::{
|
||||
DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeService,
|
||||
},
|
||||
invitation::PendingDeliveryReceipt,
|
||||
repository::PendingDeliveryReceipt,
|
||||
ticket::parse_persisted_sender_address,
|
||||
};
|
||||
|
||||
|
||||
@@ -3,24 +3,20 @@ use std::{future::Future, path::PathBuf, sync::Arc};
|
||||
use anyhow::Context;
|
||||
use serde_json::json;
|
||||
|
||||
use super::{CoreInner, IdentityMode};
|
||||
use super::CoreInner;
|
||||
use crate::{
|
||||
api::{
|
||||
CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreStorageUsage,
|
||||
PairingEligibilitySummary, ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact,
|
||||
ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource,
|
||||
StoredTransfer, TicketInspection, TransferAccessMode,
|
||||
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,
|
||||
@@ -38,171 +34,6 @@ 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]
|
||||
@@ -255,39 +86,25 @@ impl VnidropCore {
|
||||
limits: CoreLimits,
|
||||
network_config: CoreNetworkConfig,
|
||||
) -> Result<Arc<Self>, VnidropError> {
|
||||
Self::initialize_with_identity_mode(
|
||||
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(
|
||||
app_data_dir,
|
||||
event_sink,
|
||||
limits,
|
||||
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,
|
||||
},
|
||||
)
|
||||
network_config.mode,
|
||||
relay_urls,
|
||||
))
|
||||
.map_err(VnidropError::initialization)?;
|
||||
Ok(Arc::new(Self { runtime, inner }))
|
||||
}
|
||||
|
||||
pub fn status(&self) -> RuntimeStatus {
|
||||
@@ -454,230 +271,6 @@ 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)
|
||||
|
||||
@@ -179,7 +179,7 @@ impl CoreInner {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn shutdown(&self) {
|
||||
pub(super) async fn shutdown(&self) {
|
||||
if self.shutdown_started.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,18 +6,14 @@
|
||||
//! - [`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)]
|
||||
@@ -56,15 +52,11 @@ 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,
|
||||
pairing_eligibility::PairingEligibilityService,
|
||||
repository::Repository,
|
||||
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},
|
||||
};
|
||||
@@ -96,15 +88,8 @@ 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>,
|
||||
@@ -123,9 +108,6 @@ 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 {
|
||||
@@ -133,14 +115,6 @@ 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,
|
||||
@@ -148,29 +122,11 @@ 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 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 secret_key = load_or_create_secret(&app_data_dir).await?;
|
||||
let repository = Repository::open(&app_data_dir).await?;
|
||||
let store_root = app_data_dir.join("blobs");
|
||||
let mut store_options = FsStoreOptions::new(&store_root);
|
||||
store_options.gc = Some(GcConfig {
|
||||
@@ -357,64 +313,17 @@ 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 {
|
||||
@@ -423,15 +332,8 @@ 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),
|
||||
@@ -445,19 +347,8 @@ 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",
|
||||
@@ -469,12 +360,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -526,7 +411,38 @@ pub(crate) fn filter_peer_addr_for_relay_mode(
|
||||
relay_mode: CoreRelayMode,
|
||||
custom_relay_urls: &[RelayUrl],
|
||||
) -> Result<EndpointAddr> {
|
||||
crate::ticket::filter_peer_addr_for_relay_mode(addr, relay_mode, custom_relay_urls)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_for_relay(
|
||||
|
||||
@@ -27,7 +27,7 @@ use crate::{
|
||||
AtomicOutputFile,
|
||||
},
|
||||
handshake::{DeliveryReceipt, HandshakeResponse, HandshakeService},
|
||||
invitation::{PendingDeliveryReceiptInsert, ReceivedArtifactInsert, TransferUpsert},
|
||||
repository::{PendingDeliveryReceiptInsert, ReceivedArtifactInsert, TransferUpsert},
|
||||
ticket::{
|
||||
encode_persisted_sender_address, parse_transfer_ticket_with_limits, ParsedTransferTicket,
|
||||
},
|
||||
@@ -419,18 +419,6 @@ 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")
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
//! 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
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ use crate::{
|
||||
collect_import_files_with_limits, default_collection_name,
|
||||
read_stream_from_blocking_reader, TransferImport,
|
||||
},
|
||||
invitation::TransferUpsert,
|
||||
repository::TransferUpsert,
|
||||
ticket::VnidropTicket,
|
||||
transfer_state::{TransferDirection, TransferStatus},
|
||||
util::non_empty,
|
||||
|
||||
@@ -1,832 +0,0 @@
|
||||
//! 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())
|
||||
}
|
||||
}
|
||||
@@ -1,850 +0,0 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
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;
|
||||
@@ -1,494 +0,0 @@
|
||||
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(¶meters),
|
||||
],
|
||||
)
|
||||
.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(¶meters)],
|
||||
)
|
||||
.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) })
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,660 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
//! 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)
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
//! 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()
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
//! 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,
|
||||
};
|
||||
@@ -1,457 +0,0 @@
|
||||
//! 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}")),
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,486 +0,0 @@
|
||||
//! 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}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
@@ -1,58 +1,21 @@
|
||||
#[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"]
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,527 +0,0 @@
|
||||
//! 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]"));
|
||||
}
|
||||
@@ -1,599 +0,0 @@
|
||||
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"
|
||||
);
|
||||
}
|
||||
@@ -44,22 +44,6 @@ 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);
|
||||
@@ -68,29 +52,3 @@ 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 { .. }
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
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");
|
||||
}
|
||||
@@ -11,8 +11,6 @@ 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]
|
||||
@@ -23,12 +21,3 @@ 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());
|
||||
}
|
||||
|
||||
@@ -1,558 +0,0 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,889 +0,0 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,841 +0,0 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
@@ -1,843 +0,0 @@
|
||||
//! 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
|
||||
}
|
||||
@@ -1,646 +0,0 @@
|
||||
//! 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)
|
||||
));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
api::{CoreEvent, ReceivedLocatorKind},
|
||||
invitation::{
|
||||
repository::{
|
||||
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(), 13);
|
||||
assert_eq!(repository.schema_version().await.unwrap(), 7);
|
||||
repository
|
||||
.insert_transfer(transfer(
|
||||
7,
|
||||
@@ -87,7 +87,6 @@ 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),
|
||||
@@ -646,7 +645,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(), 13);
|
||||
assert_eq!(repository.schema_version().await.unwrap(), 7);
|
||||
let stored = repository.list_transfers().await.unwrap().remove(0);
|
||||
assert_eq!(stored.transfer_id, 7);
|
||||
assert_eq!(stored.local_id, "legacy-7-send");
|
||||
@@ -664,7 +663,6 @@ 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,
|
||||
@@ -713,7 +711,6 @@ 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),
|
||||
@@ -778,7 +775,6 @@ 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),
|
||||
@@ -861,7 +857,6 @@ 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),
|
||||
|
||||
@@ -9,11 +9,10 @@ use iroh_blobs::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
invitation::{PendingDeliveryReceiptInsert, Repository, TransferUpsert},
|
||||
runtime::{consume_request_updates, CoreInner, IdentityMode, RequestStreamOutcome},
|
||||
secure_secret::{lock_profile, FaultInjectingSecretStore},
|
||||
repository::{PendingDeliveryReceiptInsert, Repository, TransferUpsert},
|
||||
runtime::{consume_request_updates, RequestStreamOutcome},
|
||||
transfer_state::{TransferDirection, TransferStatus},
|
||||
CoreEvent, CoreEventSink, CoreLimits, CoreRelayMode, VnidropCore, VnidropError,
|
||||
CoreEvent, CoreEventSink, VnidropCore, VnidropError,
|
||||
};
|
||||
|
||||
struct TestSink;
|
||||
@@ -66,46 +65,6 @@ 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();
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
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()));
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
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)));
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
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
|
||||
));
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
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
@@ -9,8 +9,7 @@ use crate::{
|
||||
api::{CoreLimits, CoreRelayMode, TransferMetadata},
|
||||
ticket::{
|
||||
encode_persisted_sender_address, parse_persisted_sender_address, parse_transfer_ticket,
|
||||
parse_transfer_ticket_with_limits, relay_profiles_compatible, ticket_matches_relay_profile,
|
||||
VnidropTicket,
|
||||
parse_transfer_ticket_with_limits, ticket_matches_relay_profile, VnidropTicket,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -286,60 +285,3 @@ 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,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -184,76 +184,6 @@ 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
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -98,11 +98,9 @@ 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\":"));
|
||||
// Production events redact endpoint ids; typed APIs remain the source of truth.
|
||||
assert!(!completed
|
||||
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);
|
||||
|
||||
@@ -1458,57 +1458,6 @@
|
||||
"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": {
|
||||
@@ -1791,6 +1740,57 @@
|
||||
"ru": "Получайте уведомления об активности передач, пока VniDrop работает в фоне."
|
||||
}
|
||||
},
|
||||
"notifications_background_sharing_body": {
|
||||
"context": "Android foreground-service notification: explains why VniDrop stays active.",
|
||||
"targets": [
|
||||
"kmp"
|
||||
],
|
||||
"translations": {
|
||||
"en": "VniDrop is ready to share your files in the background.",
|
||||
"fr": "VniDrop est prêt à partager vos fichiers en arrière-plan.",
|
||||
"es": "VniDrop está listo para compartir sus archivos en segundo plano.",
|
||||
"it": "VniDrop è pronto a condividere i tuoi file in background.",
|
||||
"de": "VniDrop kann Ihre Dateien im Hintergrund freigeben.",
|
||||
"pt": "O VniDrop está pronto para partilhar os seus ficheiros em segundo plano.",
|
||||
"pl": "VniDrop jest gotowy do udostępniania plików w tle.",
|
||||
"nl": "VniDrop is klaar om uw bestanden op de achtergrond te delen.",
|
||||
"ru": "VniDrop готов отправлять ваши файлы в фоновом режиме."
|
||||
}
|
||||
},
|
||||
"notifications_background_sharing_channel": {
|
||||
"context": "Android system notification channel for an active outgoing share.",
|
||||
"targets": [
|
||||
"kmp"
|
||||
],
|
||||
"translations": {
|
||||
"en": "Active transfers",
|
||||
"fr": "Transferts actifs",
|
||||
"es": "Transferencias activas",
|
||||
"it": "Trasferimenti attivi",
|
||||
"de": "Aktive Übertragungen",
|
||||
"pt": "Transferências ativas",
|
||||
"pl": "Aktywne transfery",
|
||||
"nl": "Actieve overdrachten",
|
||||
"ru": "Активные передачи"
|
||||
}
|
||||
},
|
||||
"notifications_background_sharing_title": {
|
||||
"context": "Android foreground-service notification title while an outgoing share is available.",
|
||||
"targets": [
|
||||
"kmp"
|
||||
],
|
||||
"translations": {
|
||||
"en": "Sharing in the background",
|
||||
"fr": "Partage en arrière-plan",
|
||||
"es": "Compartiendo en segundo plano",
|
||||
"it": "Condivisione in background",
|
||||
"de": "Freigabe im Hintergrund",
|
||||
"pt": "Partilha em segundo plano",
|
||||
"pl": "Udostępnianie w tle",
|
||||
"nl": "Delen op de achtergrond",
|
||||
"ru": "Отправка в фоне"
|
||||
}
|
||||
},
|
||||
"notifications_enabled_message": {
|
||||
"context": "Settings > Notifications: confirmation when notifications are enabled.",
|
||||
"translations": {
|
||||
@@ -4668,550 +4668,6 @@
|
||||
"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.
|
Before Width: | Height: | Size: 4.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.1 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user