max / makenotwork
6 files changed,
+118 insertions,
-7 deletions
| @@ -1826,6 +1826,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1826 | 1826 | checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" | |
| 1827 | 1827 | dependencies = [ | |
| 1828 | 1828 | "once_cell", | |
| 1829 | + | "ring", | |
| 1829 | 1830 | "rustls-pki-types", | |
| 1830 | 1831 | "rustls-webpki", | |
| 1831 | 1832 | "subtle", | |
| @@ -2187,6 +2188,7 @@ dependencies = [ | |||
| 2187 | 2188 | "rand", | |
| 2188 | 2189 | "reqwest", | |
| 2189 | 2190 | "rusqlite", | |
| 2191 | + | "rustls", | |
| 2190 | 2192 | "serde", | |
| 2191 | 2193 | "serde_json", | |
| 2192 | 2194 | "sha2 0.11.0", |
| @@ -73,6 +73,10 @@ rusqlite = { version = "0.39", features = ["bundled", "functions"], optional = t | |||
| 73 | 73 | ||
| 74 | 74 | [dev-dependencies] | |
| 75 | 75 | wiremock = "0.6" | |
| 76 | + | # reqwest is built `rustls-no-provider`, so real consumers install a rustls crypto | |
| 77 | + | # provider at startup (audiofiles installs ring). The test suite has no such app, so | |
| 78 | + | # it installs ring itself before building any client. Dev-only: never in a consumer build. | |
| 79 | + | rustls = { version = "0.23", default-features = false, features = ["ring"] } | |
| 76 | 80 | # Self dev-dependency: turns on the `testing` feature for this crate's own | |
| 77 | 81 | # unit/integration test builds without leaking it into a consumer's default set. | |
| 78 | 82 | # `default-features = false` so it only ADDS `testing` to whatever the test run |
| @@ -269,8 +269,9 @@ path)` replaces both with a multipart session and holds one part at a time: | |||
| 269 | 269 | returns the part geometry both sides then derive identically. | |
| 270 | 270 | 2. It seals 1 MiB v3 chunks straight out of the file, staging them until a full | |
| 271 | 271 | part is ready, and PUTs each part to a presigned URL signed for that exact | |
| 272 | - | `Content-Length`. Presigned part URLs are pulled 100 at a time as the upload | |
| 273 | - | progresses. | |
| 272 | + | `Content-Length`. Each presigned part URL is requested on its own, one per | |
| 273 | + | part, since a part's checksum only exists once it has been sealed and only one | |
| 274 | + | part is ever held in memory. | |
| 274 | 275 | 3. It recomputes the SHA-256 as it reads, so a file that changed since the | |
| 275 | 276 | caller hashed it fails rather than landing under a stale content address. | |
| 276 | 277 | 4. Any failure past `start` aborts the session, since uploaded parts bill until | |
| @@ -282,7 +283,7 @@ is not persisted. | |||
| 282 | 283 | ||
| 283 | 284 | ## Keychain integration | |
| 284 | 285 | ||
| 285 | - | The `keychain` feature (default on) uses the `keyring` crate (v3) to store | |
| 286 | + | The `keychain` feature (default on) uses the `keyring` crate (v4) to store | |
| 286 | 287 | the master key in the OS credential store: | |
| 287 | 288 | ||
| 288 | 289 | - **macOS**: Keychain Services |
| @@ -1,9 +1,10 @@ | |||
| 1 | 1 | //! Content-addressed blob upload and download. | |
| 2 | 2 | //! | |
| 3 | 3 | //! Blobs are encrypted client-side and stored under the SHA-256 of their | |
| 4 | - | //! ciphertext. Uploads go through a presigned URL after a size/quota check; | |
| 5 | - | //! downloads are re-hashed on arrival and discarded on any mismatch, so a | |
| 6 | - | //! server that swaps or rolls back a blob cannot slip it past the client. | |
| 4 | + | //! plaintext. Uploads go through a presigned URL after a size/quota check; | |
| 5 | + | //! downloads are decrypted and re-hashed on arrival and discarded on any | |
| 6 | + | //! mismatch, so a server that swaps or rolls back a blob cannot slip it past | |
| 7 | + | //! the client. | |
| 7 | 8 | ||
| 8 | 9 | use std::path::Path; | |
| 9 | 10 | ||
| @@ -282,13 +283,46 @@ impl SyncKitClient { | |||
| 282 | 283 | )); | |
| 283 | 284 | } | |
| 284 | 285 | ||
| 286 | + | // The part geometry is server-supplied, so bound it before it drives any | |
| 287 | + | // allocation. Without this a hostile part_count aborts the client on an | |
| 288 | + | // over-large `Vec`/`BytesMut` reservation, and a part_count/part_size that | |
| 289 | + | // does not match the blob defeats the one-part-plus-one-chunk memory bound. | |
| 290 | + | // The plan must describe exactly the ciphertext we are about to upload | |
| 291 | + | // (the server derives it from this same `size_bytes`), so recompute and | |
| 292 | + | // compare rather than trust it. | |
| 293 | + | const MAX_PART_SIZE: usize = 1 << 30; // 1 GiB; real plan parts are well under this | |
| 294 | + | const MAX_PART_COUNT: u32 = 10_000; // S3's hard multipart-part ceiling | |
| 295 | + | if part_size > MAX_PART_SIZE { | |
| 296 | + | return Err(SyncKitError::Internal(format!( | |
| 297 | + | "server part_size {part_size} exceeds the {MAX_PART_SIZE}-byte ceiling" | |
| 298 | + | ))); | |
| 299 | + | } | |
| 300 | + | if start.part_count > MAX_PART_COUNT { | |
| 301 | + | return Err(SyncKitError::Internal(format!( | |
| 302 | + | "server part_count {} exceeds the {MAX_PART_COUNT}-part ceiling", | |
| 303 | + | start.part_count | |
| 304 | + | ))); | |
| 305 | + | } | |
| 306 | + | let total = usize::try_from(size_bytes).map_err(|_| { | |
| 307 | + | SyncKitError::Internal(format!("blob size {size_bytes} is not a valid length")) | |
| 308 | + | })?; | |
| 309 | + | let expected_parts = total.div_ceil(part_size); | |
| 310 | + | if start.part_count as usize != expected_parts { | |
| 311 | + | return Err(SyncKitError::Internal(format!( | |
| 312 | + | "server multipart plan (part_count {}, part_size {part_size}) does not match the \ | |
| 313 | + | {total}-byte blob (expected {expected_parts} parts)", | |
| 314 | + | start.part_count | |
| 315 | + | ))); | |
| 316 | + | } | |
| 317 | + | ||
| 285 | 318 | let mut file = tokio::fs::File::open(path).await.map_err(|e| { | |
| 286 | 319 | SyncKitError::Internal(format!("open blob file {}: {e}", path.display())) | |
| 287 | 320 | })?; | |
| 288 | 321 | ||
| 289 | 322 | let chunk_count = crypto::blob_chunk_count_for(plaintext_len); | |
| 290 | 323 | let mut hasher = Sha256::new(); | |
| 291 | - | let mut plain = vec![0u8; crypto::BLOB_CHUNK_SIZE]; | |
| 324 | + | // Plaintext scratch: zeroized on drop so a decrypted chunk does not linger. | |
| 325 | + | let mut plain = zeroize::Zeroizing::new(vec![0u8; crypto::BLOB_CHUNK_SIZE]); | |
| 292 | 326 | let mut staged = BytesMut::with_capacity(part_size + crypto::BLOB_CHUNK_SIZE); | |
| 293 | 327 | staged.extend_from_slice(&crypto::blob_header_bytes(plaintext_len)); | |
| 294 | 328 |
| @@ -327,6 +327,18 @@ pub struct SyncKitClient { | |||
| 327 | 327 | impl SyncKitClient { | |
| 328 | 328 | /// Create a new client with the given configuration. | |
| 329 | 329 | pub fn new(config: SyncKitConfig) -> Self { | |
| 330 | + | // reqwest is built with `rustls-no-provider`; a real consumer app installs the | |
| 331 | + | // process-wide crypto provider (audiofiles installs ring) before it ever builds | |
| 332 | + | // a client. Unit tests have no such app, so install ring here once, or the | |
| 333 | + | // client build below would panic. Never compiled into a consumer build. | |
| 334 | + | #[cfg(test)] | |
| 335 | + | { | |
| 336 | + | static PROVIDER: std::sync::Once = std::sync::Once::new(); | |
| 337 | + | PROVIDER.call_once(|| { | |
| 338 | + | let _ = rustls::crypto::ring::default_provider().install_default(); | |
| 339 | + | }); | |
| 340 | + | } | |
| 341 | + | ||
| 330 | 342 | let https_only = requires_https(&config.server_url); | |
| 331 | 343 | let http = Client::builder() | |
| 332 | 344 | .timeout(Duration::from_secs(30)) |
| @@ -46,7 +46,18 @@ fn test_ids() -> (UserId, AppId) { | |||
| 46 | 46 | ) | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | + | /// Install the rustls crypto provider once. reqwest is built `rustls-no-provider`, | |
| 50 | + | /// so a real consumer app installs one at startup (audiofiles installs ring); these | |
| 51 | + | /// tests have no such app, so they install ring themselves before building a client. | |
| 52 | + | fn ensure_crypto_provider() { | |
| 53 | + | static PROVIDER: std::sync::Once = std::sync::Once::new(); | |
| 54 | + | PROVIDER.call_once(|| { | |
| 55 | + | let _ = rustls::crypto::ring::default_provider().install_default(); | |
| 56 | + | }); | |
| 57 | + | } | |
| 58 | + | ||
| 49 | 59 | fn client_for(server: &MockServer) -> SyncKitClient { | |
| 60 | + | ensure_crypto_provider(); | |
| 50 | 61 | SyncKitClient::new(SyncKitConfig { | |
| 51 | 62 | server_url: server.uri(), | |
| 52 | 63 | api_key: "test-api-key".to_string(), | |
| @@ -2849,6 +2860,7 @@ async fn concurrent_push_100_entries_each() { | |||
| 2849 | 2860 | // ── Timeout tests ── | |
| 2850 | 2861 | ||
| 2851 | 2862 | fn short_timeout_client(server: &MockServer) -> SyncKitClient { | |
| 2863 | + | ensure_crypto_provider(); | |
| 2852 | 2864 | let http = reqwest::Client::builder() | |
| 2853 | 2865 | .timeout(Duration::from_millis(100)) | |
| 2854 | 2866 | .connect_timeout(Duration::from_millis(100)) | |
| @@ -3430,6 +3442,52 @@ mod blob_multipart { | |||
| 3430 | 3442 | } | |
| 3431 | 3443 | ||
| 3432 | 3444 | #[tokio::test] | |
| 3445 | + | async fn streaming_upload_rejects_a_server_part_plan_that_lies_about_geometry() { | |
| 3446 | + | let server = MockServer::start().await; | |
| 3447 | + | let client = authed_client(&server); | |
| 3448 | + | let key = synckit_client::crypto::generate_master_key(); | |
| 3449 | + | client.set_master_key_raw(key); | |
| 3450 | + | ||
| 3451 | + | // A blob that genuinely spans several 1 MiB parts. | |
| 3452 | + | let plaintext: Vec<u8> = (0..(synckit_client::crypto::BLOB_CHUNK_SIZE * 3 + 7)) | |
| 3453 | + | .map(|i| i as u8) | |
| 3454 | + | .collect(); | |
| 3455 | + | let hash = hex::encode(sha2::Sha256::digest(&plaintext)); | |
| 3456 | + | let file = temp_blob("liar.bin", &plaintext); | |
| 3457 | + | ||
| 3458 | + | // Hostile server: claims the whole multi-part blob fits in ONE part. | |
| 3459 | + | // Trusting it would defeat the one-part-in-memory bound, so the client | |
| 3460 | + | // must refuse before minting or PUTting anything. | |
| 3461 | + | Mock::given(method("POST")) | |
| 3462 | + | .and(path(START_PATH)) | |
| 3463 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 3464 | + | "upload_id": "test-upload-id", | |
| 3465 | + | "part_size": 1024 * 1024, | |
| 3466 | + | "part_count": 1, | |
| 3467 | + | "already_exists": false, | |
| 3468 | + | }))) | |
| 3469 | + | .mount(&server) | |
| 3470 | + | .await; | |
| 3471 | + | ||
| 3472 | + | let err = client | |
| 3473 | + | .blob_upload_streaming(&hash, &file) | |
| 3474 | + | .await | |
| 3475 | + | .unwrap_err(); | |
| 3476 | + | assert!( | |
| 3477 | + | matches!(err, SyncKitError::Internal(ref m) if m.contains("does not match")), | |
| 3478 | + | "expected a geometry-mismatch rejection, got {err:?}" | |
| 3479 | + | ); | |
| 3480 | + | let reqs = server.received_requests().await.unwrap(); | |
| 3481 | + | assert_eq!( | |
| 3482 | + | hits(&reqs, PART_PUT_PATH), | |
| 3483 | + | 0, | |
| 3484 | + | "no part may be uploaded once the plan is rejected" | |
| 3485 | + | ); | |
| 3486 | + | ||
| 3487 | + | std::fs::remove_file(&file).ok(); | |
| 3488 | + | } | |
| 3489 | + | ||
| 3490 | + | #[tokio::test] | |
| 3433 | 3491 | async fn streaming_upload_handles_an_empty_file() { | |
| 3434 | 3492 | let server = MockServer::start().await; | |
| 3435 | 3493 | let client = authed_client(&server); |